openclaw/src/gateway/session-kill-http.ts
clay-datacurve 7b61ca1b06
Session management improvements and dashboard API (#50101)
* fix: make cleanup "keep" persist subagent sessions indefinitely

* feat: expose subagent session metadata in sessions list

* fix: include status and timing in sessions_list tool

* fix: hide injected timestamp prefixes in chat ui

* feat: push session list updates over websocket

* feat: expose child subagent sessions in subagents list

* feat: add admin http endpoint to kill sessions

* Emit session.message websocket events for transcript updates

* Estimate session costs in sessions list

* Add direct session history HTTP and SSE endpoints

* Harden dashboard session events and history APIs

* Add session lifecycle gateway methods

* Add dashboard session API improvements

* Add dashboard session model and parent linkage support

* fix: tighten dashboard session API metadata

* Fix dashboard session cost metadata

* Persist accumulated session cost

* fix: stop followup queue drain cfg crash

* Fix dashboard session create and model metadata

* fix: stop guessing session model costs

* Gateway: cache OpenRouter pricing for configured models

* Gateway: add timeout session status

* Fix subagent spawn test config loading

* Gateway: preserve operator scopes without device identity

* Emit user message transcript events and deduplicate plugin warnings

* feat: emit sessions.changed lifecycle event on subagent spawn

Adds a session-lifecycle-events module (similar to transcript-events)
that emits create events when subagents are spawned. The gateway
server.impl.ts listens for these events and broadcasts sessions.changed
with reason=create to SSE subscribers, so dashboards can pick up new
subagent sessions without polling.

* Gateway: allow persistent dashboard orchestrator sessions

* fix: preserve operator scopes for token-authenticated backend clients

Backend clients (like agent-dashboard) that authenticate with a valid gateway
token but don't present a device identity were getting their scopes stripped.
The scope-clearing logic ran before checking the device identity decision,
so even when evaluateMissingDeviceIdentity returned 'allow' (because
roleCanSkipDeviceIdentity passed for token-authed operators), scopes were
already cleared.

Fix: also check decision.kind before clearing scopes, so token-authenticated
operators keep their requested scopes.

* Gateway: allow operator-token session kills

* Fix stale active subagent status after follow-up runs

* Fix dashboard image attachments in sessions send

* Fix completed session follow-up status updates

* feat: stream session tool events to operator UIs

* Add sessions.steer gateway coverage

* Persist subagent timing in session store

* Fix subagent session transcript event keys

* Fix active subagent session status in gateway

* bump session label max to 512

* Fix gateway send session reactivation

* fix: publish terminal session lifecycle state

* feat: change default session reset to effectively never

- Change DEFAULT_RESET_MODE from "daily" to "idle"
- Change DEFAULT_IDLE_MINUTES from 60 to 0 (0 = disabled/never)
- Allow idleMinutes=0 through normalization (don't clamp to 1)
- Treat idleMinutes=0 as "no idle expiry" in evaluateSessionFreshness
- Default behavior: mode "idle" + idleMinutes 0 = sessions never auto-reset
- Update test assertion for new default mode

* fix: prep session management followups (#50101) (thanks @clay-datacurve)

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-03-19 12:12:30 +09:00

152 lines
4.6 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import {
killControlledSubagentRun,
killSubagentRunAdmin,
resolveSubagentController,
} from "../agents/subagent-control.js";
import { getSubagentRunByChildSessionKey } from "../agents/subagent-registry.js";
import { loadConfig } from "../config/config.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import {
authorizeHttpGatewayConnect,
isLocalDirectRequest,
type ResolvedGatewayAuth,
} from "./auth.js";
import { sendGatewayAuthFailure, sendJson, sendMethodNotAllowed } from "./http-common.js";
import { getBearerToken } from "./http-utils.js";
import { ADMIN_SCOPE, WRITE_SCOPE, authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { loadSessionEntry } from "./session-utils.js";
const REQUESTER_SESSION_KEY_HEADER = "x-openclaw-requester-session-key";
function canBearerTokenKillSessions(token: string | undefined, authOk: boolean): boolean {
if (!token || !authOk) {
return false;
}
// Authenticated HTTP bearer requests are operator-authenticated control-plane
// calls, so treat them as carrying the standard write/admin operator scopes.
const bearerScopes = [ADMIN_SCOPE, WRITE_SCOPE];
return (
authorizeOperatorScopesForMethod("sessions.delete", bearerScopes).allowed ||
authorizeOperatorScopesForMethod("sessions.abort", bearerScopes).allowed
);
}
function resolveSessionKeyFromPath(pathname: string): string | null {
const match = pathname.match(/^\/sessions\/([^/]+)\/kill$/);
if (!match) {
return null;
}
try {
const decoded = decodeURIComponent(match[1] ?? "").trim();
return decoded || null;
} catch {
return null;
}
}
export async function handleSessionKillHttpRequest(
req: IncomingMessage,
res: ServerResponse,
opts: {
auth: ResolvedGatewayAuth;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
},
): Promise<boolean> {
const cfg = loadConfig();
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const sessionKey = resolveSessionKeyFromPath(url.pathname);
if (!sessionKey) {
return false;
}
if (req.method !== "POST") {
sendMethodNotAllowed(res, "POST");
return true;
}
const token = getBearerToken(req);
const authResult = await authorizeHttpGatewayConnect({
auth: opts.auth,
connectAuth: token ? { token, password: token } : null,
req,
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
});
if (!authResult.ok) {
sendGatewayAuthFailure(res, authResult);
return true;
}
const { entry, canonicalKey } = loadSessionEntry(sessionKey);
if (!entry) {
sendJson(res, 404, {
ok: false,
error: {
type: "not_found",
message: `Session not found: ${sessionKey}`,
},
});
return true;
}
const trustedProxies = opts.trustedProxies ?? cfg.gateway?.trustedProxies;
const allowRealIpFallback = opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback;
const requesterSessionKey = req.headers[REQUESTER_SESSION_KEY_HEADER]?.toString().trim();
const allowLocalAdminKill = isLocalDirectRequest(req, trustedProxies, allowRealIpFallback);
const allowBearerOperatorKill = canBearerTokenKillSessions(token, authResult.ok);
if (!requesterSessionKey && !allowLocalAdminKill && !allowBearerOperatorKill) {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message:
"Session kills require a local admin request, requester session ownership, or an authorized operator token.",
},
});
return true;
}
const allowAdminKill = allowLocalAdminKill || allowBearerOperatorKill;
let killed = false;
if (!allowAdminKill && requesterSessionKey) {
const runEntry = getSubagentRunByChildSessionKey(canonicalKey);
if (runEntry) {
const result = await killControlledSubagentRun({
cfg,
controller: resolveSubagentController({ cfg, agentSessionKey: requesterSessionKey }),
entry: runEntry,
});
if (result.status === "forbidden") {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message: result.error,
},
});
return true;
}
killed = result.status === "ok";
}
} else {
const result = await killSubagentRunAdmin({
cfg,
sessionKey: canonicalKey,
});
killed = result.killed;
}
sendJson(res, 200, {
ok: true,
killed,
});
return true;
}