From 20422d519a3ad1ead04a4bdeccdee469b53891c1 Mon Sep 17 00:00:00 2001 From: Teo | Nexcore Date: Sat, 26 Sep 2026 19:46:03 +0300 Subject: [PATCH 1/6] fix(terminal): restore each Codex card to its own conversation --- src/agent-runtime/hook-helper.mjs | 9 + src/agent-runtime/runtime-client.mjs | 13 +- src/main/index.ts | 3 +- src/main/services/TerminalManager.ts | 33 +++- src/main/services/TerminalSessionStore.ts | 27 ++- .../services/agent-runtime/RuntimeGateway.ts | 18 +- src/main/services/terminalLaunch.ts | 24 ++- tests/agent-runtime-gateway.test.mjs | 123 +++++++++++++ tests/terminal-launch.test.mjs | 84 ++++++++- tests/terminal-session-exact-resume.test.mjs | 171 ++++++++++++++++++ tests/terminal-session-restore.test.mjs | 3 +- tests/terminal-session-store.test.mjs | 69 ++++++- 12 files changed, 559 insertions(+), 18 deletions(-) create mode 100644 tests/terminal-session-exact-resume.test.mjs diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index a60a655e..7cbe023d 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -40,6 +40,14 @@ const turnId = firstString( input?.prompt_id, input?.promptId ); +const codexThreadId = firstString( + input?.session_id, + input?.sessionId, + input?.thread_id, + input?.threadId, + input?.conversation_id, + input?.conversationId +); const finalAnswer = state === "idle" && event === "Stop" && typeof input?.last_assistant_message === "string" ? input.last_assistant_message : null; @@ -55,6 +63,7 @@ await reportLifecycle({ state, event, turnId, + ...(codexThreadId ? { codexThreadId } : {}), ...(result === undefined ? {} : { result }), ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }); diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index aae729c9..88d1d8b5 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -11,7 +11,7 @@ import { const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, codexThreadId, result, lastAssistantMessage }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -20,6 +20,10 @@ export async function reportLifecycle({ state, event, turnId = null, result, las const capabilityToken = process.env[AGENT_RUNTIME_ENV.capabilityToken]; if (!address || !terminalSessionId || !provider || !capabilityToken) return false; + const validCodexThreadId = provider === "codex" && typeof codexThreadId === "string" && isCanonicalUuid(codexThreadId) + ? codexThreadId.toLowerCase() + : undefined; + const message = { v: RUNTIME_PROTOCOL_VERSION, type: "lifecycle", @@ -29,6 +33,7 @@ export async function reportLifecycle({ state, event, turnId = null, result, las state, event, turnId: normalizedId(turnId), + ...(validCodexThreadId !== undefined ? { codexThreadId: validCodexThreadId } : {}), ...(result === undefined ? {} : { result }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); @@ -98,3 +103,9 @@ function sendMessage(address, payload, accepted) { function normalizedId(value) { return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null; } + +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isCanonicalUuid(value) { + return typeof value === "string" && CANONICAL_UUID_RE.test(value); +} diff --git a/src/main/index.ts b/src/main/index.ts index ee3eb3a3..b8d11de7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -341,7 +341,8 @@ async function initializeServices(): Promise { terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, - ...(signal.turnId ? { requestId: signal.turnId } : {}) + ...(signal.turnId ? { requestId: signal.turnId } : {}), + ...(signal.codexThreadId ? { codexThreadId: signal.codexThreadId } : {}) }); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 66e43358..148fdd94 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -83,6 +83,8 @@ interface ManagedSession { lifecycle: ProviderLifecycleParser | null; awaitingInitialResize: boolean; resumeOnLaunch: boolean; + resumeThreadId?: string; + codexThreadId?: string; captureResult: boolean; } @@ -90,6 +92,7 @@ export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; requestId?: string; + codexThreadId?: string; } /** @@ -324,14 +327,18 @@ export class TerminalManager { : null; session.awaitingInitialResize = true; session.resumeOnLaunch = false; + delete session.resumeThreadId; + delete session.codexThreadId; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; session.metadata.failureDetails = null; this.emitSession(session.metadata); + this.schedulePersistence(); return snapshot(session); } + delete session.codexThreadId; session.agentOrchestration?.cleanup(); const launched = this.spawnProcess( id, @@ -368,6 +375,7 @@ export class TerminalManager { if (runtimeStatus) session.metadata.status = runtimeStatus; } this.emitSession(session.metadata, failureOrigin); + this.schedulePersistence(); return snapshot(session); } @@ -432,6 +440,12 @@ export class TerminalManager { const session = this.sessions.get(id); if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; + if (signal.codexThreadId && session.metadata.provider === "codex" + && signal.codexThreadId !== session.codexThreadId) { + session.codexThreadId = signal.codexThreadId; + this.schedulePersistence(); + } + const nextStatus = signal.state; if (session.metadata.status === nextStatus) return; session.metadata.status = nextStatus; @@ -580,7 +594,9 @@ export class TerminalManager { INITIAL_TERMINAL_ROWS, descriptor.provider !== "terminal", false, - descriptor.role + descriptor.role, + undefined, + descriptor.codexThreadId ); process = launched.process; agentBrowser = launched.agentBrowser; @@ -613,6 +629,7 @@ export class TerminalManager { : null, awaitingInitialResize: awaitMeasuredGrid, resumeOnLaunch: awaitMeasuredGrid && descriptor.provider !== "terminal", + ...(descriptor.codexThreadId ? { resumeThreadId: descriptor.codexThreadId, codexThreadId: descriptor.codexThreadId } : {}), captureResult: false }; this.sessions.set(descriptor.id, session); @@ -631,7 +648,7 @@ export class TerminalManager { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata)) + [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.codexThreadId)) ); } @@ -654,6 +671,8 @@ export class TerminalManager { session.awaitingInitialResize = false; const resumePrevious = session.resumeOnLaunch; session.resumeOnLaunch = false; + const resumeThreadId = session.resumeThreadId; + delete session.resumeThreadId; try { const launched = this.spawnProcess( id, @@ -664,7 +683,9 @@ export class TerminalManager { session.rows, resumePrevious, session.captureResult, - session.metadata.role + session.metadata.role, + undefined, + resumeThreadId ); session.process = launched.process; session.agentBrowser = launched.agentBrowser; @@ -701,7 +722,8 @@ export class TerminalManager { resumePrevious = false, captureResult = false, role: SessionRole = "agent", - answerCaptureGrantExpiresAt?: number + answerCaptureGrantExpiresAt?: number, + resumeThreadId?: string ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -755,7 +777,8 @@ export class TerminalManager { const launch = resolveTerminalLaunch(provider, profile, providerArgs, { environment: { ...baseEnvironment, ...providerEnvironment }, ...(providerCli ? { providerCli } : {}), - resumePrevious + resumePrevious, + ...(resumeThreadId ? { resumeThreadId } : {}) }); return { process: this.spawnPty(launch.command, launch.args, { diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index e8389abc..25f462b0 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -40,6 +40,7 @@ export interface PersistedTerminalSession { position: Point; size: Size; parentSessionId?: string; + codexThreadId?: string; } interface PersistedTerminalSessionState { @@ -106,7 +107,20 @@ export class TerminalSessionStore { } } -export function persistedTerminalSession(metadata: SessionMetadata): PersistedTerminalSession { +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function normalizeCodexThreadId(provider: ProviderId, candidate: unknown): string | undefined { + if (provider !== "codex") return undefined; + if (typeof candidate !== "string") return undefined; + const trimmed = candidate.trim().toLowerCase(); + return UUID_REGEX.test(trimmed) ? trimmed : undefined; +} + +export function persistedTerminalSession( + metadata: SessionMetadata, + codexThreadId?: unknown +): PersistedTerminalSession { + const normalizedCodexThreadId = normalizeCodexThreadId(metadata.provider, codexThreadId); return { id: metadata.id, provider: metadata.provider, @@ -117,7 +131,8 @@ export function persistedTerminalSession(metadata: SessionMetadata): PersistedTe cwd: metadata.cwd, position: { ...metadata.position }, size: { ...metadata.size }, - ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}) + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + ...(normalizedCodexThreadId !== undefined ? { codexThreadId: normalizedCodexThreadId } : {}) }; } @@ -150,6 +165,11 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste : undefined; if (session.parentSessionId !== undefined && parentSessionId === undefined) continue; if (role === "subagent" && parentSessionId === undefined) continue; + // A damaged or obsolete conversation ID must not make the whole card disappear. + // It can still restore with Codex's interactive resume picker. + const codexThreadId = session.provider === "codex" && typeof session.codexThreadId === "string" && UUID_REGEX.test(session.codexThreadId.trim()) + ? session.codexThreadId.trim().toLowerCase() + : undefined; sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -163,7 +183,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste width: clamp(session.size.width, 420, 1_600), height: clamp(session.size.height, 260, 1_100) }, - ...(parentSessionId !== undefined ? { parentSessionId } : {}) + ...(parentSessionId !== undefined ? { parentSessionId } : {}), + ...(codexThreadId !== undefined ? { codexThreadId } : {}) }); ids.add(session.id); } diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index a575cd75..7fd4f551 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -29,6 +29,7 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; + codexThreadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; answerCaptureGrantExpiresAt?: number; @@ -58,6 +59,7 @@ interface ParsedLifecycleMessage { state: RuntimeLifecycleState; event: string; turnId: string | null; + codexThreadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; } @@ -314,6 +316,7 @@ export class RuntimeGateway { state: message.state, event: message.event, turnId: message.turnId, + ...(message.codexThreadId === undefined ? {} : { codexThreadId: message.codexThreadId }), ...(message.result === undefined ? {} : { result: message.result }), ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) }; @@ -321,7 +324,12 @@ export class RuntimeGateway { signal.answerCaptureGrantExpiresAt = lease.answerCaptureGrantExpiresAt; } // Captured text is delivered once and never stored in the lifecycle lease. - lease.latest = { state: signal.state, event: signal.event, turnId: signal.turnId }; + lease.latest = { + state: signal.state, + event: signal.event, + turnId: signal.turnId, + ...(signal.codexThreadId === undefined ? {} : { codexThreadId: signal.codexThreadId }) + }; this.onSignal?.(message.terminalSessionId, signal); } } @@ -337,6 +345,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { "capabilityToken", "event", "provider", "state", "terminalSessionId", "turnId", "type", "v" ]; if (value.result !== undefined) expected.push("result"); + if (value.codexThreadId !== undefined) expected.push("codexThreadId"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -359,6 +368,11 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { || value.event.length > 80 || (value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) ) throw new Error("Runtime message fields are invalid."); + if (value.codexThreadId !== undefined && ( + value.provider !== "codex" + || typeof value.codexThreadId !== "string" + || !CANONICAL_UUID_RE.test(value.codexThreadId) + )) throw new Error("Runtime codexThreadId is invalid."); if (value.result !== undefined && ( value.state !== "idle" || value.event !== "Stop" || !isRecord(value.result) || Object.keys(value.result).sort().join(",") !== "text,truncated" @@ -372,6 +386,8 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { return value as unknown as ParsedLifecycleMessage; } +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 0f4e795c..53cbee8a 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -19,8 +19,11 @@ interface LaunchResolutionOptions { fileExists?: (path: string) => boolean; providerCli?: ProviderCliResolution; resumePrevious?: boolean; + resumeThreadId?: string; } +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const WINDOWS_NATIVE_EXTENSIONS = [".exe", ".com"]; export function resolveTerminalLaunch( @@ -51,7 +54,7 @@ export function resolveTerminalLaunch( const providerArgs = [ ...(profile === "yolo" && provider !== "opencode" ? DANGEROUS_ARGUMENTS[provider] : []), ...agentBrowserArgs, - ...(options.resumePrevious ? RESUME_ARGUMENTS[provider] : []) + ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []) ]; const combinedEnvironment = { ...providerCli.environment, @@ -72,11 +75,26 @@ export function resolveTerminalLaunch( }; } +function resolveResumeArguments( + provider: Exclude, + resumeThreadId?: string +): string[] { + if (provider === "codex") { + if (resumeThreadId) { + if (!UUID_REGEX.test(resumeThreadId)) { + throw new Error(`Invalid Codex thread ID format: "${resumeThreadId}". Expected a canonical UUID.`); + } + return ["resume", resumeThreadId.toLowerCase()]; + } + return ["resume"]; + } + return RESUME_ARGUMENTS[provider]; +} + // Per-provider instead of a fallthrough: the old `return ["--continue"]` default would // have handed an unverified flag to whatever provider was added next. A missing entry is // now a compile error. -const RESUME_ARGUMENTS: Record, string[]> = { - codex: ["resume", "--last"], +const RESUME_ARGUMENTS: Record, string[]> = { claude: ["--continue"], qwen: ["--continue"], kimi: ["--continue"], diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index f60e0861..89992067 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -107,6 +107,129 @@ test("RuntimeGateway rejects a wrong capability and ignores a stale turn complet assert.equal(gateway.currentStatus("terminal-two"), "working"); }); +test("RuntimeGateway propagates codexThreadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-codex-thread", "codex"); + + const validUuid = "12345678-1234-1234-1234-123456789abc"; + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + const child = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: capability.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: capability.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + child.stdin.end(JSON.stringify({ turn_id: "turn-codex-1", session_id: validUuid })); + const result = await childResult(child); + assert.equal(result.code, 0, result.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.turnId, "turn-codex-1"); + assert.equal(signals[0].signal.codexThreadId, validUuid); +}); + +test("RuntimeGateway normalizes uppercase UUID to lowercase canonical UUID for codex", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-codex-upper", "codex"); + + const upperUuid = "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D"; + const lowerUuid = upperUuid.toLowerCase(); + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + const child = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: capability.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: capability.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + child.stdin.end(JSON.stringify({ turn_id: "turn-codex-upper", session_id: upperUuid })); + const result = await childResult(child); + assert.equal(result.code, 0, result.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.codexThreadId, lowerUuid); +}); + +test("RuntimeGateway ignores codexThreadId when non-canonical or cross-provider", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + + // 1. Cross-provider: claude provider with session_id UUID in hook input + const claudeCap = gateway.registerSession("terminal-claude-test", "claude"); + const validUuid = "12345678-1234-1234-1234-123456789abc"; + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + + const claudeChild = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: claudeCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: claudeCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: claudeCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: claudeCap.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + claudeChild.stdin.end(JSON.stringify({ turn_id: "turn-claude-1", session_id: validUuid })); + const claudeResult = await childResult(claudeChild); + assert.equal(claudeResult.code, 0, claudeResult.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.codexThreadId, undefined); + + // 2. Malformed UUID: not canonical format (e.g. invalid chars, wrong length, path traversal) + const codexCap = gateway.registerSession("terminal-codex-malformed", "codex"); + const malformedInputs = [ + "not-a-uuid", + "12345678-1234-1234-1234-123456789abz", // 'z' is not hex + "12345678123412341234123456789abc", // no hyphens + "../../../etc/passwd", + "12345678-1234-1234-1234-123456789abc\n", + "Bearer token123456" + ]; + for (const badId of malformedInputs) { + const childBad = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: codexCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: codexCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: codexCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: codexCap.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + childBad.stdin.end(JSON.stringify({ turn_id: "turn-bad", session_id: badId })); + const badRes = await childResult(childBad); + assert.equal(badRes.code, 0, badRes.stderr); + } + // All malformed ones should either be omitted or ignored without codexThreadId + for (let i = 1; i < signals.length; i++) { + assert.equal(signals[i].signal.codexThreadId, undefined); + } + + // 3. Direct protocol injection with cross-provider codexThreadId is rejected + await send(claudeCap.address, { + ...message(claudeCap, "working", "UserPromptSubmit", "turn-claude-direct"), + codexThreadId: validUuid + }); + // Signal should not be delivered or accepted + assert.equal(signals.filter((s) => s.id === "terminal-claude-test").length, 1); +}); + test("ordinary Codex Stop reports omit answer text without an explicit capture grant", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; diff --git a/tests/terminal-launch.test.mjs b/tests/terminal-launch.test.mjs index f542a3da..0f621a01 100644 --- a/tests/terminal-launch.test.mjs +++ b/tests/terminal-launch.test.mjs @@ -68,8 +68,7 @@ test("restored agent windows use each provider's native continue mode", () => { assert.deepEqual(codex.args, [ "--dangerously-bypass-approvals-and-sandbox", "--bridge", - "resume", - "--last" + "resume" ]); for (const provider of ["claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin"]) { @@ -87,6 +86,87 @@ test("restored agent windows use each provider's native continue mode", () => { assert.deepEqual(restored.args, ["--bridge"]); }); +test("Codex restore with resumeThreadId launches codex resume ", () => { + const uuid1 = "12345678-1234-4234-8234-123456789abc"; + const uuid2 = "abcdef01-abcd-4def-9abc-def012345678"; + + const launch1 = resolveTerminalLaunch("codex", "normal", ["--bridge"], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: uuid1 + }); + assert.deepEqual(launch1.args, ["--bridge", "resume", uuid1]); + + const launch2 = resolveTerminalLaunch("codex", "yolo", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: uuid2.toUpperCase() + }); + assert.deepEqual(launch2.args, [ + "--dangerously-bypass-approvals-and-sandbox", + "resume", + uuid2.toLowerCase() + ]); +}); + +test("Codex restore without resumeThreadId launches interactive resume chooser", () => { + const launch = resolveTerminalLaunch("codex", "normal", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true + }); + assert.deepEqual(launch.args, ["resume"]); +}); + +test("Codex restore throws on malformed resumeThreadId", () => { + for (const malformed of ["not-a-uuid", "12345678-1234-1234-1234", "12345678-1234-1234-1234-123456789abc-extra", "../escape"]) { + assert.throws( + () => + resolveTerminalLaunch("codex", "normal", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: malformed + }), + /Invalid Codex thread ID format/u + ); + } +}); + +test("Other providers ignore resumeThreadId and retain standard continue flags", () => { + const uuid = "12345678-1234-4234-8234-123456789abc"; + const claude = resolveTerminalLaunch("claude", "normal", ["--bridge"], { + providerCli: available("claude", "/resolved/claude"), + resumePrevious: true, + resumeThreadId: uuid + }); + assert.deepEqual(claude.args, ["--bridge", "--continue"]); + + const qwen = resolveTerminalLaunch("qwen", "yolo", [], { + providerCli: available("qwen", "/resolved/qwen"), + resumePrevious: true, + resumeThreadId: uuid + }); + assert.deepEqual(qwen.args, ["--yolo", "--continue"]); +}); + +test("Windows batch quoting with Codex resume thread UUID", () => { + const commandPrompt = "C:\\Windows\\System32\\cmd.exe"; + const uuid = "12345678-1234-4234-8234-123456789abc"; + const providerCli = available( + "codex", + "C:\\Users\\Kisa\\AppData\\Roaming\\npm\\codex.cmd", + { launcher: "batch", commandPrompt, environment: { Path: "C:\\resolved" } } + ); + const launch = resolveTerminalLaunch("codex", "normal", ["--bridge"], { + platform: "win32", + providerCli, + resumePrevious: true, + resumeThreadId: uuid + }); + assert.equal(launch.command, commandPrompt); + assert.match(launch.args, /codex\.cmd/u); + assert.match(launch.args, new RegExp(uuid, "u")); +}); + test("OMP and Pi use their documented dangerous flags instead of the legacy default", () => { const omp = resolveTerminalLaunch("omp", "yolo", [], { providerCli: available("omp", "/resolved/omp") }); assert.deepEqual(omp.args, ["--auto-approve"]); diff --git a/tests/terminal-session-exact-resume.test.mjs b/tests/terminal-session-exact-resume.test.mjs new file mode 100644 index 00000000..fa55a463 --- /dev/null +++ b/tests/terminal-session-exact-resume.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; + +const FIRST_THREAD = "11111111-1111-4111-8111-111111111111"; +const SECOND_THREAD = "22222222-2222-4222-8222-222222222222"; + +function registry() { + return { + get(provider) { + return { + state: "available", + provider, + executable: `/resolved/${provider}`, + launcher: "native", + environment: {}, + checked: [] + }; + }, + snapshot() { return {}; } + }; +} + +function spawner(calls) { + return (command, args, options) => { + calls.push({ command, args, options }); + return { + pid: 10_000 + calls.length, + process: command, + write() {}, + resize() {}, + kill() {}, + pause() {}, + resume() {}, + onData() { return { dispose() {} }; }, + onExit() { return { dispose() {} }; } + }; + }; +} + +function manager(directory, calls) { + const instance = new TerminalManager( + () => undefined, + registry(), + undefined, + undefined, + true, + spawner(calls) + ); + instance.configureSessionPersistence(new TerminalSessionStore(directory), true); + return instance; +} + +test("restoring two Codex cards in one cwd resumes their own conversations", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-exact-resume-")); + try { + const initial = manager(directory, []); + await initial.restorePersistedSessions(); + const first = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const second = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 20, y: 20 } }); + // SessionStart is idle while a new card is already idle. The ID must still persist. + initial.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + initial.applyProviderSignal(second.id, { kind: "lifecycle", state: "idle", codexThreadId: SECOND_THREAD }); + await initial.shutdown(); + + const calls = []; + const restored = manager(directory, calls); + await restored.restorePersistedSessions(); + assert.equal(calls.length, 2); + assert.deepEqual(calls.map((call) => call.args.slice(-2)), [ + ["resume", FIRST_THREAD], + ["resume", SECOND_THREAD] + ]); + assert.deepEqual(restored.list().map((session) => session.id), [first.id, second.id]); + await restored.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("legacy Codex card without an ID opens the resume picker", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-legacy-resume-")); + try { + const store = new TerminalSessionStore(directory); + await store.replace([{ + id: "legacy-codex-card", + provider: "codex", + profile: "normal", + role: "agent", + title: "Legacy card", + titleCustomized: true, + cwd: process.cwd(), + position: { x: 0, y: 0 }, + size: { width: 700, height: 430 } + }]); + const calls = []; + const restored = manager(directory, calls); + await restored.restorePersistedSessions(); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.at(-1), "resume"); + assert.equal(calls[0].args.includes("--last"), false); + await restored.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("restarting a Codex card after an exit clears stale thread ID and launches fresh", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-restart-codex-")); + try { + const calls = []; + let exitHandler = null; + const customSpawner = (command, args, options) => { + calls.push({ command, args, options }); + return { + pid: 30_000 + calls.length, + process: command, + write() {}, + resize() {}, + kill() {}, + pause() {}, + resume() {}, + onData() { return { dispose() {} }; }, + onExit(handler) { + exitHandler = handler; + return { dispose() {} }; + } + }; + }; + + const inst = new TerminalManager( + () => undefined, + registry(), + undefined, + undefined, + true, + customSpawner + ); + const store = new TerminalSessionStore(directory); + inst.configureSessionPersistence(store, true); + await inst.restorePersistedSessions(); + + const created = inst.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + inst.applyProviderSignal(created.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + + // Verify stored + assert.equal(store.get()[0]?.codexThreadId, FIRST_THREAD); + + // Simulate exit + exitHandler?.({ exitCode: 0 }); + + // Restart the session + inst.restart(created.id); + + // Fresh restart: second spawn call shouldn't have "resume" or thread ID + assert.equal(calls.length, 2); + assert.equal(calls[1].args.includes("resume"), false); + assert.equal(calls[1].args.includes(FIRST_THREAD), false); + + // Stored session should no longer have the stale thread ID + assert.equal(store.get()[0]?.codexThreadId, undefined); + + await inst.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/terminal-session-restore.test.mjs b/tests/terminal-session-restore.test.mjs index 1a38ff78..19bd3558 100644 --- a/tests/terminal-session-restore.test.mjs +++ b/tests/terminal-session-restore.test.mjs @@ -80,7 +80,8 @@ test("opt-in restore preserves card identity and relaunches the agent in native await restored.restorePersistedSessions(); assert.equal(restoredCalls.length, 1); - assert.deepEqual(restoredCalls[0].args.slice(-2), ["resume", "--last"]); + assert.deepEqual(restoredCalls[0].args.slice(-1), ["resume"]); + assert.equal(restoredCalls[0].args.includes("--last"), false); assert.deepEqual(restored.list().map(({ buffer, revision, status, startedAt, exitCode, failureDetails, ...session }) => session), [{ id: created.id, provider: "codex", diff --git a/tests/terminal-session-store.test.mjs b/tests/terminal-session-store.test.mjs index 6dd822e6..630ca2b5 100644 --- a/tests/terminal-session-store.test.mjs +++ b/tests/terminal-session-store.test.mjs @@ -5,7 +5,8 @@ import { join } from "node:path"; import test from "node:test"; import { TerminalSessionStore, - normalizePersistedTerminalSessions + normalizePersistedTerminalSessions, + persistedTerminalSession } from "../src/main/services/TerminalSessionStore.ts"; const descriptor = { @@ -91,3 +92,69 @@ test("legacy roles restore as agents while unknown roles are dropped", async () await rm(directory, { recursive: true, force: true }); } }); + +test("codexThreadId persists and normalizes to canonical lower-case UUID for codex provider", async () => { + const threadIdUpper = "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D"; + const threadIdCanonical = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; + + const sessionMetadata = { + ...descriptor, + revision: 1, + status: "idle", + startedAt: Date.now(), + exitCode: null, + failureDetails: null + }; + + // persistedTerminalSession helper round-trips valid codexThreadId + const persisted = persistedTerminalSession(sessionMetadata, ` ${threadIdUpper} `); + assert.equal(persisted.codexThreadId, threadIdCanonical); + + // Non-codex provider ignores codexThreadId in persistedTerminalSession helper + const claudePersisted = persistedTerminalSession({ ...sessionMetadata, provider: "claude" }, threadIdUpper); + assert.equal(claudePersisted.codexThreadId, undefined); + + // Malformed thread IDs are ignored in persistedTerminalSession helper + assert.equal(persistedTerminalSession(sessionMetadata, "not-a-uuid").codexThreadId, undefined); + assert.equal(persistedTerminalSession(sessionMetadata, 12345).codexThreadId, undefined); + + // Store persistence and load round-trip + const directory = await mkdtemp(join(tmpdir(), "canvastty-terminal-state-codex-thread-")); + try { + const store = new TerminalSessionStore(directory); + await store.replace([persisted]); + const reloaded = await store.load(); + assert.equal(reloaded.length, 1); + assert.equal(reloaded[0].codexThreadId, threadIdCanonical); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("normalizePersistedTerminalSessions preserves cards with malformed or foreign thread IDs", () => { + const validUuid = "11111111-2222-3333-4444-555555555555"; + const normalized = normalizePersistedTerminalSessions({ + version: 1, + sessions: [ + // Valid codex thread ID + { ...descriptor, id: "valid-codex", provider: "codex", codexThreadId: validUuid }, + // Valid codex session without codexThreadId (backward compatibility) + { ...descriptor, id: "valid-codex-no-thread", provider: "codex" }, + // Malformed thread ID on codex session -> card survives without that ID + { ...descriptor, id: "bad-uuid", provider: "codex", codexThreadId: "invalid-uuid" }, + // Non-string thread ID on codex session -> card survives without that ID + { ...descriptor, id: "bad-type-uuid", provider: "codex", codexThreadId: 12345 }, + // codexThreadId attached to non-codex provider -> ignored + { ...descriptor, id: "claude-with-thread", provider: "claude", codexThreadId: validUuid }, + { ...descriptor, id: "terminal-with-thread", provider: "terminal", codexThreadId: validUuid } + ] + }); + + assert.deepEqual(normalized.sessions.map((s) => s.id), [ + "valid-codex", "valid-codex-no-thread", "bad-uuid", "bad-type-uuid", + "claude-with-thread", "terminal-with-thread" + ]); + assert.equal(normalized.sessions[0].codexThreadId, validUuid); + assert.equal(normalized.sessions[1].codexThreadId, undefined); + assert.ok(normalized.sessions.slice(2).every((session) => session.codexThreadId === undefined)); +}); From c1d603a6e9cb2ae159171a8e7aa88d192b8a8281 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:39:02 +0000 Subject: [PATCH 2/6] feat(sessions): restore agent sessions through one "after restart" model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #80 (teo-nex, "restore each Codex card to its own conversation"): its capture of the conversation id from authenticated lifecycle hooks, the validated id saved per card, `codex resume `, the resume picker when no id is known and a plain restart forgetting the id are kept as they are. This extends the same exact resume to Claude Code (`claude --resume `) and OpenCode (`opencode --session `); the field is renamed from codexThreadId to threadId for that, with one per-provider check (canonical UUID for Codex and Claude, `ses_` id for OpenCode) shared by the hook client, the gateway, the store and the launch, and v1 records' codexThreadId still read. Settings → General now offers Don't save / Reopen windows / Continue conversations (settings v21; the old opt-in boolean migrates true→continue, false→off). Session records move to v2 (v1 stays readable): last state at quit or exit, the thread id, a per-card restore flag, and two validated opaque plugin slots (launch options and an environment ref, 4 KB each). No scrollback, prompts or secrets are saved. Restore puts parents before children, resumes a recorded conversation by id, and without one uses a "latest in this folder" flag only when that CLI has one card in the folder (otherwise it starts fresh with a note on the card; Codex opens its picker). Finished cards come back stopped with Restart / Continue (Continue resumes the card's own conversation), and a card whose environment is unavailable is held stopped with its reason instead of running locally. Cards get an options menu with "Don't restore this card". For plugins: the v2 record's two opaque slots are where later extension points keep per-card state across restarts. A launch contributor's chosen options are saved in `options[pluginId]` and an environment's ref in `environment`, both validated and capped at 4 KB, so a restored card can be prepared or placed again (or held stopped with a reason) without the core knowing what the values mean. --- CHANGELOG.md | 1 + CHANGELOG.ru.md | 1 + CHANGELOG.zh-CN.md | 1 + docs/ARCHITECTURE.md | 4 +- src/agent-runtime/hook-helper.mjs | 5 +- src/agent-runtime/opencode-plugin.mjs | 4 +- src/agent-runtime/runtime-client.mjs | 15 +- src/agent-runtime/runtime-protocol.d.mts | 2 + src/agent-runtime/runtime-protocol.mjs | 16 ++ src/main/index.ts | 6 +- src/main/ipc/registerIpc.ts | 5 +- src/main/services/SettingsStore.ts | 22 +- src/main/services/TerminalManager.ts | 194 +++++++++----- src/main/services/TerminalSessionStore.ts | 128 +++++++-- .../services/agent-runtime/RuntimeGateway.ts | 23 +- src/main/services/sessionRestorePlan.ts | 86 ++++++ src/main/services/terminalLaunch.ts | 29 +++ src/preload/index.ts | 3 +- src/renderer/src/App.tsx | 6 +- .../src/features/settings/SettingsPanel.tsx | 11 +- .../src/features/terminal/TerminalCard.tsx | 66 ++++- .../features/workspace/WorkspaceCanvas.tsx | 4 +- src/renderer/src/lib/i18n.ts | 22 +- src/renderer/src/styles/app.css | 5 + src/shared/contracts.ts | 18 +- tests/agent-control.test.mjs | 4 +- tests/agent-runtime-gateway.test.mjs | 57 ++-- tests/api-profiles.test.mjs | 4 +- tests/attention-notifications.test.mjs | 6 +- tests/session-hierarchy.test.mjs | 6 +- tests/session-restore-v2.test.mjs | 246 ++++++++++++++++++ tests/settings-normalizer.test.mjs | 19 +- tests/settings-ui.test.mjs | 4 +- tests/terminal-launch.test.mjs | 21 +- tests/terminal-lifecycle.test.mjs | 4 +- tests/terminal-session-exact-resume.test.mjs | 14 +- tests/terminal-session-restore.test.mjs | 8 +- tests/terminal-session-store.test.mjs | 31 ++- 38 files changed, 883 insertions(+), 218 deletions(-) create mode 100644 src/main/services/sessionRestorePlan.ts create mode 100644 tests/session-restore-v2.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 078880c1..71c91d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Reworked "Windows after restart" into one "Agent sessions after restart" model: **Don't save**, **Reopen windows** (new conversations), or **Continue conversations** (the old "on" migrates here). Claude Code and OpenCode now resume their own conversation by the id their lifecycle hook reported, as Codex does (`claude --resume`, `opencode --session`); two cards of one CLI in one folder no longer continue the same conversation. Finished agents come back stopped with Restart / Continue instead of rerunning, the card options menu has **Don't restore this card**, and session records (v2, read-compatible with v1) keep no scrollback, prompts or secrets. - Made the agent orchestration endpoint an explicit setting (Settings → Agents → "Agent orchestration endpoint", `agentControlEnabled`, off by default; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` still force it on for one launch) that starts and stops the endpoint at runtime, and added an **Orchestrator** role to the launch dialog next to the normal/YOLO profile: the session keeps the provider you opened the dialog for, gets `CANVASTTY_CONTROL_CONNECTION` and `CANVASTTY_CONTROL_CLI` in its environment so the bundled CLI works without setup, shows an "Orchestrator" badge, keeps its role across restore, and the dialog offers to enable the endpoint first when it is off instead of enabling anything silently. The endpoint's `create` now accepts every agent provider (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) and reports `capabilities { result, menus }` per worker on `create` and `list`: both are `true` for Codex only; other providers' `screen` has no menu interaction, `choose`/`dismiss` fail with `NOT_SUPPORTED`, `send` relies on the idle status alone, and `result` completes as `no_result`. - Added a native Codex orchestration CLI (`agent-control/canvastty-control.mjs`, documented in `agent/orchestrator/SKILL.md`) behind `--agent-control` or `CANVASTTY_AGENT_CONTROL=1`: a local controller creates Codex sessions in a project directory, sends work, observes bounded terminal output against a screen revision, and collects the final answer. Each controller sees only the sessions it created, grants are bound to the session generation, mutation IDs are deduplicated, and only controlled sessions opt into authenticated Stop-hook result capture. No automatic approval or terminal deletion endpoint is included. - Added the opt-in Even G2 companion (Settings → Controls → Even G2, with the Even App companion under `integrations/even-g2`): Bonjour discovery, short-lived SRP-6a pairing with a six-digit code and explicit device approval, encrypted local requests and audio, per-session grants, bounded terminal presentation on the glasses HUD, local speech recognition through the pinned transcribe.cpp helper (bundled on macOS only), and session creation through the existing desktop launcher. The final answer of a Codex turn reaches the companion only for sessions spawned while the companion is enabled: the runtime hook reports it under a separate per-session grant, bounded to 4000 characters, and the gateway refuses it for any other session. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 2ed4b884..ea9a34ce 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,7 @@ ## Unreleased +- «Окна после перезапуска» стали единой моделью «Сессии агентов после перезапуска»: **Не сохранять**, **Открыть окна** (новые разговоры) или **Продолжить разговоры** (прежнее «включено» переходит сюда). Claude Code и OpenCode теперь, как и Codex, продолжают свой разговор по id, который сообщил их lifecycle hook (`claude --resume`, `opencode --session`); две карточки одного CLI в одной папке больше не продолжают один и тот же разговор. Завершённые агенты возвращаются остановленными с кнопками «Перезапустить» / «Продолжить», в меню карточки есть **Не восстанавливать это окно**, а записи сессий (v2, совместимы с v1 при чтении) не хранят буфер, промпты и секреты. - Эндпоинт оркестрации агентов стал явной настройкой (Настройки → Агенты → «Эндпоинт оркестрации агентов», `agentControlEnabled`, по умолчанию выключен; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` по-прежнему принудительно включают его на один запуск), которая запускает и останавливает эндпоинт на лету, а в диалог запуска рядом с профилем normal/YOLO добавлена роль **Оркестратор**: сессия сохраняет провайдера, для которого открыт диалог, получает в окружении `CANVASTTY_CONTROL_CONNECTION` и `CANVASTTY_CONTROL_CLI`, чтобы встроенный CLI работал без настройки, показывает бейдж «Оркестратор», сохраняет роль при восстановлении, а при выключенном эндпоинте диалог предлагает сначала включить его, ничего не включая молча. `create` эндпоинта теперь принимает любого провайдера-агента (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) и в ответах `create` и `list` сообщает `capabilities { result, menus }` для каждого воркера: оба значения `true` только для Codex; у остальных провайдеров `screen` не содержит взаимодействия с меню, `choose`/`dismiss` завершаются ошибкой `NOT_SUPPORTED`, `send` опирается только на статус idle, а `result` завершается как `no_result`. - Добавлен нативный CLI оркестрации Codex (`agent-control/canvastty-control.mjs`, описан в `agent/orchestrator/SKILL.md`), включаемый флагом `--agent-control` или `CANVASTTY_AGENT_CONTROL=1`: локальный контроллер создаёт сессии Codex в каталоге проекта, отправляет задачи, наблюдает ограниченный вывод терминала относительно ревизии экрана и получает итоговый ответ. Контроллер видит только созданные им сессии, гранты привязаны к поколению сессии, идентификаторы мутаций дедуплицируются, и только управляемые сессии включают аутентифицированный захват результата через Stop-hook. Автоматического одобрения и удаления терминалов нет. - Добавлен опциональный компаньон Even G2 (Настройки → Управление → Even G2, приложение-компаньон в `integrations/even-g2`): обнаружение через Bonjour, короткоживущее сопряжение SRP-6a с шестизначным кодом и явным подтверждением устройства, шифрованные локальные запросы и аудио, гранты на сессию, ограниченное отображение терминала на HUD очков, локальное распознавание речи через закреплённый helper transcribe.cpp (поставляется только для macOS) и создание сессий через обычный лаунчер. Итоговый ответ хода Codex попадает в компаньон только для сессий, запущенных при включённом компаньоне: runtime-hook передаёт его по отдельному гранту сессии с ограничением 4000 символов, а gateway отклоняет его для любой другой сессии. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index b92a94a2..6120ca95 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,7 @@ ## Unreleased +- 将“重启后的窗口”改为统一的“重启后的智能体会话”模型:**不保存**、**重新打开窗口**(新会话)或 **继续会话**(原先的“开启”迁移到此项)。Claude Code 和 OpenCode 现在与 Codex 一样,按其 lifecycle hook 报告的 id 继续自己的会话(`claude --resume`、`opencode --session`);同一文件夹中同一 CLI 的两张卡片不再继续同一个会话。已结束的智能体恢复为停止状态,提供“重启”/“继续”,卡片选项菜单提供 **不恢复此卡片**,会话记录(v2,可读取 v1)不保存 scrollback、提示词或密钥。 - 将代理编排端点改为显式设置(设置 → 代理 → “代理编排端点”,`agentControlEnabled`,默认关闭;`--agent-control` / `CANVASTTY_AGENT_CONTROL=1` 仍可为单次启动强制开启),并在运行时按设置启动和停止端点;启动对话框在 normal/YOLO 配置旁新增 **Orchestrator(编排器)** 角色:会话保留打开对话框时的提供方,环境中携带 `CANVASTTY_CONTROL_CONNECTION` 和 `CANVASTTY_CONTROL_CLI`,使内置 CLI 无需配置即可工作,卡片显示 “Orchestrator” 徽标,恢复会话时保留角色;端点关闭时对话框会先提示并提供开启按钮,而不会静默开启任何内容。端点的 `create` 现在接受所有代理提供方(`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`),并在 `create` 和 `list` 响应中为每个工作会话报告 `capabilities { result, menus }`:仅 Codex 两者都为 `true`;其他提供方的 `screen` 没有菜单交互,`choose`/`dismiss` 返回 `NOT_SUPPORTED`,`send` 仅依据 idle 状态,`result` 以 `no_result` 结束。 - 新增原生 Codex 编排 CLI(`agent-control/canvastty-control.mjs`,文档见 `agent/orchestrator/SKILL.md`),通过 `--agent-control` 或 `CANVASTTY_AGENT_CONTROL=1` 启用:本地控制器在项目目录中创建 Codex 会话、发送任务、按屏幕修订号观察有界的终端输出并收集最终回答。每个控制器只能看到自己创建的会话,授权绑定到会话代次,变更 ID 去重,且只有受控会话会启用经过认证的 Stop-hook 结果捕获。不包含自动批准或删除终端的端点。 - 新增可选的 Even G2 伴侣(设置 → 控制 → Even G2,伴侣应用位于 `integrations/even-g2`):Bonjour 发现、带六位码和显式设备批准的短期 SRP-6a 配对、加密的本地请求与音频、按会话授权、眼镜 HUD 上的有界终端展示、通过固定版本的 transcribe.cpp helper 进行本地语音识别(仅 macOS 随包提供),以及通过现有桌面启动器创建会话。Codex 一轮的最终回答只会送达在伴侣启用期间启动的会话:runtime hook 以单独的会话授权上报,限制为 4000 个字符,gateway 会拒绝任何其他会话的该字段。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5b2400a..c07feb5c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ Electron main process - `src/preload/index.ts` exposes only the typed capabilities the renderer needs. Node integration stays disabled; context isolation and sandbox stay enabled. - Terminal file drops resolve native `File` objects through preload's `webUtils.getPathForFile`, format paths for the host's default shell, and paste through xterm without submitting. File contents are not read and no new main-process IPC is exposed. - `src/main/ipc/registerIpc.ts` owns native side effects and validates access to persisted media. -- `src/main/services/TerminalManager.ts` is the source of truth for live session state and PTY buffers. It keeps scrollback in a bounded chunk buffer and coalesces PTY data into 16ms IPC batches so clear/redraw sequences reach xterm together. A plain terminal starts `idle`; an agent stays `unavailable` until its provider emits a machine-readable lifecycle signal. Codex, Claude Code, Qwen Code, Kimi Code, OpenCode, Hermes, and Grok Build then transition through `idle`, `working`, and `needs_approval` from provider hooks; exact Claude/Qwen OSC 0/2 markers remain a compatibility fallback. Human-readable terminal text and PTY existence are never treated as activity. Process exit provides only `done` or `failed`. An exited PTY may be restarted under the same session ID while preserving its card, bounds, title, and scrollback. Optional restart persistence writes only provider/profile/title/cwd/bounds descriptors through `TerminalSessionStore`; it never writes PTY scrollback, child environment, or capabilities. Restored agents use each provider's native project-scoped continue mode, while plain terminals reopen as fresh shells in their saved folder. +- `src/main/services/TerminalManager.ts` is the source of truth for live session state and PTY buffers. It keeps scrollback in a bounded chunk buffer and coalesces PTY data into 16ms IPC batches so clear/redraw sequences reach xterm together. A plain terminal starts `idle`; an agent stays `unavailable` until its provider emits a machine-readable lifecycle signal. Codex, Claude Code, Qwen Code, Kimi Code, OpenCode, Hermes, and Grok Build then transition through `idle`, `working`, and `needs_approval` from provider hooks; exact Claude/Qwen OSC 0/2 markers remain a compatibility fallback. Human-readable terminal text and PTY existence are never treated as activity. Process exit provides only `done` or `failed`. An exited PTY may be restarted under the same session ID while preserving its card, bounds, title, and scrollback. Optional restart persistence (Settings → General, "Agent sessions after restart": Don't save / Reopen windows / Continue conversations) writes session records v2 through `TerminalSessionStore`: the card descriptor, the state at quit or exit, the provider conversation id a lifecycle hook reported (`threadId`: Codex thread or Claude session UUID, OpenCode `ses_` id), the per-card restore flag, and two opaque plugin slots (launch options and an environment reference, at most 4 KB each). It never writes PTY scrollback, prompts, child environment, secrets, or capabilities. Plain terminals reopen as fresh shells in their saved folder. - `src/main/services/LimitsService.ts` reads Codex through the installed CLI's app-server protocol and Claude, Kimi, OpenCode Go, and Grok Build through their provider usage or billing endpoints. Qwen Code is multi-provider and exposes no provider-neutral read-only quota protocol, so its adapter reports `cli-not-found` or `unsupported-protocol` and never invents percentages. Provider credentials are read only inside the trusted main process, sent only to the matching provider over HTTPS, and never logged or exposed over IPC. The service owns timeout, structural normalization, caching, stale fallback, and subprocess cleanup; raw provider responses never cross IPC. - `src/main/services/SettingsStore.ts` normalizes every update and persists through a serialized atomic write. Canvas regions and sticky notes have independent persistence gates: disabling one keeps its live objects for the current process but omits that collection from the disk snapshot and therefore from the next launch. The configurable canvas launcher and UI scale use the same boundary; transient window stacking does not. - `src/main/services/PluginManager.ts` installs ready-to-run repositories without executing package scripts during install/update, rejects symlinks and oversized packages, persists the enabled registry, serves only contained package files, and enforces per-plugin permissions/storage quotas. Optional native agent-hook entries remain off by default; explicit per-hook trust is persisted in the plugin registry and compiled into a separate private atomic runtime registry. Update, module replacement, plugin disable, and uninstall revoke that trust before executable files change. @@ -91,7 +91,7 @@ Keep domain decisions in pure selectors such as `homeModel.ts`, orchestration in ## Session flow -When terminal restore is enabled, startup loads validated window descriptors before the renderer and relaunches each saved agent through its provider's native continue mode. Stable CanvasTTY session IDs preserve card identity, while region membership remains spatial and requires the complete card bounds to be inside the region at region-drag start. Grok restoration still waits for the renderer-measured xterm grid before spawning. Turning restore off clears the descriptor store immediately; it remains off by default. +When session restore is on, startup loads validated records before the renderer and restores parents before children (`sessionRestorePlan.ts`). "Continue conversations" resumes the conversation id the card's lifecycle hook reported (`codex resume `, `claude --resume `, `opencode --session `); without one Codex opens its own resume picker, and other CLIs use their "latest in this folder" flag only when that CLI has exactly one card in the folder, and otherwise start fresh and say so on the card. A plain Restart starts a new conversation and forgets the id; Continue on a stopped card resumes it. Cards that had already exited come back stopped with Restart / Continue, cards marked "Don't restore this card" do not come back, and a card placed in an environment that is unavailable comes back stopped with the reason and is never started locally. Stable CanvasTTY session IDs preserve card identity, while region membership remains spatial and requires the complete card bounds to be inside the region at region-drag start. Grok restoration still waits for the renderer-measured xterm grid before spawning. Turning restore off clears the descriptor store immediately; it remains off by default. 1. Home requests a terminal or opens a provider-specific launch card. 2. `App` sends a typed `terminal:create` request. diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index 7cbe023d..024a1376 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -40,7 +40,8 @@ const turnId = firstString( input?.prompt_id, input?.promptId ); -const codexThreadId = firstString( +// The provider's own conversation id; runtime-client keeps it only in a shape that provider issues. +const threadId = firstString( input?.session_id, input?.sessionId, input?.thread_id, @@ -63,7 +64,7 @@ await reportLifecycle({ state, event, turnId, - ...(codexThreadId ? { codexThreadId } : {}), + ...(threadId ? { threadId } : {}), ...(result === undefined ? {} : { result }), ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }); diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index 73ca8978..d2333031 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -25,7 +25,9 @@ export const CanvasTTYLifecycle = async () => ({ rootSessionId = stringField(session.id, sessionId); rootWorking = false; if (!rootSessionId) return; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) { + await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId, threadId: rootSessionId }); + } runPluginHooks("session-start", event.type, event); return; } diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index 88d1d8b5..cc441733 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -5,13 +5,14 @@ import { CAPTURE_ANSWER_EXPIRES_AT_ENV, MAX_ANSWER_CHARS, MAX_RUNTIME_MESSAGE_BYTES, + normalizeThreadId, RUNTIME_PROTOCOL_VERSION, RUNTIME_STATES } from "./runtime-protocol.mjs"; const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, codexThreadId, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, threadId, result, lastAssistantMessage }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -20,9 +21,7 @@ export async function reportLifecycle({ state, event, turnId = null, codexThread const capabilityToken = process.env[AGENT_RUNTIME_ENV.capabilityToken]; if (!address || !terminalSessionId || !provider || !capabilityToken) return false; - const validCodexThreadId = provider === "codex" && typeof codexThreadId === "string" && isCanonicalUuid(codexThreadId) - ? codexThreadId.toLowerCase() - : undefined; + const validThreadId = normalizeThreadId(provider, threadId); const message = { v: RUNTIME_PROTOCOL_VERSION, @@ -33,7 +32,7 @@ export async function reportLifecycle({ state, event, turnId = null, codexThread state, event, turnId: normalizedId(turnId), - ...(validCodexThreadId !== undefined ? { codexThreadId: validCodexThreadId } : {}), + ...(validThreadId !== undefined ? { threadId: validThreadId } : {}), ...(result === undefined ? {} : { result }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); @@ -103,9 +102,3 @@ function sendMessage(address, payload, accepted) { function normalizedId(value) { return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null; } - -const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function isCanonicalUuid(value) { - return typeof value === "string" && CANONICAL_UUID_RE.test(value); -} diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index 03fda4cd..a73b8807 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -13,3 +13,5 @@ export const AGENT_RUNTIME_ENV: Readonly<{ capabilityToken: "CANVASTTY_RUNTIME_CAPABILITY"; }>; export const RUNTIME_STATES: readonly ["idle", "working", "needs_approval"]; +/** The provider's conversation id in the one form it may be stored or passed to its CLI, or undefined. */ +export function normalizeThreadId(provider: string, value: unknown): string | undefined; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 45de46d4..96a9c23c 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -19,3 +19,19 @@ export const AGENT_RUNTIME_ENV = Object.freeze({ }); export const RUNTIME_STATES = Object.freeze(["idle", "working", "needs_approval"]); + +// A provider's own conversation id, as its lifecycle hook reports it, lets a restored +// card resume exactly that conversation. It ends up in the provider's argv, so only +// the shapes those CLIs issue are accepted: canonical UUIDs for Codex threads and +// Claude sessions (lower-cased), `ses_` tokens for OpenCode sessions. +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9]{1,120}$/; + +export function normalizeThreadId(provider, value) { + if (typeof value !== "string") return undefined; + if (provider === "codex" || provider === "claude") { + return CANONICAL_UUID_RE.test(value) ? value.toLowerCase() : undefined; + } + if (provider === "opencode") return OPENCODE_SESSION_RE.test(value) ? value : undefined; + return undefined; +} diff --git a/src/main/index.ts b/src/main/index.ts index b8d11de7..823458ee 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -342,7 +342,7 @@ async function initializeServices(): Promise { kind: "lifecycle", state: signal.state, ...(signal.turnId ? { requestId: signal.turnId } : {}), - ...(signal.codexThreadId ? { codexThreadId: signal.codexThreadId } : {}) + ...(signal.threadId ? { threadId: signal.threadId } : {}) }); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { @@ -431,7 +431,7 @@ async function initializeServices(): Promise { } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); const terminalSessionStore = new TerminalSessionStore(userDataPath); - terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().restoreTerminalSessions); + terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().sessionRestoreMode); // The orchestration bridge exists only for sessions explicitly launched with // the orchestrator role; interactive sessions never receive capabilities. @@ -567,7 +567,7 @@ async function initializeServices(): Promise { wheelBinding: activeCanvasWheelBinding(next.canvasWheelCaptureMode, next.canvasWheelOverride), navigationBinding: next.canvasNavigationOverride }); - await terminalManager?.setSessionPersistenceEnabled(next.restoreTerminalSessions); + await terminalManager?.setSessionRestoreMode(next.sessionRestoreMode); }, setCanvasNavigationShortcutCapture: (active) => { if (active) browserService?.cancelCanvasNavigationGesture(); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 1fb3ca10..8c2b4349 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -617,13 +617,16 @@ export function registerIpc({ return terminals.readBuffer(id); }); ipcMain.handle(IPC.terminalCreate, (_event, request: CreateSessionRequest) => terminals.create(request)); - ipcMain.handle(IPC.terminalRestart, (_event, id: string) => terminals.restart(id)); + ipcMain.handle(IPC.terminalRestart, (_event, id: string, options?: { resume?: unknown }) => ( + terminals.restart(id, { resume: options?.resume === true }) + )); ipcMain.on(IPC.terminalInput, (_event, id: string, data: string) => terminals.input(id, data)); ipcMain.on(IPC.terminalResize, (_event, id: string, cols: number, rows: number) => { terminals.resize(id, cols, rows); }); ipcMain.on(IPC.terminalBounds, (_event, id: string, bounds: SessionBounds) => terminals.setBounds(id, bounds)); ipcMain.handle(IPC.terminalRename, (_event, id: string, title: string) => terminals.rename(id, title)); + ipcMain.handle(IPC.terminalSetRestore, (_event, id: string, restore: boolean) => terminals.setRestore(id, restore)); ipcMain.handle(IPC.terminalDispose, (_event, id: string) => terminals.dispose(id)); // Fire-and-forget, like the other stream-reporting channels: a malformed // report is ignored rather than rejecting into the renderer. diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index a75a6e3a..5c379e6a 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -28,6 +28,7 @@ import type { PluginCanvasInstance, ProviderSecretId, RadialLauncherItemId, + SessionRestoreMode, SessionRowColorMode, ShortcutBindings, StickyNote, @@ -63,13 +64,15 @@ import { } from "../../shared/canvasNavigation.ts"; const LOCALES = new Set(["ru", "en"]); +const SESSION_RESTORE_MODES = new Set(["off", "reopen", "continue"]); const PALETTES = new Set(["sage", "lilac", "night"]); const HOME_ACCENT_PRESETS = new Set(["classic", "warm", "cool", "mono", "custom"]); const SESSION_ROW_COLOR_MODES = new Set(["monochrome", "status"]); const CANVAS_COLORS = new Set(["sage", "lilac", "night", "sand", "mist", "rose", "slate"]); const PATTERNS = new Set(["dots", "grid", "waves", "diagonal", "rings", "none"]); const MEDIA_FITS = new Set(["cover", "contain"]); -const SETTINGS_VERSION = 20; +// 21: the on/off "restoreTerminalSessions" became sessionRestoreMode (off / reopen / continue). +const SETTINGS_VERSION = 21; const GROK_LAUNCHER_SETTINGS_VERSION = 3; const EXPANDED_LIMIT_SETTINGS_VERSION = 5; const QWEN_SETTINGS_VERSION = 6; @@ -165,7 +168,7 @@ export class SettingsStore { || !("attentionQueueVisible" in source) || !("attentionQueuePlacement" in source) || !("agentControlEnabled" in source) - || !("restoreTerminalSessions" in source) + || !("sessionRestoreMode" in source) || !("persistCanvasRegions" in source) || !("persistStickyNotes" in source) || !("canvasRegions" in source) @@ -299,6 +302,15 @@ export class SettingsStore { } } +/** The old boolean migrates as it behaved: saved windows continued their conversations. */ +function normalizeSessionRestoreMode(source: Record, fallback: SessionRestoreMode | undefined): SessionRestoreMode { + if (SESSION_RESTORE_MODES.has(source.sessionRestoreMode as SessionRestoreMode)) { + return source.sessionRestoreMode as SessionRestoreMode; + } + if (typeof source.restoreTerminalSessions === "boolean") return source.restoreTerminalSessions ? "continue" : "off"; + return fallback ?? "off"; +} + function isLegacyDefaultLimitSelection(candidate: unknown[] | null): boolean { return candidate !== null && candidate.length === LEGACY_LIMIT_PROVIDERS.length @@ -314,7 +326,7 @@ function isPreQwenDefaultSelection(candidate: unknown[] | null function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform): AppSettings { return { locale: systemLocale.toLowerCase().startsWith("ru") ? "ru" : "en", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -484,9 +496,7 @@ export function normalizeSettings( return { locale: LOCALES.has(source.locale as LocaleId) ? source.locale as LocaleId : fallback.locale, - restoreTerminalSessions: typeof source.restoreTerminalSessions === "boolean" - ? source.restoreTerminalSessions - : fallback.restoreTerminalSessions ?? false, + sessionRestoreMode: normalizeSessionRestoreMode(source as Record, fallback.sessionRestoreMode), persistCanvasRegions: typeof source.persistCanvasRegions === "boolean" ? source.persistCanvasRegions : fallback.persistCanvasRegions ?? true, diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 148fdd94..a4fd0570 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -12,6 +12,7 @@ import type { SessionEvent, SessionMetadata, SessionRemovedEvent, + SessionRestoreMode, SessionSnapshot, TerminalBufferSnapshot, TerminalDataEvent @@ -36,7 +37,8 @@ import { AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, CAPTURE_ANSWER_EXPIRES_AT_ENV, - CAPTURE_RESULT_ENV + CAPTURE_RESULT_ENV, + normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; import { CONTROL_CLI_ENV, @@ -50,9 +52,11 @@ import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { resolveTerminalLaunch } from "./terminalLaunch.ts"; import { persistedTerminalSession, - type PersistedTerminalSession, + type PersistedEnvironmentRef, + type PersistedSessionExtras, type TerminalSessionStore } from "./TerminalSessionStore.ts"; +import { chooseResume, planSessionRestore, type ResumeRequest, type RestoreStep } from "./sessionRestorePlan.ts"; import type { ProviderCliRegistry, UnavailableProviderCli } from "./providerCliRegistry.ts"; import { createProviderLifecycleParser, @@ -82,17 +86,19 @@ interface ManagedSession { agentOrchestration: PreparedOrchestrationPtyLaunch | null; lifecycle: ProviderLifecycleParser | null; awaitingInitialResize: boolean; - resumeOnLaunch: boolean; - resumeThreadId?: string; - codexThreadId?: string; + resumeOnLaunch: ResumeRequest; + /** The provider's own conversation id, once its hook reported it (or from the saved record). */ + threadId?: string; captureResult: boolean; + /** Plugin options and environment ref carried into the saved record. */ + extras: PersistedSessionExtras; } export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; requestId?: string; - codexThreadId?: string; + threadId?: string; } /** @@ -122,7 +128,10 @@ export class TerminalManager { private lifecycleHooksEnabled: boolean; private agentOrchestration: OrchestrationLaunchCoordinator | null = null; private sessionStore: TerminalSessionStore | null = null; - private sessionPersistenceEnabled = false; + private sessionRestoreMode: SessionRestoreMode = "off"; + // No environment provider exists in core yet, so a placed session can only + // come back stopped; the environment registry (plugins) answers this later. + private environmentAvailable: (environment: PersistedEnvironmentRef) => boolean = () => false; private suppressPersistence = false; // The live agent-control descriptor, handed only to orchestrator-role sessions // spawned while it is set; null while the endpoint is off. @@ -152,37 +161,47 @@ export class TerminalManager { this.agentOrchestration = coordinator; } - configureSessionPersistence(store: TerminalSessionStore, enabled: boolean): void { + configureSessionPersistence(store: TerminalSessionStore, mode: SessionRestoreMode): void { this.sessionStore = store; - this.sessionPersistenceEnabled = Boolean(enabled); + this.sessionRestoreMode = mode; } async restorePersistedSessions(): Promise { const store = this.sessionStore; if (!store) return; const persisted = await store.load(); - if (!this.sessionPersistenceEnabled) { + if (this.sessionRestoreMode === "off") { if (persisted.length > 0) await store.clear(); return; } - // A subagent whose owning session is gone restores as nothing: its - // parent's runtime state no longer exists to collect its result. - const restorable = persisted.filter((descriptor) => ( - descriptor.role !== "subagent" - || persisted.some((candidate) => candidate.id === descriptor.parentSessionId) - || this.sessions.has(descriptor.parentSessionId ?? "") - )); - for (const descriptor of restorable) this.restorePersistedSession(descriptor); + // Parents come first; a subagent whose owning session is gone restores as + // nothing, since its parent's runtime state no longer exists. + const steps = planSessionRestore(persisted, this.sessionRestoreMode, { + isLiveSession: (id) => this.sessions.has(id), + environmentAvailable: (environment) => this.environmentAvailable(environment) + }); + for (const step of steps) this.restorePersistedSession(step); await this.persistSessions(); } - async setSessionPersistenceEnabled(enabled: boolean): Promise { - const next = Boolean(enabled); - if (this.sessionPersistenceEnabled === next) return; - this.sessionPersistenceEnabled = next; - if (next) await this.persistSessions(); - else await this.sessionStore?.clear(); + async setSessionRestoreMode(mode: SessionRestoreMode): Promise { + if (this.sessionRestoreMode === mode) return; + this.sessionRestoreMode = mode; + if (mode === "off") await this.sessionStore?.clear(); + else await this.persistSessions(); + } + + /** The per-card "Don't restore this card" choice. */ + setRestore(id: string, restore: boolean): SessionMetadata { + const session = this.sessions.get(id); + if (!session) throw new Error("Terminal session does not exist."); + if (typeof restore !== "boolean") throw new Error("Restore choice is invalid."); + if (restore) delete session.metadata.skipRestore; + else session.metadata.skipRestore = true; + this.emitSession(session.metadata); + this.schedulePersistence(); + return structuredClone(session.metadata); } async shutdown(): Promise { @@ -275,7 +294,7 @@ export class TerminalManager { const launched = awaitMeasuredGrid ? { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null, failure: null } : this.spawnProcess(id, request.provider, request.profile, request.cwd, - INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, false, control.captureResult, role, + INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, null, control.captureResult, role, control.answerCaptureGrantExpiresAt); if (launched.failure) applyLaunchFailure(metadata, launched.failure); @@ -297,8 +316,9 @@ export class TerminalManager { ? createProviderLifecycleParser(request.provider, request.cwd) : null, awaitingInitialResize: awaitMeasuredGrid, - resumeOnLaunch: false, - captureResult: control.captureResult === true + resumeOnLaunch: null, + captureResult: control.captureResult === true, + extras: {} }; this.sessions.set(id, session); if (launched.process) this.bindProcess(id, session, launched.process); @@ -310,10 +330,29 @@ export class TerminalManager { return snapshot(session); } - restart(id: string): SessionSnapshot { + restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); + const environment = session.extras.environment; + if (environment && !this.environmentAvailable(environment)) { + // Never run a placed session locally instead of where it belongs. + throw new Error(`This card runs in ${environment.label} from plugin ${environment.pluginId}, which is not available. It was not started locally.`); + } + delete session.extras.heldState; + delete session.metadata.restoreNote; + let resume: ResumeRequest = null; + if (options.resume === true && session.metadata.provider !== "terminal") { + const peers = [...this.sessions.values()].filter((candidate) => ( + candidate.metadata.provider === session.metadata.provider && candidate.metadata.cwd === session.metadata.cwd + )).length; + const chosen = chooseResume(session.metadata.provider, session.threadId, peers); + resume = chosen.resume; + if (chosen.note) session.metadata.restoreNote = chosen.note; + } else { + // A plain restart is a new conversation, so the old id must not be resumed later. + delete session.threadId; + } if (session.metadata.provider === "grok") { session.agentBrowser?.cleanup(); @@ -326,9 +365,7 @@ export class TerminalManager { ? createProviderLifecycleParser(session.metadata.provider, session.metadata.cwd) : null; session.awaitingInitialResize = true; - session.resumeOnLaunch = false; - delete session.resumeThreadId; - delete session.codexThreadId; + session.resumeOnLaunch = resume; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; @@ -338,7 +375,6 @@ export class TerminalManager { return snapshot(session); } - delete session.codexThreadId; session.agentOrchestration?.cleanup(); const launched = this.spawnProcess( id, @@ -347,7 +383,7 @@ export class TerminalManager { session.metadata.cwd, session.cols, session.rows, - false, + resume, session.captureResult, session.metadata.role ); @@ -440,9 +476,9 @@ export class TerminalManager { const session = this.sessions.get(id); if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; - if (signal.codexThreadId && session.metadata.provider === "codex" - && signal.codexThreadId !== session.codexThreadId) { - session.codexThreadId = signal.codexThreadId; + const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); + if (threadId && threadId !== session.threadId) { + session.threadId = threadId; this.schedulePersistence(); } @@ -546,7 +582,8 @@ export class TerminalManager { } } - private restorePersistedSession(descriptor: PersistedTerminalSession): void { + private restorePersistedSession(step: RestoreStep): void { + const descriptor = step.record; if (this.sessions.has(descriptor.id)) return; const metadata: SessionMetadata = { id: descriptor.id, @@ -563,22 +600,43 @@ export class TerminalManager { status: initialSessionStatus(descriptor.provider), startedAt: Date.now(), exitCode: null, - failureDetails: null + failureDetails: null, + ...(step.note ? { restoreNote: step.note } : {}) + }; + const extras: PersistedSessionExtras = { + ...(descriptor.options ? { options: descriptor.options } : {}), + ...(descriptor.environment ? { environment: descriptor.environment } : {}) }; let process: IPty | null = null; let agentBrowser: PreparedAgentBrowserPtyLaunch | null = null; let agentRuntime: PreparedAgentRuntimePtyLaunch | null = null; let agentOrchestration: PreparedOrchestrationPtyLaunch | null = null; - let directoryReady = true; - try { - assertDirectory(descriptor.cwd); - } catch (error) { - directoryReady = false; - metadata.status = "failed"; - metadata.exitCode = 1; - metadata.failureDetails = error instanceof Error ? error.message : String(error); + let directoryReady = step.launch !== "stopped"; + if (step.launch === "stopped") { + // A finished card comes back as it ended; a placed card whose environment + // is unavailable is held with its reason and keeps its saved state. + if (step.note === "environment-unavailable" && descriptor.environment) { + extras.heldState = descriptor.lastState; + metadata.status = "failed"; + metadata.exitCode = descriptor.exitCode ?? 1; + metadata.failureDetails = `Needs plugin ${descriptor.environment.pluginId} (${descriptor.environment.label}).`; + } else { + metadata.exitCode = descriptor.exitCode ?? (descriptor.lastState === "exited" ? 0 : 1); + metadata.status = metadata.exitCode === 0 ? "done" : "failed"; + } } + if (directoryReady) { + try { + assertDirectory(descriptor.cwd); + } catch (error) { + directoryReady = false; + metadata.status = "failed"; + metadata.exitCode = 1; + metadata.failureDetails = error instanceof Error ? error.message : String(error); + } + } + const resume: ResumeRequest = step.launch === "stopped" ? null : step.launch; const awaitMeasuredGrid = directoryReady && descriptor.provider === "grok" && this.providerClis.get(descriptor.provider).state === "available"; @@ -592,11 +650,9 @@ export class TerminalManager { descriptor.cwd, INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, - descriptor.provider !== "terminal", + resume, false, - descriptor.role, - undefined, - descriptor.codexThreadId + descriptor.role ); process = launched.process; agentBrowser = launched.agentBrowser; @@ -610,6 +666,10 @@ export class TerminalManager { } } + // A card that started comes back tied to the conversation the plan chose (none for + // a fresh start); one that did not start keeps its recorded id for Continue. + const started = process !== null || awaitMeasuredGrid; + const threadId = started ? step.threadId : descriptor.threadId; const session: ManagedSession = { metadata, process, @@ -628,9 +688,10 @@ export class TerminalManager { ? createProviderLifecycleParser(descriptor.provider, descriptor.cwd) : null, awaitingInitialResize: awaitMeasuredGrid, - resumeOnLaunch: awaitMeasuredGrid && descriptor.provider !== "terminal", - ...(descriptor.codexThreadId ? { resumeThreadId: descriptor.codexThreadId, codexThreadId: descriptor.codexThreadId } : {}), - captureResult: false + resumeOnLaunch: awaitMeasuredGrid ? resume : null, + ...(threadId ? { threadId } : {}), + captureResult: false, + extras }; this.sessions.set(descriptor.id, session); if (process) this.bindProcess(descriptor.id, session, process); @@ -644,11 +705,11 @@ export class TerminalManager { } private persistSessions(): Promise { - if (!this.sessionPersistenceEnabled || this.suppressPersistence || !this.sessionStore) { + if (this.sessionRestoreMode === "off" || this.suppressPersistence || !this.sessionStore) { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.codexThreadId)) + [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) ); } @@ -669,10 +730,8 @@ export class TerminalManager { private launchAwaitingSession(id: string, session: ManagedSession): void { if (!session.awaitingInitialResize) return; session.awaitingInitialResize = false; - const resumePrevious = session.resumeOnLaunch; - session.resumeOnLaunch = false; - const resumeThreadId = session.resumeThreadId; - delete session.resumeThreadId; + const resume = session.resumeOnLaunch; + session.resumeOnLaunch = null; try { const launched = this.spawnProcess( id, @@ -681,11 +740,9 @@ export class TerminalManager { session.metadata.cwd, session.cols, session.rows, - resumePrevious, + resume, session.captureResult, - session.metadata.role, - undefined, - resumeThreadId + session.metadata.role ); session.process = launched.process; session.agentBrowser = launched.agentBrowser; @@ -719,11 +776,10 @@ export class TerminalManager { cwd: string, cols = INITIAL_TERMINAL_COLS, rows = INITIAL_TERMINAL_ROWS, - resumePrevious = false, + resume: ResumeRequest = null, captureResult = false, role: SessionRole = "agent", - answerCaptureGrantExpiresAt?: number, - resumeThreadId?: string + answerCaptureGrantExpiresAt?: number ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -777,8 +833,8 @@ export class TerminalManager { const launch = resolveTerminalLaunch(provider, profile, providerArgs, { environment: { ...baseEnvironment, ...providerEnvironment }, ...(providerCli ? { providerCli } : {}), - resumePrevious, - ...(resumeThreadId ? { resumeThreadId } : {}) + resumePrevious: resume !== null, + ...(resume && typeof resume === "object" ? { resumeThreadId: resume.threadId } : {}) }); return { process: this.spawnPty(launch.command, launch.args, { @@ -829,6 +885,8 @@ export class TerminalManager { current.agentOrchestration?.cleanup(); current.agentOrchestration = null; this.emitSession(current.metadata); + // Recorded at the moment of exit, so a finished agent is never relaunched. + this.schedulePersistence(); }); } diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 25f462b0..a70969d1 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -8,9 +8,13 @@ import type { SessionMetadata, Size } from "../../shared/contracts.ts"; +import { normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; -export const TERMINAL_SESSION_STORE_VERSION = 1; +export const TERMINAL_SESSION_STORE_VERSION = 2; const MAX_PERSISTED_SESSIONS = 64; +/** Opaque plugin-owned JSON (launch options, environment refs) is capped per value. */ +export const MAX_PLUGIN_SLOT_BYTES = 4_096; +const MAX_OPTION_PLUGINS = 16; const PROVIDERS = new Set([ "terminal", "codex", @@ -40,7 +44,26 @@ export interface PersistedTerminalSession { position: Point; size: Size; parentSessionId?: string; - codexThreadId?: string; + /** The provider's own conversation id (Codex thread, Claude or OpenCode session) its hook reported. */ + threadId?: string; + /** State at quit or at the moment the process exited; v1 records read as "running". */ + lastState: PersistedLastState; + exitCode?: number | null; + /** False when the person chose "Don't restore this card". */ + restore: boolean; + /** Plugin launch options keyed by plugin id, each opaque and at most 4 KB. */ + options?: Record; + /** Where the session runs when a plugin placed it; opaque to core, at most 4 KB. */ + environment?: PersistedEnvironmentRef; +} + +export type PersistedLastState = "running" | "exited" | "failed"; + +export interface PersistedEnvironmentRef { + pluginId: string; + kind: string; + ref: unknown; + label: string; } interface PersistedTerminalSessionState { @@ -107,20 +130,24 @@ export class TerminalSessionStore { } } -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function normalizeCodexThreadId(provider: ProviderId, candidate: unknown): string | undefined { - if (provider !== "codex") return undefined; - if (typeof candidate !== "string") return undefined; - const trimmed = candidate.trim().toLowerCase(); - return UUID_REGEX.test(trimmed) ? trimmed : undefined; +function normalizeStoredThreadId(provider: ProviderId, candidate: unknown): string | undefined { + return typeof candidate === "string" ? normalizeThreadId(provider, candidate.trim()) : undefined; } +/** What core keeps beside the live metadata: nothing here is scrollback, prompts or secrets. */ +export type PersistedSessionExtras = Pick & { + /** Overrides the derived state while a card is held stopped (its environment is unavailable). */ + heldState?: PersistedLastState; +}; + export function persistedTerminalSession( metadata: SessionMetadata, - codexThreadId?: unknown + threadId?: unknown, + extras: PersistedSessionExtras = {} ): PersistedTerminalSession { - const normalizedCodexThreadId = normalizeCodexThreadId(metadata.provider, codexThreadId); + const normalizedThreadId = normalizeStoredThreadId(metadata.provider, threadId); + const lastState: PersistedLastState = extras.heldState + ?? (metadata.exitCode === null ? "running" : metadata.exitCode === 0 ? "exited" : "failed"); return { id: metadata.id, provider: metadata.provider, @@ -132,14 +159,20 @@ export function persistedTerminalSession( position: { ...metadata.position }, size: { ...metadata.size }, ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), - ...(normalizedCodexThreadId !== undefined ? { codexThreadId: normalizedCodexThreadId } : {}) + ...(normalizedThreadId !== undefined ? { threadId: normalizedThreadId } : {}), + lastState, + ...(lastState !== "running" ? { exitCode: metadata.exitCode } : {}), + restore: metadata.skipRestore !== true, + ...(extras.options ? { options: structuredClone(extras.options) } : {}), + ...(extras.environment ? { environment: structuredClone(extras.environment) } : {}) }; } export function normalizePersistedTerminalSessions(candidate: unknown): PersistedTerminalSessionState { if (!candidate || typeof candidate !== "object") return structuredClone(EMPTY_STATE); - const source = candidate as Partial; - if (source.version !== TERMINAL_SESSION_STORE_VERSION || !Array.isArray(source.sessions)) { + const source = candidate as { version?: unknown; sessions?: unknown }; + // v1 is read-compatible: missing v2 fields mean unknown conversation, no environment, running. + if ((source.version !== 1 && source.version !== TERMINAL_SESSION_STORE_VERSION) || !Array.isArray(source.sessions)) { return structuredClone(EMPTY_STATE); } @@ -147,7 +180,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste const ids = new Set(); for (const value of source.sessions.slice(0, MAX_PERSISTED_SESSIONS)) { if (!value || typeof value !== "object") continue; - const session = value as Partial; + // codexThreadId: the v1 name of threadId (Codex only). + const session = value as Partial & { codexThreadId?: unknown }; if (!isSessionId(session.id) || ids.has(session.id)) continue; if (!PROVIDERS.has(session.provider as ProviderId)) continue; if (session.profile !== "normal" && session.profile !== "yolo") continue; @@ -167,9 +201,18 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste if (role === "subagent" && parentSessionId === undefined) continue; // A damaged or obsolete conversation ID must not make the whole card disappear. // It can still restore with Codex's interactive resume picker. - const codexThreadId = session.provider === "codex" && typeof session.codexThreadId === "string" && UUID_REGEX.test(session.codexThreadId.trim()) - ? session.codexThreadId.trim().toLowerCase() - : undefined; + const threadId = normalizeStoredThreadId( + session.provider as ProviderId, + session.threadId ?? (session.provider === "codex" ? session.codexThreadId : undefined) + ); + const lastState: PersistedLastState = session.lastState === "exited" || session.lastState === "failed" + ? session.lastState + : "running"; + const exitCode = Number.isInteger(session.exitCode) ? session.exitCode as number : null; + const options = normalizeOptions(session.options); + const environment = normalizeEnvironment(session.environment); + // A placed session whose ref is unreadable must not come back as a local one. + if (session.environment !== undefined && !environment) continue; sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -184,13 +227,60 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste height: clamp(session.size.height, 260, 1_100) }, ...(parentSessionId !== undefined ? { parentSessionId } : {}), - ...(codexThreadId !== undefined ? { codexThreadId } : {}) + ...(threadId !== undefined ? { threadId } : {}), + lastState, + ...(lastState !== "running" ? { exitCode } : {}), + restore: session.restore !== false, + ...(options ? { options } : {}), + ...(environment ? { environment } : {}) }); ids.add(session.id); } return { version: TERMINAL_SESSION_STORE_VERSION, sessions }; } +function normalizeOptions(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const options: Record = {}; + for (const [pluginId, entry] of Object.entries(value).slice(0, MAX_OPTION_PLUGINS)) { + if (!isPluginId(pluginId) || !fitsPluginSlot(entry)) continue; + options[pluginId] = structuredClone(entry); + } + return Object.keys(options).length > 0 ? options : undefined; +} + +function normalizeEnvironment(value: unknown): PersistedEnvironmentRef | undefined { + if (!isRecord(value) || !isPluginId(value.pluginId)) return undefined; + if (typeof value.kind !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value.kind)) return undefined; + if (typeof value.label !== "string" || value.label.trim().length === 0) return undefined; + if (value.ref === undefined || !fitsPluginSlot(value.ref)) return undefined; + return { + pluginId: value.pluginId, + kind: value.kind, + ref: structuredClone(value.ref), + label: value.label.trim().slice(0, 80) + }; +} + +/** Same shape PluginManager accepts for plugin ids. */ +function isPluginId(value: unknown): value is string { + return typeof value === "string" && value.length >= 3 && value.length <= 80 + && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value) && !value.includes(".."); +} + +function fitsPluginSlot(value: unknown): boolean { + try { + const json = JSON.stringify(value); + return typeof json === "string" && Buffer.byteLength(json, "utf8") <= MAX_PLUGIN_SLOT_BYTES; + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function isSessionId(value: unknown): value is string { return typeof value === "string" && /^[a-zA-Z0-9._-]{1,128}$/.test(value); } diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 7fd4f551..6fe3c6b8 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -9,6 +9,7 @@ import { MAX_ANSWER_CHARS, MAX_RUNTIME_MESSAGE_BYTES, MAX_RESULT_CHARS, + normalizeThreadId, RUNTIME_PROTOCOL_VERSION, RUNTIME_STATES } from "../../../agent-runtime/runtime-protocol.mjs"; @@ -29,7 +30,8 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; - codexThreadId?: string; + /** The provider's own conversation id (Codex thread, Claude or OpenCode session), as its hook reported it. */ + threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; answerCaptureGrantExpiresAt?: number; @@ -59,7 +61,7 @@ interface ParsedLifecycleMessage { state: RuntimeLifecycleState; event: string; turnId: string | null; - codexThreadId?: string; + threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; } @@ -316,7 +318,7 @@ export class RuntimeGateway { state: message.state, event: message.event, turnId: message.turnId, - ...(message.codexThreadId === undefined ? {} : { codexThreadId: message.codexThreadId }), + ...(message.threadId === undefined ? {} : { threadId: message.threadId }), ...(message.result === undefined ? {} : { result: message.result }), ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) }; @@ -328,7 +330,7 @@ export class RuntimeGateway { state: signal.state, event: signal.event, turnId: signal.turnId, - ...(signal.codexThreadId === undefined ? {} : { codexThreadId: signal.codexThreadId }) + ...(signal.threadId === undefined ? {} : { threadId: signal.threadId }) }; this.onSignal?.(message.terminalSessionId, signal); } @@ -345,7 +347,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { "capabilityToken", "event", "provider", "state", "terminalSessionId", "turnId", "type", "v" ]; if (value.result !== undefined) expected.push("result"); - if (value.codexThreadId !== undefined) expected.push("codexThreadId"); + if (value.threadId !== undefined) expected.push("threadId"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -368,11 +370,10 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { || value.event.length > 80 || (value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) ) throw new Error("Runtime message fields are invalid."); - if (value.codexThreadId !== undefined && ( - value.provider !== "codex" - || typeof value.codexThreadId !== "string" - || !CANONICAL_UUID_RE.test(value.codexThreadId) - )) throw new Error("Runtime codexThreadId is invalid."); + // Only the provider's own id shape, already in its stored form, is accepted. + if (value.threadId !== undefined && normalizeThreadId(String(value.provider), value.threadId) !== value.threadId) { + throw new Error("Runtime threadId is invalid."); + } if (value.result !== undefined && ( value.state !== "idle" || value.event !== "Stop" || !isRecord(value.result) || Object.keys(value.result).sort().join(",") !== "text,truncated" @@ -386,8 +387,6 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { return value as unknown as ParsedLifecycleMessage; } -const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; - function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" diff --git a/src/main/services/sessionRestorePlan.ts b/src/main/services/sessionRestorePlan.ts new file mode 100644 index 00000000..8afdfa68 --- /dev/null +++ b/src/main/services/sessionRestorePlan.ts @@ -0,0 +1,86 @@ +import type { ProviderId, SessionRestoreMode, SessionRestoreNote } from "../../shared/contracts.ts"; +import type { PersistedEnvironmentRef, PersistedTerminalSession } from "./TerminalSessionStore.ts"; +import { canResumeLatestConversation, canResumeThreadById, resumeWithoutIdOpensPicker } from "./terminalLaunch.ts"; + +/** + * How a card starts: a new conversation, the provider's own resume without an id + * (its "latest in this folder" flag, or Codex's resume picker), an exact one, or not at all. + */ +export type ResumeRequest = null | "latest" | { threadId: string }; + +export interface RestoreStep { + record: PersistedTerminalSession; + /** "stopped" comes back as a card with Restart / Continue; nothing is launched. */ + launch: ResumeRequest | "stopped"; + note?: SessionRestoreNote; + /** + * The conversation the card stays tied to: the recorded one when nothing starts or + * it is resumed by id, none when a new conversation starts, so Continue never goes + * back to one that is no longer the card's. + */ + threadId?: string; +} + +/** + * Picks how an agent continues its own conversation: by the id its hook reported + * when there is one. Without an id Codex opens its resume picker, and a "latest in + * this folder" flag is used only when this card is the only card of that CLI in the + * folder; otherwise two cards would continue the same conversation, so it starts + * fresh and says so. + */ +export function chooseResume( + provider: ProviderId, + threadId: string | undefined, + cardsOfProviderInFolder: number +): { resume: ResumeRequest; note?: SessionRestoreNote } { + if (threadId && canResumeThreadById(provider)) return { resume: { threadId } }; + if (resumeWithoutIdOpensPicker(provider)) return { resume: "latest" }; + if (!canResumeLatestConversation(provider)) return { resume: null }; + if (cardsOfProviderInFolder <= 1) return { resume: "latest" }; + return { resume: null, note: "fresh-shared-folder" }; +} + +/** The core's one restore order and rule set, the same for every environment. */ +export function planSessionRestore( + records: readonly PersistedTerminalSession[], + mode: SessionRestoreMode, + context: { + isLiveSession(id: string): boolean; + environmentAvailable(environment: PersistedEnvironmentRef): boolean; + } +): RestoreStep[] { + if (mode === "off") return []; + let kept = records.filter((record) => record.restore); + // A subagent comes back only with its parent; a dropped parent drops its subtree. + for (let changed = true; changed;) { + const ids = new Set(kept.map((record) => record.id)); + const next = kept.filter((record) => record.role !== "subagent" + || ids.has(record.parentSessionId ?? "") || context.isLiveSession(record.parentSessionId ?? "")); + changed = next.length !== kept.length; + kept = next; + } + const byId = new Map(kept.map((record) => [record.id, record])); + const depth = (record: PersistedTerminalSession, seen = new Set()): number => { + const parent = record.parentSessionId ? byId.get(record.parentSessionId) : undefined; + if (!parent || seen.has(parent.id)) return 0; + seen.add(record.id); + return 1 + depth(parent, seen); + }; + const ordered = kept + .map((record, index) => ({ record, index, depth: depth(record) })) + .sort((left, right) => left.depth - right.depth || left.index - right.index) + .map(({ record }) => record); + + return ordered.map((record): RestoreStep => { + const recorded = record.threadId ? { threadId: record.threadId } : {}; + if (record.environment && !context.environmentAvailable(record.environment)) { + return { record, launch: "stopped", note: "environment-unavailable", ...recorded }; + } + if (record.lastState !== "running") return { record, launch: "stopped", ...recorded }; + if (record.provider === "terminal" || mode === "reopen") return { record, launch: null }; + const peers = kept.filter((candidate) => candidate.provider === record.provider && candidate.cwd === record.cwd); + const { resume, note } = chooseResume(record.provider, record.threadId, peers.length); + return { record, launch: resume, ...(note ? { note } : {}), + ...(resume && typeof resume === "object" ? { threadId: resume.threadId } : {}) }; + }); +} diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 53cbee8a..5e95febb 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { win32 } from "node:path"; import type { ProviderId } from "../../shared/contracts.ts"; +import { normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; import { openCodeYoloEnvironment } from "./openCodeConfig.ts"; import { providerTerminalBatchCommandLine, @@ -88,9 +89,37 @@ function resolveResumeArguments( } return ["resume"]; } + const byId = RESUME_BY_ID_ARGUMENTS[provider]; + if (byId && resumeThreadId) { + const threadId = normalizeThreadId(provider, resumeThreadId); + if (!threadId) throw new Error(`Invalid ${provider} session ID format: "${resumeThreadId}".`); + return byId(threadId); + } return RESUME_ARGUMENTS[provider]; } +// The same exact resume for the other CLIs whose hook reports that CLI's own session +// id, each checked against its --help: `claude -r, --resume [value]`, +// `opencode -s, --session `. Everything else continues with RESUME_ARGUMENTS. +const RESUME_BY_ID_ARGUMENTS: Partial, (id: string) => string[]>> = { + claude: (id) => ["--resume", id], + opencode: (id) => ["--session", id] +}; + +export function canResumeThreadById(provider: ProviderId): boolean { + return provider === "codex" || (provider !== "terminal" && RESUME_BY_ID_ARGUMENTS[provider] !== undefined); +} + +/** Without an id, Codex opens its own resume picker, so the person chooses; nothing is guessed. */ +export function resumeWithoutIdOpensPicker(provider: ProviderId): boolean { + return provider === "codex"; +} + +/** The CLI has a "latest conversation in this folder" flag. */ +export function canResumeLatestConversation(provider: ProviderId): boolean { + return provider !== "terminal" && provider !== "codex" && RESUME_ARGUMENTS[provider].length > 0; +} + // Per-provider instead of a fallthrough: the old `return ["--continue"]` default would // have handed an unverified flag to whatever provider was added next. A missing entry is // now a compile error. diff --git a/src/preload/index.ts b/src/preload/index.ts index f864442f..edde12fe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -188,11 +188,12 @@ const api: CanvasTTYApi = { list: () => ipcRenderer.invoke(IPC.terminalList), readBuffer: (id: string) => ipcRenderer.invoke(IPC.terminalReadBuffer, id), create: (request: CreateSessionRequest) => ipcRenderer.invoke(IPC.terminalCreate, request), - restart: (id: string) => ipcRenderer.invoke(IPC.terminalRestart, id), + restart: (id: string, options?: { resume?: boolean }) => ipcRenderer.invoke(IPC.terminalRestart, id, options), input: (id: string, data: string) => ipcRenderer.send(IPC.terminalInput, id, data), resize: (id: string, cols: number, rows: number) => ipcRenderer.send(IPC.terminalResize, id, cols, rows), setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.terminalBounds, id, bounds), rename: (id: string, title: string) => ipcRenderer.invoke(IPC.terminalRename, id, title), + setRestore: (id: string, restore: boolean) => ipcRenderer.invoke(IPC.terminalSetRestore, id, restore), dispose: (id: string) => ipcRenderer.invoke(IPC.terminalDispose, id), setVisible: (id: string, visible: boolean) => ipcRenderer.send(IPC.terminalSetVisible, id, visible), onData: (listener: (event: TerminalDataEvent) => void) => subscribe(IPC.terminalData, listener), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 754bc1ad..98188366 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -72,7 +72,7 @@ interface HomeEditDraft { const FALLBACK_SETTINGS: AppSettings = { locale: "ru", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -441,9 +441,9 @@ export function App(): React.JSX.Element { showToast(`${t(settings.locale, "sessionStarted")}: ${provider}`); }, [createSession, launchPosition, settings.locale, showToast]); - const restartSession = useCallback(async (id: string): Promise => { + const restartSession = useCallback(async (id: string, resume = false): Promise => { try { - await window.canvasTTY.terminal.restart(id); + await window.canvasTTY.terminal.restart(id, { resume }); showToast(t(settings.locale, "sessionRestarted")); } catch (error) { showToast(error instanceof Error ? error.message : t(settings.locale, "restartFailed")); diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index 74549691..450f1164 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -30,6 +30,7 @@ import type { PluginInstallPreview, PluginUpdateStatus, RadialLauncherItemId, + SessionRestoreMode, SessionRowColorMode, ShortcutAction, UpdaterState, @@ -425,9 +426,13 @@ export function SettingsPanel({ description={t(locale, "terminalSessionRestoreDescription")} > void onChange({ restoreTerminalSessions: value === "save" })} + value={settings.sessionRestoreMode} + options={[ + ["off", t(locale, "doNotSave")], + ["reopen", t(locale, "sessionRestoreReopen")], + ["continue", t(locale, "sessionRestoreContinue")] + ]} + onChange={(value) => void onChange({ sessionRestoreMode: value as SessionRestoreMode })} /> diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 6df9f022..2afb4b0a 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -66,8 +66,10 @@ interface TerminalCardProps { onRename(id: string, title: string): Promise; onRenameEnd(): void; onBoundsChange(id: string, bounds: SessionBounds): void; - onRestart(id: string): Promise; + onRestart(id: string, resume?: boolean): Promise; onDispose(id: string): void; + /** Saving sessions is on, so the per-card "Don't restore" choice applies. */ + restoreEnabled?: boolean; onOpenUrl(url: string): void; } @@ -123,7 +125,8 @@ export function TerminalCard({ onBoundsChange, onRestart, onDispose, - onOpenUrl + onOpenUrl, + restoreEnabled = false }: TerminalCardProps): React.JSX.Element { const terminalHost = useRef(null); const terminalRef = useRef(null); @@ -140,7 +143,7 @@ export function TerminalCard({ const suppressFocusReport = useRef(false); const sessionExited = useRef(session.exitCode !== null); sessionExited.current = session.exitCode !== null; - const restartAction = useRef<() => Promise>(async () => undefined); + const restartAction = useRef<(resume?: boolean) => Promise>(async () => undefined); const invertTerminalWheelRef = useRef(invertTerminalWheel); invertTerminalWheelRef.current = invertTerminalWheel; const captureCanvasWheelRef = useRef(captureCanvasWheelOverWidgets); @@ -150,6 +153,8 @@ export function TerminalCard({ const [position, setPosition] = useState(session.position); const [size, setSize] = useState(session.size); const [restarting, setRestarting] = useState(false); + const [optionsOpen, setOptionsOpen] = useState(false); + const [noteDismissed, setNoteDismissed] = useState(null); const liveBounds = useRef({ position: session.position, size: session.size }); const summaryMode = zoom < 0.5; const summaryScale = summaryMode ? Math.min(2.5, Math.max(1, 0.5 / zoom)) : 1; @@ -170,11 +175,11 @@ export function TerminalCard({ const visibleTitleRef = useRef(visibleTitle); visibleTitleRef.current = visibleTitle; - restartAction.current = async () => { + restartAction.current = async (resume = false) => { if (restarting || !sessionExited.current) return; setRestarting(true); try { - await onRestart(session.id); + await onRestart(session.id, resume); const terminal = terminalRef.current; if (terminal) window.canvasTTY.terminal.resize(session.id, terminal.cols, terminal.rows); } finally { @@ -756,6 +761,31 @@ export function TerminalCard({ )} + {session.exitCode !== null && session.provider !== "terminal" && ( + + )} + {restoreEnabled && ( + + )} + + )} {searchOpen && !summaryMode && (
; + onRestartSession(id: string, resume?: boolean): Promise; onDisposeSession(id: string): void; onBrowserBoundsChange(bounds: BrowserCanvasState): void; onFocusBrowser(): void; @@ -865,6 +865,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onBoundsChange={onSessionBoundsChange} onRestart={onRestartSession} onDispose={onDisposeSession} + restoreEnabled={settings.sessionRestoreMode !== "off"} onOpenUrl={onOpenTerminalUrl} /> ))} @@ -1017,6 +1018,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onBoundsChange={() => {}} onRestart={onRestartSession} onDispose={onDisposeSession} + restoreEnabled={settings.sessionRestoreMode !== "off"} onOpenUrl={onOpenTerminalUrl} /> ))} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index 27f575f2..7ecf6946 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -260,8 +260,15 @@ const ru = { apiProfileFieldModel: "Модель", apiProfileFieldSecret: "API-ключ", language: "Язык", - terminalSessionRestore: "Окна после перезапуска", - terminalSessionRestoreDescription: "Сохраняет открытые окна; агенты продолжают последнюю сессию в той же папке, обычные терминалы открываются заново.", + terminalSessionRestore: "Сессии агентов после перезапуска", + terminalSessionRestoreDescription: "«Открыть окна» возвращает карточки, папки и роли, а агенты начинают новый разговор. «Продолжить разговоры» возвращает каждому агенту его собственный разговор. Завершённые агенты возвращаются остановленными. Буфер терминала, промпты и секреты не сохраняются.", + sessionRestoreReopen: "Открыть окна", + sessionRestoreContinue: "Продолжить разговоры", + cardOptions: "Параметры окна", + cardSkipRestore: "Не восстанавливать это окно", + continueSession: "Продолжить разговор", + restoreNoteSharedFolder: "Начат новый разговор: в этой папке открыто ещё одно окно этого агента, и последний разговор мог принадлежать ему.", + restoreNoteEnvironment: "Окно остановлено: среда, в которой оно работало, недоступна. Локально оно не запускается.", persistCanvasRegions: "Сохранять цветные зоны после выхода", persistStickyNotes: "Сохранять заметки после выхода", doNotSave: "Не сохранять", @@ -880,8 +887,15 @@ const en: Record = { apiProfileFieldModel: "Model", apiProfileFieldSecret: "API key", language: "Language", - terminalSessionRestore: "Windows after restart", - terminalSessionRestoreDescription: "Saves open windows; agents continue the latest session in the same folder, while plain terminals reopen.", + terminalSessionRestore: "Agent sessions after restart", + terminalSessionRestoreDescription: "Reopen windows brings back cards, folders and roles, and each agent starts a new conversation. Continue conversations also resumes each agent's own conversation. Finished agents come back stopped. Scrollback, prompts and secrets are never saved.", + sessionRestoreReopen: "Reopen windows", + sessionRestoreContinue: "Continue conversations", + cardOptions: "Window options", + cardSkipRestore: "Don't restore this card", + continueSession: "Continue conversation", + restoreNoteSharedFolder: "Started a new conversation: another card of this agent uses this folder, so the latest conversation could be its.", + restoreNoteEnvironment: "Stopped: the environment this card ran in is unavailable. It is not started locally.", persistCanvasRegions: "Save colored regions after exit", persistStickyNotes: "Save notes after exit", doNotSave: "Do not save", diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index a5abfa43..172aa1ab 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1110,6 +1110,11 @@ button { border: 0; } .terminal-card__search input:focus { border-color: var(--primary); } .terminal-card__search button { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } .terminal-card__search button:hover { color: white; background: rgba(255,255,255,.16); } +.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + 6px); right: 10px; padding: 6px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 9px; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: 12px; font-weight: 700; } +.terminal-card__menu label { display: flex; align-items: center; gap: 8px; padding: 4px 2px; cursor: pointer; } +.terminal-card__note { position: absolute; z-index: 17; left: 10px; right: 10px; bottom: 10px; display: flex; align-items: flex-start; gap: 8px; padding: 7px 9px; border-radius: 9px; color: white; background: rgba(28,30,40,.94); box-shadow: var(--shadow-md); font-size: 12px; font-weight: 650; line-height: 1.35; } +.terminal-card__note span { flex: 1; min-width: 0; } +.terminal-card__note button { flex: none; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } .terminal-card__search-count { min-width: 46px; color: rgba(255,255,255,.62); font: 750 11px var(--font-mono); text-align: center; } .canvas-marquee { position: absolute; z-index: 40; border: 1px solid color-mix(in srgb, var(--primary) 78%, transparent); border-radius: 6px; background: color-mix(in srgb, var(--primary) 18%, transparent); pointer-events: none; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 3e6cc7af..01554fca 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -13,6 +13,14 @@ export type LaunchProfileId = "normal" | "yolo"; export type LaunchRole = "agent" | "orchestrator"; export type SessionRole = LaunchRole | "subagent"; export type SessionStatus = "idle" | "working" | "needs_approval" | "unavailable" | "done" | "failed"; +/** Settings → General, "Agent sessions after restart". */ +export type SessionRestoreMode = "off" | "reopen" | "continue"; +/** + * Why a restored card is not simply running as before: it started a new + * conversation because another card of that CLI shares its folder, or it is + * held stopped because the environment it ran in is unavailable. + */ +export type SessionRestoreNote = "fresh-shared-folder" | "environment-unavailable"; export type PaletteId = "sage" | "lilac" | "night"; export type HomeAccentPresetId = "classic" | "warm" | "cool" | "mono" | "custom"; export type SessionRowColorMode = "monochrome" | "status"; @@ -185,7 +193,7 @@ export interface CameraState extends Point { export interface AppSettings { locale: LocaleId; - restoreTerminalSessions: boolean; + sessionRestoreMode: SessionRestoreMode; persistCanvasRegions: boolean; persistStickyNotes: boolean; palette: PaletteId; @@ -280,6 +288,9 @@ export interface SessionMetadata { startedAt: number; exitCode: number | null; failureDetails: string | null; + /** Set when the person chose "Don't restore this card". */ + skipRestore?: boolean; + restoreNote?: SessionRestoreNote; } export interface SessionSnapshot extends SessionMetadata { @@ -1179,11 +1190,13 @@ export interface CanvasTTYApi { list(): Promise; readBuffer(id: string): Promise; create(request: CreateSessionRequest): Promise; - restart(id: string): Promise; + /** `resume` continues the card's own conversation instead of starting a new one. */ + restart(id: string, options?: { resume?: boolean }): Promise; input(id: string, data: string): void; resize(id: string, cols: number, rows: number): void; setBounds(id: string, bounds: SessionBounds): void; rename(id: string, title: string): Promise; + setRestore(id: string, restore: boolean): Promise; dispose(id: string): Promise; /** Report whether the card renders live output; hidden cards keep history but skip streaming. */ setVisible(id: string, visible: boolean): void; @@ -1312,6 +1325,7 @@ export const IPC = { terminalResize: "terminal:resize", terminalBounds: "terminal:bounds", terminalRename: "terminal:rename", + terminalSetRestore: "terminal:set-restore", terminalDispose: "terminal:dispose", terminalData: "terminal:data", terminalSession: "terminal:session", diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index a70b4978..d92e7b0f 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -240,7 +240,7 @@ test("a control write waiting on terminal replay cannot reach a restarted sessio test("YOLO persists across native restart/restore while stale control grants fail", localSocket, async (t) => { const f = await fixture(t); - f.terminals.configureSessionPersistence(new TerminalSessionStore(f.root), true); + f.terminals.configureSessionPersistence(new TerminalSessionStore(f.root), "continue"); const { session } = await f.create(); f.calls[0].pty.exit(1); await delay(2); @@ -251,7 +251,7 @@ test("YOLO persists across native restart/restore while stale control grants fai const restoredCalls = []; const restored = new TerminalManager(() => {}, registry(), undefined, undefined, true, (_command, args) => { restoredCalls.push(args); return { onData() {}, onExit() {}, kill() {}, write() {}, resize() {} }; }); - restored.configureSessionPersistence(new TerminalSessionStore(f.root), true); + restored.configureSessionPersistence(new TerminalSessionStore(f.root), "continue"); await restored.restorePersistedSessions(); t.after(() => restored.shutdown()); assert.ok(restoredCalls[0].includes("--dangerously-bypass-approvals-and-sandbox")); diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index 89992067..38cf80c6 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -46,12 +46,17 @@ test("RuntimeGateway accepts one authenticated hook event over a mode-0600 local }, stdio: ["pipe", "ignore", "pipe"] }); - child.stdin.end(JSON.stringify({ prompt: "must stay local", prompt_id: "turn-one" })); + child.stdin.end(JSON.stringify({ prompt: "must stay local", prompt_id: "turn-one", session_id: "5f1c2a90-aa11-4b22-9c33-0d44e55f6677" })); const result = await childResult(child); assert.equal(result.code, 0, result.stderr); assert.deepEqual(signals, [{ id: "terminal-one", - signal: { state: "working", event: "UserPromptSubmit", turnId: "turn-one" } + signal: { + state: "working", + event: "UserPromptSubmit", + turnId: "turn-one", + threadId: "5f1c2a90-aa11-4b22-9c33-0d44e55f6677" + } }]); assert.equal(JSON.stringify(signals).includes("must stay local"), false); }); @@ -105,9 +110,13 @@ test("RuntimeGateway rejects a wrong capability and ignores a stale turn complet assert.deepEqual(signals.map(({ signal }) => signal.state), ["working"]); assert.equal(gateway.currentStatus("terminal-two"), "working"); + + // A thread id reaches a provider argv on restore, so a flag-shaped one is refused. + await send(capability.address, { ...message(capability, "idle", "Stop", "turn-new"), threadId: "--config=evil" }); + assert.deepEqual(signals.map(({ signal }) => signal.state), ["working"]); }); -test("RuntimeGateway propagates codexThreadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { +test("RuntimeGateway propagates threadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); @@ -132,7 +141,7 @@ test("RuntimeGateway propagates codexThreadId for codex sessions with canonical assert.equal(result.code, 0, result.stderr); assert.equal(signals.length, 1); assert.equal(signals[0].signal.turnId, "turn-codex-1"); - assert.equal(signals[0].signal.codexThreadId, validUuid); + assert.equal(signals[0].signal.threadId, validUuid); }); test("RuntimeGateway normalizes uppercase UUID to lowercase canonical UUID for codex", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { @@ -160,36 +169,36 @@ test("RuntimeGateway normalizes uppercase UUID to lowercase canonical UUID for c const result = await childResult(child); assert.equal(result.code, 0, result.stderr); assert.equal(signals.length, 1); - assert.equal(signals[0].signal.codexThreadId, lowerUuid); + assert.equal(signals[0].signal.threadId, lowerUuid); }); -test("RuntimeGateway ignores codexThreadId when non-canonical or cross-provider", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { +test("RuntimeGateway ignores threadId when non-canonical or from a provider without exact resume", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); await gateway.start(); t.after(() => gateway.close()); - // 1. Cross-provider: claude provider with session_id UUID in hook input - const claudeCap = gateway.registerSession("terminal-claude-test", "claude"); + // 1. Cross-provider: qwen has no exact resume, so its session_id UUID is dropped + const qwenCap = gateway.registerSession("terminal-qwen-test", "qwen"); const validUuid = "12345678-1234-1234-1234-123456789abc"; const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); - const claudeChild = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + const qwenChild = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { env: { ...process.env, - [AGENT_RUNTIME_ENV.address]: claudeCap.address, - [AGENT_RUNTIME_ENV.terminalSessionId]: claudeCap.terminalSessionId, - [AGENT_RUNTIME_ENV.provider]: claudeCap.provider, - [AGENT_RUNTIME_ENV.capabilityToken]: claudeCap.capabilityToken + [AGENT_RUNTIME_ENV.address]: qwenCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: qwenCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: qwenCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: qwenCap.capabilityToken }, stdio: ["pipe", "ignore", "pipe"] }); - claudeChild.stdin.end(JSON.stringify({ turn_id: "turn-claude-1", session_id: validUuid })); - const claudeResult = await childResult(claudeChild); - assert.equal(claudeResult.code, 0, claudeResult.stderr); + qwenChild.stdin.end(JSON.stringify({ turn_id: "turn-qwen-1", session_id: validUuid })); + const qwenResult = await childResult(qwenChild); + assert.equal(qwenResult.code, 0, qwenResult.stderr); assert.equal(signals.length, 1); - assert.equal(signals[0].signal.codexThreadId, undefined); + assert.equal(signals[0].signal.threadId, undefined); // 2. Malformed UUID: not canonical format (e.g. invalid chars, wrong length, path traversal) const codexCap = gateway.registerSession("terminal-codex-malformed", "codex"); @@ -216,18 +225,18 @@ test("RuntimeGateway ignores codexThreadId when non-canonical or cross-provider" const badRes = await childResult(childBad); assert.equal(badRes.code, 0, badRes.stderr); } - // All malformed ones should either be omitted or ignored without codexThreadId + // All malformed ones should either be omitted or ignored without threadId for (let i = 1; i < signals.length; i++) { - assert.equal(signals[i].signal.codexThreadId, undefined); + assert.equal(signals[i].signal.threadId, undefined); } - // 3. Direct protocol injection with cross-provider codexThreadId is rejected - await send(claudeCap.address, { - ...message(claudeCap, "working", "UserPromptSubmit", "turn-claude-direct"), - codexThreadId: validUuid + // 3. Direct protocol injection with cross-provider threadId is rejected + await send(qwenCap.address, { + ...message(qwenCap, "working", "UserPromptSubmit", "turn-qwen-direct"), + threadId: validUuid }); // Signal should not be delivered or accepted - assert.equal(signals.filter((s) => s.id === "terminal-claude-test").length, 1); + assert.equal(signals.filter((s) => s.id === "terminal-qwen-test").length, 1); }); test("ordinary Codex Stop reports omit answer text without an explicit capture grant", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { diff --git a/tests/api-profiles.test.mjs b/tests/api-profiles.test.mjs index c8e395f1..545a78ce 100644 --- a/tests/api-profiles.test.mjs +++ b/tests/api-profiles.test.mjs @@ -65,7 +65,7 @@ test("the profile catalog is capped at 32 entries", () => { assert.equal(normalizeApiProfiles(many, []).length, 32); }); -test("api profiles persist through the settings store and settingsVersion reaches 20", async (t) => { +test("api profiles persist through the settings store and settingsVersion is current", async (t) => { const directory = await mkdtemp(join(tmpdir(), "canvastty-settings-apiprofiles-")); t.after(() => rm(directory, { recursive: true, force: true })); const store = new SettingsStore(directory, "en"); @@ -85,7 +85,7 @@ test("api profiles persist through the settings store and settingsVersion reache assert.equal(reloaded.apiProfiles[1].defaultModel, "claude-sonnet-4-6"); const persisted = JSON.parse(await (await import("node:fs/promises")).readFile(join(directory, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.apiProfiles.length, 2); }); diff --git a/tests/attention-notifications.test.mjs b/tests/attention-notifications.test.mjs index f85222df..cea73027 100644 --- a/tests/attention-notifications.test.mjs +++ b/tests/attention-notifications.test.mjs @@ -112,7 +112,7 @@ test("a restored session whose folder vanished announces its failure as restore- try { const store = await persistedStore(dir, join(dir, "deleted-folder")); const { manager, announcements } = createManager(t); - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); @@ -131,7 +131,7 @@ test("a restored session whose CLI is gone announces its failure as restore-deri const store = await persistedStore(dir, process.cwd()); const { manager, announcements, availability } = createManager(t); availability.state = "unavailable"; - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); @@ -147,7 +147,7 @@ test("a restored session that still launches announces no failure origin", async try { const store = await persistedStore(dir, process.cwd()); const { manager, announcements } = createManager(t); - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); diff --git a/tests/session-hierarchy.test.mjs b/tests/session-hierarchy.test.mjs index b0d16a85..1d81061f 100644 --- a/tests/session-hierarchy.test.mjs +++ b/tests/session-hierarchy.test.mjs @@ -133,7 +133,7 @@ test("hierarchy persists and orphan subagents are dropped on restore", async (t) const calls = []; const first = manager(calls); const store = new TerminalSessionStore(directory); - first.configureSessionPersistence(store, true); + first.configureSessionPersistence(store, "continue"); const parent = first.create({ provider: "codex", cwd: process.cwd(), @@ -160,7 +160,7 @@ test("hierarchy persists and orphan subagents are dropped on restore", async (t) const secondCalls = []; const second = manager(secondCalls); - second.configureSessionPersistence(new TerminalSessionStore(directory), true); + second.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await second.restorePersistedSessions(); const restored = second.list(); assert.deepEqual( @@ -178,7 +178,7 @@ test("hierarchy persists and orphan subagents are dropped on restore", async (t) const thirdCalls = []; const third = manager(thirdCalls); - third.configureSessionPersistence(new TerminalSessionStore(directory), true); + third.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await third.restorePersistedSessions(); assert.deepEqual(third.list().map((session) => session.role), ["agent"]); await third.shutdown(); diff --git a/tests/session-restore-v2.test.mjs b/tests/session-restore-v2.test.mjs new file mode 100644 index 00000000..0408358d --- /dev/null +++ b/tests/session-restore-v2.test.mjs @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { + TerminalSessionStore, + normalizePersistedTerminalSessions +} from "../src/main/services/TerminalSessionStore.ts"; +import { planSessionRestore } from "../src/main/services/sessionRestorePlan.ts"; +import { SettingsStore } from "../src/main/services/SettingsStore.ts"; + +const CONVERSATION = "5f1c2a90-aa11-4b22-9c33-0d44e55f6677"; +const base = { + provider: "claude", + profile: "normal", + role: "agent", + title: "Agent", + titleCustomized: false, + cwd: process.cwd(), + position: { x: 0, y: 0 }, + size: { width: 700, height: 430 } +}; +const record = (id, extra = {}) => ({ ...base, id, lastState: "running", restore: true, ...extra }); +const noEnvironment = { isLiveSession: () => false, environmentAvailable: () => false }; + +function registry() { + return { + get(provider) { + return { state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", + environment: { PATH: "/usr/bin" }, checked: [] }; + }, + snapshot() { return {}; } + }; +} + +function spawner(calls) { + return (command, args, options) => { + const exits = []; + calls.push({ command, args, options, exit: (code) => exits.forEach((listener) => listener({ exitCode: code })) }); + return { + pid: 30_000 + calls.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; } + }; + }; +} + +async function withManagers(t) { + const directory = await mkdtemp(join(tmpdir(), "canvastty-restore-v2-")); + const managers = []; + // Hooks run in order: stop every manager (and its pending writes) before removing the folder. + t.after(() => Promise.all(managers.map((manager) => manager.shutdown()))); + t.after(() => rm(directory, { recursive: true, force: true })); + return { + directory, + async start(mode, calls = []) { + const manager = new TerminalManager(() => undefined, registry(), undefined, undefined, true, spawner(calls)); + managers.push(manager); + manager.configureSessionPersistence(new TerminalSessionStore(directory), mode); + await manager.restorePersistedSessions(); + return { manager, calls }; + } + }; +} + +test("a v1 store is read as v2 with no environment and running state; a v1 Codex thread id is kept", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-store-v1-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const store = new TerminalSessionStore(directory); + await writeFile(store.filePath, JSON.stringify({ version: 1, sessions: [ + { ...base, id: "legacy" }, + { ...base, id: "legacy-codex", provider: "codex", codexThreadId: CONVERSATION } + ] })); + assert.deepEqual(await store.load(), [record("legacy"), record("legacy-codex", { provider: "codex", threadId: CONVERSATION })]); + await store.flush(); + const written = JSON.parse(await readFile(store.filePath, "utf8")); + assert.equal(written.version, 2); + assert.deepEqual(normalizePersistedTerminalSessions({ version: 3, sessions: [record("future")] }).sessions, []); +}); + +test("v2 slots are validated: thread ids, 4 KB plugin options and environment refs", () => { + const environment = { pluginId: "canvastty.environments", kind: "container", ref: { id: "c-1" }, label: "Container" }; + const { sessions } = normalizePersistedTerminalSessions({ + version: 2, + sessions: [ + record("kept", { + lastState: "failed", exitCode: 2, + threadId: CONVERSATION, + options: { "good.plugin": { a: 1 }, "Bad Id": {}, "big.plugin": "x".repeat(5_000) }, + environment + }), + record("flag-id", { threadId: "--resume-evil", restore: false }), + record("bad-environment", { environment: { ...environment, ref: "x".repeat(5_000) } }) + ] + }); + assert.deepEqual(sessions, [ + record("kept", { lastState: "failed", exitCode: 2, threadId: CONVERSATION, + options: { "good.plugin": { a: 1 } }, environment }), + record("flag-id", { restore: false }) + ]); +}); + +test("the restore plan orders parents first and applies the per-card and shared-folder rules", () => { + const records = [ + record("child", { role: "subagent", parentSessionId: "parent" }), + record("parent", { role: "orchestrator", threadId: CONVERSATION }), + record("skipped", { restore: false }), + record("orphan", { role: "subagent", parentSessionId: "skipped" }), + record("finished", { provider: "codex", cwd: "/elsewhere", lastState: "exited", exitCode: 0, threadId: CONVERSATION }), + record("solo", { provider: "codex", cwd: "/solo" }), + record("shared-codex", { provider: "codex", cwd: "/solo" }), + record("placed", { provider: "codex", cwd: "/placed", threadId: CONVERSATION, + environment: { pluginId: "canvastty.environments", kind: "container", ref: {}, label: "Container" } }) + ]; + const steps = planSessionRestore(records, "continue", noEnvironment); + assert.deepEqual(steps.map((step) => [step.record.id, step.launch, step.note]), [ + ["parent", { threadId: CONVERSATION }, undefined], + ["finished", "stopped", undefined], + // Without an id Codex opens its resume picker, even beside another Codex card. + ["solo", "latest", undefined], + ["shared-codex", "latest", undefined], + ["placed", "stopped", "environment-unavailable"], + ["child", null, "fresh-shared-folder"] + ]); + // Stopped and held cards keep their id for Continue; only an exact resume carries it into a start. + assert.deepEqual(steps.map((step) => step.threadId), + [CONVERSATION, CONVERSATION, undefined, undefined, CONVERSATION, undefined]); + const reopen = planSessionRestore(records, "reopen", noEnvironment); + assert.deepEqual(reopen.map((step) => step.launch), [null, "stopped", null, null, "stopped", null]); + assert.deepEqual(reopen.map((step) => step.threadId), + [undefined, CONVERSATION, undefined, undefined, CONVERSATION, undefined]); + assert.deepEqual(planSessionRestore(records, "off", noEnvironment), []); +}); + +test("Continue resumes each card's own conversation and never shares the folder's latest one", async (t) => { + const fixture = await withManagers(t); + const { manager } = await fixture.start("continue"); + const first = manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const opencode = manager.create({ provider: "opencode", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", threadId: CONVERSATION }); + manager.applyProviderSignal(opencode.id, { kind: "lifecycle", state: "idle", threadId: "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn" }); + await manager.shutdown(); + + const { manager: restored, calls } = await fixture.start("continue"); + const [claudeById, claudeShared, codexSolo, opencodeById] = calls.map((call) => call.args); + assert.deepEqual(opencodeById.slice(-2), ["--session", "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn"]); + assert.deepEqual(claudeById.slice(-2), ["--resume", CONVERSATION]); + assert.equal(claudeShared.includes("--continue"), false); + assert.equal(codexSolo.at(-1), "resume"); + assert.deepEqual(restored.list().map((session) => session.restoreNote), [undefined, "fresh-shared-folder", undefined, undefined]); + const saved = JSON.parse(await readFile(join(fixture.directory, "terminal-sessions.json"), "utf8")); + assert.equal(saved.sessions[0].threadId, CONVERSATION); + assert.doesNotMatch(JSON.stringify(saved), /buffer|prompt|token|capability/u); +}); + +test("Reopen windows starts every agent fresh and forgets the old conversation; Don't save clears the store", async (t) => { + const fixture = await withManagers(t); + const savedThreadIds = async () => JSON.parse(await readFile(join(fixture.directory, "terminal-sessions.json"), "utf8")) + .sessions.map((session) => session.threadId); + // 1. A running card is saved with conversation A. + const { manager } = await fixture.start("continue"); + const card = manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.applyProviderSignal(card.id, { kind: "lifecycle", state: "idle", threadId: CONVERSATION }); + await manager.shutdown(); + assert.deepEqual(await savedThreadIds(), [CONVERSATION]); + + // 2. Reopen starts it fresh and no lifecycle hook reports a new id. + const { manager: reopened, calls } = await fixture.start("reopen"); + // 3. Neither the launch nor the saved record is tied to A, also after switching to Continue conversations. + assert.equal(calls.length, 1); + assert.equal(calls[0].args.includes("--resume"), false); + assert.equal(calls[0].args.includes(CONVERSATION), false); + assert.deepEqual(await savedThreadIds(), [undefined]); + await reopened.setSessionRestoreMode("continue"); + assert.deepEqual(await savedThreadIds(), [undefined]); + // 4. After the fresh process exits, Continue does not resume A. + calls[0].exit(0); + reopened.restart(card.id, { resume: true }); + assert.equal(calls.length, 2); + assert.equal(calls[1].args.includes("--resume"), false); + assert.equal(calls[1].args.includes(CONVERSATION), false); + await reopened.setSessionRestoreMode("off"); + assert.deepEqual(new TerminalSessionStore(fixture.directory).get(), []); + assert.deepEqual(await new TerminalSessionStore(fixture.directory).load(), []); +}); + +test("finished cards come back stopped with Restart and Continue; skipped cards stay gone", async (t) => { + const fixture = await withManagers(t); + const { manager, calls: firstCalls } = await fixture.start("continue"); + const finished = manager.create({ provider: "claude", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const skipped = manager.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.applyProviderSignal(finished.id, { kind: "lifecycle", state: "idle", threadId: CONVERSATION }); + firstCalls[0].exit(0); + assert.equal(manager.setRestore(skipped.id, false).skipRestore, true); + await manager.shutdown(); + + const { manager: restored, calls } = await fixture.start("continue"); + assert.equal(calls.length, 0); + assert.deepEqual(restored.list().map(({ id, status, exitCode }) => ({ id, status, exitCode })), + [{ id: finished.id, status: "done", exitCode: 0 }]); + restored.restart(finished.id, { resume: true }); + assert.deepEqual(calls[0].args.slice(-2), ["--resume", CONVERSATION]); + // A plain Restart is a new conversation: the id is forgotten, so Continue later cannot pick the old one. + calls[0].exit(0); + restored.restart(finished.id); + assert.equal(calls[1].args.includes("--resume"), false); + assert.equal(restored.list()[0].exitCode, null); + calls[1].exit(0); + restored.restart(finished.id, { resume: true }); + assert.deepEqual(calls[2].args.slice(-1), ["--continue"]); +}); + +test("a placed card whose environment is unavailable stays stopped and never runs locally", async (t) => { + const fixture = await withManagers(t); + const environment = { pluginId: "canvastty.environments", kind: "container", ref: { id: "c-1" }, label: "Container" }; + await new TerminalSessionStore(fixture.directory).replace([record("placed", { environment, threadId: CONVERSATION })]); + const { manager, calls } = await fixture.start("continue"); + assert.equal(calls.length, 0); + const [card] = manager.list(); + assert.equal(card.restoreNote, "environment-unavailable"); + assert.match(card.failureDetails, /canvastty\.environments/u); + assert.throws(() => manager.restart("placed"), /not started locally/u); + assert.equal(calls.length, 0); + await manager.shutdown(); + const [kept] = await new TerminalSessionStore(fixture.directory).load(); + assert.equal(kept.lastState, "running"); + assert.equal(kept.threadId, CONVERSATION); + assert.deepEqual(kept.environment, environment); +}); + +test("settings migrate the old switch: true continues conversations, false saves nothing", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-settings-restore-mode-")); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const [legacy, expected] of [[true, "continue"], [false, "off"]]) { + await writeFile(join(directory, "settings.json"), JSON.stringify({ settingsVersion: 20, restoreTerminalSessions: legacy })); + const settings = await new SettingsStore(directory, "en").load(); + assert.equal(settings.sessionRestoreMode, expected); + const written = JSON.parse(await readFile(join(directory, "settings.json"), "utf8")); + assert.equal(written.sessionRestoreMode, expected); + assert.equal("restoreTerminalSessions" in written, false); + } +}); diff --git a/tests/settings-normalizer.test.mjs b/tests/settings-normalizer.test.mjs index 987f7d56..8eb47c07 100644 --- a/tests/settings-normalizer.test.mjs +++ b/tests/settings-normalizer.test.mjs @@ -12,7 +12,7 @@ import { const fallback = { locale: "en", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -160,7 +160,8 @@ test("terminal restore remains opt-in and canvas regions are bounded and normali }] }, fallback); - assert.equal(normalized.restoreTerminalSessions, true); + // The old on/off switch continued conversations, so "on" migrates to "continue". + assert.equal(normalized.sessionRestoreMode, "continue"); assert.deepEqual(normalized.canvasRegions, [{ id: "region-1", title: "Backend", @@ -168,7 +169,11 @@ test("terminal restore remains opt-in and canvas regions are bounded and normali position: { x: 20, y: 30 }, size: { width: 360, height: 3_000 } }]); - assert.equal(normalizeSettings({ restoreTerminalSessions: "yes" }, fallback).restoreTerminalSessions, false); + assert.equal(normalizeSettings({ restoreTerminalSessions: "yes" }, fallback).sessionRestoreMode, "off"); + assert.equal(normalizeSettings({ restoreTerminalSessions: false }, fallback).sessionRestoreMode, "off"); + assert.equal(normalizeSettings({ sessionRestoreMode: "reopen", restoreTerminalSessions: true }, fallback).sessionRestoreMode, "reopen"); + assert.equal(normalizeSettings({ sessionRestoreMode: "later" }, fallback).sessionRestoreMode, "off"); + assert.equal("restoreTerminalSessions" in normalized, false); }); test("colored regions and notes use independent exit persistence gates", async () => { @@ -419,7 +424,7 @@ test("the Qwen migration does not rerun the older expanded-limit migration", asy assert.deepEqual(loaded.homeLimitProviders, ["codex", "claude", "kimi"]); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, ["codex", "claude", "kimi"]); } finally { @@ -442,7 +447,7 @@ test("the limit-display migration preserves a version-three launcher subset", as assert.deepEqual(loaded.homeLimitProviders, fallback.homeLimitProviders); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, fallback.homeLimitProviders); } finally { @@ -462,7 +467,7 @@ test("the expanded limit migration preserves a curated version-four subset", asy assert.deepEqual(loaded.homeLimitProviders, ["kimi"]); const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.agentLifecycleHooksEnabled, true); assert.deepEqual(persisted.homeLimitProviders, ["kimi"]); } finally { @@ -614,7 +619,7 @@ test("existing profiles migrate minimap interaction to click and persist later c const store = new SettingsStore(dir, "en"); assert.equal((await store.load()).minimapInteractionMode, "click"); let persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); - assert.equal(persisted.settingsVersion, 20); + assert.equal(persisted.settingsVersion, 21); assert.equal(persisted.minimapInteractionMode, "click"); await store.update({ minimapInteractionMode: "drag" }); diff --git a/tests/settings-ui.test.mjs b/tests/settings-ui.test.mjs index 75e0e00d..bea2e2d3 100644 --- a/tests/settings-ui.test.mjs +++ b/tests/settings-ui.test.mjs @@ -92,8 +92,8 @@ test("canvas overlays share configurable collision-safe corner slots", async () test("General keeps independent persistence controls", async () => { const settings = await readFile(settingsPanelPath, "utf8"); - assert.match(settings, /settings\.restoreTerminalSessions \? "save" : "discard"/); - assert.match(settings, /restoreTerminalSessions: value === "save"/); + assert.match(settings, /value=\{settings\.sessionRestoreMode\}/); + assert.match(settings, /\["off", t\(locale, "doNotSave"\)\],\s*\["reopen", t\(locale, "sessionRestoreReopen"\)\],\s*\["continue", t\(locale, "sessionRestoreContinue"\)\]/); assert.match(settings, /settings\.persistCanvasRegions \? "save" : "discard"/); assert.match(settings, /persistCanvasRegions: value === "save"/); assert.match(settings, /settings\.persistStickyNotes \? "save" : "discard"/); diff --git a/tests/terminal-launch.test.mjs b/tests/terminal-launch.test.mjs index 0f621a01..02def236 100644 --- a/tests/terminal-launch.test.mjs +++ b/tests/terminal-launch.test.mjs @@ -131,14 +131,29 @@ test("Codex restore throws on malformed resumeThreadId", () => { } }); -test("Other providers ignore resumeThreadId and retain standard continue flags", () => { +test("Claude and OpenCode resume their own session by id; other providers keep their continue flags", () => { const uuid = "12345678-1234-4234-8234-123456789abc"; const claude = resolveTerminalLaunch("claude", "normal", ["--bridge"], { providerCli: available("claude", "/resolved/claude"), resumePrevious: true, - resumeThreadId: uuid + resumeThreadId: uuid.toUpperCase() + }); + assert.deepEqual(claude.args, ["--bridge", "--resume", uuid]); + + const opencode = resolveTerminalLaunch("opencode", "normal", [], { + providerCli: available("opencode", "/resolved/opencode"), + resumePrevious: true, + resumeThreadId: "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn" }); - assert.deepEqual(claude.args, ["--bridge", "--continue"]); + assert.deepEqual(opencode.args, ["--session", "ses_7a1b2c3d4ffeAbCdEfGhIjKlMn"]); + + for (const [provider, malformed] of [["claude", "--config=evil"], ["claude", "not-a-uuid"], ["opencode", uuid], ["opencode", "ses_../x"]]) { + assert.throws(() => resolveTerminalLaunch(provider, "normal", [], { + providerCli: available(provider, `/resolved/${provider}`), + resumePrevious: true, + resumeThreadId: malformed + }), /session ID format/u); + } const qwen = resolveTerminalLaunch("qwen", "yolo", [], { providerCli: available("qwen", "/resolved/qwen"), diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 811f7881..8eafbd43 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -167,13 +167,13 @@ test("an exited PTY can restart in place without recreating its xterm card", asy readFile(terminalManagerPath, "utf8") ]); - assert.match(manager, /restart\(id: string\): SessionSnapshot/); + assert.match(manager, /restart\(id: string, options: \{ resume\?: boolean \} = \{\}\): SessionSnapshot/); assert.match(manager, /session\.metadata\.exitCode === null/); assert.match(manager, /session\.metadata\.status = initialSessionStatus\(session\.metadata\.provider\)/); assert.match(manager, /session\.metadata\.failureDetails = null/); assert.match(manager, /if \(launched\.process\) this\.bindProcess\(id, session, launched\.process\)/); assert.match(card, /shouldRestartExitedTerminal\(event, sessionExited\.current\)/); - assert.match(card, /onRestart\(session\.id\)/); + assert.match(card, /onRestart\(session\.id, resume\)/); }); test("failed PTYs preserve their final sanitized output as failure details", async () => { diff --git a/tests/terminal-session-exact-resume.test.mjs b/tests/terminal-session-exact-resume.test.mjs index fa55a463..c557eddf 100644 --- a/tests/terminal-session-exact-resume.test.mjs +++ b/tests/terminal-session-exact-resume.test.mjs @@ -51,7 +51,7 @@ function manager(directory, calls) { true, spawner(calls) ); - instance.configureSessionPersistence(new TerminalSessionStore(directory), true); + instance.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); return instance; } @@ -63,8 +63,8 @@ test("restoring two Codex cards in one cwd resumes their own conversations", asy const first = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); const second = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 20, y: 20 } }); // SessionStart is idle while a new card is already idle. The ID must still persist. - initial.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); - initial.applyProviderSignal(second.id, { kind: "lifecycle", state: "idle", codexThreadId: SECOND_THREAD }); + initial.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", threadId: FIRST_THREAD }); + initial.applyProviderSignal(second.id, { kind: "lifecycle", state: "idle", threadId: SECOND_THREAD }); await initial.shutdown(); const calls = []; @@ -141,14 +141,14 @@ test("restarting a Codex card after an exit clears stale thread ID and launches customSpawner ); const store = new TerminalSessionStore(directory); - inst.configureSessionPersistence(store, true); + inst.configureSessionPersistence(store, "continue"); await inst.restorePersistedSessions(); const created = inst.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); - inst.applyProviderSignal(created.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + inst.applyProviderSignal(created.id, { kind: "lifecycle", state: "idle", threadId: FIRST_THREAD }); // Verify stored - assert.equal(store.get()[0]?.codexThreadId, FIRST_THREAD); + assert.equal(store.get()[0]?.threadId, FIRST_THREAD); // Simulate exit exitHandler?.({ exitCode: 0 }); @@ -162,7 +162,7 @@ test("restarting a Codex card after an exit clears stale thread ID and launches assert.equal(calls[1].args.includes(FIRST_THREAD), false); // Stored session should no longer have the stale thread ID - assert.equal(store.get()[0]?.codexThreadId, undefined); + assert.equal(store.get()[0]?.threadId, undefined); await inst.shutdown(); } finally { diff --git a/tests/terminal-session-restore.test.mjs b/tests/terminal-session-restore.test.mjs index 19bd3558..5984b7ad 100644 --- a/tests/terminal-session-restore.test.mjs +++ b/tests/terminal-session-restore.test.mjs @@ -52,7 +52,7 @@ test("opt-in restore preserves card identity and relaunches the agent in native true, fakeSpawner(firstCalls) ); - first.configureSessionPersistence(new TerminalSessionStore(directory), true); + first.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await first.restorePersistedSessions(); const created = first.create({ provider: "codex", @@ -76,7 +76,7 @@ test("opt-in restore preserves card identity and relaunches the agent in native true, fakeSpawner(restoredCalls) ); - restored.configureSessionPersistence(new TerminalSessionStore(directory), true); + restored.configureSessionPersistence(new TerminalSessionStore(directory), "continue"); await restored.restorePersistedSessions(); assert.equal(restoredCalls.length, 1); @@ -122,7 +122,7 @@ test("the default opt-out clears old descriptors instead of restoring them", asy true, fakeSpawner(calls) ); - manager.configureSessionPersistence(store, false); + manager.configureSessionPersistence(store, "off"); await manager.restorePersistedSessions(); assert.deepEqual(manager.list(), []); assert.deepEqual(store.get(), []); @@ -155,7 +155,7 @@ test("a restored Grok session still waits for the measured grid before continuin true, fakeSpawner(calls) ); - manager.configureSessionPersistence(store, true); + manager.configureSessionPersistence(store, "continue"); await manager.restorePersistedSessions(); assert.equal(calls.length, 0); manager.resize("grok-session", 71, 17); diff --git a/tests/terminal-session-store.test.mjs b/tests/terminal-session-store.test.mjs index 630ca2b5..2847ba31 100644 --- a/tests/terminal-session-store.test.mjs +++ b/tests/terminal-session-store.test.mjs @@ -18,7 +18,9 @@ const descriptor = { titleCustomized: true, cwd: process.cwd(), position: { x: 120, y: 40 }, - size: { width: 700, height: 430 } + size: { width: 700, height: 430 }, + lastState: "running", + restore: true }; test("terminal window descriptors persist atomically without scrollback or environment", async () => { @@ -93,7 +95,7 @@ test("legacy roles restore as agents while unknown roles are dropped", async () } }); -test("codexThreadId persists and normalizes to canonical lower-case UUID for codex provider", async () => { +test("threadId persists and normalizes to canonical lower-case UUID for codex provider", async () => { const threadIdUpper = "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D"; const threadIdCanonical = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; @@ -106,17 +108,18 @@ test("codexThreadId persists and normalizes to canonical lower-case UUID for cod failureDetails: null }; - // persistedTerminalSession helper round-trips valid codexThreadId + // persistedTerminalSession helper round-trips a valid thread id const persisted = persistedTerminalSession(sessionMetadata, ` ${threadIdUpper} `); - assert.equal(persisted.codexThreadId, threadIdCanonical); + assert.equal(persisted.threadId, threadIdCanonical); - // Non-codex provider ignores codexThreadId in persistedTerminalSession helper - const claudePersisted = persistedTerminalSession({ ...sessionMetadata, provider: "claude" }, threadIdUpper); - assert.equal(claudePersisted.codexThreadId, undefined); + // A provider without exact resume ignores the id; Claude keeps its UUID session id + const qwenPersisted = persistedTerminalSession({ ...sessionMetadata, provider: "qwen" }, threadIdUpper); + assert.equal(qwenPersisted.threadId, undefined); + assert.equal(persistedTerminalSession({ ...sessionMetadata, provider: "claude" }, threadIdUpper).threadId, threadIdCanonical); // Malformed thread IDs are ignored in persistedTerminalSession helper - assert.equal(persistedTerminalSession(sessionMetadata, "not-a-uuid").codexThreadId, undefined); - assert.equal(persistedTerminalSession(sessionMetadata, 12345).codexThreadId, undefined); + assert.equal(persistedTerminalSession(sessionMetadata, "not-a-uuid").threadId, undefined); + assert.equal(persistedTerminalSession(sessionMetadata, 12345).threadId, undefined); // Store persistence and load round-trip const directory = await mkdtemp(join(tmpdir(), "canvastty-terminal-state-codex-thread-")); @@ -125,7 +128,7 @@ test("codexThreadId persists and normalizes to canonical lower-case UUID for cod await store.replace([persisted]); const reloaded = await store.load(); assert.equal(reloaded.length, 1); - assert.equal(reloaded[0].codexThreadId, threadIdCanonical); + assert.equal(reloaded[0].threadId, threadIdCanonical); } finally { await rm(directory, { recursive: true, force: true }); } @@ -136,7 +139,7 @@ test("normalizePersistedTerminalSessions preserves cards with malformed or forei const normalized = normalizePersistedTerminalSessions({ version: 1, sessions: [ - // Valid codex thread ID + // Valid codex thread ID, under its v1 name { ...descriptor, id: "valid-codex", provider: "codex", codexThreadId: validUuid }, // Valid codex session without codexThreadId (backward compatibility) { ...descriptor, id: "valid-codex-no-thread", provider: "codex" }, @@ -154,7 +157,7 @@ test("normalizePersistedTerminalSessions preserves cards with malformed or forei "valid-codex", "valid-codex-no-thread", "bad-uuid", "bad-type-uuid", "claude-with-thread", "terminal-with-thread" ]); - assert.equal(normalized.sessions[0].codexThreadId, validUuid); - assert.equal(normalized.sessions[1].codexThreadId, undefined); - assert.ok(normalized.sessions.slice(2).every((session) => session.codexThreadId === undefined)); + assert.equal(normalized.sessions[0].threadId, validUuid); + assert.equal(normalized.sessions[1].threadId, undefined); + assert.ok(normalized.sessions.slice(2).every((session) => session.threadId === undefined)); }); From 01be49d4745dc28de2c5c329b8f71508051eb48b Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:22:51 +0000 Subject: [PATCH 3/6] feat(plugins): add plugin services behind a separate native-code trust Manifest apiVersion 2 adds `services`: bundled single-file JavaScript entries (integrity-declared like hook entries in modular plugins). A new PluginServiceSupervisor runs each service of an enabled plugin as its own process (process.execPath + ELECTRON_RUN_AS_NODE, cwd = plugin folder, allow-listed environment without keys, NODE_OPTIONS or CANVASTTY_*), speaks newline-delimited JSON-RPC 2.0 over stdio (1 MB messages, 15 s request timeouts, 64 pending), restarts with backoff (at most 5 in 10 minutes), stops politely then with SIGTERM/SIGKILL on disable, uninstall, update, module change, revoke and quit, and keeps a bounded per-plugin log. Services run only after a separate per-plugin "Extension native code" confirmation in Settings -> Agents. Install never grants it; it pins each entry's SHA-256 (checked before every start) and is revoked by update, module change, disable, or a changed entry file. How a plugin uses it: its sandboxed surfaces call their own plugin's services with host.service.request(serviceId, method, params) and receive host.service.onEvent; the plugin id is bound by the frame host or the identity-checked plugin window. A service may call back `log`, own-plugin `storage.*` (storage permission), `event`, and `secrets.get` (secrets permission) for its own plugin's secret, for example an API key of a model it calls; anything else is -32601. This is the base the following extension points (launch, environments, decisions, tools, sessions, cards) add host requests to. Docs (en/ru/zh), schema, plugin-api.d.ts, example examples/plugins/service-echo (a canvas app that calls its service, and a token the page saves and the service reads), and tests/plugin-services.test.mjs, tests/plugin-policy-budget-secrets.test.mjs. --- CHANGELOG.md | 1 + CHANGELOG.ru.md | 1 + CHANGELOG.zh-CN.md | 1 + docs/ARCHITECTURE.md | 7 +- docs/canvastty-plugin.schema.json | 28 +- docs/plugin-api.d.ts | 57 ++ docs/plugins.md | 52 +- docs/plugins.ru.md | 46 +- docs/plugins.zh-CN.md | 46 +- examples/plugins/service-echo/apps/echo.css | 5 + examples/plugins/service-echo/apps/echo.html | 21 + examples/plugins/service-echo/apps/echo.js | 35 ++ .../service-echo/canvastty.plugin.json | 27 + .../plugins/service-echo/services/echo.mjs | 57 ++ src/main/index.ts | 35 ++ src/main/ipc/registerIpc.ts | 32 ++ src/main/services/PluginManager.ts | 216 ++++++- src/main/services/PluginServiceSupervisor.ts | 526 ++++++++++++++++++ src/preload/index.ts | 9 + src/preload/plugin.ts | 7 + src/renderer/src/App.tsx | 11 + .../src/features/plugins/PluginFrame.tsx | 25 +- .../settings/PluginServicesSettings.tsx | 157 ++++++ .../src/features/settings/SettingsPanel.tsx | 8 + src/renderer/src/lib/i18n.ts | 20 + src/renderer/src/styles/app.css | 3 + src/shared/contracts.ts | 58 +- tests/plugin-manager.test.mjs | 2 +- tests/plugin-policy-budget-secrets.test.mjs | 58 ++ tests/plugin-services.test.mjs | 419 ++++++++++++++ 30 files changed, 1935 insertions(+), 35 deletions(-) create mode 100644 examples/plugins/service-echo/apps/echo.css create mode 100644 examples/plugins/service-echo/apps/echo.html create mode 100644 examples/plugins/service-echo/apps/echo.js create mode 100644 examples/plugins/service-echo/canvastty.plugin.json create mode 100644 examples/plugins/service-echo/services/echo.mjs create mode 100644 src/main/services/PluginServiceSupervisor.ts create mode 100644 src/renderer/src/features/settings/PluginServicesSettings.tsx create mode 100644 tests/plugin-policy-budget-secrets.test.mjs create mode 100644 tests/plugin-services.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c91d2c..3c10090c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Added plugin services (manifest apiVersion 2, `services`): bundled single-file JavaScript that runs as a supervised child process only after the separate per-plugin **Extension native code** confirmation in Settings → Agents (off by default, never granted by install, revoked by update, module change, disable, or a changed entry file). Services get a minimal environment without keys or CanvasTTY internals, speak JSON-RPC over stdio with 1 MB messages and 15 s timeouts, restart with backoff, stop on disable, uninstall, update and quit, and log to a bounded per-plugin log. Plugin surfaces call their own plugin's services through `host.service.request` and receive `host.service.onEvent`; services may call back `log`, own-plugin `storage` and `event`, and read their own plugin's secrets with `secrets.get` (needs `secrets`). Example: `examples/plugins/service-echo` (its service also reads a token the page saved). - Reworked "Windows after restart" into one "Agent sessions after restart" model: **Don't save**, **Reopen windows** (new conversations), or **Continue conversations** (the old "on" migrates here). Claude Code and OpenCode now resume their own conversation by the id their lifecycle hook reported, as Codex does (`claude --resume`, `opencode --session`); two cards of one CLI in one folder no longer continue the same conversation. Finished agents come back stopped with Restart / Continue instead of rerunning, the card options menu has **Don't restore this card**, and session records (v2, read-compatible with v1) keep no scrollback, prompts or secrets. - Made the agent orchestration endpoint an explicit setting (Settings → Agents → "Agent orchestration endpoint", `agentControlEnabled`, off by default; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` still force it on for one launch) that starts and stops the endpoint at runtime, and added an **Orchestrator** role to the launch dialog next to the normal/YOLO profile: the session keeps the provider you opened the dialog for, gets `CANVASTTY_CONTROL_CONNECTION` and `CANVASTTY_CONTROL_CLI` in its environment so the bundled CLI works without setup, shows an "Orchestrator" badge, keeps its role across restore, and the dialog offers to enable the endpoint first when it is off instead of enabling anything silently. The endpoint's `create` now accepts every agent provider (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) and reports `capabilities { result, menus }` per worker on `create` and `list`: both are `true` for Codex only; other providers' `screen` has no menu interaction, `choose`/`dismiss` fail with `NOT_SUPPORTED`, `send` relies on the idle status alone, and `result` completes as `no_result`. - Added a native Codex orchestration CLI (`agent-control/canvastty-control.mjs`, documented in `agent/orchestrator/SKILL.md`) behind `--agent-control` or `CANVASTTY_AGENT_CONTROL=1`: a local controller creates Codex sessions in a project directory, sends work, observes bounded terminal output against a screen revision, and collects the final answer. Each controller sees only the sessions it created, grants are bound to the session generation, mutation IDs are deduplicated, and only controlled sessions opt into authenticated Stop-hook result capture. No automatic approval or terminal deletion endpoint is included. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index ea9a34ce..8798567c 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,7 @@ ## Unreleased +- Добавлены сервисы плагинов (манифест apiVersion 2, `services`): собранный одним файлом JavaScript, который запускается отдельным дочерним процессом под надзором хоста только после отдельного подтверждения **Нативный код расширений** для плагина в Настройки → Агенты (по умолчанию выключено, установка его не даёт, обновление, смена модулей, выключение или изменённый файл entry его снимают). Сервис получает минимальное окружение без ключей и внутренних переменных CanvasTTY, общается по JSON-RPC через stdio (сообщения до 1 МБ, таймаут 15 с), перезапускается с паузами, останавливается при выключении, удалении, обновлении и выходе и пишет в ограниченный журнал плагина. Поверхности плагина обращаются к сервисам своего плагина через `host.service.request` и получают `host.service.onEvent`; сервис может вызывать `log`, `storage` своего плагина и `event` и читать секреты своего плагина через `secrets.get` (нужно `secrets`). Пример: `examples/plugins/service-echo` (его сервис ещё и читает токен, сохранённый страницей). - «Окна после перезапуска» стали единой моделью «Сессии агентов после перезапуска»: **Не сохранять**, **Открыть окна** (новые разговоры) или **Продолжить разговоры** (прежнее «включено» переходит сюда). Claude Code и OpenCode теперь, как и Codex, продолжают свой разговор по id, который сообщил их lifecycle hook (`claude --resume`, `opencode --session`); две карточки одного CLI в одной папке больше не продолжают один и тот же разговор. Завершённые агенты возвращаются остановленными с кнопками «Перезапустить» / «Продолжить», в меню карточки есть **Не восстанавливать это окно**, а записи сессий (v2, совместимы с v1 при чтении) не хранят буфер, промпты и секреты. - Эндпоинт оркестрации агентов стал явной настройкой (Настройки → Агенты → «Эндпоинт оркестрации агентов», `agentControlEnabled`, по умолчанию выключен; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` по-прежнему принудительно включают его на один запуск), которая запускает и останавливает эндпоинт на лету, а в диалог запуска рядом с профилем normal/YOLO добавлена роль **Оркестратор**: сессия сохраняет провайдера, для которого открыт диалог, получает в окружении `CANVASTTY_CONTROL_CONNECTION` и `CANVASTTY_CONTROL_CLI`, чтобы встроенный CLI работал без настройки, показывает бейдж «Оркестратор», сохраняет роль при восстановлении, а при выключенном эндпоинте диалог предлагает сначала включить его, ничего не включая молча. `create` эндпоинта теперь принимает любого провайдера-агента (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) и в ответах `create` и `list` сообщает `capabilities { result, menus }` для каждого воркера: оба значения `true` только для Codex; у остальных провайдеров `screen` не содержит взаимодействия с меню, `choose`/`dismiss` завершаются ошибкой `NOT_SUPPORTED`, `send` опирается только на статус idle, а `result` завершается как `no_result`. - Добавлен нативный CLI оркестрации Codex (`agent-control/canvastty-control.mjs`, описан в `agent/orchestrator/SKILL.md`), включаемый флагом `--agent-control` или `CANVASTTY_AGENT_CONTROL=1`: локальный контроллер создаёт сессии Codex в каталоге проекта, отправляет задачи, наблюдает ограниченный вывод терминала относительно ревизии экрана и получает итоговый ответ. Контроллер видит только созданные им сессии, гранты привязаны к поколению сессии, идентификаторы мутаций дедуплицируются, и только управляемые сессии включают аутентифицированный захват результата через Stop-hook. Автоматического одобрения и удаления терминалов нет. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 6120ca95..6e012b5a 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,7 @@ ## Unreleased +- 新增插件服务(manifest apiVersion 2,`services`):打包为单文件的 JavaScript,仅在 设置 → Agents 中为该插件单独确认 **Extension native code** 后才作为受监管的子进程运行(默认关闭,安装不会授予;更新、更换模块、禁用或 entry 文件被修改都会撤销)。服务获得不含密钥和 CanvasTTY 内部变量的最小环境,通过 stdio 使用 JSON-RPC(消息上限 1 MB,超时 15 秒),退避重启,在禁用、卸载、更新和退出时停止,并写入有界的插件日志。插件界面通过 `host.service.request` 调用自身插件的服务,并通过 `host.service.onEvent` 接收事件;服务可回调 `log`、自身插件的 `storage` 和 `event`,并可用 `secrets.get` 读取自身插件的机密(需要 `secrets`)。示例:`examples/plugins/service-echo`(其服务还会读取页面保存的令牌)。 - 将“重启后的窗口”改为统一的“重启后的智能体会话”模型:**不保存**、**重新打开窗口**(新会话)或 **继续会话**(原先的“开启”迁移到此项)。Claude Code 和 OpenCode 现在与 Codex 一样,按其 lifecycle hook 报告的 id 继续自己的会话(`claude --resume`、`opencode --session`);同一文件夹中同一 CLI 的两张卡片不再继续同一个会话。已结束的智能体恢复为停止状态,提供“重启”/“继续”,卡片选项菜单提供 **不恢复此卡片**,会话记录(v2,可读取 v1)不保存 scrollback、提示词或密钥。 - 将代理编排端点改为显式设置(设置 → 代理 → “代理编排端点”,`agentControlEnabled`,默认关闭;`--agent-control` / `CANVASTTY_AGENT_CONTROL=1` 仍可为单次启动强制开启),并在运行时按设置启动和停止端点;启动对话框在 normal/YOLO 配置旁新增 **Orchestrator(编排器)** 角色:会话保留打开对话框时的提供方,环境中携带 `CANVASTTY_CONTROL_CONNECTION` 和 `CANVASTTY_CONTROL_CLI`,使内置 CLI 无需配置即可工作,卡片显示 “Orchestrator” 徽标,恢复会话时保留角色;端点关闭时对话框会先提示并提供开启按钮,而不会静默开启任何内容。端点的 `create` 现在接受所有代理提供方(`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`),并在 `create` 和 `list` 响应中为每个工作会话报告 `capabilities { result, menus }`:仅 Codex 两者都为 `true`;其他提供方的 `screen` 没有菜单交互,`choose`/`dismiss` 返回 `NOT_SUPPORTED`,`send` 仅依据 idle 状态,`result` 以 `no_result` 结束。 - 新增原生 Codex 编排 CLI(`agent-control/canvastty-control.mjs`,文档见 `agent/orchestrator/SKILL.md`),通过 `--agent-control` 或 `CANVASTTY_AGENT_CONTROL=1` 启用:本地控制器在项目目录中创建 Codex 会话、发送任务、按屏幕修订号观察有界的终端输出并收集最终回答。每个控制器只能看到自己创建的会话,授权绑定到会话代次,变更 ID 去重,且只有受控会话会启用经过认证的 Stop-hook 结果捕获。不包含自动批准或删除终端的端点。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c07feb5c..0ca26167 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,6 +19,7 @@ Electron main process ├── TerminalManager → node-pty lifecycle, bounded scrollback, and output batching ├── LimitsService → sanitized provider-limit adapters and cache ├── PluginManager → GitHub install, manifest validation, assets, permissions, storage, hook trust registry + ├── PluginServiceSupervisor → trusted plugin services as child processes, JSON-RPC over stdio ├── PluginSecretsService → OS-backed encrypted plugin credentials with fail-closed availability ├── PluginMediaService → user-granted music folders, ranged audio streams, playlist files ├── HermesHudService → permission-gated Hermes Desktop HUD lifecycle through a fixed control contract @@ -39,6 +40,7 @@ Electron main process - `src/main/services/LimitsService.ts` reads Codex through the installed CLI's app-server protocol and Claude, Kimi, OpenCode Go, and Grok Build through their provider usage or billing endpoints. Qwen Code is multi-provider and exposes no provider-neutral read-only quota protocol, so its adapter reports `cli-not-found` or `unsupported-protocol` and never invents percentages. Provider credentials are read only inside the trusted main process, sent only to the matching provider over HTTPS, and never logged or exposed over IPC. The service owns timeout, structural normalization, caching, stale fallback, and subprocess cleanup; raw provider responses never cross IPC. - `src/main/services/SettingsStore.ts` normalizes every update and persists through a serialized atomic write. Canvas regions and sticky notes have independent persistence gates: disabling one keeps its live objects for the current process but omits that collection from the disk snapshot and therefore from the next launch. The configurable canvas launcher and UI scale use the same boundary; transient window stacking does not. - `src/main/services/PluginManager.ts` installs ready-to-run repositories without executing package scripts during install/update, rejects symlinks and oversized packages, persists the enabled registry, serves only contained package files, and enforces per-plugin permissions/storage quotas. Optional native agent-hook entries remain off by default; explicit per-hook trust is persisted in the plugin registry and compiled into a separate private atomic runtime registry. Update, module replacement, plugin disable, and uninstall revoke that trust before executable files change. +- `src/main/services/PluginServiceSupervisor.ts` runs the `services` of an apiVersion 2 plugin only after the separate per-plugin "native code" confirmation, which pins each entry's SHA-256 and is revoked like hook trust. Each service is a `process.execPath` + `ELECTRON_RUN_AS_NODE` child in the plugin folder with an allow-listed environment (no provider keys, tokens, `NODE_OPTIONS`, or `CANVASTTY_*`), newline-delimited JSON-RPC 2.0 over stdio with 1 MB messages and 15 s request timeouts, restart with backoff (at most 5 in 10 minutes), and a bounded per-plugin log. Its host API is `log`, own-plugin `storage.*` behind the `storage` permission, and `event` to the plugin's own surfaces; surfaces reach only their own plugin's services (`service.request`). - `src/main/services/PluginSecretsService.ts` serializes per-plugin secret writes, encrypts the complete bounded payload through Electron `safeStorage`, rejects plaintext-only backends, and removes each encrypted file on uninstall. `ProviderSecretsService.ts` applies the same architecture to provider API keys for BYOK-capable CLIs: values stay in the main process, and the renderer contract exposes only per-key `configured` flags plus set/clear actions. `ApiProfile` settings entries name model backends (protocol, HTTPS base URL, secret reference) for the same BYOK runtimes; they are not agent providers, and the settings normalizer drops invalid profiles instead of repairing them. - `src/main/services/PluginMediaService.ts` persists per-plugin grants only after a native folder choice, hides absolute paths, skips symlinks, and serves contained audio with HTTP Range semantics. Playlist reads stay inside granted libraries; writes are bounded and atomic under the library's `Playlists/` directory. - `src/main/services/HermesHudService.ts` is the only plugin-facing native application controller. It resolves the installed Hermes CLI through the immutable provider registry, sends only the fixed `--hud`/`--quit` control commands, and derives visible state from Hermes Desktop's validated live runtime record. It never accepts executable paths, arguments, PIDs, or arbitrary commands from plugin code. @@ -52,7 +54,7 @@ Electron main process The primary `BrowserWindow` is created and shown with a lightweight local startup page before settings, plugins, media, and IPC services initialize. Successful initialization replaces that page with the trusted renderer; bootstrap failures replace it with a visible error page and retain a native-dialog fallback. The main process holds Electron's single-instance lock; a rejected second launch raises the running window through the `second-instance` handler so the app never appears to ignore a launch, while background plugin and browser requests never restore, show, or focus an existing window. Native browser contents are focused programmatically only while their owner `BrowserWindow` is already focused; explicit user pointer input remains the only cross-surface focus route. -Runtime plugin code is never imported into main or the trusted renderer bundle. HOME widgets and canvas apps run in sandboxed iframes with an opaque origin. Separate plugin windows use a dedicated narrow preload which forwards the same message SDK through an IPC handler that verifies the actual `canvastty-plugin:///` sender URL. Explicitly trusted agent hooks run only in isolated child processes, not in either trusted JavaScript context; they are privileged OS code rather than sandboxed web contributions. Arbitrary native OS windows are not embedded. +Runtime plugin code is never imported into main or the trusted renderer bundle. HOME widgets and canvas apps run in sandboxed iframes with an opaque origin. Separate plugin windows use a dedicated narrow preload which forwards the same message SDK through an IPC handler that verifies the actual `canvastty-plugin:///` sender URL. Explicitly trusted agent hooks and plugin services run only in isolated child processes, not in either trusted JavaScript context; they are privileged OS code rather than sandboxed web contributions. Arbitrary native OS windows are not embedded. Plugin music access is capability-based rather than generic filesystem access. Media scans return library IDs, relative paths, metadata, and `canvastty-media://` stream URLs; raw playlist text remains the only format-neutral file content exposed. A media URL is resolved only for the owning enabled plugin and only beneath a previously selected library root. Removing a plugin revokes its persisted folder grants. @@ -81,6 +83,7 @@ App ├── AgentLaunchDialog fixed provider + folder + profile + launch └── SettingsPanel two-pane icon-sidebar modal for General, Appearance, Agents, Controls, Browser, Plugins, and About ├── AgentHooksSettings built-in status revocation and explicit plugin-hook trust + ├── PluginServicesSettings per-plugin native-code trust, service state and log ├── AboutSettings app identity and expandable hook/data/security FAQ └── PluginSettingsSection install preview, permissions, registry, and contributions ``` @@ -126,6 +129,6 @@ Session counters, progress bars, and statuses must always derive from actual `Se - Add a provider in `ProviderId`, `providers.ts`, `TerminalManager.resolveLaunch`, the official provider asset map, and an optional safe limit adapter. - Add a persisted setting to `AppSettings`, defaults/normalization in `SettingsStore`, and the owning feature only. Settings owns user-facing canvas controls and shortcuts; camera math and snapping geometry remain pure renderer concerns. - Add a canvas entity as a separate feature component with an explicit position and callbacks; keep camera ownership in `WorkspaceCanvas`. -- Publish a runtime extension with `canvastty.plugin.json` API v1 and static HTML/CSS/JS entries. Contribution kinds are `home-widget`, `canvas-app`, and `window`; capability access is restricted to declared permissions. See [Runtime plugins](plugins.md). +- Publish a runtime extension with `canvastty.plugin.json` API v1 (or v2 for `services`) and static HTML/CSS/JS entries. Contribution kinds are `home-widget`, `canvas-app`, and `window`; capability access is restricted to declared permissions. See [Runtime plugins](plugins.md). Every extension should pass `npm run typecheck`, `npm run build`, and a real Electron interaction check. diff --git a/docs/canvastty-plugin.schema.json b/docs/canvastty-plugin.schema.json index 9cbde4f2..7bd17a04 100644 --- a/docs/canvastty-plugin.schema.json +++ b/docs/canvastty-plugin.schema.json @@ -1,16 +1,19 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/howdeploy/CanvasTTY/blob/main/docs/canvastty-plugin.schema.json", - "title": "CanvasTTY plugin manifest v1", + "title": "CanvasTTY plugin manifest (apiVersion 1 and 2)", "type": "object", "additionalProperties": false, "required": ["apiVersion", "id", "name", "version", "description", "permissions", "contributions"], "anyOf": [ { "properties": { "contributions": { "minItems": 1 } } }, - { "required": ["hooks"], "properties": { "hooks": { "minItems": 1 } } } + { "required": ["hooks"], "properties": { "hooks": { "minItems": 1 } } }, + { "required": ["services"], "properties": { "services": { "minItems": 1 } } } ], + "if": { "properties": { "apiVersion": { "const": 1 } } }, + "then": { "not": { "required": ["services"] } }, "properties": { - "apiVersion": { "const": 1 }, + "apiVersion": { "enum": [1, 2] }, "id": { "type": "string", "minLength": 3, "maxLength": 80, "pattern": "^(?!.*\\.\\.)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" }, "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "version": { "type": "string", "maxLength": 40, "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" }, @@ -47,6 +50,13 @@ "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/agentHook" } + }, + "services": { + "description": "apiVersion 2 only: bundled single-file services run as separate processes after the user trusts the plugin's native code.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/service" } } }, "$defs": { @@ -102,6 +112,18 @@ "module": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" } } }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "entry"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 80 }, + "description": { "type": "string", "minLength": 1, "maxLength": 240 }, + "entry": { "type": "string", "minLength": 1, "maxLength": 180, "pattern": "^(?!/)(?!.*\\\\)(?!\\.\\.?/)(?!.*(?:/\\.\\.?/|/\\.\\.?$|//)).+\\.(?:js|mjs|cjs)$" }, + "module": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" } + } + }, "baseContribution": { "type": "object", "additionalProperties": false, diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index 66237995..97519643 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -29,6 +29,7 @@ export interface CanvasTTYPluginHost { request(method: "secrets.get", params: { key: string }): Promise; request(method: "secrets.set", params: { key: string; value: string }): Promise; request(method: "secrets.delete", params: { key: string }): Promise; + request(method: "service.request", params: { serviceId: string; method: string; params?: unknown }): Promise; request(method: string, params?: Record): Promise; storage: { get(key: string): Promise; @@ -58,6 +59,12 @@ export interface CanvasTTYPluginHost { open(): Promise; close(): Promise; }; + /** Talks to this plugin's own services only (apiVersion 2 `services`). */ + service: { + /** Rejects when the service is not running (native code not trusted, disabled, restarting, failed) or after 15 s. */ + request(serviceId: string, method: string, params?: unknown): Promise; + onEvent(listener: (event: CanvasTTYPluginServiceEvent) => void): () => void; + }; onContext(listener: (context: CanvasTTYPluginContext) => void): () => void; onStorageChange(listener: (key: string, value: unknown) => void): () => void; } @@ -135,3 +142,53 @@ export interface CanvasTTYAgentHookInput { providerEvent: string; payload: unknown; } + +export interface CanvasTTYPluginServiceEvent { + serviceId: string; + event: string; + data: unknown; +} + +/** + * Plugin services (manifest apiVersion 2). A service is a bundled single-file Node.js program that + * CanvasTTY runs as a separate process after the user trusts the plugin's native code. It speaks + * newline-delimited JSON-RPC 2.0 over stdin/stdout, at most 1 MB per message. + */ +export interface CanvasTTYPluginServiceManifestEntry { + id: string; + title: string; + description?: string; + /** `.js`, `.mjs` or `.cjs` inside the plugin; integrity-declared in modular plugins. */ + entry: string; + module?: string; +} + +/** Params of the first host notification, `canvastty.initialize`. */ +export interface CanvasTTYServiceContext { + apiVersion: 2; + pluginId: string; + serviceId: string; + /** `/plugin-data/`: created before start, removed on uninstall. */ + dataDir: string; + locale: string; + hostVersion: string; +} + +/** Notifications the host sends to a service. */ +export type CanvasTTYServiceHostNotification = + | { jsonrpc: "2.0"; method: "canvastty.initialize"; params: CanvasTTYServiceContext } + | { jsonrpc: "2.0"; method: "canvastty.shutdown"; params: Record }; + +/** Methods a service may call on the host. Every other method is answered with error -32601. */ +export interface CanvasTTYServiceHostApi { + /** Request or notification. */ + log(params: { level?: "info" | "warn" | "error"; message: string }): null; + /** Needs the `storage` permission. */ + "storage.get"(params: { key: string }): unknown; + /** Needs the `storage` permission. */ + "storage.set"(params: { key: string; value: unknown }): null; + /** Notification only: delivered to this plugin's surfaces through `host.service.onEvent`. */ + event(params: { event: string; data?: unknown }): void; + /** Needs the `secrets` permission: the plugin's own secret, or null. */ + "secrets.get"(params: { key: string }): string | null; +} diff --git a/docs/plugins.md b/docs/plugins.md index 6be85806..88d6bdc1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,7 +2,7 @@ [English](plugins.md) · [Русский](plugins.ru.md) · [简体中文](plugins.zh-CN.md) · [Docs home](README.md) -CanvasTTY runtime plugins are installed from an HTTPS GitHub repository. A plugin can contribute sandboxed web surfaces and can optionally declare agent hook scripts. Web contributions run without Node.js. Agent hooks are a separate, explicit trust boundary and stay disabled until the user enables each hook in **Settings → Agents → Hooks**. +CanvasTTY runtime plugins are installed from an HTTPS GitHub repository. A plugin can contribute sandboxed web surfaces and can optionally declare agent hook scripts and long-lived services. Web contributions run without Node.js. Agent hooks and services are native code: a separate, explicit trust boundary that stays off until the user enables each hook in **Settings → Agents → Hooks** and each plugin's services in **Settings → Agents → Extension native code**. ## Trust model @@ -15,6 +15,7 @@ Installing a plugin is equivalent to allowing third-party browser code to run lo - Every privileged SDK method is gated by a manifest permission. Permissions are shown before the user confirms installation. - Sandboxed web contributions never receive provider credentials, PTY buffers, working directories, raw provider responses, or filesystem access. - Disabling or uninstalling a plugin immediately stops serving its assets and closes its separate windows. +- Declared services follow the same rule as hooks, per plugin: install never starts them, and update, module changes, disabling, or a changed entry file revokes the confirmation. Services run out of process; no plugin code runs in the CanvasTTY main process. - Declared agent hooks are never enabled by install, update, or module changes. Enabling one is equivalent to running that repository's JavaScript as a native application with the current user's OS privileges, access to the provider event payload, and potential access to user-readable configuration or credentials. Updating the plugin, replacing modules, or disabling the plugin revokes every enabled hook so changed code must be trusted again. CanvasTTY does not embed arbitrary native OS windows. A `window` contribution is a sandboxed CanvasTTY-owned `BrowserWindow`. Native reparenting is not portable or reliable across Wayland, macOS, Windows, DPI modes, popups, and GPU surfaces. @@ -35,7 +36,7 @@ windows/focus.js hooks/audit.mjs ``` -An end-to-end sandboxed web-surface example (without a privileged hook) lives in [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). +An end-to-end sandboxed web-surface example (without a privileged hook) lives in [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). A minimal service with a canvas app that calls it lives in [`examples/plugins/service-echo`](../examples/plugins/service-echo). Editor tooling can use the [manifest JSON Schema](canvastty-plugin.schema.json) and [SDK TypeScript declarations](plugin-api.d.ts). ## Manifest v1 @@ -121,6 +122,49 @@ interface CanvasTTYAgentHookInput { Hook stdout/stderr is discarded, execution is time-bounded, and CanvasTTY's internal runtime/browser capability tokens are removed from the child environment. This is isolation from host internals, not a sandbox: the hook still has the user's normal filesystem and process privileges. +### Services (apiVersion 2) + +A manifest with `"apiVersion": 2` may declare up to 8 `services`. Version 1 manifests stay valid; only `services` needs version 2. + +```json +"services": [ + { "id": "echo", "title": "Echo", "description": "Echoes requests.", "entry": "services/echo.mjs" } +] +``` + +A service has a stable `id`, a `title`, an optional `description` and `module`, and an `entry` ending in `.js`, `.mjs`, or `.cjs`. The entry must be a bundled single file (for example built with esbuild): the installer runs no build and no `npm install`, and Electron and node-pty are not available to it. In a modular plugin the entry must be integrity-declared by its `module`, or by `coreFiles` when it has none, exactly like hook entries. When the user trusts the plugin's native code, CanvasTTY records the entry's SHA-256 and checks it again before every start; a changed file is never run and the confirmation is revoked on the next launch. + +Lifecycle: every service of an enabled, trusted plugin runs as its own process (`process.execPath` with `ELECTRON_RUN_AS_NODE=1`) with the plugin folder as its working directory. The environment is minimal: `PATH`, `HOME`, user, shell, locale, temp and XDG folders, `SSH_AUTH_SOCK`, and the Windows system folders. Provider keys, tokens, `NODE_OPTIONS`, and every `CANVASTTY_*` variable are removed. A service that exits unexpectedly restarts after 1, 2, 4, 8, then 16 s; after more than 5 unexpected exits in 10 minutes it stays failed until its trust is confirmed again. Disabling, uninstalling, updating, changing modules, revoking trust, or quitting CanvasTTY stops it: first a `canvastty.shutdown` notification and closed stdin, then `SIGTERM`, then `SIGKILL`. `/plugin-data/` is created for the service and removed on uninstall. Its stderr, non-protocol stdout, `log` calls, and lifecycle events go to a bounded per-plugin log (the last 300 entries) shown under the plugin in **Settings → Agents → Extension native code**. + +Protocol: newline-delimited JSON-RPC 2.0 over stdin/stdout, at most 1 MB per message in each direction. A larger message from the host is refused; a larger line from the service is dropped and logged. The host first sends a notification: + +```json +{"jsonrpc":"2.0","method":"canvastty.initialize","params":{"apiVersion":2,"pluginId":"com.example.service-echo","serviceId":"echo","dataDir":"…/plugin-data/com.example.service-echo","locale":"en","hostVersion":"1.5.2"}} +``` + +Requests from the plugin's own surfaces arrive with the method and params chosen by the surface; method names starting with `canvastty.` are reserved for the host. Answer with `{"jsonrpc":"2.0","id":…,"result":…}` or `{"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}`. A request unanswered within 15 s fails with a timeout error, as does a request while the service is stopped, restarting, or failed; at most 64 requests wait at once per service. + +A service may call back this host API (the base that later extension points add to; anything else is answered with error `-32601`): + +| Method | Kind | Gate | Result | +|:--|:--|:--|:--| +| `log` `{ level?: "info" \| "warn" \| "error", message }` | request or notification | none | Adds a line to the plugin log | +| `storage.get` `{ key }` | request | `storage` permission | The same isolated 64 KB storage as `host.storage.get` | +| `storage.set` `{ key, value }` | request | `storage` permission | Writes it and notifies the plugin's surfaces | +| `event` `{ event, data }` | notification | none | Delivered to this plugin's live surfaces through `host.service.onEvent` | +| `secrets.get` `{ key }` | request | `secrets` permission | The plugin's own secret (the same store as `host.secrets`), or `null`. For keys a service needs itself (an API key for a model it calls); never send one back to a surface | + +The host binds every call to the service's own plugin; a service cannot name another plugin, read another plugin's secrets, or reach sessions. The example [`service-echo`](../examples/plugins/service-echo) saves a token from its page with `host.secrets.set` and its service reads it with `secrets.get`, answering only whether one is set. + +UI channel: sandboxed surfaces call their own plugin's services, and only those: + +```js +const reply = await host.service.request("echo", "echo", { text: "hi" }); +host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); +``` + +The permission is implicit when the plugin declares a service. The host relays opaque JSON and never adds credentials. A request to a service that is not running (not trusted yet, disabled, restarting, failed) or that times out rejects with an error. + host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. ## Permissions @@ -128,7 +172,7 @@ host.onStorageChange(listener) notifies every live contribution of the same plug | Permission | SDK capability | Data boundary | |:--|:--|:--| | `storage` | `storage.get`, `storage.set` | Isolated JSON storage, 64 KB per plugin | -| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete` | String secrets encrypted with Electron `safeStorage`; fails closed when protected OS storage is unavailable | +| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete`; a service's `secrets.get` | String secrets encrypted with Electron `safeStorage`; fails closed when protected OS storage is unavailable. A trusted service reads its own plugin's secrets only | | `sessions:read` | `sessions.list` | ID, provider, title, status, start time, exit code only | | `limits:read` | `limits.get` | The same sanitized `LimitsSnapshot` used by HOME | | `launcher:open` | `launcher.open` | Opens the built-in provider Focus Card or terminal action; it does not bypass user launch choices | @@ -185,7 +229,7 @@ if (library) { } ``` -Supported methods are `host.getContext`, `storage.*`, `secrets.*`, `sessions.list`, `limits.get`, `launcher.open`, `canvas.open`, `external.open`, `browser.open`, `window.open`, `media.*`, `playlists.*`, and `hermesHud.*`. `canvas.open` opens or focuses a `canvas-app` contribution from the same plugin, placing it beside the requesting canvas card when possible. `browser.open` completes only after the workspace creates or focuses its Browser card and navigates it once; it accepts normalized HTTP(S) URLs only (not free-text searches, `file:`, `data:`, `javascript:`, `about:`, or credentialed URLs). `window.open` may target only a `window` contribution declared by the same plugin. `hermesHud.open` and `hermesHud.close` use a fixed Hermes control contract; plugins cannot choose an executable, arguments, or PID. +Supported methods are `host.getContext`, `storage.*`, `secrets.*`, `sessions.list`, `limits.get`, `launcher.open`, `canvas.open`, `external.open`, `browser.open`, `window.open`, `media.*`, `playlists.*`, `hermesHud.*`, and `service.request` (see [Services](#services-apiversion-2)). `canvas.open` opens or focuses a `canvas-app` contribution from the same plugin, placing it beside the requesting canvas card when possible. `browser.open` completes only after the workspace creates or focuses its Browser card and navigates it once; it accepts normalized HTTP(S) URLs only (not free-text searches, `file:`, `data:`, `javascript:`, `about:`, or credentialed URLs). `window.open` may target only a `window` contribution declared by the same plugin. `hermesHud.open` and `hermesHud.close` use a fixed Hermes control contract; plugins cannot choose an executable, arguments, or PID. Use `storage` for non-sensitive JSON preferences and `secrets` only for credentials such as OAuth tokens or API keys. Secrets are string-only, limited to 32 keys / 16 KB per value / 64 KB per plugin, removed on uninstall, and never fall back to plaintext storage. A secret call fails explicitly when the operating system cannot provide protected encryption. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index cee62344..ffbe0666 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -2,7 +2,7 @@ [English](plugins.md) · [Русский](plugins.ru.md) · [简体中文](plugins.zh-CN.md) · [Документация](README.ru.md) -Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub-репозитория. Он может добавить sandboxed web-поверхности и опционально объявить scripts хуков агентов. Web-contributions работают без Node.js; каждый hook script остаётся выключенным, пока пользователь отдельно не включит его в **Настройки → Агенты → Хуки**. +Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub-репозитория. Он может добавить sandboxed web-поверхности и опционально объявить scripts хуков агентов. Он также может объявить долгоживущие сервисы. Web-contributions работают без Node.js. Хуки и сервисы — нативный код: каждый hook script остаётся выключенным, пока пользователь отдельно не включит его в **Настройки → Агенты → Хуки**, а сервисы плагина — пока он не подтвердит их в **Настройки → Агенты → Нативный код расширений**. ## Модель доверия @@ -15,6 +15,7 @@ Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub- - Каждый привилегированный SDK-метод требует permission из manifest. Полный список разрешений показывается до подтверждения установки. - Sandboxed web-contributions не получают учётные данные провайдеров, PTY buffer, рабочие каталоги, сырые ответы API или доступ к файловой системе. - Выключение или удаление плагина сразу прекращает отдачу его ресурсов и закрывает отдельные окна. +- Сервисы подчиняются тому же правилу, что и хуки, для плагина целиком: установка их не запускает, а обновление, смена modules, выключение или изменённый файл entry снимают подтверждение. Сервисы работают вне процесса; код плагинов не выполняется в main-процессе CanvasTTY. - Agent hooks никогда не включаются автоматически. Включённый hook script эквивалентен нативному приложению: он получает payload события агента, выполняется с правами учётной записи пользователя и потенциально видит доступные ей конфиги или credentials. Обновление, смена modules или выключение плагина отзывает все такие разрешения. CanvasTTY не встраивает произвольные нативные окна ОС. Contribution `window` — это sandboxed `BrowserWindow`, которым владеет CanvasTTY. Native reparenting ненадёжен и непереносим между Wayland, macOS, Windows, разными DPI, popup и GPU surfaces. @@ -35,7 +36,7 @@ windows/focus.js hooks/audit.mjs ``` -Рабочий пример sandboxed web-поверхностей без привилегированного хука: [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). +Рабочий пример sandboxed web-поверхностей без привилегированного хука: [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). Минимальный сервис с canvas-приложением, которое его вызывает: [`examples/plugins/service-echo`](../examples/plugins/service-echo). Для IDE доступны [JSON Schema manifest](canvastty-plugin.schema.json) и [TypeScript declarations SDK](plugin-api.d.ts). ## Manifest v1 @@ -106,6 +107,45 @@ Hook-only plugin использует пустой массив `contributions` Script запускается отдельным процессом из каталога плагина и получает JSON через stdin с полями `apiVersion`, `pluginId`, `hookId`, `terminalSessionId`, `provider`, `event`, `providerEvent`, `payload`. Stdout/stderr отбрасываются, время выполнения ограничено, внутренние capability-токены CanvasTTY удаляются из environment. Это защита host internals, а не sandbox: script всё ещё может читать/менять файлы и запускать процессы с обычными правами пользователя. +### Сервисы (apiVersion 2) + +Манифест с `"apiVersion": 2` может объявить до 8 `services`. Манифесты версии 1 остаются валидными; версия 2 нужна только для `services`. + +```json +"services": [ + { "id": "echo", "title": "Echo", "description": "Отвечает эхом.", "entry": "services/echo.mjs" } +] +``` + +У сервиса стабильный `id`, `title`, необязательные `description` и `module`, и `entry` с расширением `.js`, `.mjs` или `.cjs`. Entry должен быть собранным одним файлом (например, esbuild): установщик ничего не собирает и не запускает `npm install`, Electron и node-pty сервису недоступны. В модульном плагине entry должен быть объявлен с хешем в своём `module` (или в `coreFiles`), как entry хуков. Когда пользователь доверяет нативному коду плагина, CanvasTTY запоминает SHA-256 entry и проверяет его перед каждым запуском; изменённый файл не запускается, а доверие снимается при следующем старте. + +Жизненный цикл: каждый сервис включённого и доверенного плагина работает отдельным процессом (`process.execPath` с `ELECTRON_RUN_AS_NODE=1`), рабочая папка — папка плагина. Окружение минимальное: `PATH`, `HOME`, пользователь, shell, локаль, временные и XDG-папки, `SSH_AUTH_SOCK` и системные папки Windows. Ключи провайдеров, токены, `NODE_OPTIONS` и все `CANVASTTY_*` удаляются. Неожиданно завершившийся сервис перезапускается через 1, 2, 4, 8, затем 16 с; после более чем 5 неожиданных завершений за 10 минут он остаётся в ошибке, пока доверие не подтвердят заново. Выключение, удаление, обновление, смена модулей, снятие доверия или выход из CanvasTTY останавливают его: сначала уведомление `canvastty.shutdown` и закрытый stdin, затем `SIGTERM`, затем `SIGKILL`. Для сервиса создаётся `/plugin-data/`, при удалении плагина папка удаляется. stderr, stdout вне протокола, вызовы `log` и события жизненного цикла пишутся в ограниченный журнал плагина (последние 300 записей) в **Настройки → Агенты → Нативный код расширений**. + +Протокол: JSON-RPC 2.0 построчно через stdin/stdout, не больше 1 МБ на сообщение в каждую сторону. Более крупный запрос хоста отклоняется, более длинная строка сервиса отбрасывается и попадает в журнал. Первым хост отправляет уведомление `canvastty.initialize` с `{ apiVersion: 2, pluginId, serviceId, dataDir, locale, hostVersion }`. + +Запросы от собственных поверхностей плагина приходят с методом и параметрами, которые выбрала поверхность; методы с префиксом `canvastty.` зарезервированы за хостом. Отвечайте `{"jsonrpc":"2.0","id":…,"result":…}` или `{"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}`. Запрос без ответа за 15 с завершается ошибкой таймаута, как и запрос к остановленному, перезапускающемуся или упавшему сервису; одновременно ждут не больше 64 запросов на сервис. + +Сервис может вызывать API хоста (это основа, которую расширят следующие точки расширения; всё остальное получает ошибку `-32601`): + +| Метод | Вид | Условие | Результат | +|:--|:--|:--|:--| +| `log` `{ level?: "info" \| "warn" \| "error", message }` | запрос или уведомление | нет | Строка в журнале плагина | +| `storage.get` `{ key }` | запрос | разрешение `storage` | То же изолированное хранилище 64 КБ, что `host.storage.get` | +| `storage.set` `{ key, value }` | запрос | разрешение `storage` | Запись и уведомление поверхностей плагина | +| `event` `{ event, data }` | уведомление | нет | Доставляется открытым поверхностям плагина через `host.service.onEvent` | +| `secrets.get` `{ key }` | запрос | разрешение `secrets` | Собственный секрет плагина (то же хранилище, что `host.secrets`) или `null`. Для ключей, которые нужны самому сервису (API-ключ модели, которую он вызывает); никогда не отправляйте его обратно на страницу | + +Хост привязывает каждый вызов к плагину самого сервиса: сервис не может назвать другой плагин, прочитать секреты другого плагина или получить доступ к сессиям. Пример [`service-echo`](../examples/plugins/service-echo) сохраняет токен со своей страницы через `host.secrets.set`, а его сервис читает его через `secrets.get` и отвечает только, задан ли он. + +Канал UI: sandboxed-поверхности обращаются только к сервисам своего плагина: + +```js +const reply = await host.service.request("echo", "echo", { text: "hi" }); +host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); +``` + +Разрешение неявное, если плагин объявил сервис. Хост передаёт непрозрачный JSON и никогда не добавляет credentials. Запрос к неработающему сервису (ещё не доверен, выключен, перезапускается, упал) или по таймауту завершается ошибкой. + host.onStorageChange(listener) сообщает всем открытым поверхностям того же плагина — canvas cards, HOME widgets и отдельным окнам — об изменениях через host.storage.set, поэтому нескольким поверхностям не требуется постоянный polling. ## Permissions @@ -113,7 +153,7 @@ host.onStorageChange(listener) сообщает всем открытым пов | Permission | Возможность SDK | Граница данных | |:--|:--|:--| | `storage` | `storage.get`, `storage.set` | Изолированное JSON-хранилище, 64 КБ на плагин | -| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete` | Строковые секреты, зашифрованные через Electron `safeStorage`; без защищённого хранилища ОС вызов завершается ошибкой | +| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete`; `secrets.get` сервиса | Строковые секреты, зашифрованные через Electron `safeStorage`; без защищённого хранилища ОС вызов завершается ошибкой. Доверенный сервис читает только секреты своего плагина | | `sessions:read` | `sessions.list` | Только ID, provider, title, status, startedAt и exitCode | | `limits:read` | `limits.get` | Тот же очищенный `LimitsSnapshot`, который использует HOME | | `launcher:open` | `launcher.open` | Открывает штатную Focus Card или запуск терминала; не обходит пользовательский выбор | diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 748b268f..e90b98c3 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -2,7 +2,7 @@ [English](plugins.md) · [Русский](plugins.ru.md) · [简体中文](plugins.zh-CN.md) · [文档首页](README.zh-CN.md) -CanvasTTY 运行时插件从 HTTPS GitHub 仓库安装。插件可以提供 sandboxed web contribution,也可以声明可选的 agent hook 脚本。Web contribution 不具备 Node.js 能力;每个 hook 在用户于 **设置 → Agents → Hooks** 中单独确认信任前始终关闭。 +CanvasTTY 运行时插件从 HTTPS GitHub 仓库安装。插件可以提供 sandboxed web contribution,也可以声明可选的 agent hook 脚本和长期运行的服务。Web contribution 不具备 Node.js 能力。Hook 和服务属于原生代码:每个 hook 在用户于 **设置 → Agents → Hooks** 中单独确认信任前始终关闭,插件的服务在 **设置 → Agents → Extension native code** 中确认前不会运行。 ## 信任模型 @@ -15,6 +15,7 @@ CanvasTTY 运行时插件从 HTTPS GitHub 仓库安装。插件可以提供 sand - 每个特权 SDK 方法都由 manifest 中的权限把关。权限会在用户确认安装之前展示。 - Sandboxed web contribution 不会收到服务商凭据、PTY 缓冲区、工作目录、原始服务商响应或文件系统访问权限。 - 禁用或卸载插件会立即停止提供其资源,并关闭其独立窗口。 +- 服务按插件整体遵循与 hook 相同的规则:安装不会启动服务,更新、更换 module、禁用插件或 entry 文件被修改都会撤销确认。服务在进程外运行;插件代码不会在 CanvasTTY 主进程中执行。 - Agent hook 不会随安装自动启用。启用后,该脚本等同于原生应用:它会接收 agent 事件 payload、以当前用户权限运行,并可能访问该用户可读的配置或凭据;更新插件、更换 module 或禁用插件都会撤销全部 hook 信任。 CanvasTTY 不嵌入任意的原生操作系统窗口。`window` 贡献是一个由 CanvasTTY 持有的 sandboxed `BrowserWindow`。原生 reparenting 在 Wayland、macOS、Windows、不同 DPI 模式、弹窗和 GPU surface 之间既不可移植也不可靠。 @@ -35,7 +36,7 @@ windows/focus.js hooks/audit.mjs ``` -不包含特权 hook 的 sandboxed web surface 端到端示例见 [`examples/plugins/studio-kit`](../examples/plugins/studio-kit)。 +不包含特权 hook 的 sandboxed web surface 端到端示例见 [`examples/plugins/studio-kit`](../examples/plugins/studio-kit)。调用自身服务的最小 canvas 应用示例见 [`examples/plugins/service-echo`](../examples/plugins/service-echo)。 编辑器工具可以使用 [manifest JSON Schema](canvastty-plugin.schema.json) 和 [SDK TypeScript 声明](plugin-api.d.ts)。 ## Manifest v1 @@ -104,6 +105,45 @@ Hook-only 插件使用空的 `contributions` 与非空的 `hooks`。安装只复 脚本在独立进程中运行,并通过 stdin 接收包含 `apiVersion`、`pluginId`、`hookId`、`terminalSessionId`、`provider`、`event`、`providerEvent` 与 `payload` 的 JSON。Stdout/stderr 会被丢弃,执行时间受限,CanvasTTY 内部 capability token 会从子进程环境中移除。这不是 sandbox:脚本仍以当前用户权限读写文件或启动进程。 +### 服务(apiVersion 2) + +`"apiVersion": 2` 的 manifest 最多可声明 8 个 `services`。版本 1 的 manifest 仍然有效;只有 `services` 需要版本 2。 + +```json +"services": [ + { "id": "echo", "title": "Echo", "description": "回显请求。", "entry": "services/echo.mjs" } +] +``` + +服务包含稳定的 `id`、`title`、可选的 `description` 和 `module`,以及以 `.js`、`.mjs` 或 `.cjs` 结尾的 `entry`。entry 必须是打包好的单文件(例如用 esbuild 构建):安装器不执行构建也不运行 `npm install`,服务无法使用 Electron 和 node-pty。在模块化插件中,entry 必须像 hook entry 一样由其 `module`(或 `coreFiles`)声明完整性。用户信任插件的原生代码时,CanvasTTY 记录 entry 的 SHA-256,并在每次启动前重新校验;被修改的文件不会运行,信任会在下次启动时撤销。 + +生命周期:已启用且受信任插件的每个服务都作为独立进程运行(`process.execPath` 加 `ELECTRON_RUN_AS_NODE=1`),工作目录为插件目录。环境变量最小化:`PATH`、`HOME`、用户、shell、语言区域、临时目录与 XDG 目录、`SSH_AUTH_SOCK` 以及 Windows 系统目录;provider 密钥、令牌、`NODE_OPTIONS` 和所有 `CANVASTTY_*` 变量都会被移除。意外退出的服务会在 1、2、4、8、16 秒后重启;10 分钟内意外退出超过 5 次后保持失败状态,直到重新确认信任。禁用、卸载、更新、更换模块、撤销信任或退出 CanvasTTY 都会停止服务:先发送 `canvastty.shutdown` 通知并关闭 stdin,然后 `SIGTERM`,最后 `SIGKILL`。服务会获得 `/plugin-data/` 目录,卸载时删除。stderr、协议之外的 stdout、`log` 调用和生命周期事件写入每个插件的有界日志(最近 300 条),显示在 **设置 → Agents → Extension native code**。 + +协议:通过 stdin/stdout 的逐行 JSON-RPC 2.0,每个方向单条消息最多 1 MB。更大的宿主请求会被拒绝,服务输出的超长行会被丢弃并记录。宿主首先发送 `canvastty.initialize` 通知,参数为 `{ apiVersion: 2, pluginId, serviceId, dataDir, locale, hostVersion }`。 + +来自插件自身界面的请求使用界面选择的方法和参数;以 `canvastty.` 开头的方法名保留给宿主。用 `{"jsonrpc":"2.0","id":…,"result":…}` 或 `{"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}` 应答。15 秒内未应答的请求以超时错误结束;服务已停止、正在重启或失败时的请求同样返回错误;每个服务同时最多等待 64 个请求。 + +服务可以回调以下宿主 API(后续扩展点在此基础上扩展;其他方法返回错误 `-32601`): + +| 方法 | 类型 | 条件 | 结果 | +|:--|:--|:--|:--| +| `log` `{ level?: "info" \| "warn" \| "error", message }` | 请求或通知 | 无 | 写入插件日志 | +| `storage.get` `{ key }` | 请求 | `storage` 权限 | 与 `host.storage.get` 相同的隔离 64 KB 存储 | +| `storage.set` `{ key, value }` | 请求 | `storage` 权限 | 写入并通知插件界面 | +| `event` `{ event, data }` | 通知 | 无 | 通过 `host.service.onEvent` 发送给该插件的活动界面 | +| `secrets.get` `{ key }` | 请求 | `secrets` 权限 | 插件自己的机密(与 `host.secrets` 同一存储),或 `null`。用于服务自身需要的密钥(例如它调用的模型的 API 密钥);绝不要把它发回界面 | + +宿主把每次调用绑定到服务自身的插件:服务无法指定其他插件、读取其他插件的机密或访问会话。示例 [`service-echo`](../examples/plugins/service-echo) 在其页面用 `host.secrets.set` 保存令牌,其服务用 `secrets.get` 读取,只回答是否已设置。 + +UI 通道:sandboxed 界面只能调用自身插件的服务: + +```js +const reply = await host.service.request("echo", "echo", { text: "hi" }); +host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); +``` + +插件声明服务即隐含该权限。宿主只转发不透明的 JSON,从不附加凭据。对未运行(尚未信任、已禁用、重启中、失败)的服务的请求或超时请求会返回错误。 + host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 ## 权限 @@ -111,7 +151,7 @@ host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一 | 权限 | SDK 能力 | 数据边界 | |:--|:--|:--| | `storage` | `storage.get`、`storage.set` | 隔离的 JSON 存储,每个插件 64 KB | -| `secrets` | `secrets.get`、`secrets.set`、`secrets.delete` | 通过 Electron `safeStorage` 加密的字符串机密;操作系统没有受保护存储时会明确失败 | +| `secrets` | `secrets.get`、`secrets.set`、`secrets.delete`;服务的 `secrets.get` | 通过 Electron `safeStorage` 加密的字符串机密;操作系统没有受保护存储时会明确失败。受信任的服务只能读取自身插件的机密 | | `sessions:read` | `sessions.list` | 仅限 ID、服务商、标题、状态、开始时间、退出码 | | `limits:read` | `limits.get` | 与 HOME 使用的同一个脱敏 `LimitsSnapshot` | | `launcher:open` | `launcher.open` | 打开内置服务商的 Focus Card 或终端动作;不会绕过用户的启动选择 | diff --git a/examples/plugins/service-echo/apps/echo.css b/examples/plugins/service-echo/apps/echo.css new file mode 100644 index 00000000..20c51067 --- /dev/null +++ b/examples/plugins/service-echo/apps/echo.css @@ -0,0 +1,5 @@ +body { margin: 0; font: 600 14px/1.5 system-ui, sans-serif; color: #eef0f6; background: #353442; } +.echo { display: grid; gap: 10px; padding: 16px; } +.echo input { padding: 8px 10px; border: 1px solid rgba(255,255,255,.15); border-radius: 10px; color: inherit; background: rgba(255,255,255,.06); font: inherit; } +.echo button { min-height: 38px; border: 0; border-radius: 10px; color: #30313d; background: #b9d4a8; cursor: pointer; font-weight: 800; } +.echo output { min-height: 22px; overflow-wrap: anywhere; } diff --git a/examples/plugins/service-echo/apps/echo.html b/examples/plugins/service-echo/apps/echo.html new file mode 100644 index 00000000..be6d6909 --- /dev/null +++ b/examples/plugins/service-echo/apps/echo.html @@ -0,0 +1,21 @@ + + + + + + + Service echo + + +
+ + + Not called yet. + + + +
+ + + + diff --git a/examples/plugins/service-echo/apps/echo.js b/examples/plugins/service-echo/apps/echo.js new file mode 100644 index 00000000..864fe240 --- /dev/null +++ b/examples/plugins/service-echo/apps/echo.js @@ -0,0 +1,35 @@ +const host = window.CanvasTTYPlugin; +const text = document.querySelector("#text"); +const send = document.querySelector("#send"); +const result = document.querySelector("#result"); + +send.addEventListener("click", async () => { + result.textContent = "Calling…"; + try { + const reply = await host.service.request("echo", "echo", { text: text.value }); + result.textContent = `Echo #${reply.count}: ${reply.echo.text}`; + } catch (error) { + // Not trusted yet, disabled, crashed or timed out: the host returns an error, never a reply. + result.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`; + } +}); + +// Write-only: the page saves the token into the plugin's secrets and asks the service whether it can read it. +document.querySelector("#save-token").addEventListener("click", async () => { + const token = document.querySelector("#token"); + await host.secrets.set("token", token.value); + token.value = ""; + result.textContent = "Token saved."; +}); +document.querySelector("#check-token").addEventListener("click", async () => { + try { + const reply = await host.service.request("echo", "token"); + result.textContent = reply.set ? "The service sees a token." : "No token is set."; + } catch (error) { + result.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`; + } +}); + +host.service.onEvent(({ serviceId, event, data }) => { + document.body.dataset.lastEvent = `${serviceId}:${event}:${data?.count ?? ""}`; +}); diff --git a/examples/plugins/service-echo/canvastty.plugin.json b/examples/plugins/service-echo/canvastty.plugin.json new file mode 100644 index 00000000..cc4baa99 --- /dev/null +++ b/examples/plugins/service-echo/canvastty.plugin.json @@ -0,0 +1,27 @@ +{ + "apiVersion": 2, + "id": "com.example.service-echo", + "name": "Service Echo", + "version": "1.0.0", + "description": "Minimal plugin service: a canvas app button that calls its own service, which echoes the text back. The app can also save a token into the plugin's secrets, and the service reads it with secrets.get (it answers only whether one is set).", + "author": "CanvasTTY contributors", + "permissions": ["storage", "secrets"], + "services": [ + { + "id": "echo", + "title": "Echo", + "description": "Echoes requests and counts them in plugin storage.", + "entry": "services/echo.mjs" + } + ], + "contributions": [ + { + "id": "echo", + "kind": "canvas-app", + "title": "Service echo", + "description": "Sends text to the plugin's own service and shows the reply.", + "entry": "apps/echo.html", + "defaultSize": { "width": 420, "height": 260 } + } + ] +} diff --git a/examples/plugins/service-echo/services/echo.mjs b/examples/plugins/service-echo/services/echo.mjs new file mode 100644 index 00000000..95fd2770 --- /dev/null +++ b/examples/plugins/service-echo/services/echo.mjs @@ -0,0 +1,57 @@ +// A CanvasTTY plugin service: newline-delimited JSON-RPC 2.0 over stdin/stdout. +// Bundled single file, no dependencies. Anything written to stderr ends up in the plugin log. +import { createInterface } from "node:readline"; + +const pending = new Map(); +let nextId = 1; +let context = null; + +const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); +const callHost = (method, params) => new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + send({ id, method, params }); +}); + +async function handle(method, params) { + if (method === "echo") { + const count = ((await callHost("storage.get", { key: "count" })) ?? 0) + 1; + await callHost("storage.set", { key: "count", value: count }); + send({ method: "event", params: { event: "echoed", data: { count } } }); + return { echo: params, count, serviceId: context?.serviceId ?? null }; + } + if (method === "token") { + // The service reads the plugin's own secret (needs the secrets permission). + // Never send it back to a page: answer only whether it is set. + const token = await callHost("secrets.get", { key: "token" }); + return { set: typeof token === "string" && token.length > 0 }; + } + throw new Error(`Unknown method: ${method}`); +} + +createInterface({ input: process.stdin }).on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.method === "canvastty.initialize") { + context = message.params; + send({ method: "log", params: { level: "info", message: `echo ready for ${context.pluginId}` } }); + return; + } + if (message.method === "canvastty.shutdown") process.exit(0); + if (typeof message.method === "string" && message.id !== undefined) { + handle(message.method, message.params).then( + (result) => send({ id: message.id, result }), + (error) => send({ id: message.id, error: { code: -32000, message: error.message } }) + ); + return; + } + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result); +}).on("close", () => process.exit(0)); diff --git a/src/main/index.ts b/src/main/index.ts index 823458ee..029a8796 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { IPC, type LocaleId, type PluginCanvasRequest, + type PluginServiceEvent, type SessionStatus, type UpdaterState, type UpdaterStateEvent @@ -25,6 +26,7 @@ import { type ProviderCliRegistry } from "./services/providerCliRegistry"; import { PluginManager } from "./services/PluginManager"; +import { PluginServiceSupervisor } from "./services/PluginServiceSupervisor"; import { GithubAuthService } from "./services/GithubAuthService"; import { PluginMediaService } from "./services/PluginMediaService"; import { PluginSecretsService } from "./services/PluginSecretsService"; @@ -122,6 +124,7 @@ let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; let pluginManager: PluginManager | null = null; +let pluginServices: PluginServiceSupervisor | null = null; let githubAuth: GithubAuthService | null = null; let pluginMediaService: PluginMediaService | null = null; let pluginSecretsService: PluginSecretsService | null = null; @@ -267,6 +270,26 @@ async function initializeServices(): Promise { await settings.load(); pluginManager = new PluginManager(userDataPath); await pluginManager.load(); + // Trusted plugin services run as separate processes, started the way plugin hooks are. + pluginServices = new PluginServiceSupervisor({ + command: process.execPath, + hostVersion: app.getVersion(), + locale: () => settings.get().locale, + host: { + storageGet: (pluginId, key) => pluginManager!.storageGet(pluginId, key), + storageSet: async (pluginId, key, value) => { + await pluginManager!.storageSet(pluginId, key, value); + broadcastPluginStorageChange(pluginId, key, value); + }, + emit: (pluginId, serviceId, event, data) => broadcastPluginServiceEvent({ pluginId, serviceId, event, data }), + secretGet: (pluginId, key) => { + if (!pluginSecretsService) throw new Error("Plugin secrets are not ready yet."); + return pluginSecretsService.get(pluginId, key); + } + } + }); + pluginManager.setServiceObserver((specs) => pluginServices!.sync(specs)); + void pluginServices.sync(pluginManager.trustedServiceSpecs()); canvasNavigationInput = new CanvasNavigationInputController( { @@ -546,6 +569,7 @@ async function initializeServices(): Promise { terminals: terminalManager, limits: limitsService, plugins: pluginManager, + pluginServices, pluginMedia: pluginMediaService, pluginSecrets: pluginSecretsService, providerSecrets: providerSecretsService!, @@ -921,6 +945,7 @@ async function shutdownServices(): Promise { if (agentGateway) await Promise.allSettled([agentGateway.close()]); if (runtimeGateway) await Promise.allSettled([runtimeGateway.close()]); if (browserService) await Promise.allSettled([browserService.dispose()]); + if (pluginServices) await Promise.allSettled([pluginServices.dispose()]); if (pluginManager) await Promise.allSettled([pluginManager.dispose()]); } @@ -985,6 +1010,16 @@ function broadcastPluginStorageChange(pluginId: string, key: string, value: unkn } } +function broadcastPluginServiceEvent(event: PluginServiceEvent): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC.pluginsServiceEvent, event); + } + for (const [window, ownerPluginId] of pluginWindows) { + if (ownerPluginId !== event.pluginId || window.isDestroyed()) continue; + window.webContents.send(IPC.pluginsServiceEvent, event); + } +} + function securePluginStorageAvailable(): boolean { if (!safeStorage.isEncryptionAvailable()) return false; return process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"; diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 8c2b4349..5acefa12 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -22,6 +22,7 @@ import { providerCliAvailability, type ProviderCliRegistry } from "../services/p import type { TerminalManager } from "../services/TerminalManager"; import type { LimitsService } from "../services/LimitsService"; import type { PluginManager } from "../services/PluginManager"; +import type { PluginServiceSupervisor } from "../services/PluginServiceSupervisor"; import type { PluginMediaService } from "../services/PluginMediaService"; import type { PluginSecretsService } from "../services/PluginSecretsService"; import type { ProviderSecretsService } from "../services/ProviderSecretsService"; @@ -48,6 +49,7 @@ interface Dependencies { terminals: TerminalManager; limits: LimitsService; plugins: PluginManager; + pluginServices: PluginServiceSupervisor; pluginMedia: PluginMediaService; pluginSecrets: PluginSecretsService; providerSecrets: ProviderSecretsService; @@ -80,6 +82,7 @@ export function registerIpc({ terminals, limits, plugins, + pluginServices, pluginMedia, pluginSecrets, providerSecrets, @@ -98,6 +101,13 @@ export function registerIpc({ updater }: Dependencies): (window: BrowserWindow | null) => void { const pluginBrowserOpenBroker = new PluginBrowserOpenBroker(getMainWindow); + // A surface reaches only its own plugin's services: the caller's plugin id is bound by the + // renderer frame host or by the identity-checked plugin window, never taken from plugin code. + const requestPluginService = (pluginId: string, values: Record): Promise => { + const serviceId = stringValue(values.serviceId, "serviceId"); + plugins.assertService(pluginId, serviceId); + return pluginServices.request(pluginId, serviceId, stringValue(values.method, "method"), values.params); + }; const requestPluginBrowserOpen = async (pluginId: string, value: unknown): Promise => { plugins.assertPermission(pluginId, "browser:open"); await pluginBrowserOpenBroker.request(pluginId, normalizePluginBrowserUrl(value)); @@ -268,11 +278,32 @@ export function registerIpc({ } return plugins.setHookEnabled(pluginId, hookId, enabled); }); + ipcMain.handle(IPC.pluginsSetNativeCodeTrusted, (event, pluginId: string, trusted: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof pluginId !== "string" || typeof trusted !== "boolean") throw new Error("Plugin native code state is invalid."); + return plugins.setNativeCodeTrusted(pluginId, trusted); + }); + ipcMain.handle(IPC.pluginsServiceReport, (event, pluginId: string) => { + assertMainRenderer(event, getMainWindow); + if (typeof pluginId !== "string") throw new Error("Plugin identifier is required."); + return pluginServices.report(pluginId); + }); + ipcMain.handle(IPC.pluginsServiceRequest, ( + event, + pluginId: string, + serviceId: string, + method: string, + params: unknown + ) => { + assertMainRenderer(event, getMainWindow); + return requestPluginService(pluginId, { serviceId, method, params }); + }); ipcMain.handle(IPC.pluginsUninstall, async (_event, pluginId: string) => { closePluginWindows(pluginId); await pluginSecrets.revokeAll(pluginId); await pluginMedia.revokeAll(pluginId); await plugins.uninstall(pluginId); + pluginServices.forget(pluginId); }); ipcMain.handle(IPC.pluginsOpenCanvas, ( _event, @@ -486,6 +517,7 @@ export function registerIpc({ playlistContent(values.content) ); } + if (method === "service.request") return requestPluginService(pluginId, values); if (method === "window.open") { const targetId = stringValue(values.contributionId, "contributionId"); const target = plugins.contribution(pluginId, targetId); diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 17e63527..aa0b2410 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -27,6 +27,7 @@ import type { PluginModule, PluginModuleAsset, PluginPermission, + PluginService, PluginUpdateStatus, Size } from "../../shared/contracts"; @@ -36,6 +37,7 @@ import { PLUGIN_API_VERSION } from "../../shared/contracts.ts"; import { isValidSemver } from "../../shared/hostVersion.ts"; +import type { PluginServiceSpec } from "./PluginServiceSupervisor.ts"; const MANIFEST_FILE = "canvastty.plugin.json"; /** Plugins keep their metadata (manifest, icon, etc.) in the metadata/ folder. */ @@ -71,6 +73,8 @@ const MAX_STORAGE_BYTES = 64 * 1024; const MAX_MANIFEST_BYTES = 128 * 1024; const MAX_RUNTIME_HOOK_REGISTRY_BYTES = 1024 * 1024; const MAX_PLUGIN_ICON_BYTES = 512 * 1024; +const MAX_PLUGIN_SERVICES = 8; +const PLUGIN_DATA_DIR = "plugin-data"; const PLUGIN_INPUT_BRIDGE_URL = "canvastty-plugin://host/input-bridge.js"; const AGENT_PROVIDERS = new Set([ "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity" @@ -115,6 +119,8 @@ interface StoredPluginRecord { installedAt: number; selectedModules?: string[]; enabledHooks?: string[]; + /** Service id -> SHA-256 of its entry when the user trusted the plugin's native code. */ + trustedServices?: Record; } export interface RuntimePluginHookRegistration { @@ -162,7 +168,11 @@ export class PluginManager { private readonly registryPath: string; private readonly versionsPath: string; private readonly hookRegistryPath: string; + private readonly dataRoot: string; private readonly plugins = new Map(); + /** Plugin id -> service id -> trusted entry SHA-256. Present only while native code is trusted. */ + private readonly serviceTrust = new Map>(); + private serviceObserver: ((specs: PluginServiceSpec[]) => Promise) | null = null; private readonly pending = new Map(); private readonly updatingPlugins = new Map>(); private readonly storageWrites = new Map>(); @@ -185,6 +195,7 @@ export class PluginManager { this.registryPath = join(userDataPath, REGISTRY_FILE); this.versionsPath = join(userDataPath, VERSIONS_FILE); this.hookRegistryPath = join(userDataPath, "lifecycle", RUNTIME_HOOK_REGISTRY_FILE); + this.dataRoot = join(userDataPath, PLUGIN_DATA_DIR); this.downloadRepository = downloadRepository ?? downloadGithubManifest; this.downloadFullRepository = downloadRepository ?? downloadGithubRepository; this.downloadModuleFiles = downloadModuleFiles; @@ -227,6 +238,7 @@ export class PluginManager { const persistedRuntimeHooks = await readRuntimeHookRegistry(this.hookRegistryPath); this.plugins.clear(); + this.serviceTrust.clear(); for (const [pluginId, record] of Object.entries(registry)) { if (!isStoredRecord(record) || !isPluginId(pluginId)) continue; try { @@ -249,8 +261,17 @@ export class PluginManager { active.hooks?.find((hook) => hook.id === hookId) ) )) - : [] + : [], + nativeCodeTrusted: false }); + if (record.enabled && record.trustedServices) { + const trust = await this.currentServiceTrust(pluginId, active).catch(() => null); + // Any difference from what the user trusted (a changed file, another module set) revokes it. + if (trust && sameServiceTrust(trust, record.trustedServices)) { + this.serviceTrust.set(pluginId, trust); + this.plugins.get(pluginId)!.nativeCodeTrusted = true; + } + } } catch (error) { console.warn(`CanvasTTY plugin ${pluginId} could not be loaded.`, error); } @@ -356,7 +377,8 @@ export class PluginManager { enabled: true, installedAt: Date.now(), selectedModules: modules, - enabledHooks: [] + enabledHooks: [], + nativeCodeTrusted: false }; this.plugins.set(installed.manifest.id, installed); await this.persistRegistry(); @@ -374,14 +396,22 @@ export class PluginManager { const plugin = this.requirePlugin(pluginId); const wasEnabled = plugin.enabled; const previousEnabledHooks = [...plugin.enabledHooks]; + const previousTrust = this.serviceTrust.get(pluginId); plugin.enabled = Boolean(enabled); - if (!plugin.enabled || !wasEnabled) plugin.enabledHooks = []; + if (!plugin.enabled || !wasEnabled) { + plugin.enabledHooks = []; + this.revokeNativeCode(plugin); + } try { await this.persistRegistry(); } catch (error) { if (plugin.enabled) { plugin.enabled = wasEnabled; plugin.enabledHooks = previousEnabledHooks; + if (previousTrust) { + this.serviceTrust.set(pluginId, previousTrust); + plugin.nativeCodeTrusted = true; + } } await this.persistRegistry().catch(() => undefined); throw error; @@ -411,6 +441,64 @@ export class PluginManager { return structuredClone(activePlugin(plugin)); } + /** + * The separate "Native code" confirmation: lets every service of this plugin run as a process with + * the user's OS privileges. It pins each entry's SHA-256; update, module change and disable revoke it. + */ + async setNativeCodeTrusted(pluginId: string, trusted: boolean): Promise { + const plugin = this.requireEnabledPlugin(pluginId); + const active = activeManifest(plugin.manifest, plugin.selectedModules); + if (!active.services?.length) throw new Error("Plugin has no services."); + const previous = this.serviceTrust.get(pluginId); + if (trusted) { + this.serviceTrust.set(pluginId, await this.currentServiceTrust(pluginId, active)); + plugin.nativeCodeTrusted = true; + } else { + this.revokeNativeCode(plugin); + } + try { + await this.persistRegistry(); + } catch (error) { + if (trusted) { + if (previous) this.serviceTrust.set(pluginId, previous); + else this.revokeNativeCode(plugin); + plugin.nativeCodeTrusted = Boolean(previous); + } + await this.persistRegistry().catch(() => undefined); + throw error; + } + return structuredClone(activePlugin(plugin)); + } + + /** Called with the trusted services after every registry change (the supervisor's desired set). */ + setServiceObserver(observer: ((specs: PluginServiceSpec[]) => Promise) | null): void { + this.serviceObserver = observer; + } + + trustedServiceSpecs(): PluginServiceSpec[] { + const specs: PluginServiceSpec[] = []; + for (const plugin of this.plugins.values()) { + const trust = this.serviceTrust.get(plugin.manifest.id); + if (!plugin.enabled || !plugin.nativeCodeTrusted || !trust) continue; + const manifest = activeManifest(plugin.manifest, plugin.selectedModules); + const root = join(this.pluginRoot, plugin.manifest.id); + for (const service of manifest.services ?? []) { + const sha256 = trust[service.id]; + if (!sha256) continue; + specs.push({ + pluginId: plugin.manifest.id, + serviceId: service.id, + root, + entryPath: join(root, ...service.entry.split("/")), + sha256, + dataDir: join(this.dataRoot, plugin.manifest.id), + permissions: [...manifest.permissions] + }); + } + } + return specs; + } + get runtimeHookRegistryPath(): string { return this.hookRegistryPath; } @@ -436,8 +524,9 @@ export class PluginManager { if (!plugin.manifest.modules?.length) throw new Error("Plugin does not declare optional modules."); const selected = normalizeSelectedModules(plugin.manifest, selectedModules); if (selected.length !== new Set(selectedModules).size) throw new Error("Plugin module selection is invalid."); - if (plugin.enabledHooks.length > 0) { + if (plugin.enabledHooks.length > 0 || plugin.nativeCodeTrusted) { plugin.enabledHooks = []; + this.revokeNativeCode(plugin); await this.persistRegistry(); } const directory = await mkdtemp(join(this.stagingRoot, "modules-")); @@ -485,8 +574,9 @@ export class PluginManager { async uninstall(pluginId: string): Promise { const plugin = this.requirePlugin(pluginId); - if (plugin.enabledHooks.length > 0) { + if (plugin.enabledHooks.length > 0 || plugin.nativeCodeTrusted) { plugin.enabledHooks = []; + this.revokeNativeCode(plugin); try { await this.persistRegistry(); } catch (error) { @@ -504,6 +594,7 @@ export class PluginManager { } await rm(join(this.pluginRoot, plugin.manifest.id), { recursive: true, force: true }); await rm(join(this.storageRoot, `${plugin.manifest.id}.json`), { force: true }); + await rm(join(this.dataRoot, plugin.manifest.id), { recursive: true, force: true }); } async searchGithubPlugins(query: string): Promise { @@ -698,8 +789,9 @@ export class PluginManager { private async performPluginUpdate(pluginId: string): Promise { const plugin = this.requirePlugin(pluginId); - if (plugin.enabledHooks.length > 0) { + if (plugin.enabledHooks.length > 0 || plugin.nativeCodeTrusted) { plugin.enabledHooks = []; + this.revokeNativeCode(plugin); await this.persistRegistry(); } const sourceUrl = plugin.sourceUrl; @@ -742,8 +834,9 @@ export class PluginManager { enabled: plugin.enabled, installedAt: plugin.installedAt, selectedModules: selected, - // Updated native hook code must be reviewed and trusted again. - enabledHooks: [] + // Updated native hook and service code must be reviewed and trusted again. + enabledHooks: [], + nativeCodeTrusted: false }; this.plugins.set(pluginId, updated); await this.persistRegistry(); @@ -824,6 +917,13 @@ export class PluginManager { return structuredClone(contribution); } + assertService(pluginId: string, serviceId: string): void { + const plugin = activePlugin(this.requireEnabledPlugin(pluginId)); + if (!plugin.manifest.services?.some((service) => service.id === serviceId)) { + throw new Error("Plugin service does not exist."); + } + } + hasPermission(pluginId: string, permission: PluginPermission): boolean { const plugin = activePlugin(this.requireEnabledPlugin(pluginId)); return plugin.manifest.permissions.includes(permission); @@ -958,7 +1058,10 @@ export class PluginManager { enabled: plugin.enabled, installedAt: plugin.installedAt, selectedModules: plugin.selectedModules, - enabledHooks: plugin.enabledHooks + enabledHooks: plugin.enabledHooks, + ...(plugin.nativeCodeTrusted && this.serviceTrust.has(id) + ? { trustedServices: { ...this.serviceTrust.get(id)! } } + : {}) }] satisfies [string, StoredPluginRecord])); const snapshot = JSON.stringify(registry, null, 2); const desiredHookRegistry = this.runtimeHookRegistry(); @@ -967,6 +1070,7 @@ export class PluginManager { throw new Error("Enabled plugin hooks exceed the runtime registry limit."); } const temporaryPath = `${this.registryPath}.tmp`; + const services = this.trustedServiceSpecs(); const write = this.registryWrite.catch(() => undefined).then(async () => { const currentHookRegistry = await readRuntimeHookRegistry(this.hookRegistryPath); const interimHookRegistry = safeRuntimeHookInterim(currentHookRegistry, desiredHookRegistry); @@ -982,11 +1086,32 @@ export class PluginManager { if (interimHookSnapshot !== hookSnapshot) { await writeRuntimeHookRegistry(this.hookRegistryPath, hookSnapshot); } + // Revocations stop services before the caller replaces or removes their files. + await this.serviceObserver?.(services).catch((error: unknown) => { + console.warn("CanvasTTY plugin services could not be updated.", error); + }); }); this.registryWrite = write; return write; } + private revokeNativeCode(plugin: InstalledPlugin): void { + plugin.nativeCodeTrusted = false; + this.serviceTrust.delete(plugin.manifest.id); + } + + private async currentServiceTrust(pluginId: string, manifest: PluginManifest): Promise> { + const root = join(this.pluginRoot, pluginId); + const trust: Record = {}; + for (const service of manifest.services ?? []) { + const path = await containedFile(root, service.entry); + const metadata = await stat(path); + if (metadata.size > MAX_ASSET_BYTES) throw new Error(`Plugin service entry is too large: ${service.entry}.`); + trust[service.id] = createHash("sha256").update(await readFile(path)).digest("hex"); + } + return trust; + } + private runtimeHookRegistry(): RuntimeHookRegistry { const hooks: Record = {}; for (const plugin of this.plugins.values()) { @@ -1046,10 +1171,14 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { assertOnlyKeys(candidate, [ "apiVersion", "id", "name", "version", "description", "description.ru", "description.en", "icon", "author", "homepage", "settingsContribution", "coreFiles", "modules", "permissions", - "contributions", "hooks", "platforms", "minHostVersion" + "contributions", "hooks", "services", "platforms", "minHostVersion" ], "Plugin manifest"); - if (candidate.apiVersion !== PLUGIN_API_VERSION) { - throw new Error(`Plugin apiVersion must be ${PLUGIN_API_VERSION}.`); + if (candidate.apiVersion !== 1 && candidate.apiVersion !== PLUGIN_API_VERSION) { + throw new Error(`Plugin apiVersion must be 1 or ${PLUGIN_API_VERSION}.`); + } + const apiVersion = candidate.apiVersion === 1 ? 1 : PLUGIN_API_VERSION; + if (apiVersion === 1 && candidate.services !== undefined) { + throw new Error(`Plugin services require apiVersion ${PLUGIN_API_VERSION}.`); } const id = requiredString(candidate.id, "id", 80); if (!isPluginId(id) || id === "host") throw new Error("Plugin id must be a lowercase DNS-style identifier."); @@ -1101,6 +1230,7 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { const modules = validateModules(candidate.modules); const moduleIds = new Set(modules.map((module) => module.id)); const hooks = validateAgentHooks(candidate.hooks, moduleIds); + const services = validateServices(candidate.services, moduleIds); const coreFiles = candidate.coreFiles === undefined ? [] : validateModuleFiles(candidate.coreFiles, "coreFiles"); if (modules.length > 0 && coreFiles.length === 0) { throw new Error("Modular plugins must declare at least one coreFiles asset."); @@ -1116,8 +1246,8 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { if (!Array.isArray(candidate.contributions) || candidate.contributions.length > 32) { throw new Error("Plugin contributions must be an array of at most 32 items."); } - if (candidate.contributions.length === 0 && hooks.length === 0) { - throw new Error("Plugin must declare at least one contribution or agent hook."); + if (candidate.contributions.length === 0 && hooks.length === 0 && services.length === 0) { + throw new Error("Plugin must declare at least one contribution, agent hook, or service."); } const contributionIds = new Set(); const contributions = candidate.contributions.map((value) => { @@ -1138,7 +1268,7 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { } return { - apiVersion: PLUGIN_API_VERSION, + apiVersion, id, name, version, @@ -1153,6 +1283,7 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { permissions, contributions, ...(hooks.length ? { hooks } : {}), + ...(services.length ? { services } : {}), ...(settingsContribution ? { settingsContribution } : {}), ...(coreFiles.length ? { coreFiles } : {}), ...(modules.length ? { modules } : {}) @@ -1221,6 +1352,32 @@ function validateAgentHooks(value: unknown, moduleIds: ReadonlySet): Plu }); } +function validateServices(value: unknown, moduleIds: ReadonlySet): PluginService[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PLUGIN_SERVICES) { + throw new Error(`Plugin services must contain between 1 and ${MAX_PLUGIN_SERVICES} items.`); + } + const ids = new Set(); + return value.map((candidate) => { + if (!isRecord(candidate)) throw new Error("Every plugin service must be an object."); + assertOnlyKeys(candidate, ["id", "title", "description", "entry", "module"], "Plugin service"); + const id = requiredString(candidate.id, "service id", 64); + if (!isContributionId(id) || ids.has(id)) throw new Error(`Plugin service id is invalid or duplicated: ${id}.`); + ids.add(id); + const title = requiredString(candidate.title, "service title", 80); + const description = optionalString(candidate.description, "service description", 240); + const entry = assetPath(requiredString(candidate.entry, "service entry", 180)); + if (![".js", ".mjs", ".cjs"].includes(extname(entry))) { + throw new Error("Plugin service entry must be a bundled JavaScript file."); + } + const module = optionalString(candidate.module, "service module", 64); + if (module && (!isContributionId(module) || !moduleIds.has(module))) { + throw new Error(`Plugin service references an unknown module: ${module}.`); + } + return { id, title, ...(description ? { description } : {}), entry, ...(module ? { module } : {}) }; + }); +} + function validateContribution(value: unknown): PluginContribution { if (!isRecord(value)) throw new Error("Every plugin contribution must be an object."); assertOnlyKeys(value, [ @@ -1315,6 +1472,7 @@ async function assertManifestAssets(root: string, manifest: PluginManifest): Pro if (contribution.icon) await containedFile(root, contribution.icon); } for (const hook of manifest.hooks ?? []) await containedFile(root, hook.entry); + for (const service of manifest.services ?? []) await containedFile(root, service.entry); } async function containedFile(root: string, relativePath: string): Promise { @@ -2107,6 +2265,12 @@ function assertModularContributionFiles(manifest: PluginManifest): void { throw new Error(`Hook entry must belong to its declared module: ${hook.id}.`); } } + for (const service of manifest.services ?? []) { + const available = service.module ? moduleFiles.get(service.module) : coreFiles; + if (!available?.has(service.entry)) { + throw new Error(`Service entry must belong to its declared module: ${service.id}.`); + } + } } async function materializeModularPackage( @@ -2138,8 +2302,9 @@ async function materializeModularPackage( function activeManifest(manifest: PluginManifest, selectedModules: readonly string[]): PluginManifest { const selected = new Set(selectedModules); const contributions = manifest.contributions.filter((contribution) => !contribution.module || selected.has(contribution.module)); - const { settingsContribution, hooks: declaredHooks = [], ...rest } = manifest; + const { settingsContribution, hooks: declaredHooks = [], services: declaredServices = [], ...rest } = manifest; const hooks = declaredHooks.filter((hook) => !hook.module || selected.has(hook.module)); + const services = declaredServices.filter((service) => !service.module || selected.has(service.module)); const permissions = [ ...manifest.permissions, ...(manifest.modules ?? []).filter((module) => selected.has(module.id)).flatMap((module) => module.permissions) @@ -2149,6 +2314,7 @@ function activeManifest(manifest: PluginManifest, selectedModules: readonly stri permissions: [...new Set(permissions)], contributions, ...(hooks.length ? { hooks } : {}), + ...(services.length ? { services } : {}), ...(settingsContribution && contributions.some((item) => item.id === settingsContribution) ? { settingsContribution } : {}) @@ -2387,9 +2553,18 @@ function isStoredRecord(value: unknown): value is StoredPluginRecord { && (value.enabledHooks === undefined || ( Array.isArray(value.enabledHooks) && value.enabledHooks.every((item) => typeof item === "string") )) + && (value.trustedServices === undefined || ( + isRecord(value.trustedServices) + && Object.values(value.trustedServices).every((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/.test(hash)) + )) ); } +function sameServiceTrust(current: Record, stored: Record): boolean { + const ids = Object.keys(current); + return ids.length === Object.keys(stored).length && ids.every((id) => stored[id] === current[id]); +} + function runtimeHookKey(pluginId: string, hookId: string): string { return `${pluginId}:${hookId}`; } @@ -2560,6 +2735,7 @@ const PLUGIN_SDK_SOURCE = `(() => { const pending = new Map(); const listeners = new Set(); const storageListeners = new Set(); + const serviceListeners = new Set(); let nextId = 1; const post = (message) => parent.postMessage({ source: "canvastty-plugin", ...message }, "*"); const request = (method, params = {}) => new Promise((resolve, reject) => { @@ -2581,6 +2757,7 @@ const PLUGIN_SDK_SOURCE = `(() => { if (message.type === "storage-change") { storageListeners.forEach((listener) => listener(message.key, message.value)); } + if (message.type === "service-event") serviceListeners.forEach((listener) => listener(message.value)); }); window.CanvasTTYPlugin = Object.freeze({ ready: () => post({ type: "ready" }), @@ -2613,6 +2790,13 @@ const PLUGIN_SDK_SOURCE = `(() => { open: () => request("hermesHud.open"), close: () => request("hermesHud.close") }), + service: Object.freeze({ + request: (serviceId, method, params) => request("service.request", { serviceId, method, params }), + onEvent: (listener) => { + serviceListeners.add(listener); + return () => serviceListeners.delete(listener); + } + }), onContext: (listener) => { listeners.add(listener); return () => listeners.delete(listener); diff --git a/src/main/services/PluginServiceSupervisor.ts b/src/main/services/PluginServiceSupervisor.ts new file mode 100644 index 00000000..77d51877 --- /dev/null +++ b/src/main/services/PluginServiceSupervisor.ts @@ -0,0 +1,526 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import type { + PluginPermission, + PluginServiceLogEntry, + PluginServiceReport, + PluginServiceState, + PluginServiceStatus +} from "../../shared/contracts"; + +/** One trusted service the supervisor should keep running. Built by PluginManager. */ +export interface PluginServiceSpec { + pluginId: string; + serviceId: string; + /** Plugin package root: the process working directory. */ + root: string; + /** Absolute entry path inside `root`. */ + entryPath: string; + /** SHA-256 of the entry recorded when the user trusted it; checked before every start. */ + sha256: string; + /** `/plugin-data/`, created before start and removed on uninstall. */ + dataDir: string; + permissions: readonly PluginPermission[]; +} + +/** Host calls a service may make back. Everything else is rejected. */ +export interface PluginServiceHost { + storageGet(pluginId: string, key: string): Promise; + storageSet(pluginId: string, key: string, value: unknown): Promise; + emit(pluginId: string, serviceId: string, event: string, data: unknown): void; + /** One of the plugin's own secrets (already checked for `secrets`), or null when it is not set. */ + secretGet?(pluginId: string, key: string): Promise; +} + +export interface PluginServiceSupervisorOptions { + /** Executable that runs JavaScript: Electron's `process.execPath` with ELECTRON_RUN_AS_NODE. */ + command: string; + hostVersion: string; + locale(): string; + host: PluginServiceHost; + /** Environment the minimal child environment is picked from (default `process.env`). */ + environment?: NodeJS.ProcessEnv; + requestTimeoutMs?: number; + stopGraceMs?: number; + restartDelaysMs?: readonly number[]; + maxRestarts?: number; + restartWindowMs?: number; + maxFrameBytes?: number; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; +const DEFAULT_STOP_GRACE_MS = 2_000; +const DEFAULT_RESTART_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000]; +const DEFAULT_MAX_RESTARTS = 5; +const DEFAULT_RESTART_WINDOW_MS = 10 * 60_000; +export const PLUGIN_SERVICE_MAX_FRAME_BYTES = 1024 * 1024; +const MAX_PENDING_REQUESTS = 64; +const MAX_LOG_ENTRIES = 300; +const MAX_LOG_MESSAGE = 2_000; +const HOST_METHOD_PREFIX = "canvastty."; + +/** + * Variables a service inherits. Everything else (provider keys, tokens, CANVASTTY_* runtime + * internals, NODE_OPTIONS) is dropped: a service gets what it needs to find system tools, no more. + */ +const INHERITED_ENVIRONMENT = new Set([ + "PATH", "Path", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LANGUAGE", "TERM", "TZ", + "TMPDIR", "TMP", "TEMP", "SSH_AUTH_SOCK", + "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", + "SystemRoot", "SYSTEMROOT", "windir", "WINDIR", "ComSpec", "COMSPEC", "PATHEXT", + "USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramData", "HOMEDRIVE", "HOMEPATH" +]); + +export function pluginServiceEnvironment(source: NodeJS.ProcessEnv): Record { + const environment: Record = {}; + for (const [name, value] of Object.entries(source)) { + if (typeof value === "string" && (INHERITED_ENVIRONMENT.has(name) || name.startsWith("LC_"))) { + environment[name] = value; + } + } + environment.ELECTRON_RUN_AS_NODE = "1"; + return environment; +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +interface ServiceRecord { + spec: PluginServiceSpec; + state: PluginServiceState; + child: ChildProcess | null; + pending: Map; + nextId: number; + stdout: string; + discarding: boolean; + crashes: number[]; + restarts: number; + restartTimer: NodeJS.Timeout | null; + removed: boolean; + exited: Promise | null; + lastError?: string; +} + +/** + * Runs trusted plugin services as separate processes and speaks newline-delimited JSON-RPC 2.0 + * with them over stdio. Plugin code never runs in the Electron main process. + */ +export class PluginServiceSupervisor { + private readonly services = new Map(); + private readonly logs = new Map(); + private readonly options: Required> & { + environment: NodeJS.ProcessEnv; + }; + private syncing = Promise.resolve(); + private disposed = false; + + constructor(options: PluginServiceSupervisorOptions) { + this.options = { + environment: process.env, + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + stopGraceMs: DEFAULT_STOP_GRACE_MS, + restartDelaysMs: DEFAULT_RESTART_DELAYS_MS, + maxRestarts: DEFAULT_MAX_RESTARTS, + restartWindowMs: DEFAULT_RESTART_WINDOW_MS, + maxFrameBytes: PLUGIN_SERVICE_MAX_FRAME_BYTES, + ...options + }; + } + + /** Makes the running set equal to `specs`: stops removed or changed services, starts new ones. */ + sync(specs: readonly PluginServiceSpec[]): Promise { + const next = this.syncing.catch(() => undefined).then(async () => { + const desired = new Map(specs.map((spec) => [serviceKey(spec.pluginId, spec.serviceId), spec])); + const stops: Promise[] = []; + for (const [key, record] of this.services) { + const spec = desired.get(key); + if (!spec || !sameSpec(spec, record.spec) || this.disposed) stops.push(this.remove(key, record)); + } + await Promise.all(stops); + if (this.disposed) return; + for (const [key, spec] of desired) { + if (this.services.has(key)) continue; + const record: ServiceRecord = { + spec, + state: "stopped", + child: null, + pending: new Map(), + nextId: 1, + stdout: "", + discarding: false, + crashes: [], + restarts: 0, + restartTimer: null, + removed: false, + exited: null + }; + this.services.set(key, record); + await this.start(record); + } + }); + this.syncing = next; + return next; + } + + /** Sends a request from the plugin's own UI to its own service. */ + request(pluginId: string, serviceId: string, method: string, params: unknown): Promise { + const record = this.services.get(serviceKey(pluginId, serviceId)); + if (typeof method !== "string" || !/^[A-Za-z0-9_.:/-]{1,80}$/.test(method) || method.startsWith(HOST_METHOD_PREFIX)) { + return Promise.reject(new Error("Plugin service method is invalid.")); + } + if (!record || !record.child || (record.state !== "running" && record.state !== "starting")) { + return Promise.reject(new Error("Plugin service is not running.")); + } + if (record.pending.size >= MAX_PENDING_REQUESTS) { + return Promise.reject(new Error("Plugin service is busy.")); + } + const id = record.nextId++; + let frame: string; + try { + frame = JSON.stringify({ jsonrpc: "2.0", id, method, params: params === undefined ? null : params }); + } catch { + return Promise.reject(new Error("Plugin service request must be JSON serializable.")); + } + if (Buffer.byteLength(frame, "utf8") >= this.options.maxFrameBytes) { + return Promise.reject(new Error("Plugin service request exceeds the 1 MB message limit.")); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + record.pending.delete(id); + reject(new Error("Plugin service request timed out.")); + }, this.options.requestTimeoutMs); + timer.unref(); + record.pending.set(id, { resolve, reject, timer }); + if (!this.write(record, frame)) { + clearTimeout(timer); + record.pending.delete(id); + reject(new Error("Plugin service is not running.")); + } + }); + } + + report(pluginId: string): PluginServiceReport { + const services: PluginServiceStatus[] = []; + for (const record of this.services.values()) { + if (record.spec.pluginId !== pluginId) continue; + services.push({ + serviceId: record.spec.serviceId, + state: record.state, + restarts: record.restarts, + ...(record.lastError ? { lastError: record.lastError } : {}) + }); + } + return { services, log: structuredClone(this.logs.get(pluginId) ?? []) }; + } + + /** Drops the in-memory log of an uninstalled plugin. */ + forget(pluginId: string): void { + this.logs.delete(pluginId); + } + + async dispose(): Promise { + this.disposed = true; + await this.sync([]); + } + + private async start(record: ServiceRecord): Promise { + const { spec } = record; + record.restartTimer = null; + if (record.removed || this.disposed) return; + record.state = "starting"; + try { + const content = await readFile(spec.entryPath); + if (createHash("sha256").update(content).digest("hex") !== spec.sha256) { + // The file changed after the user trusted it: never run it, and do not retry. + this.fail(record, "The service entry changed after it was trusted. Trust the plugin's native code again."); + return; + } + await mkdir(spec.dataDir, { recursive: true, mode: 0o700 }); + } catch (error) { + this.fail(record, `The service could not start: ${errorText(error)}`); + return; + } + if (record.removed || this.disposed) { + record.state = "stopped"; + return; + } + + const child = spawn(this.options.command, [spec.entryPath], { + cwd: spec.root, + env: pluginServiceEnvironment(this.options.environment), + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + record.child = child; + record.stdout = ""; + record.discarding = false; + record.exited = new Promise((resolve) => { + let settled = false; + const finish = (code: number | null, signal: NodeJS.Signals | null, error?: Error): void => { + if (settled) return; + settled = true; + resolve(); + this.exited(record, child, code, signal, error); + }; + child.once("error", (error) => finish(null, null, error)); + child.once("exit", (code, signal) => finish(code, signal)); + }); + child.once("spawn", () => { + if (record.child === child) record.state = "running"; + this.log(spec, "host", "info", `Started (pid ${child.pid ?? "?"}).`); + }); + child.stdin?.on("error", () => undefined); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => this.stdout(record, chunk)); + child.stderr?.setEncoding("utf8"); + let stderr = ""; + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + const lines = stderr.split("\n"); + stderr = lines.pop() ?? ""; + if (stderr.length > MAX_LOG_MESSAGE) { + lines.push(stderr); + stderr = ""; + } + for (const line of lines) if (line.trim()) this.log(spec, "stderr", "warn", line); + }); + this.write(record, JSON.stringify({ + jsonrpc: "2.0", + method: "canvastty.initialize", + params: { + apiVersion: 2, + pluginId: spec.pluginId, + serviceId: spec.serviceId, + dataDir: spec.dataDir, + locale: this.options.locale(), + hostVersion: this.options.hostVersion + } + })); + } + + private exited( + record: ServiceRecord, + child: ChildProcess, + code: number | null, + signal: NodeJS.Signals | null, + error?: Error + ): void { + if (record.child !== child) return; + record.child = null; + for (const [id, pending] of record.pending) { + clearTimeout(pending.timer); + pending.reject(new Error("Plugin service stopped.")); + record.pending.delete(id); + } + if (record.removed || this.disposed) { + record.state = "stopped"; + this.log(record.spec, "host", "info", "Stopped."); + return; + } + const reason = error ? errorText(error) : signal ? `signal ${signal}` : `exit code ${code ?? "?"}`; + const now = Date.now(); + record.crashes = record.crashes.filter((at) => now - at < this.options.restartWindowMs); + record.crashes.push(now); + if (record.crashes.length > this.options.maxRestarts) { + this.fail(record, `The service stopped unexpectedly (${reason}) too often and will not be restarted.`); + return; + } + const delays = this.options.restartDelaysMs; + const delay = delays[Math.min(record.crashes.length - 1, delays.length - 1)] ?? 1_000; + record.state = "backoff"; + record.lastError = `The service stopped unexpectedly (${reason}).`; + this.log(record.spec, "host", "warn", `${record.lastError} Restarting in ${Math.round(delay / 1000)} s.`); + record.restartTimer = setTimeout(() => { + record.restarts += 1; + void this.start(record); + }, delay); + record.restartTimer.unref(); + } + + private fail(record: ServiceRecord, message: string): void { + record.state = "failed"; + record.lastError = message; + this.log(record.spec, "host", "error", message); + } + + private async remove(key: string, record: ServiceRecord): Promise { + record.removed = true; + this.services.delete(key); + if (record.restartTimer) clearTimeout(record.restartTimer); + record.restartTimer = null; + const child = record.child; + if (!child) { + record.state = "stopped"; + return; + } + // Polite first: a shutdown notification and closed stdin, then SIGTERM, then SIGKILL. + this.write(record, JSON.stringify({ jsonrpc: "2.0", method: "canvastty.shutdown", params: {} })); + child.stdin?.end(); + const exited = record.exited ?? Promise.resolve(); + if (await settlesWithin(exited, this.options.stopGraceMs)) return; + child.kill("SIGTERM"); + if (await settlesWithin(exited, this.options.stopGraceMs)) return; + child.kill("SIGKILL"); + await settlesWithin(exited, this.options.stopGraceMs); + } + + private write(record: ServiceRecord, frame: string): boolean { + const stdin = record.child?.stdin; + if (!stdin || stdin.destroyed || !stdin.writable) return false; + stdin.write(`${frame}\n`); + return true; + } + + private stdout(record: ServiceRecord, chunk: string): void { + record.stdout += chunk; + let newline = record.stdout.indexOf("\n"); + while (newline >= 0) { + const line = record.stdout.slice(0, newline); + record.stdout = record.stdout.slice(newline + 1); + if (record.discarding) record.discarding = false; + else if (Buffer.byteLength(line, "utf8") > this.options.maxFrameBytes) this.dropFrame(record); + else this.frame(record, line); + newline = record.stdout.indexOf("\n"); + } + if (Buffer.byteLength(record.stdout, "utf8") > this.options.maxFrameBytes) { + // Skip the rest of an oversized frame up to its newline instead of buffering it. + if (!record.discarding) this.dropFrame(record); + record.discarding = true; + record.stdout = ""; + } + } + + private dropFrame(record: ServiceRecord): void { + this.log(record.spec, "host", "warn", "Dropped a service message larger than 1 MB."); + } + + private frame(record: ServiceRecord, line: string): void { + if (!line.trim()) return; + let message: unknown; + try { + message = JSON.parse(line); + } catch { + this.log(record.spec, "stdout", "info", line); + return; + } + if (!isRecord(message)) return; + const id = message.id; + if (typeof message.method === "string") { + if (typeof id === "number" || typeof id === "string") { + void this.hostRequest(record, message.method, message.params).then( + (result) => this.write(record, JSON.stringify({ jsonrpc: "2.0", id, result: result ?? null })), + (error: unknown) => this.write(record, JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code: error instanceof UnknownMethodError ? -32601 : -32000, message: errorText(error) } + })) + ); + } else { + this.hostNotification(record, message.method, message.params); + } + return; + } + if (typeof id !== "number") return; + const pending = record.pending.get(id); + if (!pending) return; + record.pending.delete(id); + clearTimeout(pending.timer); + if (isRecord(message.error)) { + const text = typeof message.error.message === "string" ? message.error.message : "Plugin service request failed."; + pending.reject(new Error(text.slice(0, 240))); + } else { + pending.resolve(message.result ?? null); + } + } + + /** The complete host API a service can call. Each method is checked against the manifest. */ + private async hostRequest(record: ServiceRecord, method: string, params: unknown): Promise { + const { spec } = record; + const values = isRecord(params) ? params : {}; + if (method === "log") { + this.serviceLog(spec, values); + return null; + } + if (method === "storage.get" || method === "storage.set") { + if (!spec.permissions.includes("storage")) throw new Error("Plugin does not have the storage permission."); + if (typeof values.key !== "string") throw new Error("Plugin storage key is invalid."); + if (method === "storage.get") return this.options.host.storageGet(spec.pluginId, values.key); + await this.options.host.storageSet(spec.pluginId, values.key, values.value); + return null; + } + if (method === "secrets.get" && this.options.host.secretGet) { + // The plugin's own secrets only, to its own trusted native code; never to a web surface of another plugin. + if (!spec.permissions.includes("secrets")) throw new Error("Plugin does not have the secrets permission."); + if (typeof values.key !== "string") throw new Error("Plugin secret key is invalid."); + const value = await this.options.host.secretGet(spec.pluginId, values.key); + return value; + } + throw new UnknownMethodError(`Unknown host method: ${method.slice(0, 80)}.`); + } + + private hostNotification(record: ServiceRecord, method: string, params: unknown): void { + const values = isRecord(params) ? params : {}; + if (method === "log") { + this.serviceLog(record.spec, values); + return; + } + if (method === "event" && typeof values.event === "string" && /^[A-Za-z0-9_.:-]{1,64}$/.test(values.event)) { + this.options.host.emit(record.spec.pluginId, record.spec.serviceId, values.event, values.data ?? null); + } + } + + private serviceLog(spec: PluginServiceSpec, values: Record): void { + const level = values.level === "warn" || values.level === "error" ? values.level : "info"; + const message = typeof values.message === "string" ? values.message : JSON.stringify(values.message ?? ""); + this.log(spec, "service", level, message); + } + + private log( + spec: PluginServiceSpec, + source: PluginServiceLogEntry["source"], + level: PluginServiceLogEntry["level"], + message: string + ): void { + const entries = this.logs.get(spec.pluginId) ?? []; + entries.push({ at: Date.now(), serviceId: spec.serviceId, source, level, message: message.slice(0, MAX_LOG_MESSAGE) }); + if (entries.length > MAX_LOG_ENTRIES) entries.splice(0, entries.length - MAX_LOG_ENTRIES); + this.logs.set(spec.pluginId, entries); + } +} + +class UnknownMethodError extends Error {} + +function serviceKey(pluginId: string, serviceId: string): string { + return `${pluginId}:${serviceId}`; +} + +function sameSpec(left: PluginServiceSpec, right: PluginServiceSpec): boolean { + return left.root === right.root + && left.entryPath === right.entryPath + && left.sha256 === right.sha256 + && left.dataDir === right.dataDir + && left.permissions.length === right.permissions.length + && left.permissions.every((permission) => right.permissions.includes(permission)); +} + +function settlesWithin(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + void promise.then(() => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 240); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index edde12fe..1add7c6f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,7 @@ import type { PluginBrowserOpenResponse, PluginCanvasRequest, PluginLauncherRequest, + PluginServiceEvent, PluginStorageChangeEvent, PluginUpdateStatus, SessionBounds, @@ -98,6 +99,14 @@ const api: CanvasTTYApi = { setHookEnabled: (pluginId: string, hookId: string, enabled: boolean) => ( ipcRenderer.invoke(IPC.pluginsSetHookEnabled, pluginId, hookId, enabled) ), + setNativeCodeTrusted: (pluginId: string, trusted: boolean) => ( + ipcRenderer.invoke(IPC.pluginsSetNativeCodeTrusted, pluginId, trusted) + ), + serviceReport: (pluginId: string) => ipcRenderer.invoke(IPC.pluginsServiceReport, pluginId), + serviceRequest: (pluginId: string, serviceId: string, method: string, params: unknown) => ( + ipcRenderer.invoke(IPC.pluginsServiceRequest, pluginId, serviceId, method, params) + ), + onServiceEvent: (listener: (event: PluginServiceEvent) => void) => subscribe(IPC.pluginsServiceEvent, listener), uninstall: (pluginId: string) => ipcRenderer.invoke(IPC.pluginsUninstall, pluginId), openCanvas: (pluginId: string, contributionId: string, sourceCanvasInstanceId?: string) => ( ipcRenderer.invoke(IPC.pluginsOpenCanvas, pluginId, contributionId, sourceCanvasInstanceId) diff --git a/src/preload/plugin.ts b/src/preload/plugin.ts index 50d3c7fb..a3f28176 100644 --- a/src/preload/plugin.ts +++ b/src/preload/plugin.ts @@ -2,6 +2,7 @@ import { ipcRenderer } from "electron"; const PLUGIN_HOST_INVOKE = "plugins:host-invoke"; const PLUGIN_STORAGE_CHANGED = "plugins:storage-changed"; +const PLUGIN_SERVICE_EVENT = "plugins:service-event"; const pluginId = argument("--canvastty-plugin-id="); const contributionId = argument("--canvastty-contribution-id="); @@ -51,6 +52,12 @@ ipcRenderer.on(PLUGIN_STORAGE_CHANGED, (_event, change: unknown) => { window.postMessage({ source: "canvastty-host", type: "storage-change", key: change.key, value: change.value }, "*"); }); +ipcRenderer.on(PLUGIN_SERVICE_EVENT, (_event, message: unknown) => { + if (!isRecord(message) || message.pluginId !== pluginId) return; + const { serviceId, event, data } = message; + window.postMessage({ source: "canvastty-host", type: "service-event", value: { serviceId, event, data } }, "*"); +}); + function argument(prefix: string): string { const value = process.argv.find((candidate) => candidate.startsWith(prefix))?.slice(prefix.length); if (!value) throw new Error("CanvasTTY plugin window identity is missing."); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 98188366..2cda972a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -841,6 +841,16 @@ export function App(): React.JSX.Element { } }, [refreshPlugins]); + const setPluginNativeCodeTrusted = useCallback(async (pluginId: string, trusted: boolean): Promise => { + try { + const updated = await window.canvasTTY.plugins.setNativeCodeTrusted(pluginId, trusted); + setPlugins((current) => current.map((plugin) => plugin.manifest.id === pluginId ? updated : plugin)); + } catch (error) { + await refreshPlugins().catch(() => undefined); + throw error; + } + }, [refreshPlugins]); + const setPluginModules = useCallback(async (pluginId: string, selectedModules: string[]): Promise => { let updated: InstalledPlugin; try { @@ -1214,6 +1224,7 @@ export function App(): React.JSX.Element { onSetPluginModules={setPluginModules} onSetPluginEnabled={setPluginEnabled} onSetPluginHookEnabled={setPluginHookEnabled} + onSetPluginNativeCodeTrusted={setPluginNativeCodeTrusted} onUninstallPlugin={uninstallPlugin} onOpenPluginContribution={openPluginContribution} onToggleHomeWidget={toggleHomeWidget} diff --git a/src/renderer/src/features/plugins/PluginFrame.tsx b/src/renderer/src/features/plugins/PluginFrame.tsx index 8d352e06..8e359530 100644 --- a/src/renderer/src/features/plugins/PluginFrame.tsx +++ b/src/renderer/src/features/plugins/PluginFrame.tsx @@ -170,6 +170,16 @@ export function PluginFrame({ postToFrame(frame.current, { source: "canvastty-host", type: "storage-change", key, value }); }), [plugin.manifest.id]); + const hasServices = Boolean(plugin.manifest.services?.length); + useEffect(() => { + if (!hasServices) return; + const pluginId = plugin.manifest.id; + return window.canvasTTY.plugins.onServiceEvent(({ pluginId: owner, serviceId, event, data }) => { + if (owner !== pluginId) return; + postToFrame(frame.current, { source: "canvastty-host", type: "service-event", value: { serviceId, event, data } }); + }); + }, [hasServices, plugin.manifest.id]); + return (