diff --git a/docs/GJC-WORKER-PROTOCOL.md b/docs/GJC-WORKER-PROTOCOL.md index 2fb1b949..4ecd35e2 100644 --- a/docs/GJC-WORKER-PROTOCOL.md +++ b/docs/GJC-WORKER-PROTOCOL.md @@ -117,7 +117,7 @@ scope is fixed by this specification, and a frame that gets it wrong is rejected | `turn.start` | scoped | Run a turn in an existing conversation. | | `turn.abort` | scoped | Stop the running turn. | | `turn.steer` | scoped | Inject input into the running turn without ending it. | -| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an active run. | +| `goal.inspect` | scoped | Read the exact persisted provider session's goal for an authenticated owner and resolved project cwd; refuses an app-owned active run. Inspection uses read-only inventory and an SDK-validated immutable transcript snapshot, projecting only the current branch without constructing a session manager: it never recovers backups, rewrites the transcript, persists replay sanitation, or changes source sidecars or image blobs, including when an external CLI is still writing. | | `goal.control` | scoped | Read or control a goal on the exact worker `runId`; requires matching app session, owner, cwd, and expected goal ID. The trusted server may pass literal `stopAfterMutation: false` for pause/drop so the enclosing native worktree job owns cancellation. | | `ask.reply` | scoped | Answer a question the worker raised with `ask.presented`. | diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index cddac43e..558a1fd0 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -9,7 +9,8 @@ import { resolveModelRoleValue } from '@gajae-code/coding-agent/config/model-res import { Settings } from '@gajae-code/coding-agent/config/settings'; import { AuthStorage } from '@gajae-code/coding-agent/session/auth-storage'; import { SessionDisposalIncompleteError } from '@gajae-code/coding-agent/session/agent-session'; -import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; +import { parseSessionEntries, SessionManager, type SessionEntry } from '@gajae-code/coding-agent/session/session-manager'; +import { MemorySessionStorage } from '@gajae-code/coding-agent/session/session-storage'; import { executeAcpBuiltinSlashCommand } from '@gajae-code/coding-agent/slash-commands/acp-builtins'; import { initTheme, theme } from '@gajae-code/coding-agent/modes/theme/theme'; import { generateSessionTitle } from '@gajae-code/coding-agent/utils/title-generator'; @@ -791,17 +792,48 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { } async #inspectGjcGoal(scope: GjcGoalScope, providerSessionId: string, sessionRoot: string): Promise { - const manager = await resumeManager(providerSessionId, sessionRoot); + // list/open can recover backups and persist replay sanitation. A goal read must + // never acquire write ownership of a transcript an external CLI may be using. + const matches = (await SessionManager.listForResumePickerReadOnly('', sessionRoot)) + .filter((session) => session.id === providerSessionId); + if (matches.length !== 1) throw new Error(FAILURE); + const captured = SessionManager.captureTranscriptStrict(matches[0].path); + if (captured.kind !== 'captured') throw new Error(FAILURE); + const { snapshot } = captured; try { - const { state, scope: owner } = readPersistedGjcGoal(manager); - const actualCwd = await realpath(manager.getCwd()); + if (snapshot.identity.sessionId !== providerSessionId) throw new Error(FAILURE); + // Validate an immutable copy with the SDK. Never construct a manager: even + // recovery hydration can rebuild sidecars and write shared image blobs. + const bytes = Buffer.from(snapshot.materialize()); + const storage = new MemorySessionStorage(); + storage.writeBytesOwnedSync(snapshot.identity.canonicalPath, bytes); + const inspected = await SessionManager.inspectSessionTailReadOnly(snapshot.identity.canonicalPath, storage); + if (inspected.kind === 'error' || inspected.identity.sessionId !== providerSessionId + || inspected.identity.sha256 !== snapshot.identity.sha256) throw new Error(FAILURE); + const entries = parseSessionEntries(bytes.toString('utf8')); + const header = entries[0]; + if (header?.type !== 'session') throw new Error(FAILURE); + const sessionEntries = entries.filter((entry): entry is SessionEntry => entry.type !== 'session'); + const byId = new Map(sessionEntries.map((entry) => [entry.id, entry])); + const branch: SessionEntry[] = []; + const visited = new Set(); + // Match getBranch(): the last entry is the leaf, not every chronological mode change. + for (let entry = sessionEntries.at(-1); entry && !visited.has(entry.id); + entry = entry.parentId ? byId.get(entry.parentId) : undefined) { + visited.add(entry.id); + branch.push(entry); + } + branch.reverse(); + const { state, scope: owner } = readPersistedGjcGoal({ getBranch: () => branch }); + const actualCwd = await realpath(header.cwd); if (actualCwd !== (owner?.cwd ?? scope.cwd)) throw new Error('Goal working directory does not match the persisted session.'); + if (snapshot.revalidate().kind !== 'valid') throw new Error(FAILURE); return { supported: true, goal: state?.goal ?? null, runId: null, canControl: owner ? matchesGjcGoalOwner(owner, scope) : !state, resumeRequired: state?.goal.status === 'active', }; - } finally { await manager.close(); } + } finally { snapshot.close(); } } async controlGjcGoal(runId: string, scope: GjcGoalScope, command?: GjcGoalCommand, stopAfterMutation = true): Promise { diff --git a/server/gjc-goal-session.ts b/server/gjc-goal-session.ts index 6f5e857d..dcbb9ebc 100644 --- a/server/gjc-goal-session.ts +++ b/server/gjc-goal-session.ts @@ -23,7 +23,7 @@ type GoalManager = { const OWNER_ENTRY = 'gajae-goal-owner-v1'; export const GJC_GOAL_MODEL_OPERATIONS = ['create', 'get', 'pause', 'resume', 'complete'] as const; -export function readPersistedGjcGoal(manager: GoalManager): { state?: GjcGoalState; scope?: GjcGoalScope } { +export function readPersistedGjcGoal(manager: Pick): { state?: GjcGoalState; scope?: GjcGoalScope } { let state: GjcGoalState | undefined; let scope: GjcGoalScope | undefined; for (const entry of manager.getBranch()) { diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 2e4400b7..501c75f4 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -1,16 +1,17 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import net from 'node:net'; import { isAbsolute, join, relative } from 'node:path'; import { test } from 'node:test'; +import { BlobStore } from '@gajae-code/coding-agent/session/blob-store'; import { ACP_BUILTIN_SLASH_COMMANDS } from '@gajae-code/coding-agent/slash-commands/acp-builtins'; import { createAgentSession, discoverAuthStorage, type AutomationTools } from '@gajae-code/coding-agent/sdk/session'; import { ModelRegistry } from '@gajae-code/coding-agent/config/model-registry'; import { Settings } from '@gajae-code/coding-agent/config/settings'; -import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; +import { CURRENT_SESSION_VERSION, SessionManager } from '@gajae-code/coding-agent/session/session-manager'; import { SessionDisposalIncompleteError } from '@gajae-code/coding-agent/session/agent-session'; import { AsyncJobManager } from '@gajae-code/coding-agent/async/job-manager'; import { registerCustomApi, unregisterCustomApis } from '@gajae-code/ai/api-registry'; @@ -1316,6 +1317,193 @@ test('resume opens the sole exact session file and never re-emits session.create } finally { await f.close(); } }); +async function goalTranscriptSnapshot(path: string) { + const bytes = await readFile(path); + const { ino, size, mtimeNs, ctimeNs } = await stat(path, { bigint: true }); + return { bytes, identity: { ino, size, mtimeNs, ctimeNs } }; +} + +for (const status of [null, 'active', 'paused', 'complete'] as const) { + test(`goal inspection is read-only for a live managed transcript: ${status ?? 'no goal on current branch'}`, async () => { + const f = await fixture(); + let manager: SessionManager | undefined; + try { + await mkdir(join(f.root, 'project')); + const cwd = await realpath(join(f.root, 'project')); + const scope = { appSessionId: 'goal-inspection', owner: 'number:1', cwd }; + manager = SessionManager.create(cwd, SessionManager.managedDestination(cwd, join(f.root, 'agent'))); + assert.equal(manager.isManagedDestination(), true); + manager.appendMessage({ role: 'user', content: 'Keep the external CLI session active', timestamp: Date.now() }); + const answer: AssistantMessage = { + ...identityAnswer('Offline response'), api: 'openai-responses', provider: 'openai', + content: [{ type: 'thinking', thinking: 'Offline reasoning', thinkingSignature: 'live-provider-signature' }, + { type: 'text', text: 'Offline response' }], + providerPayload: { type: 'openaiResponsesHistory', provider: 'openai', + items: [{ type: 'reasoning', id: 'rs_live', encrypted_content: 'live-provider-payload', summary: [] }] }, + }; + const branchPoint = manager.appendMessage(answer); + const goal = { id: 'persisted-goal', objective: 'Preserve the live CLI transcript', status: status ?? 'active', + tokensUsed: 17, timeUsedSeconds: 3, createdAt: 100, updatedAt: 200 }; + manager.appendCustomEntry('gajae-goal-owner-v1', status ? scope : { ...scope, owner: 'abandoned-owner' }); + manager.appendModeChange(status === 'paused' ? 'goal_paused' : 'goal', { goal }); + if (status === null) { + // A goal on an abandoned branch must not leak into the current leaf's projection. + manager.branch(branchPoint); + manager.appendMessage({ role: 'user', content: 'Continue without the abandoned goal', timestamp: Date.now() }); + } + await manager.flush(); + const path = manager.getSessionFile()!; + const before = await goalTranscriptSnapshot(path); + assert.match(before.bytes.toString(), /live-provider-signature/); + assert.match(before.bytes.toString(), /live-provider-payload/); + const inspect = (owner = scope) => f.adapter.inspectGjcGoal(owner, manager!.getSessionId(), manager!.getSessionDir()); + const snapshot = await inspect(); + const after = await goalTranscriptSnapshot(path); + assert.deepEqual(after.bytes, before.bytes, 'goal inspection must not persist replay sanitation'); + assert.deepEqual(after.identity, before.identity, 'goal inspection must not touch the transcript'); + assert.deepEqual(snapshot, { + supported: true, goal: status ? goal : null, runId: null, canControl: true, resumeRequired: status === 'active', + }); + if (status) { + assert.deepEqual(await inspect({ ...scope, owner: 'number:2' }), { ...snapshot, canControl: false }); + } else { + await assert.rejects(inspect({ ...scope, cwd: f.root }), /working directory does not match/); + } + assert.deepEqual(await goalTranscriptSnapshot(path), before); + assert.deepEqual(f.sessions, [], 'goal inspection must not construct a provider session'); + const message = { role: 'user' as const, content: 'The original CLI owner can still append', timestamp: Date.now() }; + const id = manager.appendMessage(message); + await manager.flush(); + const continued = await readFile(path); + assert.deepEqual(continued.subarray(0, before.bytes.length), before.bytes); + const last = JSON.parse(continued.toString().trimEnd().split('\n').at(-1)!); + assert.equal(last.id, id); + assert.deepEqual(last.message, message, 'the original managed writer must persist its next append'); + } finally { + try { if (manager) assert.equal((await manager.closeStrict()).kind, 'closed'); } + finally { await f.close(); } + } + }); +} + +for (const destination of ['managed', 'explicit'] as const) { + test(`goal inspection preserves compacted ${destination} session sidecars on success and rejection`, async () => { + const f = await fixture(); + const cwd = await realpath(f.root); + const manager = SessionManager.create(cwd, destination === 'managed' + ? SessionManager.managedDestination(cwd, join(f.root, 'agent')) : f.root); + try { + const first = manager.appendMessage({ role: 'user', content: 'Before compaction', timestamp: Date.now() }); + manager.appendMessage(identityAnswer('A compacted answer')); + manager.appendCompaction('Retain the current goal', undefined, first, 100); + const goal = { id: 'compacted-goal', objective: 'Preserve sidecars', status: 'active', + tokensUsed: 1, timeUsedSeconds: 1, createdAt: 100, updatedAt: 200 }; + manager.appendModeChange('goal', { goal }); + await manager.flush(); + const path = manager.getSessionFile()!; + const sidecarDir = path.slice(0, -6); + await mkdir(sidecarDir, { recursive: true }); + const sidecars = [`${path}.spill.idx`, ...['idx', 'tail', 'commit', 'capture-probe.tmp'] + .map((suffix) => join(sidecarDir, `.session-memory.spill.${suffix}`))]; + for (const file of sidecars) await writeFile(file, `External writer owns ${file}`); + const originals = await Promise.all([path, ...sidecars].map(goalTranscriptSnapshot)); + const inventory = (await readdir(sidecarDir)).sort(); + const scope = { appSessionId: 'compacted-inspection', owner: 'number:1', cwd }; + const snapshot = await f.adapter.inspectGjcGoal(scope, manager.getSessionId(), manager.getSessionDir()); + assert.deepEqual(snapshot.goal, goal); + assert.equal(snapshot.resumeRequired, true); + await assert.rejects(f.adapter.inspectGjcGoal({ ...scope, cwd: join(cwd, 'other') }, + manager.getSessionId(), manager.getSessionDir()), /working directory does not match/); + assert.deepEqual(await Promise.all([path, ...sidecars].map(goalTranscriptSnapshot)), originals); + assert.deepEqual((await readdir(sidecarDir)).sort(), inventory); + const id = manager.appendMessage({ role: 'user', content: 'Continue after inspection', timestamp: Date.now() }); + await manager.flush(); + assert.equal(JSON.parse((await readFile(path, 'utf8')).trimEnd().split('\n').at(-1)!).id, id); + assert.deepEqual(f.sessions, []); + } finally { + try { assert.equal((await manager.closeStrict()).kind, 'closed'); } + finally { await f.close(); } + } + }); +} + +test('goal inspection never materializes image blobs or rewrites their shared store', async () => { + const f = await fixture(); + const originalPut = BlobStore.prototype.putSync; + let writes = 0; + BlobStore.prototype.putSync = () => { + writes++; + throw new Error('Goal inspection attempted a shared blob write'); + }; + try { + const cwd = await realpath(f.root); + const id = 'image-goal'; + const path = join(cwd, 'image.jsonl'); + const records = [ + { type: 'session', version: CURRENT_SESSION_VERSION, starredPatchVersion: 1, id, cwd, timestamp: new Date().toISOString() }, + { type: 'message', id: 'image-message', parentId: null, timestamp: new Date().toISOString(), + message: { role: 'user', timestamp: Date.now(), content: [ + { type: 'image', mimeType: 'image/png', data: Buffer.alloc(128 * 1024, 1).toString('base64') }, + ] } }, + ]; + await writeFile(path, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`); + const before = await goalTranscriptSnapshot(path); + const scope = { appSessionId: id, owner: 'number:1', cwd }; + const result = await f.adapter.inspectGjcGoal(scope, id, cwd); + assert.equal(result.goal, null); + await assert.rejects(f.adapter.inspectGjcGoal({ ...scope, cwd: join(cwd, 'wrong') }, id, cwd), /working directory does not match/); + assert.equal(writes, 0, 'goal reads must not hydrate images into the filesystem BlobStore'); + assert.deepEqual(await goalTranscriptSnapshot(path), before); + } finally { BlobStore.prototype.putSync = originalPut; await f.close(); } +}); + +test('goal inspection inventory leaves orphan backups and missing targets untouched', async () => { + const f = await fixture(); + try { + const scope = { appSessionId: 'goal-inventory', owner: 'number:1', cwd: await realpath(f.root) }; + const header = { type: 'session', version: CURRENT_SESSION_VERSION, id: 'orphan-goal', cwd: scope.cwd, + timestamp: new Date().toISOString() }; + const target = join(f.root, 'orphan.jsonl'); + const backup = `${target}.123.bak`; + await writeFile(backup, `${JSON.stringify(header)}\n`); + const before = await goalTranscriptSnapshot(backup); + const inventory = await readdir(f.root); + await assert.rejects(f.adapter.inspectGjcGoal(scope, 'missing-goal', f.root)); + assert.deepEqual(await readdir(f.root), inventory, 'read-only inventory must not recover orphan backups'); + assert.deepEqual(await goalTranscriptSnapshot(backup), before); + await assert.rejects(f.adapter.inspectGjcGoal(scope, header.id, f.root)); + await assert.rejects(stat(target), { code: 'ENOENT' }); + const missingRoot = join(f.root, 'missing-sessions'); + await assert.rejects(f.adapter.inspectGjcGoal(scope, 'missing-goal', missingRoot)); + await assert.rejects(stat(missingRoot), { code: 'ENOENT' }); + assert.deepEqual(await goalTranscriptSnapshot(backup), before); + assert.deepEqual(f.sessions, []); + } finally { await f.close(); } +}); + +test('goal inspection rejects malformed and ambiguous transcripts without mutation', async () => { + const f = await fixture(); + try { + const scope = { appSessionId: 'goal-invalid', owner: 'number:1', cwd: await realpath(f.root) }; + const header = { type: 'session', version: CURRENT_SESSION_VERSION, id: 'malformed-goal', cwd: scope.cwd, + timestamp: new Date().toISOString() }; + const malformed = join(f.root, 'malformed.jsonl'); + await writeFile(malformed, `${JSON.stringify(header)}\n{"type":\n`); + const before = await goalTranscriptSnapshot(malformed); + await assert.rejects(f.adapter.inspectGjcGoal(scope, header.id, f.root)); + assert.deepEqual(await goalTranscriptSnapshot(malformed), before); + const first = join(f.root, 'first.jsonl'); + const duplicate = join(f.root, 'duplicate.jsonl'); + await writeFile(first, `${JSON.stringify({ ...header, id: 'duplicate-goal' })}\n`); + await copyFile(first, duplicate); + const originals = await Promise.all([first, duplicate].map(goalTranscriptSnapshot)); + await assert.rejects(f.adapter.inspectGjcGoal(scope, 'duplicate-goal', f.root)); + assert.deepEqual(await Promise.all([first, duplicate].map(goalTranscriptSnapshot)), originals); + assert.deepEqual(await goalTranscriptSnapshot(malformed), before); + assert.deepEqual(f.sessions, []); + } finally { await f.close(); } +}); + /** Real SDK construction; prompts are intercepted before any model transport can run. */ async function identityFixture(behavior: { realPrompts?: boolean;