mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(gateway): terminal detach/reattach with output replay, terminal.list/text
This commit is contained in:
parent
be8b5f6c4f
commit
5938c7809a
19 changed files with 1006 additions and 26 deletions
|
|
@ -6605,6 +6605,134 @@ public struct TerminalCloseParams: Codable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
public struct TerminalAttachParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
sessionid: String)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TerminalAttachResult: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let agentid: String
|
||||
public let shell: String
|
||||
public let cwd: String
|
||||
public let confined: Bool
|
||||
public let buffer: String
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
agentid: String,
|
||||
shell: String,
|
||||
cwd: String,
|
||||
confined: Bool,
|
||||
buffer: String)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.agentid = agentid
|
||||
self.shell = shell
|
||||
self.cwd = cwd
|
||||
self.confined = confined
|
||||
self.buffer = buffer
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case agentid = "agentId"
|
||||
case shell
|
||||
case cwd
|
||||
case confined
|
||||
case buffer
|
||||
}
|
||||
}
|
||||
|
||||
public struct TerminalSessionInfo: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let agentid: String
|
||||
public let shell: String
|
||||
public let cwd: String
|
||||
public let confined: Bool
|
||||
public let attached: Bool
|
||||
public let createdatms: Int
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
agentid: String,
|
||||
shell: String,
|
||||
cwd: String,
|
||||
confined: Bool,
|
||||
attached: Bool,
|
||||
createdatms: Int)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.agentid = agentid
|
||||
self.shell = shell
|
||||
self.cwd = cwd
|
||||
self.confined = confined
|
||||
self.attached = attached
|
||||
self.createdatms = createdatms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case agentid = "agentId"
|
||||
case shell
|
||||
case cwd
|
||||
case confined
|
||||
case attached
|
||||
case createdatms = "createdAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TerminalListResult: Codable, Sendable {
|
||||
public let sessions: [TerminalSessionInfo]
|
||||
|
||||
public init(
|
||||
sessions: [TerminalSessionInfo])
|
||||
{
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessions
|
||||
}
|
||||
}
|
||||
|
||||
public struct TerminalTextParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
sessionid: String)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TerminalTextResult: Codable, Sendable {
|
||||
public let text: String
|
||||
|
||||
public init(
|
||||
text: String)
|
||||
{
|
||||
self.text = text
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case text
|
||||
}
|
||||
}
|
||||
|
||||
public struct TerminalAckResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
|
||||
|
|
|
|||
|
|
@ -717,6 +717,26 @@ See [Multiple Gateways](/gateway/multiple-gateways).
|
|||
- `debounceMs`: debounce window in ms before config changes are applied (non-negative integer).
|
||||
- `deferralTimeoutMs`: optional maximum time in ms to wait for in-flight operations before forcing a restart or channel hot reload. Omit it to use the default bounded wait (`300000`); set `0` to wait indefinitely and log periodic still-pending warnings.
|
||||
|
||||
### `gateway.terminal`
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
terminal: {
|
||||
enabled: true,
|
||||
shell: "/bin/zsh",
|
||||
detachedSessionTimeoutSeconds: 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Operator terminal served to the Control UI and mobile clients: a PTY-backed shell on the gateway host, restricted to admin-scope operator sessions. Changing any `gateway.terminal.*` key restarts the gateway.
|
||||
|
||||
- `enabled`: master switch for the terminal surface (default: `true`). Disabling removes the browser/mobile shell entirely.
|
||||
- `shell`: shell executable to launch. Unset uses the host login shell (`$SHELL` on Unix, `%ComSpec%` on Windows).
|
||||
- `detachedSessionTimeoutSeconds`: how long a session survives after its connection drops (page reload, laptop sleep), staying reattachable with its recent output replayed (default: `300`). Set `0` to kill sessions the moment their connection drops. Detached sessions keep running their commands, so shorten this on shared or exposed hosts.
|
||||
|
||||
---
|
||||
|
||||
## Hooks
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ Imported themes are stored only in the current browser profile. They are not wri
|
|||
- Raw JSON editor "Reset to saved" preserves the raw-authored shape (formatting, comments, `$include` layout) instead of re-rendering a flattened snapshot, so external edits survive a reset when the snapshot can safely round-trip.
|
||||
- Structured SecretRef object values are rendered read-only in form text inputs to prevent accidental object-to-string corruption.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Operator terminal">
|
||||
- Dockable PTY shell on the gateway host (`terminal.*` RPCs), restricted to admin-scope operator sessions; hidden unless the gateway advertises it (`gateway.terminal.enabled`).
|
||||
- Sessions start in the target agent's workspace; fully sandboxed agents (`sandbox.mode: "all"`) are refused fail-closed.
|
||||
- Dropped connections (page reload, laptop sleep) detach sessions instead of killing them; on reconnect the same browser tab reattaches its sessions and replays recent output. Detached sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300; `0` restores kill-on-disconnect).
|
||||
- `terminal.list` shows attachable sessions, `terminal.attach` adopts one (tmux-style take-over), and `terminal.text` reads a session's recent output as plain text without attaching — an agent/tooling affordance.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Debug, logs, update">
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`).
|
||||
|
|
|
|||
|
|
@ -252,6 +252,10 @@ import {
|
|||
LogsTailResultSchema,
|
||||
type TerminalAckResult,
|
||||
TerminalAckResultSchema,
|
||||
type TerminalAttachParams,
|
||||
TerminalAttachParamsSchema,
|
||||
type TerminalAttachResult,
|
||||
TerminalAttachResultSchema,
|
||||
type TerminalCloseParams,
|
||||
TerminalCloseParamsSchema,
|
||||
type TerminalDataEvent,
|
||||
|
|
@ -262,12 +266,20 @@ import {
|
|||
TerminalExitEventSchema,
|
||||
type TerminalInputParams,
|
||||
TerminalInputParamsSchema,
|
||||
type TerminalListResult,
|
||||
TerminalListResultSchema,
|
||||
type TerminalOpenParams,
|
||||
TerminalOpenParamsSchema,
|
||||
type TerminalOpenResult,
|
||||
TerminalOpenResultSchema,
|
||||
type TerminalResizeParams,
|
||||
TerminalResizeParamsSchema,
|
||||
type TerminalSessionInfo,
|
||||
TerminalSessionInfoSchema,
|
||||
type TerminalTextParams,
|
||||
TerminalTextParamsSchema,
|
||||
type TerminalTextResult,
|
||||
TerminalTextResultSchema,
|
||||
type ModelsListParams,
|
||||
ModelsListParamsSchema,
|
||||
type NodeDescribeParams,
|
||||
|
|
@ -938,6 +950,10 @@ export const validateTerminalResizeParams = lazyCompile<TerminalResizeParams>(
|
|||
);
|
||||
export const validateTerminalCloseParams =
|
||||
lazyCompile<TerminalCloseParams>(TerminalCloseParamsSchema);
|
||||
export const validateTerminalAttachParams = lazyCompile<TerminalAttachParams>(
|
||||
TerminalAttachParamsSchema,
|
||||
);
|
||||
export const validateTerminalTextParams = lazyCompile<TerminalTextParams>(TerminalTextParamsSchema);
|
||||
export const validateTerminalEvent = lazyCompile<TerminalEvent>(TerminalEventSchema);
|
||||
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
|
||||
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
|
||||
|
|
@ -1226,6 +1242,12 @@ export {
|
|||
TerminalInputParamsSchema,
|
||||
TerminalResizeParamsSchema,
|
||||
TerminalCloseParamsSchema,
|
||||
TerminalAttachParamsSchema,
|
||||
TerminalAttachResultSchema,
|
||||
TerminalSessionInfoSchema,
|
||||
TerminalListResultSchema,
|
||||
TerminalTextParamsSchema,
|
||||
TerminalTextResultSchema,
|
||||
TerminalAckResultSchema,
|
||||
TerminalDataEventSchema,
|
||||
TerminalExitEventSchema,
|
||||
|
|
@ -1459,6 +1481,12 @@ export type {
|
|||
TerminalInputParams,
|
||||
TerminalResizeParams,
|
||||
TerminalCloseParams,
|
||||
TerminalAttachParams,
|
||||
TerminalAttachResult,
|
||||
TerminalSessionInfo,
|
||||
TerminalListResult,
|
||||
TerminalTextParams,
|
||||
TerminalTextResult,
|
||||
TerminalAckResult,
|
||||
TerminalDataEvent,
|
||||
TerminalExitEvent,
|
||||
|
|
|
|||
|
|
@ -303,14 +303,20 @@ import {
|
|||
} from "./tasks.js";
|
||||
import {
|
||||
TerminalAckResultSchema,
|
||||
TerminalAttachParamsSchema,
|
||||
TerminalAttachResultSchema,
|
||||
TerminalCloseParamsSchema,
|
||||
TerminalDataEventSchema,
|
||||
TerminalEventSchema,
|
||||
TerminalExitEventSchema,
|
||||
TerminalInputParamsSchema,
|
||||
TerminalListResultSchema,
|
||||
TerminalOpenParamsSchema,
|
||||
TerminalOpenResultSchema,
|
||||
TerminalResizeParamsSchema,
|
||||
TerminalSessionInfoSchema,
|
||||
TerminalTextParamsSchema,
|
||||
TerminalTextResultSchema,
|
||||
} from "./terminal.js";
|
||||
import {
|
||||
WizardCancelParamsSchema,
|
||||
|
|
@ -574,6 +580,12 @@ export const ProtocolSchemas = {
|
|||
TerminalInputParams: TerminalInputParamsSchema,
|
||||
TerminalResizeParams: TerminalResizeParamsSchema,
|
||||
TerminalCloseParams: TerminalCloseParamsSchema,
|
||||
TerminalAttachParams: TerminalAttachParamsSchema,
|
||||
TerminalAttachResult: TerminalAttachResultSchema,
|
||||
TerminalSessionInfo: TerminalSessionInfoSchema,
|
||||
TerminalListResult: TerminalListResultSchema,
|
||||
TerminalTextParams: TerminalTextParamsSchema,
|
||||
TerminalTextResult: TerminalTextResultSchema,
|
||||
TerminalAckResult: TerminalAckResultSchema,
|
||||
TerminalDataEvent: TerminalDataEventSchema,
|
||||
TerminalExitEvent: TerminalExitEventSchema,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,76 @@ export const TerminalCloseParamsSchema = Type.Object(
|
|||
);
|
||||
export type TerminalCloseParams = Static<typeof TerminalCloseParamsSchema>;
|
||||
|
||||
/**
|
||||
* Rebinds a live-or-detached session to the calling admin connection.
|
||||
* Attach is take-over (tmux-like): the previous owner, if still connected,
|
||||
* receives `terminal.exit` with reason "detached".
|
||||
*/
|
||||
export const TerminalAttachParamsSchema = Type.Object(
|
||||
{ sessionId: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export type TerminalAttachParams = Static<typeof TerminalAttachParamsSchema>;
|
||||
|
||||
/** Result of a successful attach; mirrors open plus the replay buffer. */
|
||||
export const TerminalAttachResultSchema = Type.Object(
|
||||
{
|
||||
sessionId: NonEmptyString,
|
||||
agentId: NonEmptyString,
|
||||
shell: NonEmptyString,
|
||||
cwd: NonEmptyString,
|
||||
confined: Type.Boolean(),
|
||||
// Recent raw output from the server's bounded ring buffer, replayed into
|
||||
// the client emulator before live terminal.data resumes. Not a true screen
|
||||
// snapshot: after truncation it can start mid-escape-sequence; emulators
|
||||
// recover on the next full repaint (prompt, clear, resize redraw).
|
||||
buffer: Type.String(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export type TerminalAttachResult = Static<typeof TerminalAttachResultSchema>;
|
||||
|
||||
/** One attachable session, as reported by terminal.list. */
|
||||
export const TerminalSessionInfoSchema = Type.Object(
|
||||
{
|
||||
sessionId: NonEmptyString,
|
||||
agentId: NonEmptyString,
|
||||
shell: NonEmptyString,
|
||||
cwd: NonEmptyString,
|
||||
confined: Type.Boolean(),
|
||||
/** False while the session is detached (no connection owns its stream). */
|
||||
attached: Type.Boolean(),
|
||||
createdAtMs: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export type TerminalSessionInfo = Static<typeof TerminalSessionInfoSchema>;
|
||||
|
||||
/**
|
||||
* Sessions a reconnecting admin client can attach. All admin connections see
|
||||
* the same list: the terminal surface is already operator.admin (full host
|
||||
* access), so cross-connection visibility adds no privilege.
|
||||
*/
|
||||
export const TerminalListResultSchema = Type.Object(
|
||||
{ sessions: Type.Array(TerminalSessionInfoSchema) },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export type TerminalListResult = Static<typeof TerminalListResultSchema>;
|
||||
|
||||
/** Reads the current output buffer as plain text without attaching. */
|
||||
export const TerminalTextParamsSchema = Type.Object(
|
||||
{ sessionId: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export type TerminalTextParams = Static<typeof TerminalTextParamsSchema>;
|
||||
|
||||
/** Plain-text buffer contents (ANSI stripped); an agent/LLM affordance. */
|
||||
export const TerminalTextResultSchema = Type.Object(
|
||||
{ text: Type.String() },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export type TerminalTextResult = Static<typeof TerminalTextResultSchema>;
|
||||
|
||||
/** Shared ok/void result for input, resize, and close. */
|
||||
export const TerminalAckResultSchema = Type.Object(
|
||||
{ ok: Type.Boolean() },
|
||||
|
|
@ -97,6 +167,9 @@ export const TerminalExitEventSchema = Type.Object(
|
|||
Type.Literal("process_exit"),
|
||||
Type.Literal("closed"),
|
||||
Type.Literal("disconnected"),
|
||||
// Another admin connection attached the session away; the session is
|
||||
// still alive server-side, but no longer streams to this connection.
|
||||
Type.Literal("detached"),
|
||||
Type.Literal("error"),
|
||||
]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"Enables the operator terminal for admin-scope clients when true (default: false). This exposes a browser/mobile shell with the gateway process environment, so enable it only for trusted operator deployments. Changing this restarts the gateway so connected clients reload with the correct terminal availability and content-security policy.",
|
||||
"gateway.terminal.shell":
|
||||
"Shell executable the operator terminal launches. Leave unset to use the host login shell ($SHELL on Unix, %ComSpec% on Windows), or pin an explicit interpreter for a consistent operator environment.",
|
||||
"gateway.terminal.detachedSessionTimeoutSeconds":
|
||||
"Seconds a terminal session survives after its connection drops (laptop sleep, page reload), staying reattachable via terminal.attach with its recent output replayed. Set 0 to kill sessions the moment the connection drops. Default: 300 (5 minutes). Detached sessions keep running their commands, so shorten this on shared or exposed hosts.",
|
||||
"gateway.auth":
|
||||
"Authentication policy for gateway HTTP/WebSocket access including mode, credentials, trusted-proxy behavior, and rate limiting. Keep auth enabled for every non-loopback deployment.",
|
||||
"gateway.auth.mode":
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||
"gateway.terminal": "Operator Terminal",
|
||||
"gateway.terminal.enabled": "Operator Terminal Enabled",
|
||||
"gateway.terminal.shell": "Operator Terminal Shell",
|
||||
"gateway.terminal.detachedSessionTimeoutSeconds": "Operator Terminal Detached Session Timeout",
|
||||
"gateway.auth": "Gateway Auth",
|
||||
"gateway.auth.mode": "Gateway Auth Mode",
|
||||
"gateway.auth.allowTailscale": "Gateway Auth Allow Tailscale Identity",
|
||||
|
|
|
|||
|
|
@ -276,6 +276,12 @@ export type GatewayTerminalConfig = {
|
|||
* ($SHELL on Unix, %ComSpec% on Windows).
|
||||
*/
|
||||
shell?: string;
|
||||
/**
|
||||
* How long (seconds) a session survives after its connection drops, staying
|
||||
* reattachable via terminal.attach. 0 kills sessions on disconnect
|
||||
* immediately. Default: 300.
|
||||
*/
|
||||
detachedSessionTimeoutSeconds?: number;
|
||||
};
|
||||
|
||||
/** Gateway config reload strategy for managed installs. */
|
||||
|
|
|
|||
|
|
@ -1098,6 +1098,7 @@ export const OpenClawSchema = z
|
|||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
shell: z.string().optional(),
|
||||
detachedSessionTimeoutSeconds: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
|
|
|||
|
|
@ -237,6 +237,12 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
|||
{ name: "nativeHook.invoke", scope: "operator.admin", advertise: false },
|
||||
{ name: "web.login.start", scope: "operator.admin", advertise: false },
|
||||
{ name: "web.login.wait", scope: "operator.admin", advertise: false },
|
||||
// Terminal detach/reattach surface. Appended at the end (not next to the
|
||||
// other terminal.* methods) so previously advertised method indices stay
|
||||
// stable for older clients.
|
||||
{ name: "terminal.attach", scope: "operator.admin" },
|
||||
{ name: "terminal.list", scope: "operator.admin" },
|
||||
{ name: "terminal.text", scope: "operator.admin" },
|
||||
] as const;
|
||||
|
||||
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,24 @@ function makeOpts(
|
|||
write: vi.fn(() => true),
|
||||
resize: vi.fn(() => true),
|
||||
close: vi.fn(() => true),
|
||||
attach: vi.fn(() => ({
|
||||
sessionId: "s1",
|
||||
agentId: "main",
|
||||
cwd: "/work",
|
||||
shell: "/bin/zsh",
|
||||
buffer: "history",
|
||||
})),
|
||||
list: vi.fn(() => [
|
||||
{
|
||||
sessionId: "s1",
|
||||
agentId: "main",
|
||||
shell: "/bin/zsh",
|
||||
cwd: "/work",
|
||||
attached: false,
|
||||
createdAtMs: 1,
|
||||
},
|
||||
]),
|
||||
snapshot: vi.fn(() => "10%\r100%"),
|
||||
};
|
||||
const respond = vi.fn();
|
||||
const runtimeConfig = { gateway: { terminal: terminalConfig } } as OpenClawConfig;
|
||||
|
|
@ -28,6 +46,7 @@ function makeOpts(
|
|||
}),
|
||||
isTerminalEnabled: () => policyConfig.gateway?.terminal?.enabled === true,
|
||||
terminalSessions: sessions,
|
||||
logGateway: { info: vi.fn() },
|
||||
// Only the fields the terminal handlers touch are needed here.
|
||||
} as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0]["context"];
|
||||
const opts = {
|
||||
|
|
@ -114,3 +133,81 @@ describe("terminal.resize kill switch", () => {
|
|||
expect(respond).toHaveBeenCalledWith(true, { ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal.attach", () => {
|
||||
it("returns the session facts plus the replay buffer", async () => {
|
||||
const { opts, sessions, respond } = makeOpts({ sessionId: "s1" }, { enabled: true });
|
||||
await terminalHandlers["terminal.attach"](opts);
|
||||
expect(sessions.attach).toHaveBeenCalledWith("conn-1", "s1");
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
sessionId: "s1",
|
||||
agentId: "main",
|
||||
shell: "/bin/zsh",
|
||||
cwd: "/work",
|
||||
confined: false,
|
||||
buffer: "history",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to hand out a PTY stream when the terminal is disabled", async () => {
|
||||
const { opts, sessions, respond } = makeOpts({ sessionId: "s1" }, { enabled: false });
|
||||
await terminalHandlers["terminal.attach"](opts);
|
||||
expect(sessions.attach).not.toHaveBeenCalled();
|
||||
expect(respond.mock.calls[0][0]).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown sessions", async () => {
|
||||
const { opts, sessions, respond } = makeOpts({ sessionId: "gone" }, { enabled: true });
|
||||
sessions.attach.mockReturnValue(undefined as never);
|
||||
await terminalHandlers["terminal.attach"](opts);
|
||||
expect(respond.mock.calls[0][0]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal.list", () => {
|
||||
it("lists sessions with the confined flag applied", async () => {
|
||||
const { opts, respond } = makeOpts(undefined, { enabled: true });
|
||||
await terminalHandlers["terminal.list"](opts);
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: "s1",
|
||||
agentId: "main",
|
||||
shell: "/bin/zsh",
|
||||
cwd: "/work",
|
||||
attached: false,
|
||||
createdAtMs: 1,
|
||||
confined: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty list when the terminal is disabled", async () => {
|
||||
const { opts, sessions, respond } = makeOpts(undefined, { enabled: false });
|
||||
await terminalHandlers["terminal.list"](opts);
|
||||
expect(sessions.list).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(true, { sessions: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal.text", () => {
|
||||
it("returns the buffer rendered as plain text", async () => {
|
||||
const { opts, respond } = makeOpts({ sessionId: "s1" }, { enabled: true });
|
||||
await terminalHandlers["terminal.text"](opts);
|
||||
// The raw snapshot carries a CR overwrite; the handler collapses it.
|
||||
expect(respond).toHaveBeenCalledWith(true, { text: "100%" });
|
||||
});
|
||||
|
||||
it("rejects unknown sessions and disabled terminals", async () => {
|
||||
const unknown = makeOpts({ sessionId: "gone" }, { enabled: true });
|
||||
unknown.sessions.snapshot.mockReturnValue(undefined as never);
|
||||
await terminalHandlers["terminal.text"](unknown.opts);
|
||||
expect(unknown.respond.mock.calls[0][0]).toBe(false);
|
||||
|
||||
const disabled = makeOpts({ sessionId: "s1" }, { enabled: false });
|
||||
await terminalHandlers["terminal.text"](disabled.opts);
|
||||
expect(disabled.sessions.snapshot).not.toHaveBeenCalled();
|
||||
expect(disabled.respond.mock.calls[0][0]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import {
|
|||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateTerminalAttachParams,
|
||||
validateTerminalCloseParams,
|
||||
validateTerminalInputParams,
|
||||
validateTerminalOpenParams,
|
||||
validateTerminalResizeParams,
|
||||
validateTerminalTextParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { renderTerminalBufferText } from "../terminal/buffer-text.js";
|
||||
import { buildTerminalEnv } from "../terminal/launch.js";
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
|
|
@ -171,4 +174,100 @@ export const terminalHandlers: GatewayRequestHandlers = {
|
|||
const ok = context.terminalSessions?.close(connId, p.sessionId) ?? false;
|
||||
respond(true, { ok });
|
||||
},
|
||||
|
||||
"terminal.attach": async (opts) => {
|
||||
const { params, respond, context } = opts;
|
||||
if (!validateTerminalAttachParams(params)) {
|
||||
invalid(
|
||||
respond,
|
||||
`invalid terminal.attach params: ${formatValidationErrors(validateTerminalAttachParams.errors)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const connId = requireConnId(opts);
|
||||
if (!connId) {
|
||||
return;
|
||||
}
|
||||
const p = params as { sessionId: string };
|
||||
// Same defense-in-depth as input/resize: the disable restart may still be
|
||||
// in flight, so refuse handing a live PTY stream to a new connection.
|
||||
if (!context.terminalSessions || !terminalEnabled(context)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available"));
|
||||
return;
|
||||
}
|
||||
const attached = context.terminalSessions.attach(connId, p.sessionId);
|
||||
if (!attached) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unknown terminal session "${p.sessionId}"`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.logGateway.info(
|
||||
`terminal attached session=${attached.sessionId} agent=${attached.agentId} conn=${connId}`,
|
||||
);
|
||||
respond(true, {
|
||||
sessionId: attached.sessionId,
|
||||
agentId: attached.agentId,
|
||||
shell: attached.shell,
|
||||
cwd: attached.cwd,
|
||||
confined: false,
|
||||
buffer: attached.buffer,
|
||||
});
|
||||
},
|
||||
|
||||
"terminal.list": async (opts) => {
|
||||
const { respond, context } = opts;
|
||||
const connId = requireConnId(opts);
|
||||
if (!connId) {
|
||||
return;
|
||||
}
|
||||
// An empty list (not an error) when the surface is off/unwired keeps the
|
||||
// reconnect flow simple: clients just fall back to opening fresh sessions.
|
||||
const sessions =
|
||||
context.terminalSessions && terminalEnabled(context)
|
||||
? context.terminalSessions.list().map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
agentId: session.agentId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
// Mirrors terminal.open: only unconfined host shells exist today.
|
||||
confined: false,
|
||||
attached: session.attached,
|
||||
createdAtMs: session.createdAtMs,
|
||||
}))
|
||||
: [];
|
||||
respond(true, { sessions });
|
||||
},
|
||||
|
||||
"terminal.text": async (opts) => {
|
||||
const { params, respond, context } = opts;
|
||||
if (!validateTerminalTextParams(params)) {
|
||||
invalid(
|
||||
respond,
|
||||
`invalid terminal.text params: ${formatValidationErrors(validateTerminalTextParams.errors)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const connId = requireConnId(opts);
|
||||
if (!connId) {
|
||||
return;
|
||||
}
|
||||
const p = params as { sessionId: string };
|
||||
if (!context.terminalSessions || !terminalEnabled(context)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available"));
|
||||
return;
|
||||
}
|
||||
const raw = context.terminalSessions.snapshot(p.sessionId);
|
||||
if (raw === undefined) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unknown terminal session "${p.sessionId}"`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { text: renderTerminalBufferText(raw) });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -943,11 +943,17 @@ export async function startGatewayServer(
|
|||
broadcastVoiceWakeChanged,
|
||||
hasTalkNodeConnected,
|
||||
} = createGatewayNodeSessionRuntime({ broadcast });
|
||||
const { TerminalSessionManager } = await import("./terminal/session-manager.js");
|
||||
const { TerminalSessionManager, DEFAULT_TERMINAL_DETACH_SECONDS } =
|
||||
await import("./terminal/session-manager.js");
|
||||
// One PTY store per gateway. Emits each session's bytes only to the owning
|
||||
// connection so terminals stay private to the operator that opened them.
|
||||
// Startup config is enough here: gateway.terminal.* changes restart the
|
||||
// gateway (config-reload-plan), so the grace period never drifts at runtime.
|
||||
const terminalSessions = new TerminalSessionManager({
|
||||
emit: (connId, event, payload) => broadcastToConnIds(event, payload, new Set([connId])),
|
||||
detachGraceMs:
|
||||
(cfgAtStart.gateway?.terminal?.detachedSessionTimeoutSeconds ??
|
||||
DEFAULT_TERMINAL_DETACH_SECONDS) * 1000,
|
||||
});
|
||||
applyGatewayLaneConcurrency(cfgAtStart);
|
||||
|
||||
|
|
|
|||
|
|
@ -479,9 +479,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
|||
}
|
||||
const context = buildRequestContext();
|
||||
context.unsubscribeAllSessionEvents(connId);
|
||||
// Kill any PTY shells this connection owned; an operator's terminals must
|
||||
// not outlive the socket that opened them.
|
||||
context.terminalSessions?.closeForConn(connId);
|
||||
// Detach (or, with a zero grace period, kill) any PTY shells this
|
||||
// connection owned; detached sessions stay reattachable via
|
||||
// terminal.attach until their reaper fires.
|
||||
context.terminalSessions?.handleDisconnect(connId);
|
||||
let currentDisconnectedNodeId: string | null = null;
|
||||
if (client?.connect?.role === "node") {
|
||||
currentDisconnectedNodeId = context.nodeRegistry.unregister(connId);
|
||||
|
|
|
|||
24
src/gateway/terminal/buffer-text.test.ts
Normal file
24
src/gateway/terminal/buffer-text.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { renderTerminalBufferText } from "./buffer-text.js";
|
||||
|
||||
describe("renderTerminalBufferText", () => {
|
||||
it("strips ANSI color and erase sequences", () => {
|
||||
expect(renderTerminalBufferText("\u001b[32mok\u001b[0m done\u001b[2K")).toBe("ok done");
|
||||
});
|
||||
|
||||
it("collapses carriage-return overwrites to the last write per line", () => {
|
||||
expect(renderTerminalBufferText("10%\r20%\r100%\ndone")).toBe("100%\ndone");
|
||||
});
|
||||
|
||||
it("keeps text before a line-terminating CRLF", () => {
|
||||
expect(renderTerminalBufferText("hello\r\nworld\r\n")).toBe("hello\nworld\n");
|
||||
});
|
||||
|
||||
it("drops residual control bytes but keeps tabs", () => {
|
||||
expect(renderTerminalBufferText("a\u0007b\tc")).toBe("ab\tc");
|
||||
});
|
||||
|
||||
it("strips OSC title sequences", () => {
|
||||
expect(renderTerminalBufferText("\u001b]0;title\u0007prompt$ ")).toBe("prompt$ ");
|
||||
});
|
||||
});
|
||||
31
src/gateway/terminal/buffer-text.ts
Normal file
31
src/gateway/terminal/buffer-text.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Renders a session's raw output ring as plain text for terminal.text — an
|
||||
// agent/LLM affordance that wants readable output, not escape sequences.
|
||||
import { stripAnsiSequences } from "../../../packages/terminal-core/src/ansi.js";
|
||||
|
||||
// Built at runtime so the source stays free of literal control characters and
|
||||
// the no-control-regex lint rule cannot statically detect them (same approach
|
||||
// as terminal-core's sanitizeForLog). Tab survives; \r/\n are handled above.
|
||||
const C0_EXCEPT_TAB_CR_LF = `${String.fromCharCode(0x00)}-${String.fromCharCode(0x08)}${String.fromCharCode(0x0b)}${String.fromCharCode(0x0c)}${String.fromCharCode(0x0e)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}`;
|
||||
const CONTROL_BYTES_REGEX = new RegExp(`[${C0_EXCEPT_TAB_CR_LF}]`, "g");
|
||||
|
||||
/**
|
||||
* Approximates what a terminal would show without running a VT emulator:
|
||||
* strips ANSI sequences, collapses carriage-return overwrites (progress bars
|
||||
* emit "10%\r20%\r30%" — keep the last write per line), and drops remaining
|
||||
* C0 control bytes. Cursor-movement layouts (vim, htop) will not reconstruct
|
||||
* faithfully; a true screen snapshot is a tracked follow-up.
|
||||
*/
|
||||
export function renderTerminalBufferText(raw: string): string {
|
||||
const stripped = stripAnsiSequences(raw);
|
||||
return stripped
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const segments = line.split("\r");
|
||||
const last = segments[segments.length - 1];
|
||||
// A trailing \r ("text\r\n" split) leaves an empty last segment; the
|
||||
// carriage return did not overwrite anything yet, so keep the text.
|
||||
const kept = last === "" && segments.length > 1 ? segments[segments.length - 2] : last;
|
||||
return (kept ?? "").replace(CONTROL_BYTES_REGEX, "");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ describe("TerminalSessionManager", () => {
|
|||
expect(manager.size).toBe(2);
|
||||
emit.mockClear();
|
||||
|
||||
manager.closeForConn("conn-1");
|
||||
manager.handleDisconnect("conn-1");
|
||||
expect(manager.size).toBe(0);
|
||||
expect(ptys[0].killed).toBe(true);
|
||||
expect(ptys[1].killed).toBe(true);
|
||||
|
|
@ -232,7 +232,7 @@ describe("TerminalSessionManager", () => {
|
|||
});
|
||||
const openPromise = manager.open(baseRequest({ connId: "conn-x" }));
|
||||
// Connection drops while the shell is still spawning.
|
||||
manager.closeForConn("conn-x");
|
||||
manager.handleDisconnect("conn-x");
|
||||
release?.();
|
||||
const outcome = await openPromise;
|
||||
expect(outcome.ok).toBe(false);
|
||||
|
|
@ -281,3 +281,234 @@ describe("TerminalSessionManager", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalSessionManager output ring", () => {
|
||||
it("bounds buffered output by evicting whole head chunks", async () => {
|
||||
const fake = makeFakePty();
|
||||
const manager = new TerminalSessionManager({
|
||||
emit: vi.fn(),
|
||||
spawn: async () => fake,
|
||||
scrollbackChars: 8,
|
||||
});
|
||||
const outcome = await manager.open(baseRequest());
|
||||
if (!outcome.ok) {
|
||||
throw new Error("expected open");
|
||||
}
|
||||
fake.emitData("abcd");
|
||||
fake.emitData("efgh");
|
||||
expect(manager.snapshot(outcome.sessionId)).toBe("abcdefgh");
|
||||
fake.emitData("ijkl");
|
||||
// Cap exceeded: the oldest whole chunk goes; boundaries stay intact.
|
||||
expect(manager.snapshot(outcome.sessionId)).toBe("efghijkl");
|
||||
});
|
||||
|
||||
it("keeps only the tail of a single oversized chunk", async () => {
|
||||
const fake = makeFakePty();
|
||||
const manager = new TerminalSessionManager({
|
||||
emit: vi.fn(),
|
||||
spawn: async () => fake,
|
||||
scrollbackChars: 8,
|
||||
});
|
||||
const outcome = await manager.open(baseRequest());
|
||||
if (!outcome.ok) {
|
||||
throw new Error("expected open");
|
||||
}
|
||||
fake.emitData("0123456789AB");
|
||||
expect(manager.snapshot(outcome.sessionId)).toBe("456789AB");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown sessions", () => {
|
||||
const manager = new TerminalSessionManager({ emit: vi.fn() });
|
||||
expect(manager.snapshot("nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalSessionManager detach/reattach", () => {
|
||||
async function openDetachable(options?: {
|
||||
detachGraceMs?: number;
|
||||
maxDetachedSessions?: number;
|
||||
}) {
|
||||
const emit = vi.fn();
|
||||
const fake = makeFakePty();
|
||||
const manager = new TerminalSessionManager({
|
||||
emit,
|
||||
spawn: async () => fake,
|
||||
detachGraceMs: options?.detachGraceMs ?? 60_000,
|
||||
maxDetachedSessions: options?.maxDetachedSessions,
|
||||
});
|
||||
const outcome = await manager.open(baseRequest());
|
||||
if (!outcome.ok) {
|
||||
throw new Error("expected open");
|
||||
}
|
||||
return { manager, fake, emit, sessionId: outcome.sessionId };
|
||||
}
|
||||
|
||||
it("detaches sessions on disconnect and reaps them after the grace period", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, fake, emit } = await openDetachable();
|
||||
manager.handleDisconnect("conn-1");
|
||||
expect(manager.size).toBe(1);
|
||||
expect(fake.killed).toBe(false);
|
||||
// Output while detached is buffered, never emitted to a dead conn.
|
||||
emit.mockClear();
|
||||
fake.emitData("while away");
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(59_999);
|
||||
expect(fake.killed).toBe(false);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(fake.killed).toBe(true);
|
||||
expect(manager.size).toBe(0);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("attach rebinds a detached session, replays the buffer, and resumes streaming", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, fake, emit, sessionId } = await openDetachable();
|
||||
fake.emitData("before ");
|
||||
manager.handleDisconnect("conn-1");
|
||||
fake.emitData("away ");
|
||||
emit.mockClear();
|
||||
|
||||
const attached = manager.attach("conn-2", sessionId);
|
||||
expect(attached?.buffer).toBe("before away ");
|
||||
expect(attached?.agentId).toBe("main");
|
||||
// The reaper is cancelled: the session survives past the grace deadline.
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(fake.killed).toBe(false);
|
||||
|
||||
fake.emitData("live");
|
||||
expect(emit).toHaveBeenCalledWith("conn-2", TERMINAL_EVENT_DATA, {
|
||||
sessionId,
|
||||
seq: 1,
|
||||
data: "live",
|
||||
});
|
||||
expect(manager.write("conn-2", sessionId, "ls\n")).toBe(true);
|
||||
expect(manager.write("conn-1", sessionId, "ls\n")).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("attach takes over a live session and notifies the previous owner", async () => {
|
||||
const { manager, fake, emit, sessionId } = await openDetachable();
|
||||
emit.mockClear();
|
||||
const attached = manager.attach("conn-2", sessionId);
|
||||
expect(attached?.sessionId).toBe(sessionId);
|
||||
expect(emit).toHaveBeenCalledWith("conn-1", TERMINAL_EVENT_EXIT, {
|
||||
sessionId,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
reason: "detached",
|
||||
});
|
||||
emit.mockClear();
|
||||
fake.emitData("output");
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
expect(emit.mock.calls[0][0]).toBe("conn-2");
|
||||
// The old owner's disconnect later must not tear down the stolen session.
|
||||
manager.handleDisconnect("conn-1");
|
||||
expect(manager.size).toBe(1);
|
||||
expect(manager.write("conn-2", sessionId, "x")).toBe(true);
|
||||
});
|
||||
|
||||
it("attach returns undefined for unknown or reaped sessions", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, sessionId } = await openDetachable();
|
||||
expect(manager.attach("conn-2", "nope")).toBeUndefined();
|
||||
manager.handleDisconnect("conn-1");
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(manager.attach("conn-2", sessionId)).toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-detaches with a fresh grace period when the adopting connection drops", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, fake, sessionId } = await openDetachable();
|
||||
manager.handleDisconnect("conn-1");
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(manager.attach("conn-2", sessionId)).toBeDefined();
|
||||
manager.handleDisconnect("conn-2");
|
||||
// The second detach restarts the clock; the original deadline is void.
|
||||
vi.advanceTimersByTime(59_999);
|
||||
expect(fake.killed).toBe(false);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(fake.killed).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("caps detached sessions by killing the oldest", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const ptys = [makeFakePty(), makeFakePty()];
|
||||
let idx = 0;
|
||||
const manager = new TerminalSessionManager({
|
||||
emit: vi.fn(),
|
||||
spawn: async () => ptys[idx++],
|
||||
detachGraceMs: 60_000,
|
||||
maxDetachedSessions: 1,
|
||||
});
|
||||
await manager.open(baseRequest({ connId: "conn-1" }));
|
||||
await manager.open(baseRequest({ connId: "conn-2" }));
|
||||
manager.handleDisconnect("conn-1");
|
||||
vi.advanceTimersByTime(1);
|
||||
manager.handleDisconnect("conn-2");
|
||||
expect(ptys[0].killed).toBe(true);
|
||||
expect(ptys[1].killed).toBe(false);
|
||||
expect(manager.size).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("lists sessions with attachment state, oldest first", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const ptys = [makeFakePty(), makeFakePty()];
|
||||
let idx = 0;
|
||||
const manager = new TerminalSessionManager({
|
||||
emit: vi.fn(),
|
||||
spawn: async () => ptys[idx++],
|
||||
detachGraceMs: 60_000,
|
||||
});
|
||||
const first = await manager.open(baseRequest({ connId: "conn-1" }));
|
||||
vi.advanceTimersByTime(5);
|
||||
const second = await manager.open(baseRequest({ connId: "conn-2" }));
|
||||
if (!first.ok || !second.ok) {
|
||||
throw new Error("expected opens");
|
||||
}
|
||||
manager.handleDisconnect("conn-2");
|
||||
const listed = manager.list();
|
||||
expect(listed.map((s) => s.sessionId)).toEqual([first.sessionId, second.sessionId]);
|
||||
expect(listed[0]).toMatchObject({ attached: true, agentId: "main", shell: "/bin/zsh" });
|
||||
expect(listed[1]).toMatchObject({ attached: false });
|
||||
expect(listed[1].createdAtMs).toBeGreaterThan(listed[0].createdAtMs);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("shutdown hard-kills detached sessions and clears their reapers", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, fake } = await openDetachable();
|
||||
manager.handleDisconnect("conn-1");
|
||||
manager.disposeAll();
|
||||
expect(fake.killed).toBe(true);
|
||||
expect(manager.size).toBe(0);
|
||||
// No reaper left behind to fire against the disposed session.
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,27 +12,103 @@ export type TerminalSpawner = typeof spawnTerminalPty;
|
|||
export const TERMINAL_EVENT_DATA = "terminal.data" as const;
|
||||
export const TERMINAL_EVENT_EXIT = "terminal.exit" as const;
|
||||
|
||||
type TerminalExitReason = "process_exit" | "closed" | "disconnected" | "error";
|
||||
type TerminalExitReason = "process_exit" | "closed" | "disconnected" | "detached" | "error";
|
||||
|
||||
type TerminalSession = {
|
||||
id: string;
|
||||
connId: string;
|
||||
/** Owning connection; null while the session is detached. */
|
||||
connId: string | null;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
shell: string;
|
||||
pty: TerminalPtyHandle;
|
||||
seq: number;
|
||||
closed: boolean;
|
||||
createdAtMs: number;
|
||||
buffer: TerminalOutputRing;
|
||||
/** Kills the session when a detach outlives the grace period. */
|
||||
reaper: ReturnType<typeof setTimeout> | null;
|
||||
detachedAtMs: number | null;
|
||||
};
|
||||
|
||||
/** One session's facts as reported by terminal.list. */
|
||||
export type TerminalSessionSummary = {
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
shell: string;
|
||||
cwd: string;
|
||||
attached: boolean;
|
||||
createdAtMs: number;
|
||||
};
|
||||
|
||||
/** Bounds concurrent shells so a client cannot exhaust host processes. */
|
||||
const DEFAULT_MAX_SESSIONS = 24;
|
||||
/**
|
||||
* Rolling output kept per session for reattach replay and terminal.text,
|
||||
* in UTF-16 code units (≈ bytes for typical terminal output). Constant, not
|
||||
* config: ~256 KiB × session cap bounds worst-case memory at a few MiB.
|
||||
*/
|
||||
const DEFAULT_SCROLLBACK_CHARS = 256 * 1024;
|
||||
/**
|
||||
* Cap on simultaneously detached sessions; the oldest detached session is
|
||||
* killed to make room. Keeps repeated disconnects from parking a full
|
||||
* session-cap worth of headless shells.
|
||||
*/
|
||||
const DEFAULT_MAX_DETACHED_SESSIONS = 8;
|
||||
/** Default grace period before a detached session is killed (seconds). */
|
||||
export const DEFAULT_TERMINAL_DETACH_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* Bounded ring of recent PTY output. Raw bytes, not a screen snapshot: after
|
||||
* head truncation a replay can start mid-escape-sequence; emulators recover on
|
||||
* the next full repaint (prompt, clear, resize-triggered redraw). A true
|
||||
* server-side VT snapshot would need a terminal emulator per session and is a
|
||||
* tracked follow-up.
|
||||
*/
|
||||
class TerminalOutputRing {
|
||||
private chunks: string[] = [];
|
||||
private total = 0;
|
||||
|
||||
constructor(private readonly cap: number) {}
|
||||
|
||||
push(chunk: string): void {
|
||||
if (chunk.length >= this.cap) {
|
||||
this.chunks = [chunk.slice(chunk.length - this.cap)];
|
||||
this.total = this.cap;
|
||||
return;
|
||||
}
|
||||
this.chunks.push(chunk);
|
||||
this.total += chunk.length;
|
||||
// Evict whole chunks (PTY write granularity) so surviving data keeps its
|
||||
// original boundaries; the ring may briefly dip below cap, never above.
|
||||
while (this.total > this.cap && this.chunks.length > 1) {
|
||||
const head = this.chunks.shift();
|
||||
if (!head) {
|
||||
break;
|
||||
}
|
||||
this.total -= head.length;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(): string {
|
||||
return this.chunks.join("");
|
||||
}
|
||||
}
|
||||
|
||||
export type TerminalSessionManagerOptions = {
|
||||
emit: TerminalEventSink;
|
||||
spawn?: TerminalSpawner;
|
||||
maxSessions?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/**
|
||||
* How long a session may stay detached after its connection drops before it
|
||||
* is killed. 0 (default) preserves kill-on-disconnect; the config-facing
|
||||
* default lives in DEFAULT_TERMINAL_DETACH_SECONDS and is applied by the
|
||||
* gateway wiring.
|
||||
*/
|
||||
detachGraceMs?: number;
|
||||
maxDetachedSessions?: number;
|
||||
scrollbackChars?: number;
|
||||
};
|
||||
|
||||
/** Parameters for a resolved host terminal launch (isolation already checked). */
|
||||
|
|
@ -68,6 +144,9 @@ export class TerminalSessionManager {
|
|||
private readonly emit: TerminalEventSink;
|
||||
private readonly spawn: TerminalSpawner;
|
||||
private readonly maxSessions: number;
|
||||
private readonly detachGraceMs: number;
|
||||
private readonly maxDetachedSessions: number;
|
||||
private readonly scrollbackChars: number;
|
||||
// Slots reserved by opens that are still awaiting spawn. Counted against the
|
||||
// cap so concurrent opens cannot all pass the check and exceed maxSessions.
|
||||
private opening = 0;
|
||||
|
|
@ -76,6 +155,9 @@ export class TerminalSessionManager {
|
|||
this.emit = options.emit;
|
||||
this.spawn = options.spawn ?? spawnTerminalPty;
|
||||
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
||||
this.detachGraceMs = options.detachGraceMs ?? 0;
|
||||
this.maxDetachedSessions = options.maxDetachedSessions ?? DEFAULT_MAX_DETACHED_SESSIONS;
|
||||
this.scrollbackChars = options.scrollbackChars ?? DEFAULT_SCROLLBACK_CHARS;
|
||||
}
|
||||
|
||||
/** Number of live sessions; used by tests and health surfaces. */
|
||||
|
|
@ -136,19 +218,23 @@ export class TerminalSessionManager {
|
|||
pty,
|
||||
seq: 0,
|
||||
closed: false,
|
||||
createdAtMs: Date.now(),
|
||||
buffer: new TerminalOutputRing(this.scrollbackChars),
|
||||
reaper: null,
|
||||
detachedAtMs: null,
|
||||
};
|
||||
this.sessions.set(session.id, session);
|
||||
let connSessions = this.byConn.get(request.connId);
|
||||
if (!connSessions) {
|
||||
connSessions = new Set();
|
||||
this.byConn.set(request.connId, connSessions);
|
||||
}
|
||||
connSessions.add(session.id);
|
||||
this.indexByConn(request.connId, session.id);
|
||||
|
||||
pty.onData((chunk) => {
|
||||
if (session.closed) {
|
||||
return;
|
||||
}
|
||||
// Always buffer so attach can replay; stream only while a conn owns it.
|
||||
session.buffer.push(chunk);
|
||||
if (session.connId === null) {
|
||||
return;
|
||||
}
|
||||
this.emit(session.connId, TERMINAL_EVENT_DATA, {
|
||||
sessionId: session.id,
|
||||
seq: session.seq++,
|
||||
|
|
@ -208,6 +294,73 @@ export class TerminalSessionManager {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebinds a live-or-detached session to `connId` and returns the replay
|
||||
* buffer. Take-over is deliberate: the surface is operator.admin (full host
|
||||
* access already), so any admin connection may adopt any session; a previous
|
||||
* live owner is notified with reason "detached". Snapshot and rebind happen
|
||||
* in one synchronous step, so no PTY chunk can land in both the returned
|
||||
* buffer and the new owner's event stream.
|
||||
*/
|
||||
attach(
|
||||
connId: string,
|
||||
sessionId: string,
|
||||
):
|
||||
| { sessionId: string; agentId: string; cwd: string; shell: string; buffer: string }
|
||||
| undefined {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
return undefined;
|
||||
}
|
||||
if (session.reaper) {
|
||||
clearTimeout(session.reaper);
|
||||
session.reaper = null;
|
||||
}
|
||||
session.detachedAtMs = null;
|
||||
if (session.connId !== null && session.connId !== connId) {
|
||||
this.byConn.get(session.connId)?.delete(session.id);
|
||||
this.emit(session.connId, TERMINAL_EVENT_EXIT, {
|
||||
sessionId: session.id,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
reason: "detached",
|
||||
});
|
||||
}
|
||||
session.connId = connId;
|
||||
this.indexByConn(connId, session.id);
|
||||
return {
|
||||
sessionId: session.id,
|
||||
agentId: session.agentId,
|
||||
cwd: session.cwd,
|
||||
shell: session.shell,
|
||||
buffer: session.buffer.snapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every live session, oldest first; all admin connections see the same list. */
|
||||
list(): TerminalSessionSummary[] {
|
||||
return [...this.sessions.values()]
|
||||
.filter((session) => !session.closed)
|
||||
.map((session) => ({
|
||||
sessionId: session.id,
|
||||
agentId: session.agentId,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
attached: session.connId !== null,
|
||||
createdAtMs: session.createdAtMs,
|
||||
}))
|
||||
.toSorted((a, b) => a.createdAtMs - b.createdAtMs);
|
||||
}
|
||||
|
||||
/** Raw buffered output for one session, or undefined when it is gone. */
|
||||
snapshot(sessionId: string): string | undefined {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
return undefined;
|
||||
}
|
||||
return session.buffer.snapshot();
|
||||
}
|
||||
|
||||
private trackPendingOpen(connId: string, token: OpenToken): void {
|
||||
let set = this.pendingOpens.get(connId);
|
||||
if (!set) {
|
||||
|
|
@ -227,9 +380,15 @@ export class TerminalSessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
/** Tears down every session a disconnected connection owned. */
|
||||
closeForConn(connId: string): void {
|
||||
/**
|
||||
* Handles a dropped connection: detaches its sessions for later reattach
|
||||
* when a grace period is configured, otherwise kills them (legacy behavior,
|
||||
* still selected by detachedSessionTimeoutSeconds: 0).
|
||||
*/
|
||||
handleDisconnect(connId: string): void {
|
||||
// Abort opens still awaiting spawn so they don't register orphaned PTYs.
|
||||
// These stay kill-on-disconnect even with detach enabled: the open RPC
|
||||
// never answered, so the client has no session id to reattach.
|
||||
const opens = this.pendingOpens.get(connId);
|
||||
if (opens) {
|
||||
for (const token of opens) {
|
||||
|
|
@ -240,10 +399,15 @@ export class TerminalSessionManager {
|
|||
if (!ids) {
|
||||
return;
|
||||
}
|
||||
// Snapshot first: finalize() mutates the same set during iteration.
|
||||
// Snapshot first: finalize()/detach() mutate the same set during iteration.
|
||||
for (const id of Array.from(ids)) {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) {
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
if (this.detachGraceMs > 0) {
|
||||
this.detach(session);
|
||||
} else {
|
||||
this.finalize(session, "disconnected", {}, { silent: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -261,7 +425,9 @@ export class TerminalSessionManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Snapshot first: finalize() mutates the session map.
|
||||
// Snapshot first: finalize() mutates the session map. Detached sessions of
|
||||
// disallowed agents are killed too; finalize clears their reaper and skips
|
||||
// the exit event when no connection owns the stream.
|
||||
for (const session of Array.from(this.sessions.values())) {
|
||||
if (!isAllowed(session.agentId)) {
|
||||
this.finalize(session, "closed", {
|
||||
|
|
@ -271,11 +437,36 @@ export class TerminalSessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
/** Kills every session; used on gateway shutdown. */
|
||||
/** Parks a session ownerless with a reaper; PTY output keeps buffering. */
|
||||
private detach(session: TerminalSession): void {
|
||||
session.connId = null;
|
||||
session.detachedAtMs = Date.now();
|
||||
session.reaper = setTimeout(() => {
|
||||
// Silent: nobody owns the stream, so there is no socket to notify.
|
||||
this.finalize(session, "disconnected", {}, { silent: true });
|
||||
}, this.detachGraceMs);
|
||||
// Never keep the process alive just to reap an abandoned shell.
|
||||
session.reaper.unref?.();
|
||||
this.enforceDetachedCap();
|
||||
}
|
||||
|
||||
private enforceDetachedCap(): void {
|
||||
const detached = [...this.sessions.values()]
|
||||
.filter((session) => !session.closed && session.connId === null)
|
||||
.toSorted((a, b) => (a.detachedAtMs ?? 0) - (b.detachedAtMs ?? 0));
|
||||
for (const session of detached.slice(
|
||||
0,
|
||||
Math.max(0, detached.length - this.maxDetachedSessions),
|
||||
)) {
|
||||
this.finalize(session, "disconnected", {}, { silent: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down every session on gateway shutdown/stop. Silent because the
|
||||
* sockets are going away anyway (disabling the terminal is a `gateway`
|
||||
* restart, so that path also runs through here, not a live notification).
|
||||
* Tears down every session — detached ones included — on gateway
|
||||
* shutdown/stop. Silent because the sockets are going away anyway (disabling
|
||||
* the terminal is a `gateway` restart, so that path also runs through here,
|
||||
* not a live notification).
|
||||
*/
|
||||
disposeAll(): void {
|
||||
// Abort any opens still spawning so they don't register after shutdown.
|
||||
|
|
@ -290,6 +481,15 @@ export class TerminalSessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
private indexByConn(connId: string, sessionId: string): void {
|
||||
let connSessions = this.byConn.get(connId);
|
||||
if (!connSessions) {
|
||||
connSessions = new Set();
|
||||
this.byConn.set(connId, connSessions);
|
||||
}
|
||||
connSessions.add(sessionId);
|
||||
}
|
||||
|
||||
private ownedSession(connId: string, sessionId: string): TerminalSession | undefined {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.connId !== connId || session.closed) {
|
||||
|
|
@ -308,16 +508,23 @@ export class TerminalSessionManager {
|
|||
return;
|
||||
}
|
||||
session.closed = true;
|
||||
if (session.reaper) {
|
||||
clearTimeout(session.reaper);
|
||||
session.reaper = null;
|
||||
}
|
||||
this.sessions.delete(session.id);
|
||||
this.byConn.get(session.connId)?.delete(session.id);
|
||||
if (session.connId !== null) {
|
||||
this.byConn.get(session.connId)?.delete(session.id);
|
||||
}
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch {
|
||||
// Process may already be gone; the kill is best-effort teardown.
|
||||
}
|
||||
// A disconnect already dropped the socket, so emitting there is pointless;
|
||||
// process/close/error exits still notify the live client.
|
||||
if (!opts?.silent) {
|
||||
// process/close/error exits still notify the live client. Detached
|
||||
// sessions have no owner to notify at all.
|
||||
if (!opts?.silent && session.connId !== null) {
|
||||
this.emit(session.connId, TERMINAL_EVENT_EXIT, {
|
||||
sessionId: session.id,
|
||||
exitCode: detail.exitCode ?? null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue