From 628357b98ee43e7e804779273e71dd10f9daf844 Mon Sep 17 00:00:00 2001 From: hopeatina Date: Wed, 26 Aug 2026 12:55:50 -0500 Subject: [PATCH 1/4] feat: bind OpenCode sessions to OrgX context --- README.md | 130 +- package-lock.json | 12 +- package.json | 4 +- plugin.manifest.json | 2 +- src/OpenCodeDriver.test.ts | 1761 +++++++++++++++-- src/OpenCodeDriver.ts | 1518 +++++++++++--- src/activationAcceptance.test.ts | 166 ++ src/activationAcceptance.ts | 206 ++ src/cli.ts | 10 +- src/contextPackHydration.test.ts | 1001 +++++++++- src/contextPackHydration.ts | 881 ++++++++- src/continuityHealth.test.ts | 2 +- src/continuityHealth.ts | 2 +- src/gatewayProtocolBoundary.test.ts | 125 ++ src/peer.test.ts | 118 +- src/peer.ts | 71 +- src/plugin.test.ts | 331 +++- src/plugin.ts | 187 +- src/runtimeSessionContext.ts | 54 + src/sessionSummaryBridge.test.ts | 110 +- src/sessionSummaryBridge.ts | 32 +- src/wizardContextBridge.ts | 410 ++++ .../gatewaySessionContextActivation.v1.json | 83 + 23 files changed, 6589 insertions(+), 627 deletions(-) create mode 100644 src/activationAcceptance.test.ts create mode 100644 src/activationAcceptance.ts create mode 100644 src/gatewayProtocolBoundary.test.ts create mode 100644 src/runtimeSessionContext.ts create mode 100644 src/wizardContextBridge.ts create mode 100644 tests/fixtures/gatewaySessionContextActivation.v1.json diff --git a/README.md b/README.md index 1003c53..f0f4794 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ The production peer deliberately negotiates v1 today: a successful OpenCode session is not, by itself, a canonical `ProofPacket`. The protocol will move to v2 only when the driver can return the envelope-bound proof, receipt, artifact, cost, and outcome references required by `ExecutionResult`. +An unexpected protocol-v2 task dispatch therefore fails before OpenCode creates +a native session; the plugin never substitutes a v1 completion for v2 proof. -**The peer model:** this plugin opens its own authenticated WebSocket to OrgX server, receives `task.dispatch` messages, runs them in your local OpenCode session (your subscription pays the tokens), and posts receipts + deviations back. It also writes compact, redacted Work Graph events locally so audit-first reconciliation can preserve progress and fingerprints across signup. No central broker. If another peer goes down, this one keeps running. +**The peer model:** this plugin opens its own authenticated WebSocket to OrgX server, receives `task.dispatch` messages, runs them in your local OpenCode session (your user-managed provider account pays the tokens), and posts receipts + deviations back. It also writes compact, redacted Work Graph events locally so audit-first reconciliation can preserve progress and fingerprints across signup. No central broker. If another peer goes down, this one keeps running. ## Install @@ -44,11 +46,12 @@ run receipt without pretending the multi-turn OpenCode conversation ended. `session.deleted` remains the whole-session terminal boundary. The adapter observes session, user-message, permission, and tool lifecycle -events. It sends each non-synthetic user message to the local Wizard hook for -bounded Work Episode capture: at most 12 redacted excerpts, 600 characters -each. It does not send tool arguments, tool results, error text, transcripts, -credentials, or model output. The Wizard owns queue durability, -acknowledgement, retry, privacy normalization, and AWR delivery. +events. Capture is metadata-only by default. Set +`ORGX_SESSION_WORK_EPISODE_CAPTURE=bounded` to let the Wizard retain bounded, +redacted user-intent excerpts. The adapter does not send tool arguments, tool +results, error text, transcripts, credentials, or model output. The Wizard owns +queue durability, acknowledgement, retry, privacy normalization, and AWR +delivery. If the Wizard hook is absent or incompatible, the plugin records that capture is unavailable and continues without inventing a receipt. @@ -56,6 +59,68 @@ Set `ORGX_SESSION_SUMMARY_AUTO_FLUSH=off` for a deliberately offline run. The adapter and Wizard retain the capture without starting a delivery worker; `orgx-wizard hooks flush` can replay it later with server acknowledgement. +### Session context continuity + +For a Gateway-dispatched task, OrgX sends a dual-digest-bound +`context_activation` and a `user_managed` execution-attribution lease. One +digest covers the exact work context; the second covers the full session +activation wrapper, including scope, compaction, source-capsule completeness, +and omitted counts. The +lease is required before native session creation. A first-run lease may be +explicitly unknown (`provider: "other"`, `provider_id: null`); otherwise its +provider ID must exactly match the provider returned by OpenCode. The driver +creates the native OpenCode session first, +validates that activation and its canonical digest, activates the exact native +session through the Wizard, then acknowledges it in `task.started`. The first +prompt remains blocked until the Gateway returns an exact +`task.activation.accepted` tuple for the same run, both digests, and native +session. +The identical acknowledgement is retried on a bounded interval, including +across reconnectable socket closes, so a lost first acceptance frame does not +create a second native session or prompt. Rejection, mismatch, terminal close, +or the overall timeout clears that session's lease without prompting. A +missing, mismatched, or unverified v2 activation fails closed; the task is not +prompted with ambient organizational authority. + +For an interactive session without a Gateway activation, `session.created` +(or the first session-bound message/model request if that event was missed) +requests one context pack for the most specific configured anchor: task, +workstream, initiative, then workspace. Hydration is exactly once per native +OpenCode session. Internal model requests without a native session ID receive +no OrgX context or authority. + +The full pack and any pending activation are stored outside the repository in +owner-only Wizard state. Each directory is keyed by the SHA-256 of the resolved +project cwd plus native session ID, so two sessions in one checkout cannot +overwrite each other's files. By default the state is under +`$ORGX_WIZARD_CONFIG_HOME/opencode-contexts/` or +`$XDG_CONFIG_HOME/useorgx/wizard/opencode-contexts/` (falling back to +`~/.config/useorgx/wizard/opencode-contexts/`). No runtime context file is +written into `.opencode/` or made visible to Git. + +Whether the context came from a Gateway dispatch or the interactive fetch, the +plugin passes that exact object to: + +```bash +orgx-wizard sessions context set --file - --cwd \ + --source-client opencode --session-id \ + --context-sha256 --json +``` + +The digest is calculated from recursively key-sorted JSON (array order is +preserved and undefined values are omitted). The plugin accepts activation only +when the Wizard returns the v1 acknowledgement and v2 activation versions plus +the exact resolved cwd, source client, native session ID, and digest. It clears +that exact lease on `session.deleted`, and it clears stale authority when a +refresh fails or returns an invalid API envelope. If the Wizard is offline, the +plugin keeps the exact context in that session's private pending file. +Interactive sessions may continue with briefing explicitly marked +non-authoritative; Gateway dispatches fail before prompting. Context fetch and +activation each time out after 3 seconds by default. +`ORGX_CONTEXT_PACK_TIMEOUT_MS` and +`ORGX_SESSION_CONTEXT_ACTIVATION_TIMEOUT_MS` accept values from 250 to 10,000 +milliseconds. + ### V2 beta canary The production plugin stays on OpenCode's stable plugin contract. Set @@ -92,6 +157,7 @@ npm install -g @useorgx/orgx-opencode-plugin@alpha export ORGX_API_KEY=oxk_... export ORGX_WORKSPACE_ID= +export OPENCODE_SERVER_URL=http://127.0.0.1:4096 orgx-opencode-plugin ``` @@ -103,6 +169,8 @@ import { startPeer } from '@useorgx/orgx-opencode-plugin/sdk'; const peer = await startPeer({ apiKey: process.env.ORGX_API_KEY!, workspaceId: process.env.ORGX_WORKSPACE_ID!, + openCodeServerUrl: 'http://127.0.0.1:4096', + openCodeDirectory: process.cwd(), }); // later: await peer.stop(); @@ -110,20 +178,39 @@ await peer.stop(); ## How it talks to OpenCode -The peer discovers the local OpenCode daemon via its state file: - -| OS | Path | -|---|---| -| macOS / Linux | `~/.opencode/state.json` | -| Windows | `%APPDATA%/opencode/state.json` | - -The state file tells us which local port the daemon listens on. The driver then: - -1. `GET /status` — verifies auth + reports subscription health -2. `POST /sessions` — creates a fresh session bound to the dispatched task -3. `GET /sessions/:id/events` (NDJSON stream) — drives progress - -Each `file_edit` / `tool_call` event becomes a `task.step` wire message. Every skill rule fetched from `/api/v1/plan-skills` runs against the event stream; matches become `task.deviation` events (deduped per (run_id, skill_id, fingerprint)). +The native plugin passes OpenCode's authoritative `serverUrl` and project +directory directly to the peer. The standalone CLI accepts the credential-free +loopback origin through `OPENCODE_SERVER_URL`; its local state-file lookup is a +compatibility fallback only. Userinfo, remote hosts, paths, query strings, and +redirects are rejected. + +The driver uses the installed `@opencode-ai/sdk/v2` client rather than private +HTTP routes: + +1. `global.health`, `provider.list`, and `session.status` report health. +2. `session.create` returns the native session ID and authoritative + `Session.model.providerID` dispatch lease. +3. OrgX context is activated for that exact ID and acknowledged. +4. The Gateway durably accepts that exact activation acknowledgement. +5. `event.subscribe` provides an abortable, exact-session progress stream. +6. `session.prompt` sends the first turn with the verified bounded context. +7. `session.diff` reads files attributable to the returned user message; + `session.abort` cancels only the session mapped to that OrgX run. + +The terminal `AssistantMessage.providerID` must exactly match the session's +provider lease. Anthropic and OpenAI are reported from that field rather than +guessed from configuration, and the terminal source subtype remains +`user_managed`. Presence reports the latest observed provider in +`execution_provider`, `execution_provider_id`, and +`execution_provider_observed_at`. `execution_auth_method` remains `null` +because the official SDK does not distinguish an opaque stored OAuth +credential from a stored API key; the plugin does not guess. + +Each attributed file diff or tool event becomes a `task.step` wire message. +Every skill rule fetched from `/api/v1/plan-skills` runs against that stream; +matches become `task.deviation` events, deduped per run, skill, and fingerprint. +A completed model turn is reported as `awaiting_review`, not `shipped`, until a +separate acceptance or delivery receipt proves the stronger lifecycle state. ## Work Graph reconciliation @@ -136,6 +223,9 @@ the local trail. These JSONL records are intentionally compact. They include source client, event kind, run/session handles, repo path, evidence refs, and counts; they do not include raw prompts, raw transcripts, API keys, tokens, or storage state. +For a Gateway activation, `task_started` is written only after the exact +activation acceptance arrives; a proposed, rejected, or timed-out activation +never enters the local organizational-truth trail as started work. The OrgX wizard can later use them to detect missed OrgX writeback, generate a shareable public Work Graph readout, and hydrate the fingerprint into a signed-up workspace. diff --git a/package-lock.json b/package-lock.json index cb58960..30a4951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.14", + "version": "0.1.0-alpha.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.14", + "version": "0.1.0-alpha.15", "license": "MIT", "dependencies": { "@opencode-ai/sdk": "^1.18.2", "@sentry/node": "10.65.0", - "@useorgx/orgx-gateway-sdk": "github:useorgx/orgx-gateway-sdk#40871edeb9b92eaf4403c0ba7270a74204ba425f" + "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#90fbab66eaedeb27f5f34b0ac4101c5ca4f5e173" }, "bin": { "orgx-opencode-plugin": "dist/cli.js", @@ -1330,9 +1330,9 @@ } }, "node_modules/@useorgx/orgx-gateway-sdk": { - "version": "0.1.0-alpha.3", - "resolved": "git+ssh://git@github.com/useorgx/orgx-gateway-sdk.git#40871edeb9b92eaf4403c0ba7270a74204ba425f", - "integrity": "sha512-AXrPZXM1IMxAA8x6fZlGxenTIDZGkYf18NJTPPBMRkUpG0pMop1xrZkWkaZZGp6ST4baqniysmO7DoudGoHN5w==", + "version": "0.1.0-alpha.10", + "resolved": "git+ssh://git@github.com/useorgx/orgx-gateway-sdk.git#90fbab66eaedeb27f5f34b0ac4101c5ca4f5e173", + "integrity": "sha512-61gDMjQkxj2Qyyzz5CqhywOOuES+XLuB1zAaBPpvlAc4BAPTJGk3Z5tJrSktahcQOn93Uzf0ZjxquduwmVQcUQ==", "license": "MIT" }, "node_modules/@vitest/expect": { diff --git a/package.json b/package.json index d4f10ed..8184f9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.14", + "version": "0.1.0-alpha.15", "publishConfig": { "access": "public" }, @@ -41,7 +41,7 @@ "dependencies": { "@opencode-ai/sdk": "^1.18.2", "@sentry/node": "10.65.0", - "@useorgx/orgx-gateway-sdk": "github:useorgx/orgx-gateway-sdk#40871edeb9b92eaf4403c0ba7270a74204ba425f" + "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#90fbab66eaedeb27f5f34b0ac4101c5ca4f5e173" }, "devDependencies": { "@opencode-ai/plugin": "^1.18.2", diff --git a/plugin.manifest.json b/plugin.manifest.json index afcd2a9..e0d6d5d 100644 --- a/plugin.manifest.json +++ b/plugin.manifest.json @@ -1,6 +1,6 @@ { "plugin_name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.14", + "version": "0.1.0-alpha.15", "manifest_fingerprint": "", "signature": "", "capabilities": [ diff --git a/src/OpenCodeDriver.test.ts b/src/OpenCodeDriver.test.ts index bd8b5e0..92ab88f 100644 --- a/src/OpenCodeDriver.test.ts +++ b/src/OpenCodeDriver.test.ts @@ -1,276 +1,1613 @@ -/** - * OpenCodeDriver unit tests — mock the local daemon via a fake fetch. - */ +import type { + Event as NativeEvent, + OpencodeClient, +} from '@opencode-ai/sdk/v2'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + OpenCodeDriver, + resolveDispatchWorkScope, + resolveSafeOpenCodeServerUrl, +} from './OpenCodeDriver'; +import { ActivationAcceptanceBroker } from './activationAcceptance'; +import { + canonicalJsonSha256, + sessionWorkContextSha256, +} from './contextPackHydration'; -import { OpenCodeDriver } from './OpenCodeDriver'; +const PROJECT_DIR = '/work/repo'; +const SERVER_URL = 'http://127.0.0.1:4096'; -type Handler = (url: string, init?: RequestInit) => Response | Promise; +function executionAttribution(providerId: string | null = null) { + return { + provider: providerId === null + ? 'other' + : providerId.includes('anthropic') + ? 'anthropic' + : providerId.includes('openai') + ? 'openai' + : 'other', + provider_id: providerId, + source_sub_type: 'user_managed', + observed_at: '2026-08-26T16:00:00.000Z', + }; +} -function installFetch(handler: Handler) { - const orig = globalThis.fetch; - // @ts-expect-error test override - globalThis.fetch = (input: RequestInfo, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as Request).url; - return Promise.resolve(handler(url, init)); +function activatedHydration() { + return { + ok: true, + additionalContext: 'accepted exact-session OrgX context', + sessionContext: { + activated: true, + reason: 'wizard_activated' as const, + }, }; - return () => { - globalThis.fetch = orig; +} + +function coreSessionActivation(workContext: Record) { + return { + schema_version: 'orgx-session-activation/v1', + scope: { + workspace_id: 'workspace-1', + initiative_id: 'initiative-1', + workstream_id: 'workstream-1', + task_id: 'task-1', + }, + work_context: workContext, + compaction: { + compacted: false, + summary_truncated: false, + omitted_counts: { + authoritative_decisions: 0, + open_risks: 0, + acceptance_criteria: 0, + artifact_refs: 0, + evidence_refs: 0, + active_constraints: 0, + pending_expectations: 0, + applied_learnings: 0, + recent_receipt_refs: 0, + }, + source_capsule: { + id: 'capsule-1', + content_digest: `sha256:${'c'.repeat(64)}`, + generated_at: '2026-08-26T15:00:00.000Z', + projection_consistency: 'best_effort_multi_read', + omitted_counts: { + authoritative_decisions: 0, + applied_learnings: 0, + pending_expectations: 0, + open_risks: 0, + recent_receipt_refs: 0, + }, + source_completeness: { + authoritative_decisions: { + status: 'complete', + candidates_unvalidated: 0, + }, + current_intent: { + status: 'complete', + candidates_unvalidated: 0, + }, + recent_receipt_refs: { + status: 'complete', + candidates_unvalidated: 0, + }, + }, + }, + }, }; } -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, +function activationSha256(workContext: Record) { + return `sha256:${canonicalJsonSha256(coreSessionActivation(workContext))}`; +} + +function assistantInfo(overrides: Record = {}) { + return { + id: 'assistant-1', + parentID: 'user-1', + sessionID: 'session-1', + role: 'assistant', + time: { created: 1, completed: 2 }, + modelID: 'claude-sonnet', + providerID: 'anthropic', + mode: 'build', + agent: 'build', + path: { cwd: PROJECT_DIR, root: PROJECT_DIR }, + cost: 0, + tokens: { + total: 3400, + input: 1000, + output: 2200, + reasoning: 200, + cache: { read: 0, write: 0 }, + }, + ...overrides, + }; +} + +function completedToolPart() { + return { + id: 'part-tool-1', + sessionID: 'session-1', + messageID: 'assistant-1', + type: 'tool', + callID: 'call-1', + tool: 'read_file', + state: { + status: 'completed', + input: {}, + output: 'private output must not be emitted', + title: 'read billing.py', + metadata: {}, + time: { start: 1, end: 2 }, + }, + }; +} + +function createSdkFixture({ + events = [], + promptInfo = assistantInfo(), + parts = [completedToolPart()], + diffs = [ + { + file: 'tests/billing.py', + additions: 4, + deletions: 2, + status: 'modified' as const, + }, + ], + delayPrompt = false, + promptBarrier, + sessionProviderId, +}: { + events?: NativeEvent[]; + promptInfo?: ReturnType; + parts?: unknown[]; + diffs?: Array<{ + file?: string; + additions: number; + deletions: number; + status?: 'added' | 'deleted' | 'modified'; + }>; + delayPrompt?: boolean; + promptBarrier?: Promise; + sessionProviderId?: string | null; +} = {}) { + const selectedProviderId = + sessionProviderId === undefined ? promptInfo.providerID : sessionProviderId; + const create = vi.fn(async () => ({ + data: { + id: 'session-1', + ...(selectedProviderId + ? { + model: { + id: promptInfo.modelID, + providerID: selectedProviderId, + }, + } + : {}), + }, + })); + const prompt = vi.fn(async () => { + if (promptBarrier) await promptBarrier; + if (delayPrompt) await new Promise((done) => setTimeout(done, 0)); + return { data: { info: promptInfo, parts } }; }); + const diff = vi.fn(async () => ({ data: diffs })); + const abort = vi.fn(async () => ({ data: true })); + const eventAbortSignals: AbortSignal[] = []; + const subscribe = vi.fn(async (_parameters, options) => { + if (options?.signal) eventAbortSignals.push(options.signal); + return { + stream: (async function* () { + for (const event of events) yield event; + })(), + }; + }); + const client = { + global: { + health: vi.fn(async () => ({ + data: { healthy: true, version: '1.18.2' }, + })), + }, + provider: { + list: vi.fn(async () => ({ + data: { all: [], default: {}, connected: ['anthropic'] }, + })), + }, + event: { subscribe }, + session: { + status: vi.fn(async () => ({ + data: { 'session-1': { type: 'busy' } }, + })), + create, + prompt, + diff, + abort, + }, + } as unknown as OpencodeClient; + return { + client, + create, + prompt, + diff, + abort, + subscribe, + eventAbortSignals, + }; } -function ndjsonResponse(lines: unknown[]): Response { - const body = lines.map((l) => JSON.stringify(l)).join('\n') + '\n'; - return new Response(body, { - status: 200, - headers: { 'content-type': 'application/x-ndjson' }, +function driverWithFixture( + fixture: ReturnType, + overrides: ConstructorParameters[0] = {} +) { + return new OpenCodeDriver({ + openCodeServerUrl: SERVER_URL, + defaultDirectory: PROJECT_DIR, + workspaceId: 'workspace-1', + orgxApiKey: 'oxk_test_only', + orgxBaseUrl: 'https://useorgx.com', + createClient: vi.fn(() => fixture.client), + hydrateContextPack: vi.fn(async () => activatedHydration()), + clearSessionWorkContext: vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + })), + skillRules: async () => [], + workGraphOutboxPath: false, + ...overrides, }); } -describe('OpenCodeDriver', () => { - let restore: () => void = () => undefined; - let statePath: string; +async function collect( + driver: OpenCodeDriver, + task: Record, + context: Record = {} +) { + const messages: unknown[] = []; + for await (const message of driver.dispatch( + { + title: 'parametrize billing tests', + driver: 'opencode', + execution_attribution: executionAttribution(), + ...task, + }, + { run_id: 'run-1', idempotency_key: 'key-1', ...context } + )) { + messages.push(message); + } + return messages; +} - beforeEach(async () => { - const { mkdtemp, writeFile } = await import('fs/promises'); - const { tmpdir } = await import('os'); - const { join } = await import('path'); - const dir = await mkdtemp(join(tmpdir(), 'ocd-test-')); - statePath = join(dir, 'state.json'); - await writeFile( - statePath, - JSON.stringify({ port: 65123, version: '0.9.0', session_count: 0 }) - ); - }); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('OpenCodeDriver official SDK boundary', () => { + it('detects health and provider connection through official SDK methods', async () => { + const fixture = createSdkFixture(); + const createClient = vi.fn(() => fixture.client); + const driver = driverWithFixture(fixture, { createClient }); - afterEach(() => { - restore(); + await expect(driver.detect()).resolves.toEqual({ + installed: true, + authenticated: true, + version: '1.18.2', + subscription_active: true, + }); + expect(createClient).toHaveBeenCalledWith({ + baseUrl: SERVER_URL, + redirect: 'error', + }); + expect(fixture.client.global.health).toHaveBeenCalledOnce(); + expect(fixture.client.provider.list).toHaveBeenCalledOnce(); }); - it('detect returns installed + authenticated when daemon is healthy', async () => { - restore = installFetch((url) => { - if (url.includes('/status')) { - return jsonResponse({ version: '0.9.0', authed: true }); + it('drives the installed SDK over its official session endpoints', async () => { + const requests: Array<{ method: string; url: URL; body?: unknown; redirect: string }> = []; + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + const body = request.method === 'POST' ? await request.clone().json().catch(() => undefined) : undefined; + requests.push({ + method: request.method, + url, + body, + redirect: request.redirect, + }); + if (request.method === 'POST' && url.pathname === '/session') { + return new Response(JSON.stringify({ + id: 'session-1', + model: { id: 'claude-sonnet', providerID: 'anthropic' }, + }), { + headers: { 'content-type': 'application/json' }, + }); + } + if (request.method === 'GET' && url.pathname === '/event') { + return new Response('', { + headers: { 'content-type': 'text/event-stream' }, + }); + } + if ( + request.method === 'POST' && + url.pathname === '/session/session-1/message' + ) { + return new Response( + JSON.stringify({ info: assistantInfo(), parts: [] }), + { headers: { 'content-type': 'application/json' } } + ); + } + if ( + request.method === 'GET' && + url.pathname === '/session/session-1/diff' + ) { + return new Response(JSON.stringify([]), { + headers: { 'content-type': 'application/json' }, + }); } return new Response('not found', { status: 404 }); }); - const d = new OpenCodeDriver({ statePath }); - const s = await d.detect(); - expect(s.installed).toBe(true); - expect(s.authenticated).toBe(true); - expect(s.version).toBe('0.9.0'); + const driver = new OpenCodeDriver({ + openCodeServerUrl: SERVER_URL, + defaultDirectory: PROJECT_DIR, + workspaceId: 'workspace-1', + orgxApiKey: 'oxk_test_only', + orgxBaseUrl: 'https://useorgx.com', + hydrateContextPack: vi.fn(async () => activatedHydration()), + clearSessionWorkContext: vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + })), + workGraphOutboxPath: false, + }); + + const messages = await collect(driver, {}); + + expect(messages.at(-1)).toMatchObject({ kind: 'task.completed' }); + expect(requests.map(({ method, url }) => `${method} ${url.pathname}`)).toEqual( + expect.arrayContaining([ + 'POST /session', + 'GET /event', + 'POST /session/session-1/message', + 'GET /session/session-1/diff', + ]) + ); + const prompt = requests.find( + ({ method, url }) => + method === 'POST' && url.pathname === '/session/session-1/message' + ); + expect(prompt?.body).toMatchObject({ + system: 'accepted exact-session OrgX context', + parts: [{ type: 'text', text: 'parametrize billing tests' }], + }); + expect( + requests.find(({ url }) => url.pathname === '/session/session-1/diff') + ?.url.searchParams.get('messageID') + ).toBe('user-1'); + expect(requests.every(({ redirect }) => redirect === 'error')).toBe(true); }); - it('detect returns installed=false when state file is missing', async () => { - const d = new OpenCodeDriver({ statePath: '/nonexistent/state.json' }); - const s = await d.detect(); - expect(s.installed).toBe(false); + it('accepts only credential-free loopback server origins', () => { + expect(resolveSafeOpenCodeServerUrl('http://localhost:4096/')).toBe( + 'http://localhost:4096' + ); + expect(resolveSafeOpenCodeServerUrl('https://127.0.0.1:4096')).toBe( + 'https://127.0.0.1:4096' + ); + expect(resolveSafeOpenCodeServerUrl('http://[::1]:4096')).toBe( + 'http://[::1]:4096' + ); + expect(resolveSafeOpenCodeServerUrl('http://user:secret@localhost:4096')).toBeNull(); + expect(resolveSafeOpenCodeServerUrl('http://localhost:4096/path')).toBeNull(); + expect(resolveSafeOpenCodeServerUrl('https://example.com')).toBeNull(); }); - it('dispatch yields task.started → task.step → task.completed', async () => { - const events = [ - { kind: 'tool_call', tool: 'read_file', summary: 'billing.py' }, - { - kind: 'file_edit', - path: 'tests/billing.py', - summary: 'replaced class-based with @parametrize', + it('creates, activates the returned session, then prompts with that context', async () => { + const fixture = createSdkFixture(); + const hydrateContextPack = vi.fn(async () => activatedHydration()); + const driver = driverWithFixture(fixture, { hydrateContextPack }); + + const messages = await collect(driver, { + workspace_id: 'workspace-1', + initiative_id: 'initiative-1', + workstream_id: 'workstream-1', + task_id: 'task-1', + repo_path: PROJECT_DIR, + }); + + expect(fixture.create.mock.invocationCallOrder[0]).toBeLessThan( + hydrateContextPack.mock.invocationCallOrder[0] + ); + expect(hydrateContextPack.mock.invocationCallOrder[0]).toBeLessThan( + fixture.prompt.mock.invocationCallOrder[0] + ); + expect(hydrateContextPack).toHaveBeenCalledWith({ + env: expect.objectContaining({ + ORGX_API_KEY: 'oxk_test_only', + ORGX_BASE_URL: 'https://useorgx.com', + ORGX_WORKSPACE_ID: 'workspace-1', + ORGX_INITIATIVE_ID: 'initiative-1', + ORGX_WORKSTREAM_ID: 'workstream-1', + ORGX_TASK_ID: 'task-1', + }), + projectDir: PROJECT_DIR, + sessionId: 'session-1', + }); + expect(fixture.prompt).toHaveBeenCalledWith( + expect.objectContaining({ + sessionID: 'session-1', + directory: PROJECT_DIR, + system: 'accepted exact-session OrgX context', + }), + expect.objectContaining({ throwOnError: true, redirect: 'error' }) + ); + expect(fixture.diff).toHaveBeenCalledWith( + expect.objectContaining({ messageID: 'user-1' }), + expect.objectContaining({ throwOnError: true }) + ); + expect(messages.map((message) => (message as { kind: string }).kind)).toEqual([ + 'task.started', + 'task.step', + 'task.step', + 'task.completed', + ]); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.completed', + outcome_kind: 'awaiting_review', + tokens_used: 3400, + provider: 'anthropic', + source_sub_type: 'user_managed', + }); + expect(fixture.eventAbortSignals).toHaveLength(1); + expect(fixture.eventAbortSignals[0].aborted).toBe(true); + }); + + it('binds and reports an OpenAI execution from official session and assistant fields', async () => { + const fixture = createSdkFixture({ + promptInfo: assistantInfo({ + modelID: 'gpt-5', + providerID: 'openai', + }), + parts: [], + diffs: [], + }); + const driver = driverWithFixture(fixture); + + const messages = await collect(driver, { + repo_path: PROJECT_DIR, + execution_attribution: executionAttribution('openai'), + }); + + expect(messages.at(-1)).toMatchObject({ + kind: 'task.completed', + provider: 'openai', + source_sub_type: 'user_managed', + }); + expect(driver.executionProviderLease()).toMatchObject({ + provider: 'openai', + providerId: 'openai', + }); + }); + + it('requires execution attribution before native session creation', async () => { + const fixture = createSdkFixture(); + const messages = await collect(driverWithFixture(fixture), { + execution_attribution: undefined, + repo_path: PROJECT_DIR, + }); + + expect(fixture.create).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OrgX dispatch is missing execution attribution', + }); + }); + + it('rejects a non-null provider lease that disagrees with the native session', async () => { + const fixture = createSdkFixture(); + const messages = await collect(driverWithFixture(fixture), { + execution_attribution: executionAttribution('openai'), + repo_path: PROJECT_DIR, + }); + + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OpenCode session provider does not match execution attribution', + }); + }); + + it('consumes the exact Gateway activation and acknowledges it before prompting', async () => { + const fixture = createSdkFixture(); + const context = { + schema_version: 'orgx-session-work-context/v1', + provenance: 'producer_asserted', + intent: { + summary: 'Continue the accepted slice.', + acceptance_criteria: ['Focused checks pass'], + constraints: ['Do not invent authority'], }, - { kind: 'assistant_completed', tokens_used: 3400 }, - ]; - restore = installFetch((url) => { - if (url.includes('/sessions') && !url.includes('/events')) { - return jsonResponse({ session_id: 'sess-abc' }); - } - if (url.includes('/events')) { - return ndjsonResponse(events); - } - return new Response('not found', { status: 404 }); + authority: { + mode: 'explicit', + status: 'granted', + scope: { actions: ['edit'], resources: [], systems: ['opencode'] }, + constraints: [], + }, + cost: { availability: 'not_observed' }, + artifact_refs: [], + evidence_refs: [], + }; + const digest = `sha256:${sessionWorkContextSha256(context)}`; + const sessionActivation = coreSessionActivation(context); + const activationDigest = `sha256:${canonicalJsonSha256(sessionActivation)}`; + const activateProvidedSessionWorkContext = vi.fn(async () => + activatedHydration() + ); + const hydrateContextPack = vi.fn(async () => activatedHydration()); + const awaitActivationAcceptance = vi.fn(async () => undefined); + const driver = driverWithFixture(fixture, { + activateProvidedSessionWorkContext, + hydrateContextPack, + awaitActivationAcceptance, }); - const d = new OpenCodeDriver({ - statePath, - skillRules: async () => [], + const messages = await collect( + driver, + { + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: sessionActivation, + context_sha256: digest, + activation_sha256: activationDigest, + }, + }, + { protocol_version: 1 } + ); + + expect(hydrateContextPack).not.toHaveBeenCalled(); + expect(activateProvidedSessionWorkContext).toHaveBeenCalledWith({ + context, + activationEnvelope: sessionActivation, + env: expect.objectContaining({ + ORGX_WORKSPACE_ID: 'workspace-1', + ORGX_INITIATIVE_ID: 'initiative-1', + ORGX_WORKSTREAM_ID: 'workstream-1', + ORGX_TASK_ID: 'task-1', + }), + projectDir: PROJECT_DIR, + sessionId: 'session-1', }); - const messages: unknown[] = []; - for await (const m of d.dispatch( - { title: 'parametrize billing tests', driver: 'opencode' }, - { run_id: 'r1', idempotency_key: 'k1' } - )) { - messages.push(m); - } - const kinds = messages.map((m) => (m as { kind: string }).kind); - expect(kinds).toContain('task.started'); - expect(kinds.filter((k) => k === 'task.step').length).toBe(2); - expect(kinds[kinds.length - 1]).toBe('task.completed'); - }); - - it('dispatch spools compact Work Graph events without raw summaries', async () => { - const { mkdtemp, readFile } = await import('fs/promises'); - const { tmpdir } = await import('os'); - const { join } = await import('path'); - const dir = await mkdtemp(join(tmpdir(), 'ocd-work-graph-')); - const outbox = join(dir, 'events.jsonl'); - const events = [ - { kind: 'tool_call', tool: 'read_file', summary: 'billing.py' }, + expect(fixture.create.mock.invocationCallOrder[0]).toBeLessThan( + activateProvidedSessionWorkContext.mock.invocationCallOrder[0] + ); + expect(activateProvidedSessionWorkContext.mock.invocationCallOrder[0]).toBeLessThan( + fixture.prompt.mock.invocationCallOrder[0] + ); + expect(awaitActivationAcceptance).toHaveBeenCalledWith({ + runId: 'run-1', + contextSha256: digest, + activationSha256: activationDigest, + nativeSessionId: 'session-1', + }); + expect(awaitActivationAcceptance.mock.invocationCallOrder[0]).toBeLessThan( + fixture.prompt.mock.invocationCallOrder[0] + ); + expect(messages[0]).toMatchObject({ + kind: 'task.started', + session_handle: 'session-1', + context_activation_ack: { + schema_version: 'orgx-gateway-session-context-activation-ack/v1', + source_client: 'opencode', + native_session_id: 'session-1', + cwd: PROJECT_DIR, + context_sha256: digest, + activation_sha256: activationDigest, + }, + }); + expect( + (messages[0] as { context_activation_ack: { activated_at: string } }) + .context_activation_ack.activated_at + ).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('consumes the byte-equivalent core Gateway activation golden', async () => { + const { readFile } = await import('node:fs/promises'); + const fixturePath = new URL( + '../tests/fixtures/gatewaySessionContextActivation.v1.json', + import.meta.url + ); + const gatewayActivation = JSON.parse( + await readFile(fixturePath, 'utf8') + ) as { + session_activation: { + scope: { workspace_id: string }; + work_context: Record; + }; + context_sha256: `sha256:${string}`; + activation_sha256: `sha256:${string}`; + }; + expect( + `sha256:${sessionWorkContextSha256( + gatewayActivation.session_activation.work_context + )}` + ).toBe( + 'sha256:67bb73b5e76105a0fc86e6399cb1a90f2fc929ca8e0b7fee288782660ebc6601' + ); + expect( + `sha256:${canonicalJsonSha256(gatewayActivation.session_activation)}` + ).toBe( + 'sha256:9fc9376b4a3112bb2e2b3ec46e044ca60a1832126545357df52a9abddb7815a8' + ); + + const fixture = createSdkFixture(); + const activateProvidedSessionWorkContext = vi.fn(async () => + activatedHydration() + ); + const messages = await collect( + driverWithFixture(fixture, { + workspaceId: gatewayActivation.session_activation.scope.workspace_id, + activateProvidedSessionWorkContext, + awaitActivationAcceptance: vi.fn(async () => undefined), + }), { - kind: 'file_edit', - path: 'tests/billing.py', - summary: 'replaced class-based with @parametrize', + repo_path: PROJECT_DIR, + context_activation: gatewayActivation, + }, + { protocol_version: 1 } + ); + + expect(activateProvidedSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ + context: gatewayActivation.session_activation.work_context, + activationEnvelope: gatewayActivation.session_activation, + projectDir: PROJECT_DIR, + sessionId: 'session-1', + }) + ); + expect(messages[0]).toMatchObject({ + kind: 'task.started', + context_activation_ack: { + context_sha256: gatewayActivation.context_sha256, + activation_sha256: gatewayActivation.activation_sha256, + cwd: PROJECT_DIR, + native_session_id: 'session-1', }, - { kind: 'assistant_completed', tokens_used: 3400 }, - ]; - restore = installFetch((url) => { - if (url.includes('/sessions') && !url.includes('/events')) { - return jsonResponse({ session_id: 'sess-abc' }); + }); + expect(messages.at(-1)).toMatchObject({ kind: 'task.completed' }); + }); + + it('does not prompt until the Gateway accepts the exact activation', async () => { + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const digest = `sha256:${sessionWorkContextSha256(workContext)}`; + let accept!: () => void; + const awaitActivationAcceptance = vi.fn( + () => + new Promise((resolveAcceptance) => { + accept = resolveAcceptance; + }) + ); + const driver = driverWithFixture(fixture, { + activateProvidedSessionWorkContext: vi.fn(async () => activatedHydration()), + awaitActivationAcceptance, + }); + const iterator = driver.dispatch( + { + title: 'wait for acceptance', + driver: 'opencode', + execution_attribution: executionAttribution(), + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: digest, + activation_sha256: activationSha256(workContext), + }, + } as never, + { + run_id: 'run-1', + idempotency_key: 'key-1', + protocol_version: 1, } - if (url.includes('/events')) return ndjsonResponse(events); - return new Response('not found', { status: 404 }); + )[Symbol.asyncIterator](); + + await expect(iterator.next()).resolves.toMatchObject({ + value: { kind: 'task.started', session_handle: 'session-1' }, }); + const afterStarted = iterator.next(); + await Promise.resolve(); + expect(fixture.prompt).not.toHaveBeenCalled(); + + accept(); + await afterStarted; + expect(fixture.prompt).toHaveBeenCalledOnce(); + await iterator.return?.(); + }); - const d = new OpenCodeDriver({ - statePath, - skillRules: async () => [], - workGraphOutboxPath: outbox, + it('retries the identical started acknowledgement after a lost acceptance frame and prompts once', async () => { + const fixture = createSdkFixture({ parts: [], diffs: [] }); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const digest = `sha256:${sessionWorkContextSha256(workContext)}`; + let accept!: () => void; + const driver = driverWithFixture(fixture, { + activateProvidedSessionWorkContext: vi.fn(async () => activatedHydration()), + awaitActivationAcceptance: vi.fn( + () => + new Promise((resolveAcceptance) => { + accept = resolveAcceptance; + }) + ), + activationAcceptanceRetryMs: 1, }); - for await (const _m of d.dispatch( + const iterator = driver.dispatch( { - title: 'parametrize billing tests', - description: 'rewrite the tests', - repo_path: '/repo', + title: 'retry exact activation', driver: 'opencode', - }, - { run_id: 'r1', idempotency_key: 'k1' } - )) { - // consume generator + execution_attribution: executionAttribution(), + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: digest, + activation_sha256: activationSha256(workContext), + }, + } as never, + { + run_id: 'run-1', + idempotency_key: 'key-1', + protocol_version: 1, + } + )[Symbol.asyncIterator](); + + const first = await iterator.next(); + const retry = await iterator.next(); + expect(first.done).toBe(false); + expect(retry.done).toBe(false); + expect(retry.value).toEqual(first.value); + expect(fixture.prompt).not.toHaveBeenCalled(); + + accept(); + const remaining: unknown[] = []; + for (;;) { + const next = await iterator.next(); + if (next.done) break; + remaining.push(next.value); } - const lines = (await readFile(outbox, 'utf8')).trim().split('\n'); - expect(lines).toHaveLength(4); - const records = lines.map((line) => JSON.parse(line)); - expect(records.map((record) => record.event)).toEqual([ - 'task_started', - 'task_step', - 'task_step', - 'task_completed', + expect(fixture.prompt).toHaveBeenCalledOnce(); + expect(remaining.at(-1)).toMatchObject({ kind: 'task.completed' }); + }); + + it('times out without prompting and clears the exact activation lease', async () => { + const { mkdtemp, readFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const outbox = join( + await mkdtemp(join(tmpdir(), 'ocd-acceptance-timeout-')), + 'events.jsonl' + ); + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const broker = new ActivationAcceptanceBroker(8); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const messages = await collect( + driverWithFixture(fixture, { + activateProvidedSessionWorkContext: vi.fn(async () => activatedHydration()), + awaitActivationAcceptance: (expectation) => + broker.waitForAcceptance(expectation), + cancelActivationAcceptance: (runId) => + broker.rejectRun(runId, 'dispatch ended'), + activationAcceptanceRetryMs: 2, + clearSessionWorkContext, + workGraphOutboxPath: outbox, + }), + { + repo_path: PROJECT_DIR, + execution_attribution: executionAttribution(), + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); + + const starts = messages.filter( + (message) => (message as { kind: string }).kind === 'task.started' + ); + expect(starts.length).toBeGreaterThan(1); + expect(starts.every((message) => JSON.stringify(message) === JSON.stringify(starts[0]))).toBe(true); + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'Timed out waiting for Gateway context activation acceptance', + }); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ); + const events = (await readFile(outbox, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line).event); + expect(events).toContain('task_failed'); + expect(events).not.toContain('task_started'); + }); + + it('fails and clears the exact lease when activation acceptance is rejected', async () => { + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const messages = await collect( + driverWithFixture(fixture, { + activateProvidedSessionWorkContext: vi.fn(async () => activatedHydration()), + awaitActivationAcceptance: vi.fn(async () => { + throw new Error('Gateway rejected OrgX context activation'); + }), + clearSessionWorkContext, + }), + { + repo_path: PROJECT_DIR, + execution_attribution: executionAttribution(), + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); + + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages.map((message) => (message as { kind: string }).kind)).toEqual([ + 'task.started', + 'task.failed', ]); - expect(records[0]).toMatchObject({ - source: 'orgx_opencode_plugin_runtime_hook', - source_client: 'opencode', - run_id: 'r1', - session_id: 'sess-abc', - cwd: '/repo', + expect(messages.at(-1)).toMatchObject({ + reason: 'Gateway rejected OrgX context activation', }); - expect(records[0]).not.toHaveProperty('turn_id'); - expect(JSON.stringify(records).includes('rewrite the tests')).toBe(false); - expect(JSON.stringify(records).includes('replaced class-based')).toBe(false); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: PROJECT_DIR, + sessionId: 'session-1', + }) + ); }); - it('best-effort replays the private Work Graph after terminal events', async () => { - const replayWorkGraph = vi.fn(async () => undefined); - restore = installFetch((url) => { - if (url.includes('/sessions') && !url.includes('/events')) { - return jsonResponse({ session_id: 'sess-abc' }); - } - if (url.includes('/events')) { - return ndjsonResponse([{ kind: 'assistant_completed', tokens_used: 25 }]); - } - return new Response('not found', { status: 404 }); + it('rejects relative task repo paths before creating a native session', async () => { + const fixture = createSdkFixture(); + const messages = await collect(driverWithFixture(fixture), { + repo_path: 'relative/repo', }); - const d = new OpenCodeDriver({ - statePath, - skillRules: async () => [], - workGraphOutboxPath: false, - replayWorkGraph, + + expect(fixture.create).not.toHaveBeenCalled(); + expect(messages).toEqual([ + { + kind: 'task.failed', + run_id: 'run-1', + reason: 'OrgX dispatch repo_path must be absolute', + recoverable: false, + }, + ]); + }); + + it('requires an explicit absolute repo path for Gateway activation', async () => { + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const messages = await collect( + driverWithFixture(fixture, { + awaitActivationAcceptance: vi.fn(async () => undefined), + }), + { + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); + + expect(fixture.create).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OrgX context activation requires an absolute task repo_path', }); + }); - for await (const _m of d.dispatch( - { title: 'terminal replay', driver: 'opencode' }, - { run_id: 'run-terminal', idempotency_key: 'key-terminal' } - )) { - // consume generator - } + it('rejects a bad Gateway context digest without creating or prompting', async () => { + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const messages = await collect( + driverWithFixture(fixture), + { + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${'0'.repeat(64)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); - expect(replayWorkGraph).toHaveBeenCalledOnce(); + expect(fixture.create).not.toHaveBeenCalled(); + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + kind: 'task.failed', + reason: 'OrgX dispatch context activation digest mismatch', + }); }); - it('emits task.deviation when a skill rule matches a file_edit', async () => { - const events = [ + it('rejects compaction tampering even when the work-context digest is unchanged', async () => { + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const originalActivation = coreSessionActivation(workContext); + const activationDigest = `sha256:${canonicalJsonSha256(originalActivation)}`; + const tamperedActivation = structuredClone(originalActivation); + tamperedActivation.compaction.source_capsule.id = 'tampered-capsule'; + const messages = await collect( + driverWithFixture(fixture), { - kind: 'file_edit', - path: 'tests/billing.py', - summary: 'replaced class-based with parametrize', + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: tamperedActivation, + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationDigest, + }, }, - { kind: 'assistant_completed', tokens_used: 1000 }, - ]; - restore = installFetch((url) => { - if (url.includes('/sessions') && !url.includes('/events')) { - return jsonResponse({ session_id: 'sess-abc' }); - } - if (url.includes('/events')) return ndjsonResponse(events); - return new Response('not found', { status: 404 }); + { protocol_version: 1 } + ); + + expect(fixture.create).not.toHaveBeenCalled(); + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OrgX dispatch session activation digest mismatch', }); + }); - const d = new OpenCodeDriver({ - statePath, - skillRules: async () => [ - { - skill_id: 'parametrize-tests', - match: { pattern: 'parametrize', on: 'file_edit' }, - dedupe_fingerprint: 'parametrize-tests-v1', - evidence_kind: 'test_style_shift', + it('rejects task-scoped activation without exact workstream lineage', async () => { + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const sessionActivation = coreSessionActivation(workContext); + delete (sessionActivation.scope as { workstream_id?: string }).workstream_id; + const messages = await collect( + driverWithFixture(fixture), + { + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: sessionActivation, + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: `sha256:${canonicalJsonSha256(sessionActivation)}`, }, - ], - }); - const messages: unknown[] = []; - for await (const m of d.dispatch( - { title: 'parametrize billing tests', driver: 'opencode' }, - { run_id: 'r1', idempotency_key: 'k1' } - )) { - messages.push(m); - } - const deviations = messages.filter( - (m) => (m as { kind: string }).kind === 'task.deviation' + }, + { protocol_version: 1 } ); - expect(deviations).toHaveLength(1); - expect(deviations[0]).toMatchObject({ - skill_id: 'parametrize-tests', - evidence_kind: 'test_style_shift', + + expect(fixture.create).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OrgX session activation hierarchy is invalid', }); }); - it('emits task.failed when an error event lands', async () => { - const events = [ - { kind: 'error', message: 'session interrupted', recoverable: true }, - ]; - restore = installFetch((url) => { - if (url.includes('/sessions') && !url.includes('/events')) { - return jsonResponse({ session_id: 'sess-abc' }); - } - if (url.includes('/events')) return ndjsonResponse(events); - return new Response('not found', { status: 404 }); + it('fails a Gateway dispatch when the native session exposes no provider lease', async () => { + const fixture = createSdkFixture({ sessionProviderId: null }); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const activateProvidedSessionWorkContext = vi.fn(async () => + activatedHydration() + ); + const messages = await collect( + driverWithFixture(fixture, { + activateProvidedSessionWorkContext, + awaitActivationAcceptance: vi.fn(async () => undefined), + }), + { + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); + + expect(activateProvidedSessionWorkContext).not.toHaveBeenCalled(); + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OpenCode session did not expose an authoritative provider lease', + }); + }); + + it('fails and clears authority when terminal provider disagrees with the session lease', async () => { + const fixture = createSdkFixture({ + promptInfo: assistantInfo({ providerID: 'anthropic' }), + sessionProviderId: 'openai', }); - const d = new OpenCodeDriver({ - statePath, - skillRules: async () => [], + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const messages = await collect( + driverWithFixture(fixture, { + activateProvidedSessionWorkContext: vi.fn(async () => activatedHydration()), + awaitActivationAcceptance: vi.fn(async () => undefined), + clearSessionWorkContext, + }), + { + repo_path: PROJECT_DIR, + execution_attribution: executionAttribution('openai'), + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); + + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: + 'OpenCode terminal provider does not match the dispatch provider lease', }); - const messages: unknown[] = []; - for await (const m of d.dispatch( - { title: 'x', driver: 'opencode' }, - { run_id: 'r1', idempotency_key: 'k1' } - )) { - messages.push(m); - } - expect(messages[messages.length - 1]).toMatchObject({ + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ); + }); + + it('fails and clears authority when the terminal response belongs to another session', async () => { + const fixture = createSdkFixture({ + promptInfo: assistantInfo({ sessionID: 'other-session' }), + }); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const messages = await collect( + driverWithFixture(fixture, { + activateProvidedSessionWorkContext: vi.fn(async () => activatedHydration()), + awaitActivationAcceptance: vi.fn(async () => undefined), + clearSessionWorkContext, + }), + { + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); + + expect(fixture.diff).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: + 'OpenCode terminal response does not match the dispatch session lease', + }); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: PROJECT_DIR, + sessionId: 'session-1', + }) + ); + }); + + it('fails before diff attribution when terminal evidence contains another session', async () => { + const fixture = createSdkFixture({ + parts: [{ ...completedToolPart(), sessionID: 'other-session' }], + }); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const messages = await collect( + driverWithFixture(fixture, { + activateProvidedSessionWorkContext: vi.fn(async () => activatedHydration()), + awaitActivationAcceptance: vi.fn(async () => undefined), + clearSessionWorkContext, + }), + { + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { protocol_version: 1 } + ); + + expect(fixture.diff).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OpenCode terminal response contains cross-session evidence', + }); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ); + }); + + it('streams only events attributed to the exact native session', async () => { + const event = (sessionID: string): NativeEvent => + ({ + id: `event-${sessionID}`, + type: 'session.next.tool.called', + properties: { + timestamp: 1, + sessionID, + assistantMessageID: 'assistant-1', + callID: `call-${sessionID}`, + tool: 'bash', + input: {}, + provider: { executed: true }, + }, + }) as NativeEvent; + const fixture = createSdkFixture({ + events: [event('other-session'), event('session-1')], + delayPrompt: true, + parts: [], + diffs: [], + }); + const messages = await collect(driverWithFixture(fixture), {}); + const steps = messages.filter( + (message) => (message as { kind: string }).kind === 'task.step' + ); + + expect(steps).toHaveLength(1); + expect(steps[0]).toMatchObject({ + step: { kind: 'tool_call', evidence_ref: 'call-session-1' }, + }); + }); + + it('drops streamed parts whose nested session identity disagrees with the event', async () => { + const { mkdtemp, readFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const outbox = join( + await mkdtemp(join(tmpdir(), 'ocd-mixed-session-stream-')), + 'events.jsonl' + ); + const mixedSessionPart = { + id: 'event-mixed-session', + type: 'message.part.updated', + properties: { + sessionID: 'session-1', + part: { ...completedToolPart(), sessionID: 'other-session' }, + }, + } as NativeEvent; + const fixture = createSdkFixture({ + events: [mixedSessionPart], + parts: [], + diffs: [], + delayPrompt: true, + }); + const messages = await collect( + driverWithFixture(fixture, { workGraphOutboxPath: outbox }), + {} + ); + + expect( + messages.some( + (message) => (message as { kind: string }).kind === 'task.step' + ) + ).toBe(false); + const events = (await readFile(outbox, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line).event); + expect(events).toEqual(['task_started', 'task_completed']); + }); + + it('fails closed and never prompts when exact-session activation is absent', async () => { + const fixture = createSdkFixture(); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const driver = driverWithFixture(fixture, { + hydrateContextPack: vi.fn(async () => ({ + ok: true, + skipped: 'context_pack_unconfigured' as const, + sessionContext: { + activated: false, + reason: 'context_refresh_failed' as const, + }, + })), + clearSessionWorkContext, + }); + + const messages = await collect(driver, {}); + + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ kind: 'task.failed', - recoverable: true, + recoverable: false, }); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: PROJECT_DIR, + sessionId: 'session-1', + }) + ); + }); + + it('treats a 200 assistant error as failure and clears its exact lease', async () => { + const fixture = createSdkFixture({ + promptInfo: assistantInfo({ + error: { name: 'ProviderAuthError', data: { message: 'reauth required' } }, + }), + }); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const messages = await collect( + driverWithFixture(fixture, { clearSessionWorkContext }), + {} + ); + + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'reauth required', + }); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ); + }); + + it('honors a cancellation received before dispatch without creating a native session', async () => { + const fixture = createSdkFixture(); + const driver = driverWithFixture(fixture); + + await driver.cancel('run-1'); + const messages = await collect(driver, {}); + + expect(fixture.create).not.toHaveBeenCalled(); + expect(fixture.prompt).not.toHaveBeenCalled(); + expect(messages).toEqual([ + { + kind: 'task.failed', + run_id: 'run-1', + reason: 'OpenCode dispatch was cancelled', + recoverable: false, + }, + ]); + }); + + it('clears authority re-established after cancellation during Wizard activation and never starts the task', async () => { + const fixture = createSdkFixture(); + const workContext = { + schema_version: 'orgx-session-work-context/v1', + }; + let resolveActivation!: (value: ReturnType) => void; + const activateProvidedSessionWorkContext = vi.fn( + () => + new Promise>((resolve) => { + resolveActivation = resolve; + }) + ); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const driver = driverWithFixture(fixture, { + activateProvidedSessionWorkContext, + clearSessionWorkContext, + awaitActivationAcceptance: vi.fn(async () => undefined), + }); + const collecting = collect( + driver, + { + repo_path: PROJECT_DIR, + context_activation: { + schema_version: 'orgx-gateway-session-context-activation/v1', + source_client: 'opencode', + session_activation: coreSessionActivation(workContext), + context_sha256: `sha256:${sessionWorkContextSha256(workContext)}`, + activation_sha256: activationSha256(workContext), + }, + }, + { run_id: 'run-cancel-activation', protocol_version: 1 } + ); + + await vi.waitFor(() => { + expect(activateProvidedSessionWorkContext).toHaveBeenCalledOnce(); + }); + await driver.cancel('run-cancel-activation'); + expect(clearSessionWorkContext).toHaveBeenCalledOnce(); + + resolveActivation(activatedHydration()); + const messages = await collecting; + + expect(fixture.abort).toHaveBeenCalledWith( + { sessionID: 'session-1', directory: PROJECT_DIR }, + expect.objectContaining({ throwOnError: true, redirect: 'error' }) + ); + expect(clearSessionWorkContext).toHaveBeenCalledTimes(2); + expect(fixture.prompt).not.toHaveBeenCalled(); + expect( + messages.some( + (message) => (message as { kind: string }).kind === 'task.started' + ) + ).toBe(false); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OpenCode dispatch was cancelled', + }); + }); + + it('terminates cancellation when the event stream ends but the prompt remains pending', async () => { + const promptBarrier = new Promise(() => undefined); + const fixture = createSdkFixture({ + parts: [], + diffs: [], + promptBarrier, + }); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const driver = driverWithFixture(fixture, { clearSessionWorkContext }); + const iterator = driver.dispatch( + { + title: 'cancel pending prompt', + driver: 'opencode', + execution_attribution: executionAttribution(), + } as never, + { run_id: 'run-cancel-prompt', idempotency_key: 'key-cancel-prompt' } + )[Symbol.asyncIterator](); + + await expect(iterator.next()).resolves.toMatchObject({ + value: { kind: 'task.started', session_handle: 'session-1' }, + }); + const pending = iterator.next(); + await vi.waitFor(() => expect(fixture.prompt).toHaveBeenCalledOnce()); + + await driver.cancel('run-cancel-prompt'); + await expect(pending).resolves.toMatchObject({ + value: { + kind: 'task.failed', + reason: 'OpenCode dispatch was cancelled', + }, + }); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ); + expect(fixture.diff).not.toHaveBeenCalled(); + }); + + it('cancels and clears only the session mapped to the run', async () => { + const fixture = createSdkFixture(); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared' as const, + })); + const driver = driverWithFixture(fixture, { clearSessionWorkContext }); + const iterator = driver.dispatch( + { + title: 'cancel me', + driver: 'opencode', + execution_attribution: executionAttribution(), + } as never, + { run_id: 'run-cancel', idempotency_key: 'key-cancel' } + )[Symbol.asyncIterator](); + + await expect(iterator.next()).resolves.toMatchObject({ + value: { kind: 'task.started', session_handle: 'session-1' }, + }); + await driver.cancel('unmapped-run'); + expect(fixture.abort).not.toHaveBeenCalled(); + await driver.cancel('run-cancel'); + expect(fixture.abort).toHaveBeenCalledWith( + { sessionID: 'session-1', directory: PROJECT_DIR }, + expect.objectContaining({ throwOnError: true, redirect: 'error' }) + ); + expect(clearSessionWorkContext).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ); + await iterator.return?.(); + }); +}); + +describe('OpenCodeDriver scope and receipts', () => { + it('prefers v2 workRef, accepts runtime task scope, and rejects disagreement', () => { + expect( + resolveDispatchWorkScope( + { title: 'x', driver: 'opencode', workspace_id: 'workspace-1' }, + undefined, + 'workspace-1' + ) + ).toEqual({ + workspaceId: 'workspace-1', + initiativeId: undefined, + workstreamId: undefined, + taskId: undefined, + }); + expect( + resolveDispatchWorkScope( + { title: 'x', driver: 'opencode' }, + { + workRef: { + workspaceId: 'workspace-1', + initiativeId: 'initiative-1', + workstreamId: 'workstream-1', + taskId: 'task-1', + }, + } as never, + 'workspace-1' + ) + ).toMatchObject({ taskId: 'task-1', workstreamId: 'workstream-1' }); + expect(() => + resolveDispatchWorkScope( + { title: 'x', driver: 'opencode', workspace_id: 'workspace-wrong' }, + { workRef: { workspaceId: 'workspace-1' } } as never, + 'workspace-1' + ) + ).toThrow('workspaceId mismatch'); + expect(() => + resolveDispatchWorkScope( + { + title: 'x', + driver: 'opencode', + workspace_id: 'workspace-1', + workstream_id: 'workstream-orphan', + }, + undefined, + 'workspace-1' + ) + ).toThrow('workstream scope requires an initiative'); + expect(() => + resolveDispatchWorkScope( + { + title: 'x', + driver: 'opencode', + workspace_id: 'workspace-1', + initiative_id: 'initiative-1', + task_id: 'task-without-workstream', + }, + undefined, + 'workspace-1' + ) + ).toThrow('task scope requires an initiative and workstream'); + expect(() => + resolveDispatchWorkScope( + { + title: 'x', + driver: 'opencode', + workspace_id: 42 as never, + }, + undefined, + 'workspace-1' + ) + ).toThrow('workspace_id is invalid'); + }); + + it('spools compact Work Graph events without raw prompt or tool output', async () => { + const { mkdtemp, readFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const outbox = join(await mkdtemp(join(tmpdir(), 'ocd-work-graph-')), 'events.jsonl'); + const fixture = createSdkFixture(); + const driver = driverWithFixture(fixture, { workGraphOutboxPath: outbox }); + + await collect(driver, { + description: 'private task details', + repo_path: PROJECT_DIR, + }); + + const records = (await readFile(outbox, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(records.map((record) => record.event)).toEqual([ + 'task_started', + 'task_step', + 'task_step', + 'task_completed', + ]); + expect(records[0]).toMatchObject({ + source: 'orgx_opencode_plugin_runtime_hook', + source_client: 'opencode', + run_id: 'run-1', + session_id: 'session-1', + cwd: PROJECT_DIR, + }); + const serialized = JSON.stringify(records); + expect(serialized).not.toContain('private task details'); + expect(serialized).not.toContain('private output must not be emitted'); + }); + + it('emits one deviation for a matching official diff and replays terminal state', async () => { + const fixture = createSdkFixture(); + const replayWorkGraph = vi.fn(async () => undefined); + const messages = await collect( + driverWithFixture(fixture, { + replayWorkGraph, + skillRules: async () => [ + { + skill_id: 'billing-tests', + match: { pattern: 'billing', on: 'file_edit' }, + dedupe_fingerprint: 'billing-v1', + evidence_kind: 'test_change', + }, + ], + }), + {} + ); + + expect( + messages.filter( + (message) => (message as { kind: string }).kind === 'task.deviation' + ) + ).toHaveLength(1); + expect(replayWorkGraph).toHaveBeenCalledOnce(); }); }); diff --git a/src/OpenCodeDriver.ts b/src/OpenCodeDriver.ts index f0e7d30..a936092 100644 --- a/src/OpenCodeDriver.ts +++ b/src/OpenCodeDriver.ts @@ -1,138 +1,218 @@ /** - * OpenCodeDriver — the implementation detail inside orgx-opencode-plugin - * that actually drives the user's OpenCode session. + * OpenCode task driver backed only by the installed official v2 SDK. * - * OpenCode exposes a local HTTP API on http://127.0.0.1: when the - * user has the `opencode` daemon running. We discover the port via the - * standard XDG state file (~/.opencode/state.json on macOS/Linux, - * %APPDATA%/opencode/state.json on Windows). When the daemon isn't - * reachable, detect() reports not-installed and the peer surfaces this - * to the server on handshake — the server then routes new tasks to a - * different driver. - * - * Dispatch flow: - * 1. POST /sessions → create a fresh session bound to the task - * 2. POST /sessions/:id/messages with the rendered prompt - * 3. Poll /sessions/:id/events until a terminal ('assistant_completed' - * or 'error') arrives, streaming task.step events as each tool call - * or file_edit lands. - * 4. Emit task.completed with the accumulated tokens + outcome. - * - * Deviation-reporting: every file_edit event is checked against the - * configured skill rules (fetched from /api/v1/plan-skills on peer - * connect and cached per run). When a rule matches, we emit - * task.deviation — the peer forwards it to the server. + * Each dispatch creates one native session, activates an exact-session OrgX + * context lease, then sends the first prompt. Native events provide live + * progress; the terminal prompt response and official diff endpoint provide + * the deterministic completion summary. */ -import { promises as fs } from 'fs'; -import { homedir, platform } from 'os'; -import { join } from 'path'; +import { promises as fs } from 'node:fs'; +import { homedir, platform } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import { + createOpencodeClient, + type Event as NativeEvent, + type OpencodeClient, + type Part, + type SnapshotFileDiff, +} from '@opencode-ai/sdk/v2'; import type { + DispatchableTask, Driver, DriverOutboundMessage, - DriverStatus, DriverProbe, - DispatchableTask, + DriverStatus, + ExecutionEnvelope, + ProtocolVersion, } from '@useorgx/orgx-gateway-sdk'; -import { recordWorkGraphEvent } from './workGraphOutbox.js'; +import { + MAX_ADDITIONAL_CONTEXT_BYTES, + MAX_SESSION_WORK_CONTEXT_BYTES, + activateProvidedSessionWorkContext, + canonicalJsonSha256, + clearSessionWorkContext, + hydrateContextPack, + sessionWorkContextSha256, + type ContextPackHydrationResult, + type SessionContextClearance, +} from './contextPackHydration.js'; import { capturePluginException } from './sentry.js'; +import { + blockRuntimeSessionHydration, + publishRuntimeSessionHydration, +} from './runtimeSessionContext.js'; +import { recordWorkGraphEvent } from './workGraphOutbox.js'; +import type { ActivationAcceptanceExpectation } from './activationAcceptance.js'; -type OpenCodeState = { - port: number; - version: string; - session_count: number; +type Env = Record; +type DriverContext = { + run_id: string; + idempotency_key: string; + protocol_version?: ProtocolVersion; + execution_envelope?: ExecutionEnvelope; +}; +type ScopedTask = DispatchableTask & { + workspace_id?: string; + initiative_id?: string; + workstream_id?: string; + task_id?: string; + context_activation?: unknown; + execution_attribution?: unknown; +}; +type GatewayContextActivation = { + sessionActivation: Record; + workContext: Record; + scope: WorkScope; + contextSha256: `sha256:${string}`; + activationSha256: `sha256:${string}`; +}; +type ContextActivationAck = { + schema_version: 'orgx-gateway-session-context-activation-ack/v1'; + source_client: 'opencode'; + native_session_id: string; + cwd: string; + context_sha256: `sha256:${string}`; + activation_sha256: `sha256:${string}`; + activated_at: string; +}; +type WorkScope = { + workspaceId: string; + initiativeId?: string; + workstreamId?: string; + taskId?: string; +}; +type GatewayExecutionAttribution = { + provider: 'anthropic' | 'openai' | 'other'; + providerId: string | null; + sourceSubType: 'user_managed'; + observedAt: string; }; - type OpenCodeEvent = | { kind: 'tool_call'; tool: string; summary: string; ref?: string } | { kind: 'file_edit'; path: string; summary: string; diff_ref?: string } - | { kind: 'chat'; role: 'assistant' | 'user'; text: string } - | { kind: 'assistant_completed'; tokens_used: number } - | { kind: 'error'; message: string; recoverable?: boolean }; - + | { kind: 'chat' } + | { kind: 'assistant_completed'; tokens_used: number; provider: string }; type SkillRule = { skill_id: string; match: { pattern: string; on: 'file_edit' | 'tool_call' }; dedupe_fingerprint: string; evidence_kind: string; }; +type ActiveRun = { + client: OpencodeClient; + cancellation: AbortController; + directory: string; + env: Env; + providerLease?: ProviderLease; + sessionId: string; +}; +type ProviderLease = { + provider: 'anthropic' | 'openai' | 'other'; + providerId: string; + observedAt: string; +}; +type ActivationAcceptanceOutcome = + | { kind: 'accepted' } + | { kind: 'rejected'; error: unknown }; + +const DEFAULT_ACTIVATION_RETRY_MS = 500; +const MAX_ACTIVATION_RETRY_MS = 5_000; +const MAX_CANCELLED_RUN_TOMBSTONES = 1_000; +const CANCELLED_DISPATCH_REASON = 'OpenCode dispatch was cancelled'; export type OpenCodeDriverOptions = { - /** Override the state-file lookup — primarily for tests. */ + /** Native OpenCode server URL supplied by the plugin runtime. */ + openCodeServerUrl?: string; + /** Native project directory supplied by the plugin runtime. */ + defaultDirectory?: string; + /** Legacy CLI-only port discovery fallback. */ statePath?: string; - /** Async fetcher of skill rules (injected by the peer). */ + /** Official SDK factory override for tests. */ + createClient?: typeof createOpencodeClient; skillRules?: () => Promise; - /** Timeout for each polling round, in ms. */ - pollTimeoutMs?: number; - /** Local Work Graph JSONL outbox path. Set false to disable. */ workGraphOutboxPath?: string | false; - /** Source client name for Work Graph reconciliation. */ sourceClient?: string; - /** Best-effort private replay after terminal events. */ replayWorkGraph?: () => Promise; + hydrateContextPack?: typeof hydrateContextPack; + activateProvidedSessionWorkContext?: typeof activateProvidedSessionWorkContext; + clearSessionWorkContext?: typeof clearSessionWorkContext; + orgxApiKey?: string; + orgxBaseUrl?: string; + workspaceId?: string; + orgxEnv?: Env; + awaitActivationAcceptance?: ( + expectation: ActivationAcceptanceExpectation + ) => Promise; + cancelActivationAcceptance?: (runId: string) => void; + activationAcceptanceRetryMs?: number; }; export class OpenCodeDriver implements Driver { readonly id = 'opencode' as const; - private portCache: number | null = null; - private readonly opts: OpenCodeDriverOptions; + private baseUrlCache: string | null = null; + private readonly activeRuns = new Map(); + private readonly cancelledRuns = new Set(); + private latestProviderLease: ProviderLease | null = null; - constructor(opts: OpenCodeDriverOptions = {}) { - this.opts = opts; + constructor(private readonly opts: OpenCodeDriverOptions = {}) {} + + /** Latest provider selected by OpenCode's official Session.model field. */ + executionProviderLease(): ProviderLease | null { + return this.latestProviderLease ? { ...this.latestProviderLease } : null; } async detect(): Promise { try { - const port = await this.resolvePort(); - this.portCache = port; - const res = await fetch(`http://127.0.0.1:${port}/status`, { - method: 'GET', - headers: { 'x-orgx-probe': 'detect' }, - }); - if (!res.ok) { - return { - installed: true, - authenticated: false, - error: `status ${res.status}`, - }; - } - const body = (await res.json()) as { version?: string; authed?: boolean }; + const client = await this.client(); + const [health, providers] = await Promise.all([ + client.global.health({ throwOnError: true, redirect: 'error' }), + client.provider.list(undefined, { + throwOnError: true, + redirect: 'error', + }), + ]); + const authenticated = providers.data.connected.length > 0; return { - installed: true, - authenticated: body.authed !== false, - version: body.version, - subscription_active: body.authed !== false, + installed: health.data.healthy === true, + authenticated, + version: health.data.version, + subscription_active: authenticated, }; - } catch (err) { + } catch (error) { return { installed: false, authenticated: false, - error: err instanceof Error ? err.message : String(err), + error: safeErrorMessage(error), }; } } async probe(): Promise { try { - const port = await this.resolvePort(); - const res = await fetch(`http://127.0.0.1:${port}/status`, { - method: 'GET', - headers: { 'x-orgx-probe': 'liveness' }, - }); - if (!res.ok) { - return { subscription_active: false, session_alive: false }; - } - const body = (await res.json()) as { - authed?: boolean; - session_count?: number; - }; + const client = await this.client(); + const [health, providers, statuses] = await Promise.all([ + client.global.health({ throwOnError: true, redirect: 'error' }), + client.provider.list(undefined, { + throwOnError: true, + redirect: 'error', + }), + client.session.status(undefined, { + throwOnError: true, + redirect: 'error', + }), + ]); + const active = Object.values(statuses.data).filter( + (status) => status.type !== 'idle' + ).length; return { - subscription_active: body.authed !== false, - session_alive: (body.session_count ?? 0) >= 0, - queue_depth: body.session_count ?? 0, + subscription_active: + health.data.healthy === true && providers.data.connected.length > 0, + session_alive: health.data.healthy === true, + queue_depth: active, }; } catch { return { subscription_active: false, session_alive: false }; @@ -141,162 +221,595 @@ export class OpenCodeDriver implements Driver { async *dispatch( task: DispatchableTask, - context: { run_id: string; idempotency_key: string } + context: DriverContext ): AsyncIterable { - const port = this.portCache ?? (await this.resolvePort()); - const sessionHandle = await this.createSession(port, { - title: task.title, - description: task.description, - repo_path: task.repo_path, - skill_ids: task.skill_ids, - idempotency_key: context.idempotency_key, - }); - - const startedAt = new Date().toISOString(); - await this.recordWorkGraph('task_started', task, context, { - sessionHandle, - timestamp: startedAt, - task_title_chars: task.title.length, - description_chars: task.description?.length ?? 0, - skill_count: task.skill_ids?.length ?? 0, - }); - yield { - kind: 'task.started', - run_id: context.run_id, - started_at: startedAt, - session_handle: sessionHandle, - }; - - const rules = await (this.opts.skillRules?.() ?? Promise.resolve([])); - const seenFingerprints = new Set(); - let tokens = 0; - let firstResponseAt: string | null = null; - - for await (const event of this.streamSessionEvents(port, sessionHandle)) { - if (!firstResponseAt && (event.kind === 'chat' || event.kind === 'tool_call')) { - firstResponseAt = new Date().toISOString(); + if ( + context.protocol_version !== undefined && + context.protocol_version !== 1 + ) { + yield { + kind: 'task.failed', + run_id: context.run_id, + reason: + 'OpenCode proof-bearing Gateway protocol v2 finalization is not supported', + recoverable: false, + }; + return; + } + if ( + task.repo_path !== undefined && + (typeof task.repo_path !== 'string' || !isAbsolute(task.repo_path)) + ) { + yield { + kind: 'task.failed', + run_id: context.run_id, + reason: 'OrgX dispatch repo_path must be absolute', + recoverable: false, + }; + return; + } + const directory = resolve( + task.repo_path ?? this.opts.defaultDirectory ?? process.cwd() + ); + let active: ActiveRun | undefined; + let startedAt = new Date().toISOString(); + let activationWaitArmed = false; + let activationAccepted = false; + try { + this.assertNotCancelled(context.run_id); + const scopedTask = task as ScopedTask; + const executionAttribution = parseGatewayExecutionAttribution( + scopedTask.execution_attribution + ); + const providedActivation = parseGatewayContextActivation( + scopedTask.context_activation + ); + if (providedActivation && task.repo_path === undefined) { + throw new Error( + 'OrgX context activation requires an absolute task repo_path' + ); } - - if (event.kind === 'error') { - await this.recordWorkGraph('task_failed', task, context, { - sessionHandle, - recoverable: event.recoverable === true, - reason_chars: event.message.length, - }); - yield { - kind: 'task.failed', - run_id: context.run_id, - reason: event.message, - recoverable: event.recoverable === true, - }; - await this.replayWorkGraph(); - return; + let scope = resolveDispatchWorkScope( + scopedTask, + context.execution_envelope, + this.opts.workspaceId + ); + if (providedActivation) { + scope = reconcileActivationScope(scope, providedActivation.scope); } + const client = await this.client(directory); + this.assertNotCancelled(context.run_id); + const created = await client.session.create( + { directory, title: task.title }, + { throwOnError: true, redirect: 'error' } + ); + const sessionId = normalizedId(created.data.id, 'native session ID'); + const env = this.hydrationEnv(scope); + active = { + client, + cancellation: new AbortController(), + directory, + env, + sessionId, + }; + this.activeRuns.set(context.run_id, active); + this.assertNotCancelled(context.run_id); + const providerLease = readProviderLease(created.data.model?.providerID); + if (!providerLease) { + throw new Error( + 'OpenCode session did not expose an authoritative provider lease' + ); + } + if ( + executionAttribution.providerId !== null && + (providerLease.providerId !== executionAttribution.providerId || + providerLease.provider !== executionAttribution.provider) + ) { + throw new Error( + 'OpenCode session provider does not match execution attribution' + ); + } + if (providerLease) this.latestProviderLease = providerLease; + active.providerLease = providerLease; - if (event.kind === 'assistant_completed') { - tokens = event.tokens_used; - await this.recordWorkGraph('task_completed', task, context, { - sessionHandle, - tokens_used: tokens, - first_response_seen: Boolean(firstResponseAt), - }); - yield { - kind: 'task.completed', - run_id: context.run_id, - outcome_kind: 'shipped', - started_at: startedAt, - first_response_at: firstResponseAt ?? startedAt, - completed_at: new Date().toISOString(), - tokens_used: tokens, - provider: 'anthropic', - source_sub_type: 'subscription', - source_driver: 'opencode', - // OpenCode subscriptions don't expose a cost per token — we - // emit 0 so the server's BYOK aggregator records "zero - // server-side" and leaves saved_estimate_cents to a follow-up - // job that backfills from task_type_baselines. - cost_estimate_cents: 0, - }; - await this.replayWorkGraph(); - return; + const hydration = providedActivation + ? await ( + this.opts.activateProvidedSessionWorkContext ?? + activateProvidedSessionWorkContext + )({ + context: providedActivation.workContext, + activationEnvelope: providedActivation.sessionActivation, + env, + projectDir: directory, + sessionId, + }) + : await (this.opts.hydrateContextPack ?? hydrateContextPack)({ + env, + projectDir: directory, + sessionId, + }); + this.assertNotCancelled(context.run_id); + const system = verifiedAdditionalContext(hydration); + publishRuntimeSessionHydration(directory, sessionId, hydration); + startedAt = new Date().toISOString(); + const contextActivationAck: ContextActivationAck | undefined = + providedActivation + ? { + schema_version: + 'orgx-gateway-session-context-activation-ack/v1', + source_client: 'opencode', + native_session_id: sessionId, + cwd: directory, + context_sha256: providedActivation.contextSha256, + activation_sha256: providedActivation.activationSha256, + activated_at: startedAt, + } + : undefined; + const startedMessage = { + kind: 'task.started', + run_id: context.run_id, + started_at: startedAt, + session_handle: sessionId, + ...(contextActivationAck + ? { context_activation_ack: contextActivationAck } + : {}), + }; + let activationAcceptance: Promise | undefined; + if (contextActivationAck) { + if (!this.opts.awaitActivationAcceptance) { + throw new Error( + 'Gateway context activation acceptance channel is unavailable' + ); + } + activationAcceptance = this.opts + .awaitActivationAcceptance({ + runId: context.run_id, + contextSha256: contextActivationAck.context_sha256, + activationSha256: contextActivationAck.activation_sha256, + nativeSessionId: sessionId, + }) + .then( + () => ({ kind: 'accepted' as const }), + (error: unknown) => ({ kind: 'rejected' as const, error }) + ); + activationWaitArmed = true; } + this.assertNotCancelled(context.run_id); + yield startedMessage as DriverOutboundMessage; + this.assertNotCancelled(context.run_id); + if (activationAcceptance) { + for await (const retry of retryStartedUntilAccepted( + activationAcceptance, + startedMessage as DriverOutboundMessage, + this.opts.activationAcceptanceRetryMs + )) { + yield retry; + } + activationAccepted = true; + } + this.assertNotCancelled(context.run_id); + await this.recordWorkGraph('task_started', task, context, { + sessionHandle: sessionId, + timestamp: startedAt, + context_activation_acknowledged: Boolean(contextActivationAck), + context_sha256: contextActivationAck?.context_sha256, + activation_sha256: contextActivationAck?.activation_sha256, + task_title_chars: task.title.length, + description_chars: task.description?.length ?? 0, + skill_count: task.skill_ids?.length ?? 0, + }); + this.assertNotCancelled(context.run_id); + const rules = await (this.opts.skillRules?.() ?? Promise.resolve([])); + this.assertNotCancelled(context.run_id); + const seenEvidence = new Set(); + const seenDeviations = new Set(); + let firstResponseAt: string | null = null; - // Narrate progress to the server + check deviation rules. - if (event.kind === 'file_edit' || event.kind === 'tool_call') { - const summary = - event.kind === 'file_edit' - ? `edit ${event.path} — ${event.summary}` - : `call ${event.tool} — ${event.summary}`; - await this.recordWorkGraph('task_step', task, context, { - sessionHandle, - step_kind: event.kind, - evidence_ref: - event.kind === 'file_edit' - ? event.diff_ref ?? event.path - : event.ref ?? event.tool, - summary_chars: summary.length, - }); - yield { - kind: 'task.step', - run_id: context.run_id, - step: { - kind: event.kind, - summary, - evidence_ref: - event.kind === 'file_edit' - ? event.diff_ref - : event.ref, - }, - }; - - for (const rule of rules) { - if (rule.match.on !== event.kind) continue; - const text = - event.kind === 'file_edit' - ? `${event.path} ${event.summary}` - : `${event.tool} ${event.summary}`; - if (!new RegExp(rule.match.pattern).test(text)) continue; - const dedupe = `${rule.skill_id}:${rule.dedupe_fingerprint}:${context.run_id}`; - if (seenFingerprints.has(dedupe)) continue; - seenFingerprints.add(dedupe); - await this.recordWorkGraph('task_deviation', task, context, { - sessionHandle, - skill_id: rule.skill_id, - evidence_kind: rule.evidence_kind, - dedupe_key: dedupe, + for await (const event of this.runPrompt( + active, + context.run_id, + renderPrompt(task), + system, + seenEvidence + )) { + this.assertNotCancelled(context.run_id); + if (!firstResponseAt && event.kind !== 'assistant_completed') { + firstResponseAt = new Date().toISOString(); + } + if (event.kind === 'assistant_completed') { + await this.recordWorkGraph('task_completed', task, context, { + sessionHandle: sessionId, + tokens_used: event.tokens_used, + first_response_seen: Boolean(firstResponseAt), }); + this.assertNotCancelled(context.run_id); yield { - kind: 'task.deviation', + kind: 'task.completed', run_id: context.run_id, - skill_id: rule.skill_id, - evidence_kind: rule.evidence_kind, - evidence_ref: - event.kind === 'file_edit' - ? event.diff_ref ?? event.path - : event.ref ?? event.tool, - dedupe_key: dedupe, - severity: 'warn', + // A completed model turn is not proof that OrgX accepted or shipped it. + outcome_kind: 'awaiting_review', + started_at: startedAt, + first_response_at: firstResponseAt ?? startedAt, + completed_at: new Date().toISOString(), + tokens_used: event.tokens_used, + provider: providerKind(event.provider), + source_sub_type: executionAttribution.sourceSubType, + source_driver: 'opencode', + cost_estimate_cents: 0, }; + await this.replayWorkGraph(); + return; } + if (event.kind === 'chat') continue; + yield* this.progressMessages( + event, + rules, + seenDeviations, + task, + context, + sessionId + ); } + throw new Error('OpenCode prompt ended without a terminal response'); + } catch (error) { + if (active) { + if ( + this.cancelledRuns.has(context.run_id) && + !active.cancellation.signal.aborted + ) { + active.cancellation.abort(); + await this.abortNativeSession(active); + } + blockRuntimeSessionHydration(active.directory, active.sessionId); + await this.clearAuthority(active); + } + const reason = safeErrorMessage(error); + await this.recordWorkGraph('task_failed', task, context, { + sessionHandle: active?.sessionId, + recoverable: false, + reason_chars: reason.length, + }); + yield { + kind: 'task.failed', + run_id: context.run_id, + reason, + recoverable: false, + }; + await this.replayWorkGraph(); + } finally { + if (activationWaitArmed && !activationAccepted) { + this.opts.cancelActivationAcceptance?.(context.run_id); + } + if (active && this.activeRuns.get(context.run_id) === active) { + this.activeRuns.delete(context.run_id); + } + this.cancelledRuns.delete(context.run_id); } } async cancel(runId: string): Promise { - const port = this.portCache ?? (await this.resolvePort()); - await fetch(`http://127.0.0.1:${port}/sessions/cancel`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ correlation_run_id: runId }), - }).catch(() => undefined); + this.rememberCancellation(runId); + this.opts.cancelActivationAcceptance?.(runId); + const active = this.activeRuns.get(runId); + if (!active) return; + active.cancellation.abort(); + await this.abortNativeSession(active); + blockRuntimeSessionHydration(active.directory, active.sessionId); + await this.clearAuthority(active); + } + + private async *runPrompt( + active: ActiveRun, + runId: string, + prompt: string, + system: string, + seenEvidence: Set + ): AsyncGenerator { + this.assertNotCancelled(runId); + const controller = new AbortController(); + const onCancelled = () => controller.abort(); + active.cancellation.signal.addEventListener('abort', onCancelled, { + once: true, + }); + const subscription = await active.client.event.subscribe( + { directory: active.directory }, + { + signal: controller.signal, + redirect: 'error', + sseMaxRetryAttempts: 1, + } + ); + this.assertNotCancelled(runId); + const iterator = subscription.stream[Symbol.asyncIterator](); + const promptResult = active.client.session + .prompt( + { + sessionID: active.sessionId, + directory: active.directory, + system, + parts: [{ type: 'text', text: prompt }], + }, + { throwOnError: true, redirect: 'error' } + ) + .then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + let nextEvent: + | Promise< + | { ok: true; value: IteratorResult } + | { ok: false; error: unknown } + > + | undefined = iterator.next().then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + const cancellation = new Promise<{ source: 'cancel' }>((resolveCancel) => { + if (active.cancellation.signal.aborted) { + resolveCancel({ source: 'cancel' }); + return; + } + active.cancellation.signal.addEventListener( + 'abort', + () => resolveCancel({ source: 'cancel' }), + { once: true } + ); + }); + let terminal: Awaited | undefined; + try { + while (!terminal) { + if (!nextEvent) { + const result = await Promise.race([ + promptResult.then((value) => ({ + source: 'prompt' as const, + value, + })), + cancellation, + ]); + if (result.source === 'cancel') { + throw new Error(CANCELLED_DISPATCH_REASON); + } + terminal = result.value; + break; + } + const result = await Promise.race([ + promptResult.then((value) => ({ source: 'prompt' as const, value })), + nextEvent.then((value) => ({ source: 'event' as const, value })), + cancellation, + ]); + if (result.source === 'cancel') { + throw new Error(CANCELLED_DISPATCH_REASON); + } + if (result.source === 'prompt') { + terminal = result.value; + break; + } + if (!result.value.ok || result.value.value.done) { + nextEvent = undefined; + continue; + } + this.assertNotCancelled(runId); + for (const event of mapNativeEvent( + result.value.value.value, + active.sessionId, + seenEvidence + )) { + yield event; + } + nextEvent = iterator.next().then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + } + } finally { + controller.abort(); + active.cancellation.signal.removeEventListener('abort', onCancelled); + await iterator.return?.().catch(() => undefined); + } + this.assertNotCancelled(runId); + if (!terminal?.ok) throw terminal?.error ?? new Error('OpenCode prompt failed'); + const response = terminal.value.data; + if (response.info.error) throw new Error(messageError(response.info.error)); + const terminalSessionId = normalizedId( + response.info.sessionID, + 'terminal session ID' + ); + if (terminalSessionId !== active.sessionId) { + throw new Error( + 'OpenCode terminal response does not match the dispatch session lease' + ); + } + if (response.parts.some((part) => part.sessionID !== active.sessionId)) { + throw new Error( + 'OpenCode terminal response contains cross-session evidence' + ); + } + const terminalProviderId = normalizedId( + response.info.providerID, + 'terminal provider ID' + ); + if ( + active.providerLease && + terminalProviderId !== active.providerLease.providerId + ) { + throw new Error( + 'OpenCode terminal provider does not match the dispatch provider lease' + ); + } + for (const event of mapTerminalParts(response.parts, seenEvidence)) yield event; + this.assertNotCancelled(runId); + const diff = await active.client.session.diff( + { + sessionID: active.sessionId, + directory: active.directory, + messageID: response.info.parentID, + }, + { throwOnError: true, redirect: 'error' } + ); + this.assertNotCancelled(runId); + for (const event of mapDiffs(diff.data, seenEvidence)) yield event; + yield { + kind: 'assistant_completed', + tokens_used: + response.info.tokens.total ?? + response.info.tokens.input + + response.info.tokens.output + + response.info.tokens.reasoning, + provider: terminalProviderId, + }; } - // ───── Internals ────────────────────────────────────────────────────── + private async *progressMessages( + event: Extract, + rules: SkillRule[], + seenDeviations: Set, + task: DispatchableTask, + context: DriverContext, + sessionId: string + ): AsyncGenerator { + const summary = + event.kind === 'file_edit' + ? `edit ${event.path} — ${event.summary}` + : `call ${event.tool} — ${event.summary}`; + const evidenceRef = + event.kind === 'file_edit' + ? event.diff_ref ?? event.path + : event.ref ?? event.tool; + await this.recordWorkGraph('task_step', task, context, { + sessionHandle: sessionId, + step_kind: event.kind, + evidence_ref: evidenceRef, + summary_chars: summary.length, + }); + this.assertNotCancelled(context.run_id); + yield { + kind: 'task.step', + run_id: context.run_id, + step: { kind: event.kind, summary, evidence_ref: evidenceRef }, + }; + for (const rule of rules) { + if (rule.match.on !== event.kind) continue; + const text = + event.kind === 'file_edit' + ? `${event.path} ${event.summary}` + : `${event.tool} ${event.summary}`; + let matches = false; + try { + matches = new RegExp(rule.match.pattern).test(text); + } catch { + continue; + } + if (!matches) continue; + const dedupe = `${rule.skill_id}:${rule.dedupe_fingerprint}:${context.run_id}`; + if (seenDeviations.has(dedupe)) continue; + seenDeviations.add(dedupe); + await this.recordWorkGraph('task_deviation', task, context, { + sessionHandle: sessionId, + skill_id: rule.skill_id, + evidence_kind: rule.evidence_kind, + dedupe_key: dedupe, + }); + this.assertNotCancelled(context.run_id); + yield { + kind: 'task.deviation', + run_id: context.run_id, + skill_id: rule.skill_id, + evidence_kind: rule.evidence_kind, + evidence_ref: evidenceRef, + dedupe_key: dedupe, + severity: 'warn', + }; + } + } + + private hydrationEnv(scope: WorkScope): Env { + return { + ...(this.opts.orgxEnv ?? process.env), + ORGX_API_KEY: this.opts.orgxApiKey, + ORGX_BASE_URL: this.opts.orgxBaseUrl, + ORGX_WORKSPACE_ID: scope.workspaceId, + ORGX_INITIATIVE_ID: scope.initiativeId, + ORGX_WORKSTREAM_ID: scope.workstreamId, + ORGX_TASK_ID: scope.taskId, + }; + } + + private rememberCancellation(runId: string): void { + if (!this.cancelledRuns.has(runId)) { + while (this.cancelledRuns.size >= MAX_CANCELLED_RUN_TOMBSTONES) { + const oldest = this.cancelledRuns.values().next().value; + if (typeof oldest !== 'string') break; + this.cancelledRuns.delete(oldest); + } + } + this.cancelledRuns.add(runId); + } + + private assertNotCancelled(runId: string): void { + if (this.cancelledRuns.has(runId)) { + throw new Error(CANCELLED_DISPATCH_REASON); + } + } + + private async abortNativeSession(active: ActiveRun): Promise { + await active.client.session + .abort( + { sessionID: active.sessionId, directory: active.directory }, + { throwOnError: true, redirect: 'error' } + ) + .catch(() => undefined); + } + + private async clearAuthority(active: ActiveRun): Promise { + try { + return await ( + this.opts.clearSessionWorkContext ?? clearSessionWorkContext + )({ + env: active.env, + projectDir: active.directory, + sessionId: active.sessionId, + }); + } catch { + return { cleared: false, reason: 'wizard_unavailable' }; + } + } + + private async client(directory?: string): Promise { + const baseUrl = await this.resolveBaseUrl(); + return (this.opts.createClient ?? createOpencodeClient)({ + baseUrl, + ...(directory ? { directory } : {}), + redirect: 'error', + }); + } + + private async resolveBaseUrl(): Promise { + if (this.baseUrlCache) return this.baseUrlCache; + if (this.opts.openCodeServerUrl !== undefined) { + const explicit = resolveSafeOpenCodeServerUrl(this.opts.openCodeServerUrl); + if (!explicit) throw new Error('Unsafe OpenCode server URL'); + this.baseUrlCache = explicit; + return explicit; + } + const statePath = this.opts.statePath ?? defaultStatePath(); + const state = JSON.parse(await fs.readFile(statePath, 'utf8')) as { + port?: unknown; + }; + if ( + !Number.isInteger(state.port) || + (state.port as number) < 1 || + (state.port as number) > 65_535 + ) { + throw new Error(`OpenCode state file missing port: ${statePath}`); + } + this.baseUrlCache = `http://127.0.0.1:${state.port as number}`; + return this.baseUrlCache; + } private async recordWorkGraph( event: string, task: DispatchableTask, - context: { run_id: string; idempotency_key: string }, + context: DriverContext, summary: Record ): Promise { await recordWorkGraphEvent({ @@ -308,14 +821,8 @@ export class OpenCodeDriver implements Driver { typeof summary.sessionHandle === 'string' ? summary.sessionHandle : undefined, - cwd: - typeof task.repo_path === 'string' && task.repo_path.trim() - ? task.repo_path - : undefined, - summary: { - ...summary, - idempotency_key: context.idempotency_key, - }, + cwd: task.repo_path?.trim() || undefined, + summary: { ...summary, idempotency_key: context.idempotency_key }, }); } @@ -326,97 +833,506 @@ export class OpenCodeDriver implements Driver { capturePluginException(error, { stage: 'work_graph_replay' }); } } +} - private async resolvePort(): Promise { - if (this.portCache !== null) return this.portCache; - const path = this.opts.statePath ?? defaultStatePath(); - const raw = await fs.readFile(path, 'utf8'); - const state = JSON.parse(raw) as OpenCodeState; - if (!Number.isInteger(state.port) || state.port <= 0) { - throw new Error(`OpenCode state file missing port: ${path}`); - } - this.portCache = state.port; - return state.port; - } - - private async createSession( - port: number, - body: { - title: string; - description?: string; - repo_path?: string; - skill_ids?: string[]; - idempotency_key: string; - } - ): Promise { - const res = await fetch(`http://127.0.0.1:${port}/sessions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Idempotency-Key': body.idempotency_key, - }, - body: JSON.stringify({ - title: body.title, - initial_prompt: renderPrompt(body), - cwd: body.repo_path, +async function* retryStartedUntilAccepted( + outcome: Promise, + startedMessage: DriverOutboundMessage, + retryMs = DEFAULT_ACTIVATION_RETRY_MS +): AsyncGenerator { + if (!Number.isFinite(retryMs) || retryMs < 1) { + throw new Error('Gateway activation retry interval is invalid'); + } + const interval = Math.min(Math.round(retryMs), MAX_ACTIVATION_RETRY_MS); + while (true) { + let timer: ReturnType | undefined; + const result = await Promise.race([ + outcome, + new Promise<{ kind: 'retry' }>((resolveRetry) => { + timer = setTimeout(() => resolveRetry({ kind: 'retry' }), interval); + timer.unref?.(); }), - }); - if (!res.ok) { - throw new Error(`OpenCode /sessions ${res.status}`); + ]); + if (timer) clearTimeout(timer); + if (result.kind === 'accepted') return; + if (result.kind === 'rejected') throw result.error; + yield startedMessage; + } +} + +export function resolveSafeOpenCodeServerUrl(value: string): string | null { + try { + const url = new URL(value); + if ( + !['http:', 'https:'].includes(url.protocol) || + !['localhost', '127.0.0.1', '::1', '[::1]'].includes( + url.hostname.toLowerCase() + ) || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + return null; } - const json = (await res.json()) as { session_id: string }; - return json.session_id; + return url.origin; + } catch { + return null; } +} - private async *streamSessionEvents( - port: number, - sessionId: string - ): AsyncGenerator { - const url = `http://127.0.0.1:${port}/sessions/${sessionId}/events`; - const res = await fetch(url, { headers: { accept: 'application/x-ndjson' } }); - if (!res.ok || !res.body) { - throw new Error(`OpenCode /events ${res.status}`); +export function resolveDispatchWorkScope( + task: ScopedTask, + envelope: ExecutionEnvelope | undefined, + configuredWorkspaceId?: string +): WorkScope { + const fromTask = { + workspaceId: declaredId(task, 'workspace_id'), + initiativeId: declaredId(task, 'initiative_id'), + workstreamId: declaredId(task, 'workstream_id'), + taskId: declaredId(task, 'task_id'), + }; + const fromEnvelope = envelope?.workRef; + const envelopeScope = fromEnvelope + ? { + workspaceId: declaredId(fromEnvelope, 'workspaceId'), + initiativeId: declaredId(fromEnvelope, 'initiativeId'), + workstreamId: declaredId(fromEnvelope, 'workstreamId'), + taskId: declaredId(fromEnvelope, 'taskId'), + } + : undefined; + for (const key of [ + 'workspaceId', + 'initiativeId', + 'workstreamId', + 'taskId', + ] as const) { + if ( + envelopeScope?.[key] && + fromTask[key] && + envelopeScope[key] !== fromTask[key] + ) { + throw new Error(`OrgX ${key} mismatch between task and execution envelope`); } - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - while (true) { - const { value, done } = await reader.read(); - if (done) return; - buffer += decoder.decode(value, { stream: true }); - let nl: number; - while ((nl = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, nl).trim(); - buffer = buffer.slice(nl + 1); - if (!line) continue; - try { - yield JSON.parse(line) as OpenCodeEvent; - } catch { - // skip malformed lines - } + } + const workspaceId = + envelopeScope?.workspaceId ?? + fromTask.workspaceId ?? + optionalId(configuredWorkspaceId); + if (!workspaceId) throw new Error('OrgX dispatch is missing workspace scope'); + if (configuredWorkspaceId && workspaceId !== configuredWorkspaceId.trim()) { + throw new Error('OrgX dispatch workspace does not match the connected peer'); + } + const scope: WorkScope = { + workspaceId, + initiativeId: envelopeScope?.initiativeId ?? fromTask.initiativeId, + workstreamId: envelopeScope?.workstreamId ?? fromTask.workstreamId, + taskId: envelopeScope?.taskId ?? fromTask.taskId, + }; + if (scope.workstreamId && !scope.initiativeId) { + throw new Error('OrgX workstream scope requires an initiative'); + } + if (scope.taskId && (!scope.initiativeId || !scope.workstreamId)) { + throw new Error('OrgX task scope requires an initiative and workstream'); + } + return scope; +} + +function mapNativeEvent( + event: NativeEvent, + sessionId: string, + seen: Set +): OpenCodeEvent[] { + if (event.type === 'session.next.text.delta') { + if (event.properties.sessionID !== sessionId) return []; + const key = `chat:${event.properties.assistantMessageID}`; + if (seen.has(key)) return []; + seen.add(key); + return [{ kind: 'chat' }]; + } + if (event.type === 'session.next.tool.called') { + if (event.properties.sessionID !== sessionId) return []; + const key = `tool:${event.properties.callID}`; + if (seen.has(key)) return []; + seen.add(key); + return [ + { + kind: 'tool_call', + tool: event.properties.tool, + summary: 'started native tool call', + ref: event.properties.callID, + }, + ]; + } + if ( + event.type === 'message.part.updated' && + event.properties.sessionID === sessionId && + event.properties.part.sessionID === sessionId && + event.properties.part.type === 'tool' + ) { + return mapToolPart(event.properties.part, seen); + } + if ( + event.type === 'session.diff' && + event.properties.sessionID === sessionId + ) { + return mapDiffs(event.properties.diff, seen); + } + return []; +} + +function mapTerminalParts(parts: Part[], seen: Set): OpenCodeEvent[] { + const events: OpenCodeEvent[] = []; + for (const part of parts) { + if (part.type === 'text' && part.text.trim()) { + const key = `chat:${part.messageID}`; + if (!seen.has(key)) { + seen.add(key); + events.push({ kind: 'chat' }); } } + if (part.type === 'tool') events.push(...mapToolPart(part, seen)); } + return events; } -function defaultStatePath(): string { - if (platform() === 'win32') { - const base = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'); - return join(base, 'opencode', 'state.json'); +function mapToolPart( + part: Extract, + seen: Set +): OpenCodeEvent[] { + if (part.state.status !== 'completed' && part.state.status !== 'error') return []; + const key = `tool:${part.callID}`; + if (seen.has(key)) return []; + seen.add(key); + return [ + { + kind: 'tool_call', + tool: part.tool, + summary: + part.state.status === 'completed' + ? 'completed native tool call' + : 'native tool call failed', + ref: part.callID, + }, + ]; +} + +function mapDiffs( + diffs: SnapshotFileDiff[], + seen: Set +): OpenCodeEvent[] { + const events: OpenCodeEvent[] = []; + for (const diff of diffs) { + if (!diff.file) continue; + const key = `diff:${diff.file}:${diff.additions}:${diff.deletions}`; + if (seen.has(key)) continue; + seen.add(key); + events.push({ + kind: 'file_edit', + path: diff.file, + summary: `${diff.status ?? 'modified'} (+${diff.additions}/-${diff.deletions})`, + diff_ref: diff.file, + }); } - return join(homedir(), '.opencode', 'state.json'); + return events; +} + +function verifiedAdditionalContext(result: ContextPackHydrationResult): string { + const context = result.additionalContext; + if ( + !result.ok || + result.sessionContext?.activated !== true || + typeof context !== 'string' || + !context || + Buffer.byteLength(context, 'utf8') > MAX_ADDITIONAL_CONTEXT_BYTES + ) { + throw new Error( + `OrgX exact-session context activation failed (${result.sessionContext?.reason ?? result.skipped ?? 'unverified'})` + ); + } + return context; +} + +function parseGatewayContextActivation( + value: unknown +): GatewayContextActivation | null { + if (value === undefined) return null; + if (!isRecord(value)) { + throw new Error('OrgX dispatch context activation is invalid'); + } + const record = value; + const sessionActivation = record.session_activation; + const contextSha256 = record.context_sha256; + const activationSha256 = record.activation_sha256; + if ( + record.schema_version !== + 'orgx-gateway-session-context-activation/v1' || + record.source_client !== 'opencode' || + !isRecord(sessionActivation) || + sessionActivation.schema_version !== 'orgx-session-activation/v1' || + typeof contextSha256 !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(contextSha256) || + typeof activationSha256 !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(activationSha256) + ) { + throw new Error('OrgX dispatch context activation is invalid'); + } + if ( + Buffer.byteLength(JSON.stringify(sessionActivation), 'utf8') > 64 * 1024 || + !isValidActivationCompaction(sessionActivation.compaction) + ) { + throw new Error('OrgX session activation wrapper is invalid'); + } + if (`sha256:${canonicalJsonSha256(sessionActivation)}` !== activationSha256) { + throw new Error('OrgX dispatch session activation digest mismatch'); + } + const activationScope = readActivationScope(sessionActivation.scope); + const context = sessionActivation.work_context; + if ( + !isRecord(context) || + context.schema_version !== 'orgx-session-work-context/v1' || + Buffer.byteLength(JSON.stringify(context), 'utf8') > + MAX_SESSION_WORK_CONTEXT_BYTES || + `sha256:${sessionWorkContextSha256(context)}` !== contextSha256 + ) { + throw new Error('OrgX dispatch context activation digest mismatch'); + } + return { + sessionActivation, + workContext: context, + scope: activationScope, + contextSha256: contextSha256 as `sha256:${string}`, + activationSha256: activationSha256 as `sha256:${string}`, + }; +} + +function parseGatewayExecutionAttribution( + value: unknown +): GatewayExecutionAttribution { + if (!isRecord(value)) { + throw new Error('OrgX dispatch is missing execution attribution'); + } + const provider = value.provider; + const parsedProviderId = + value.provider_id === null ? null : optionalId(value.provider_id); + const observedAt = value.observed_at; + if ( + !['anthropic', 'openai', 'other'].includes(provider as string) || + (value.provider_id !== null && !parsedProviderId) || + value.source_sub_type !== 'user_managed' || + typeof observedAt !== 'string' || + !Number.isFinite(Date.parse(observedAt)) + ) { + throw new Error('OrgX dispatch execution attribution is invalid'); + } + const providerId = parsedProviderId ?? null; + if ( + (providerId === null && provider !== 'other') || + (providerId !== null && providerKind(providerId) !== provider) + ) { + throw new Error('OrgX execution attribution provider ID is inconsistent'); + } + return { + provider: provider as GatewayExecutionAttribution['provider'], + providerId, + sourceSubType: 'user_managed', + observedAt, + }; +} + +function readActivationScope(value: unknown): WorkScope { + if (!isRecord(value)) { + throw new Error('OrgX session activation scope is invalid'); + } + const workspaceId = declaredId(value, 'workspace_id'); + if (!workspaceId) { + throw new Error('OrgX session activation scope is missing workspace'); + } + const scope: WorkScope = { + workspaceId, + initiativeId: declaredId(value, 'initiative_id'), + workstreamId: declaredId(value, 'workstream_id'), + taskId: declaredId(value, 'task_id'), + }; + if (scope.workstreamId && !scope.initiativeId) { + throw new Error('OrgX session activation hierarchy is invalid'); + } + if (scope.taskId && (!scope.initiativeId || !scope.workstreamId)) { + throw new Error('OrgX session activation hierarchy is invalid'); + } + return scope; +} + +function reconcileActivationScope( + dispatchScope: WorkScope, + activationScope: WorkScope +): WorkScope { + for (const key of [ + 'workspaceId', + 'initiativeId', + 'workstreamId', + 'taskId', + ] as const) { + if ( + dispatchScope[key] !== undefined && + dispatchScope[key] !== activationScope[key] + ) { + throw new Error(`OrgX ${key} mismatch with session activation`); + } + } + return activationScope; +} + +function isValidActivationCompaction(value: unknown): boolean { + if (!isRecord(value)) return false; + if ( + typeof value.compacted !== 'boolean' || + typeof value.summary_truncated !== 'boolean' || + !hasNonNegativeCounts(value.omitted_counts, [ + 'authoritative_decisions', + 'open_risks', + 'acceptance_criteria', + 'artifact_refs', + 'evidence_refs', + 'active_constraints', + 'pending_expectations', + 'applied_learnings', + 'recent_receipt_refs', + ]) || + !isRecord(value.source_capsule) + ) { + return false; + } + const capsule = value.source_capsule; + return ( + optionalId(capsule.id) !== undefined && + typeof capsule.content_digest === 'string' && + /^sha256:[a-f0-9]{64}$/.test(capsule.content_digest) && + typeof capsule.generated_at === 'string' && + Number.isFinite(Date.parse(capsule.generated_at)) && + capsule.projection_consistency === 'best_effort_multi_read' && + hasNonNegativeCounts(capsule.omitted_counts, [ + 'authoritative_decisions', + 'applied_learnings', + 'pending_expectations', + 'open_risks', + 'recent_receipt_refs', + ]) && + isValidSourceCompleteness(capsule.source_completeness) + ); +} + +function hasNonNegativeCounts(value: unknown, keys: string[]): boolean { + if (!isRecord(value)) return false; + return keys.every( + (key) => Number.isSafeInteger(value[key]) && (value[key] as number) >= 0 + ); +} + +function isValidSourceCompleteness(value: unknown): boolean { + if (!isRecord(value)) return false; + return [ + 'authoritative_decisions', + 'current_intent', + 'recent_receipt_refs', + ].every((key) => { + const source = value[key]; + return ( + isRecord(source) && + ['complete', 'scan_limited', 'candidates_withheld'].includes( + source.status as string + ) && + Number.isSafeInteger(source.candidates_unvalidated) && + (source.candidates_unvalidated as number) >= 0 && + (source.candidates_withheld === undefined || + (Number.isSafeInteger(source.candidates_withheld) && + (source.candidates_withheld as number) >= 0)) + ); + }); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } -function renderPrompt(body: { - title: string; - description?: string; - skill_ids?: string[]; -}): string { - const parts = [body.title]; - if (body.description) parts.push('\n\n', body.description); - if (body.skill_ids?.length) { +function renderPrompt(task: DispatchableTask): string { + const parts = [task.title]; + if (task.description) parts.push('\n\n', task.description); + if (task.skill_ids?.length) { parts.push('\n\nSkills to honor:\n'); - for (const id of body.skill_ids) parts.push(` - ${id}\n`); + for (const id of task.skill_ids) parts.push(` - ${id}\n`); } return parts.join(''); } + +function providerKind(value: string): 'anthropic' | 'openai' | 'other' { + const normalized = value.toLowerCase(); + if (normalized.includes('anthropic')) return 'anthropic'; + if (normalized.includes('openai')) return 'openai'; + return 'other'; +} + +function readProviderLease(value: unknown): ProviderLease | undefined { + const providerId = optionalId(value); + if (!providerId) return undefined; + return { + provider: providerKind(providerId), + providerId, + observedAt: new Date().toISOString(), + }; +} + +function messageError(error: { name: string; data: unknown }): string { + const data = + error.data && typeof error.data === 'object' + ? (error.data as Record) + : {}; + return safeErrorMessage( + typeof data.message === 'string' ? data.message : error.name + ); +} + +function optionalId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const id = value.trim(); + if (!id) return undefined; + if (Buffer.byteLength(id, 'utf8') > 512) { + throw new Error('OrgX scope ID is too long'); + } + return id; +} + +function declaredId( + record: object, + key: string +): string | undefined { + if (!Object.prototype.hasOwnProperty.call(record, key)) return undefined; + const id = optionalId((record as Record)[key]); + if (!id) throw new Error(`OrgX ${key} is invalid`); + return id; +} + +function normalizedId(value: unknown, label: string): string { + const id = optionalId(value); + if (!id) throw new Error(`OpenCode returned no ${label}`); + return id; +} + +function safeErrorMessage(error: unknown): string { + const text = error instanceof Error ? error.message : String(error); + return text + .slice(0, 500) + .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]') + .replace(/\boxk_[A-Za-z0-9_-]+\b/g, 'oxk_[redacted]') + .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, 'sk-[redacted]') + .replace(/\b(api[_-]?key|token|secret)=([^\s&,;]+)/gi, '$1=[redacted]'); +} + +function defaultStatePath(): string { + if (platform() === 'win32') { + const base = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'); + return join(base, 'opencode', 'state.json'); + } + return join(homedir(), '.opencode', 'state.json'); +} diff --git a/src/activationAcceptance.test.ts b/src/activationAcceptance.test.ts new file mode 100644 index 0000000..fd6f4af --- /dev/null +++ b/src/activationAcceptance.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + ActivationAcceptanceBroker, + createActivationObservingWebSocketFactory, +} from './activationAcceptance'; + +const EXPECTATION = { + runId: 'run-1', + contextSha256: `sha256:${'a'.repeat(64)}` as const, + activationSha256: `sha256:${'c'.repeat(64)}` as const, + nativeSessionId: 'session-1', +}; + +function acceptedFrame(overrides: Record = {}) { + return { + kind: 'task.activation.accepted', + schema_version: 'orgx-gateway-session-context-activation-accepted/v1', + source_client: 'opencode', + run_id: 'run-1', + context_sha256: EXPECTATION.contextSha256, + activation_sha256: EXPECTATION.activationSha256, + native_session_id: 'session-1', + accepted_at: '2026-08-26T17:00:00.000Z', + ...overrides, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('ActivationAcceptanceBroker', () => { + it('resolves only the exact durable acceptance tuple', async () => { + const broker = new ActivationAcceptanceBroker(100); + const accepted = broker.waitForAcceptance(EXPECTATION); + + expect( + broker.observe( + JSON.stringify(acceptedFrame()) + ) + ).toBe(true); + await expect(accepted).resolves.toBeUndefined(); + }); + + it('rejects an explicit Gateway rejection', async () => { + const broker = new ActivationAcceptanceBroker(100); + const accepted = broker.waitForAcceptance(EXPECTATION); + + broker.observe({ + kind: 'task.activation.rejected', + run_id: 'run-1', + reason: 'server-only detail', + }); + + await expect(accepted).rejects.toThrow( + 'Gateway rejected OrgX context activation' + ); + }); + + it('rejects a mismatched digest or native session instead of ignoring it', async () => { + const broker = new ActivationAcceptanceBroker(100); + const accepted = broker.waitForAcceptance(EXPECTATION); + + broker.observe(acceptedFrame({ + context_sha256: `sha256:${'b'.repeat(64)}`, + native_session_id: 'wrong-session', + })); + + await expect(accepted).rejects.toThrow( + 'Gateway context activation acceptance mismatch' + ); + }); + + it('rejects a matching tuple carried by the wrong acceptance contract', async () => { + const broker = new ActivationAcceptanceBroker(100); + const accepted = broker.waitForAcceptance(EXPECTATION); + + broker.observe( + acceptedFrame({ + schema_version: 'orgx-gateway-session-context-activation-accepted/v0', + }) + ); + + await expect(accepted).rejects.toThrow( + 'Gateway context activation acceptance mismatch' + ); + }); + + it('rejects an acceptance with the wrong full-activation digest', async () => { + const broker = new ActivationAcceptanceBroker(100); + const accepted = broker.waitForAcceptance(EXPECTATION); + + broker.observe( + acceptedFrame({ activation_sha256: `sha256:${'d'.repeat(64)}` }) + ); + + await expect(accepted).rejects.toThrow( + 'Gateway context activation acceptance mismatch' + ); + }); + + it('rejects when the exact acceptance never arrives', async () => { + vi.useFakeTimers(); + const broker = new ActivationAcceptanceBroker(25); + const assertion = expect( + broker.waitForAcceptance(EXPECTATION) + ).rejects.toThrow('Timed out waiting for Gateway context activation acceptance'); + + await vi.advanceTimersByTimeAsync(25); + await assertion; + }); + + it('preserves pending waits across a reconnectable close', async () => { + const listeners = new Map) => void>(); + const rawSocket = { + addEventListener: vi.fn( + (type: string, listener: (event: Record) => void) => { + listeners.set(type, listener); + } + ), + close: vi.fn(), + send: vi.fn(), + }; + const broker = new ActivationAcceptanceBroker(100); + const factory = createActivationObservingWebSocketFactory( + broker, + vi.fn(() => rawSocket) + ); + const socket = factory('wss://example.test', []); + socket.addEventListener('close', vi.fn()); + const accepted = broker.waitForAcceptance(EXPECTATION); + + listeners.get('close')?.({ code: 1006, reason: 'network lost' }); + broker.observe(acceptedFrame()); + + await expect(accepted).resolves.toBeUndefined(); + }); + + it('rejects pending waits on a terminal socket close', async () => { + const listeners = new Map) => void>(); + const rawSocket = { + addEventListener: vi.fn( + (type: string, listener: (event: Record) => void) => { + listeners.set(type, listener); + } + ), + close: vi.fn(), + send: vi.fn(), + }; + const broker = new ActivationAcceptanceBroker(100); + const factory = createActivationObservingWebSocketFactory( + broker, + vi.fn(() => rawSocket) + ); + const socket = factory('wss://example.test', []); + socket.addEventListener('close', vi.fn()); + const accepted = broker.waitForAcceptance(EXPECTATION); + + listeners.get('close')?.({ code: 4401, reason: 'unauthorized' }); + + await expect(accepted).rejects.toThrow( + 'Gateway connection closed before context activation acceptance' + ); + }); +}); diff --git a/src/activationAcceptance.ts b/src/activationAcceptance.ts new file mode 100644 index 0000000..8798aec --- /dev/null +++ b/src/activationAcceptance.ts @@ -0,0 +1,206 @@ +import type { + PeerClientConfig, + WebSocketEvent, + WebSocketLike, +} from '@useorgx/orgx-gateway-sdk'; + +const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 5_000; +const MAX_ACCEPTANCE_TIMEOUT_MS = 30_000; +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; +const TERMINAL_CLOSE_CODES = new Set([1000, 4000, 4001, 4003, 4401, 4403]); + +export type ActivationAcceptanceExpectation = { + runId: string; + contextSha256: `sha256:${string}`; + activationSha256: `sha256:${string}`; + nativeSessionId: string; +}; + +type PendingAcceptance = { + expectation: ActivationAcceptanceExpectation; + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +/** + * Correlates the Gateway's durable activation acceptance with the dispatch + * that emitted the corresponding task.started acknowledgement. + */ +export class ActivationAcceptanceBroker { + private readonly pending = new Map(); + private readonly timeoutMs: number; + + constructor(timeoutMs = DEFAULT_ACCEPTANCE_TIMEOUT_MS) { + if (!Number.isFinite(timeoutMs) || timeoutMs < 1) { + throw new Error('Gateway activation acceptance timeout is invalid'); + } + this.timeoutMs = Math.min(Math.round(timeoutMs), MAX_ACCEPTANCE_TIMEOUT_MS); + } + + waitForAcceptance( + expectation: ActivationAcceptanceExpectation + ): Promise { + validateExpectation(expectation); + if (this.pending.has(expectation.runId)) { + return Promise.reject( + new Error('Duplicate Gateway activation acceptance wait') + ); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(expectation.runId); + reject( + new Error('Timed out waiting for Gateway context activation acceptance') + ); + }, this.timeoutMs); + timer.unref?.(); + this.pending.set(expectation.runId, { + expectation, + resolve, + reject, + timer, + }); + }); + } + + observe(rawMessage: unknown): boolean { + const message = parseMessage(rawMessage); + if (!message) return false; + const kind = message.kind; + if ( + kind !== 'task.activation.accepted' && + kind !== 'task.activation.rejected' + ) { + return false; + } + const runId = typeof message.run_id === 'string' ? message.run_id : ''; + const pending = this.pending.get(runId); + if (!pending) return true; + + if (kind === 'task.activation.rejected') { + this.reject(runId, 'Gateway rejected OrgX context activation'); + return true; + } + + if ( + message.schema_version !== + 'orgx-gateway-session-context-activation-accepted/v1' || + message.source_client !== 'opencode' || + typeof message.accepted_at !== 'string' || + Buffer.byteLength(message.accepted_at, 'utf8') > 64 || + !Number.isFinite(Date.parse(message.accepted_at)) || + message.context_sha256 !== pending.expectation.contextSha256 || + message.activation_sha256 !== pending.expectation.activationSha256 || + message.native_session_id !== pending.expectation.nativeSessionId + ) { + this.reject(runId, 'Gateway context activation acceptance mismatch'); + return true; + } + + clearTimeout(pending.timer); + this.pending.delete(runId); + pending.resolve(); + return true; + } + + rejectRun(runId: string, reason: string): void { + this.reject(runId, reason); + } + + rejectAll(reason = 'Gateway connection closed before context activation acceptance'): + void { + for (const runId of [...this.pending.keys()]) this.reject(runId, reason); + } + + private reject(runId: string, reason: string): void { + const pending = this.pending.get(runId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(runId); + pending.reject(new Error(reason)); + } +} + +export function createActivationObservingWebSocketFactory( + broker: ActivationAcceptanceBroker, + baseFactory: NonNullable = + defaultWebSocketFactory +): NonNullable { + return (url, protocols) => { + const socket = baseFactory(url, protocols); + return { + addEventListener(type, listener) { + socket.addEventListener(type, (event) => { + if (type === 'message') broker.observe(event.data); + if ( + type === 'close' && + TERMINAL_CLOSE_CODES.has(event.code ?? 1006) + ) { + broker.rejectAll( + 'Gateway connection closed before context activation acceptance' + ); + } + listener(event); + }); + }, + close(code, reason) { + socket.close(code, reason); + }, + send(data) { + socket.send(data); + }, + }; + }; +} + +function defaultWebSocketFactory(url: string, protocols: string[]): WebSocketLike { + const socket = new WebSocket(url, protocols); + return { + addEventListener(type, listener) { + socket.addEventListener(type, (event) => { + const value = event as unknown as WebSocketEvent; + listener({ + ...(typeof value.code === 'number' ? { code: value.code } : {}), + ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), + ...('data' in value ? { data: value.data } : {}), + }); + }); + }, + close(code, reason) { + socket.close(code, reason); + }, + send(data) { + socket.send(data); + }, + }; +} + +function validateExpectation(expectation: ActivationAcceptanceExpectation): void { + if ( + !boundedId(expectation.runId) || + !boundedId(expectation.nativeSessionId) || + !SHA256_PATTERN.test(expectation.contextSha256) || + !SHA256_PATTERN.test(expectation.activationSha256) + ) { + throw new Error('Gateway activation acceptance expectation is invalid'); + } +} + +function boundedId(value: string): boolean { + return Boolean(value.trim()) && Buffer.byteLength(value, 'utf8') <= 512; +} + +function parseMessage(value: unknown): Record | null { + try { + const parsed = + typeof value === 'string' ? (JSON.parse(value) as unknown) : value; + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} diff --git a/src/cli.ts b/src/cli.ts index 7b6e807..42c4d32 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,8 @@ async function main() { const apiKey = captureGatewayCredential(process.env); const workspaceId = process.env.ORGX_WORKSPACE_ID; const baseUrl = process.env.ORGX_BASE_URL ?? 'https://useorgx.com'; + const openCodeServerUrl = + process.env.OPENCODE_SERVER_URL ?? process.env.ORGX_OPENCODE_SERVER_URL; if (!apiKey || !workspaceId) { // eslint-disable-next-line no-console @@ -22,7 +24,13 @@ async function main() { process.exit(2); } - const peer = await startPeer({ apiKey, workspaceId, baseUrl }); + const peer = await startPeer({ + apiKey, + workspaceId, + baseUrl, + openCodeServerUrl, + openCodeDirectory: process.cwd(), + }); const shutdown = async () => { await peer.stop(); diff --git a/src/contextPackHydration.test.ts b/src/contextPackHydration.test.ts index 6e8a0ea..7a32c7a 100644 --- a/src/contextPackHydration.test.ts +++ b/src/contextPackHydration.test.ts @@ -1,25 +1,996 @@ -import { describe, it, expect } from 'vitest'; +import { EventEmitter } from 'node:events'; import { - resolveContextPackConfig, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, isAbsolute, join } from 'node:path'; +import { Writable } from 'node:stream'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + CONTEXT_PACK_FILENAME, + MAX_CONTEXT_PACK_RESPONSE_BYTES, + MAX_WIZARD_OUTPUT_BYTES, + PENDING_CONTEXT_FILENAME, + activateProvidedSessionWorkContext, buildContextPackRequest, + clearSessionWorkContext, + hydrateContextPack as hydrateContextPackImpl, + resolveContextPackConfig, + resolvePrivateContextStateDirectory, + resolveSafeBaseUrl, + sessionWorkContextSha256, + type SpawnLike, } from './contextPackHydration.js'; +type HydrationInput = NonNullable< + Parameters[0] +>; + +const testStateRoots = new Set(); + +afterEach(() => { + for (const path of testStateRoots) { + rmSync(path, { recursive: true, force: true }); + } + testStateRoots.clear(); +}); + +function testStateRoot(projectDir: string): string { + const root = join(tmpdir(), `${basename(projectDir)}-private-orgx-state`); + testStateRoots.add(root); + return root; +} + +function stateDirectory(projectDir: string, sessionId = 'session-1'): string { + return resolvePrivateContextStateDirectory({ + projectDir, + sessionId, + stateRoot: testStateRoot(projectDir), + })!; +} + +function hydrateContextPack(input: HydrationInput) { + return hydrateContextPackImpl({ + ...input, + sessionId: input.sessionId ?? 'session-1', + stateRoot: + input.stateRoot ?? + (input.projectDir && isAbsolute(input.projectDir) + ? testStateRoot(input.projectDir) + : undefined), + }); +} + +const sessionWorkContext = { + schema_version: 'orgx-session-work-context/v1', + provenance: 'producer_asserted', + intent: { + summary: 'Continue the accepted OpenCode implementation slice.', + acceptance_criteria: ['Focused checks pass'], + constraints: ['Do not invent authority'], + }, + authority: { + mode: 'explicit', + status: 'granted', + scope: { actions: ['edit'], resources: [], systems: ['opencode'] }, + constraints: [], + }, + cost: { availability: 'not_observed' }, + artifact_refs: [], + evidence_refs: [], +}; + +function response(data: unknown, status = 200): Response { + return new Response(JSON.stringify({ ok: true, data }), { status }); +} + +function wizardProcess( + calls: Array>, + exitCode = 0, + stdoutValue?: string | ((args: string[]) => string) +): SpawnLike { + return (command, args, options) => { + const child = new EventEmitter() as EventEmitter & { + stdin: Writable; + stdout: EventEmitter; + kill: () => void; + }; + const chunks: Buffer[] = []; + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(Buffer.from(chunk)); + callback(); + }, + }); + child.stdout = new EventEmitter(); + child.stdin.once('finish', () => { + calls.push({ + command, + args, + options, + input: Buffer.concat(chunks).toString('utf8'), + }); + queueMicrotask(() => { + if (exitCode === 0) { + const configured = + typeof stdoutValue === 'function' + ? stdoutValue(args) + : stdoutValue; + const cwd = args[args.indexOf('--cwd') + 1]; + const sourceClient = args[args.indexOf('--source-client') + 1]; + const sessionId = args[args.indexOf('--session-id') + 1]; + const contextSha256 = args[args.indexOf('--context-sha256') + 1]; + const clearing = args.includes('clear'); + child.stdout.emit( + 'data', + configured ?? + JSON.stringify( + clearing + ? { + ackVersion: 'orgx-session-work-context-ack/v1', + ready: false, + state: 'missing', + cleared: true, + cwd, + sourceClient, + sessionId, + } + : { + ackVersion: 'orgx-session-work-context-ack/v1', + activationVersion: + 'orgx-session-work-context-activation/v2', + ready: true, + state: 'ready', + cwd, + sourceClient, + sessionId, + contextSha256, + } + ) + ); + } + child.emit('close', exitCode); + }); + }); + child.kill = vi.fn(); + return child; + }; +} + describe('opencode context-pack hydration', () => { - it('requires both an api key and an initiative', () => { + it('resolves task, workstream, initiative, then workspace anchor priority', () => { + const config = resolveContextPackConfig({ + ORGX_API_KEY: 'oxk_test', + ORGX_BASE_URL: 'https://useorgx.com/', + ORGX_TASK_ID: 'task-1', + ORGX_WORKSTREAM_ID: 'workstream-2', + ORGX_INITIATIVE_ID: 'initiative-3', + ORGX_WORKSPACE_ID: 'workspace-4', + }); + + expect(config?.anchor).toEqual({ + type: 'task', + id: 'task-1', + requestField: 'task_id', + }); + expect(buildContextPackRequest(config!)).toEqual({ + url: 'https://useorgx.com/api/v1/context-pack', + init: { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer oxk_test', + }, + body: JSON.stringify({ + workspace_id: 'workspace-4', + initiative_id: 'initiative-3', + workstream_id: 'workstream-2', + task_id: 'task-1', + }), + redirect: 'error', + }, + }); + }); + + it('builds the canonical request field for every supported anchor', () => { + for (const [envKey, id, requestField] of [ + ['ORGX_TASK_ID', 'task-1', 'task_id'], + ['ORGX_WORKSTREAM_ID', 'workstream-1', 'workstream_id'], + ['ORGX_INITIATIVE_ID', 'initiative-1', 'initiative_id'], + ['ORGX_WORKSPACE_ID', 'workspace-1', 'workspace_id'], + ]) { + const config = resolveContextPackConfig({ + ORGX_API_KEY: 'oxk_test', + [envKey]: id, + }); + expect(JSON.parse(buildContextPackRequest(config!).init.body as string)).toEqual({ + [requestField]: id, + }); + } + }); + + it('rejects base URLs that could carry credentials or redirect insecurely', () => { + expect(resolveSafeBaseUrl('https://user:pass@useorgx.com')).toBeNull(); + expect(resolveSafeBaseUrl('https://useorgx.com?token=secret')).toBeNull(); + expect(resolveSafeBaseUrl('https://useorgx.com#secret')).toBeNull(); + expect(resolveSafeBaseUrl('https://useorgx.com/redirect')).toBeNull(); + expect(resolveSafeBaseUrl('http://useorgx.com')).toBeNull(); + expect(resolveSafeBaseUrl('http://localhost:3000/')).toBe( + 'http://localhost:3000' + ); + expect(resolveSafeBaseUrl(undefined)).toBe('https://useorgx.com'); + }); + + it('requires an API key and one authoritative anchor', () => { expect(resolveContextPackConfig({})).toBeNull(); - expect(resolveContextPackConfig({ ORGX_API_KEY: 'k' })).toBeNull(); + expect(resolveContextPackConfig({ ORGX_API_KEY: 'oxk_test' })).toBeNull(); expect( - resolveContextPackConfig({ ORGX_API_KEY: 'k', ORGX_INITIATIVE_ID: 'i1' }) - ).toEqual({ apiKey: 'k', baseUrl: 'https://useorgx.com', initiativeId: 'i1' }); - }); - it('builds the endpoint request with bearer auth', () => { - const { url, init } = buildContextPackRequest({ - apiKey: 'k', - baseUrl: 'https://useorgx.com/', - initiativeId: 'i1', + resolveContextPackConfig({ + ORGX_API_KEY: 'oxk_test', + ORGX_TASK_ID: 'task-1', + ORGX_BASE_URL: 'https://user:secret@example.test', + }) + ).toBeNull(); + }); + + it('requires a native session ID before fetching or writing context', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const fetchImpl = vi.fn(); + try { + await expect( + hydrateContextPackImpl({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + sessionId: ' ', + stateRoot: testStateRoot(projectDir), + fetchImpl, + }) + ).resolves.toEqual({ ok: true, skipped: 'session_id_unavailable' }); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(existsSync(testStateRoot(projectDir))).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('derives distinct SHA-256 state directories from resolved cwd and native session ID', () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + try { + const first = stateDirectory(projectDir, 'session-a'); + const second = stateDirectory(projectDir, 'session-b'); + expect(first).not.toBe(second); + expect(first).toMatch(/[a-f0-9]{64}$/); + expect(second).toMatch(/[a-f0-9]{64}$/); + expect(first).not.toContain('session-a'); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('hashes recursively key-sorted JSON while preserving array order', () => { + const digest = sessionWorkContextSha256({ + z: [{ b: 2, a: 1 }, undefined, 'last'], + a: { d: 4, c: 3 }, + omitted: undefined, + }); + expect(digest).toBe( + 'e2599a20c4da09859bff6b025e72065db6e559af9d36c68b82b010f5c471edda' + ); + expect(digest).toBe( + sessionWorkContextSha256({ + a: { c: 3, d: 4 }, + z: [{ a: 1, b: 2 }, 'last'], + }) + ); + expect(sessionWorkContextSha256({ values: ['a', 'b'] })).not.toBe( + sessionWorkContextSha256({ values: ['b', 'a'] }) + ); + }); + + it('retains the pack in private session state and forwards exact context to Wizard', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const requests: Array<{ url: unknown; init: RequestInit | undefined }> = []; + const wizardCalls: Array> = []; + const now = new Date('2026-08-24T20:00:00.000Z'); + try { + const result = await hydrateContextPack({ + env: { + PATH: process.env.PATH, + ORGX_API_KEY: 'oxk_test', + ORGX_GATEWAY_KEY: 'oxk_alias', + ORGX_TASK_ID: 'task-1', + }, + projectDir, + fetchImpl: vi.fn(async (url, init) => { + requests.push({ url, init }); + return response({ frame: { anchor: 'task-1' }, sessionWorkContext }); + }), + spawnImpl: wizardProcess(wizardCalls), + now, + }); + + expect(result).toMatchObject({ + ok: true, + contextPackPath: join(stateDirectory(projectDir), CONTEXT_PACK_FILENAME), + sessionContext: { activated: true, reason: 'wizard_activated' }, + }); + expect(result.additionalContext).toContain('Continue the accepted OpenCode'); + expect(requests).toHaveLength(1); + expect(JSON.parse(requests[0].init?.body as string)).toEqual({ + task_id: 'task-1', + }); + + const packPath = join(stateDirectory(projectDir), CONTEXT_PACK_FILENAME); + expect(JSON.parse(readFileSync(packPath, 'utf8'))).toEqual({ + fetchedAt: now.toISOString(), + data: { frame: { anchor: 'task-1' }, sessionWorkContext }, + }); + expect(statSync(packPath).mode & 0o777).toBe(0o600); + expect(existsSync(join(projectDir, '.opencode'))).toBe(false); + + expect(wizardCalls).toHaveLength(1); + expect(wizardCalls[0].command).toBe('orgx-wizard'); + expect(wizardCalls[0].args).toEqual([ + 'sessions', + 'context', + 'set', + '--file', + '-', + '--cwd', + projectDir, + '--source-client', + 'opencode', + '--session-id', + 'session-1', + '--context-sha256', + sessionWorkContextSha256(sessionWorkContext), + '--json', + ]); + expect(JSON.parse(wizardCalls[0].input as string)).toEqual( + sessionWorkContext + ); + const wizardEnv = ( + wizardCalls[0].options as { env: Record } + ).env; + expect(wizardEnv).not.toHaveProperty('ORGX_API_KEY'); + expect(wizardEnv).not.toHaveProperty('ORGX_GATEWAY_KEY'); + expect( + existsSync(join(stateDirectory(projectDir), PENDING_CONTEXT_FILENAME)) + ).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('activates a Gateway-provided context without a network fetch', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const wizardCalls: Array> = []; + const stateRoot = testStateRoot(projectDir); + const now = new Date('2026-08-26T15:00:00.000Z'); + const activationEnvelope = { + schema_version: 'orgx-session-activation/v1', + scope: { workspace_id: 'workspace-1', task_id: 'task-1' }, + work_context: sessionWorkContext, + compaction: { compacted: false }, + }; + try { + const result = await activateProvidedSessionWorkContext({ + activationEnvelope, + context: sessionWorkContext, + env: { PATH: process.env.PATH }, + projectDir, + sessionId: 'gateway-session-1', + stateRoot, + spawnImpl: wizardProcess(wizardCalls), + now, + }); + + expect(result).toMatchObject({ + ok: true, + sessionContext: { activated: true, reason: 'wizard_activated' }, + }); + expect(wizardCalls).toHaveLength(1); + expect(wizardCalls[0].args).toContain( + sessionWorkContextSha256(sessionWorkContext) + ); + const packPath = join( + resolvePrivateContextStateDirectory({ + projectDir, + sessionId: 'gateway-session-1', + stateRoot, + })!, + CONTEXT_PACK_FILENAME + ); + expect(JSON.parse(readFileSync(packPath, 'utf8'))).toEqual({ + receivedAt: now.toISOString(), + source: 'orgx_gateway_dispatch', + data: { sessionActivation: activationEnvelope, sessionWorkContext }, + }); + expect(statSync(packPath).mode & 0o777).toBe(0o600); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('keeps same-project sessions in distinct owner-local Wizard state', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-project-')); + const wizardHome = mkdtempSync(join(tmpdir(), 'orgx-wizard-home-')); + try { + const hydrateSession = (sessionId: string) => + hydrateContextPackImpl({ + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_TASK_ID: 'task-1', + ORGX_WIZARD_CONFIG_HOME: wizardHome, + }, + projectDir, + sessionId, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess([]), + }); + const first = await hydrateSession('session-a'); + const second = await hydrateSession('session-b'); + + expect(first.contextPackPath).toBe( + join( + resolvePrivateContextStateDirectory({ + env: { ORGX_WIZARD_CONFIG_HOME: wizardHome }, + projectDir, + sessionId: 'session-a', + })!, + CONTEXT_PACK_FILENAME + ) + ); + expect(second.contextPackPath).not.toBe(first.contextPackPath); + expect(first.contextPackPath?.startsWith(wizardHome)).toBe(true); + expect(second.contextPackPath?.startsWith(wizardHome)).toBe(true); + expect(existsSync(join(projectDir, '.opencode'))).toBe(false); + expect(statSync(first.contextPackPath!).mode & 0o777).toBe(0o600); + expect( + statSync(join(first.contextPackPath!, '..')).mode & 0o777 + ).toBe(0o700); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(wizardHome, { recursive: true, force: true }); + } + }); + + it('refuses an explicitly configured state root inside the repository and clears the session lease', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-project-')); + const repoStateRoot = join(projectDir, '.private-runtime'); + const wizardCalls: Array> = []; + try { + const result = await hydrateContextPackImpl({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + sessionId: 'session-1', + stateRoot: repoStateRoot, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess(wizardCalls), + }); + + expect(result).toMatchObject({ + ok: false, + skipped: 'context_pack_hydration_failed', + sessionContext: { + activated: false, + reason: 'context_refresh_failed', + priorActivationCleared: true, + }, + }); + expect(existsSync(repoStateRoot)).toBe(false); + expect(wizardCalls).toHaveLength(1); + expect((wizardCalls[0].args as string[]).slice(0, 3)).toEqual([ + 'sessions', + 'context', + 'clear', + ]); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('persists exact pending activation when Wizard is unavailable', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + try { + const result = await hydrateContextPack({ + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_INITIATIVE_ID: 'initiative-1', + }, + projectDir, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: () => { + throw new Error('orgx-wizard unavailable'); + }, + }); + + const pendingPath = join(stateDirectory(projectDir), PENDING_CONTEXT_FILENAME); + expect(result.sessionContext).toEqual({ + activated: false, + reason: 'wizard_unavailable', + priorActivationCleared: false, + clearReason: 'wizard_unavailable', + pendingPath, + }); + expect(JSON.parse(readFileSync(pendingPath, 'utf8'))).toEqual( + sessionWorkContext + ); + expect(statSync(pendingPath).mode & 0o777).toBe(0o600); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does not rewrite app not-observed cost when Wizard rejects the contract', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const wizardCalls: Array> = []; + try { + const result = await hydrateContextPack({ + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_TASK_ID: 'task-cost-contract', + }, + projectDir, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess(wizardCalls, 1), + }); + + expect(result.sessionContext?.reason).toBe('wizard_rejected'); + expect(JSON.parse(wizardCalls[0].input as string).cost).toEqual({ + availability: 'not_observed', + }); + const pendingPath = join(stateDirectory(projectDir), PENDING_CONTEXT_FILENAME); + expect(JSON.parse(readFileSync(pendingPath, 'utf8')).cost).toEqual({ + availability: 'not_observed', + }); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('retains a workspace pack and clears stale exact-cwd activation', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const wizardCalls: Array> = []; + try { + const result = await hydrateContextPack({ + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-1', + }, + projectDir, + fetchImpl: vi.fn(async () => + response({ contextCapsule: { workspaceId: 'workspace-1' } }) + ), + spawnImpl: wizardProcess(wizardCalls), + }); + + expect(result.sessionContext).toEqual({ + activated: false, + reason: 'not_returned', + priorActivationCleared: true, + clearReason: 'wizard_cleared', + }); + expect(wizardCalls[0].args).toEqual([ + 'sessions', + 'context', + 'clear', + '--cwd', + projectDir, + '--source-client', + 'opencode', + '--session-id', + 'session-1', + '--json', + ]); + expect( + existsSync(join(stateDirectory(projectDir), CONTEXT_PACK_FILENAME)) + ).toBe(true); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('fails open without fetching against an installed plugin cwd', async () => { + const fetchImpl = vi.fn(); + await expect( + hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir: 'relative/plugin-cache', + fetchImpl, + }) + ).resolves.toEqual({ + ok: true, + skipped: 'project_directory_unavailable', }); - expect(url).toBe('https://useorgx.com/api/client/context-pack'); - expect((init.headers as Record).authorization).toBe('Bearer k'); - expect(JSON.parse(init.body as string)).toEqual({ initiative_id: 'i1' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('fails open on offline and oversized context-pack responses', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + try { + await expect( + hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => { + throw new TypeError('offline'); + }), + }) + ).resolves.toMatchObject({ + ok: true, + skipped: 'context_pack_request_failed', + reason: 'network_error', + sessionContext: { + activated: false, + reason: 'context_refresh_failed', + priorActivationCleared: false, + clearReason: 'wizard_unavailable', + }, + }); + + await expect( + hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => + response({ padding: 'x'.repeat(MAX_CONTEXT_PACK_RESPONSE_BYTES) }) + ), + }) + ).resolves.toMatchObject({ + ok: true, + skipped: 'context_pack_response_too_large', + sessionContext: { + activated: false, + reason: 'context_refresh_failed', + }, + }); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('rejects a 200 response whose API envelope says ok false and clears stale state', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const privateDir = stateDirectory(projectDir); + const packPath = join(privateDir, CONTEXT_PACK_FILENAME); + const pendingPath = join(privateDir, PENDING_CONTEXT_FILENAME); + const wizardCalls: Array> = []; + mkdirSync(privateDir, { recursive: true }); + writeFileSync(packPath, 'stale-pack'); + writeFileSync(pendingPath, 'stale-pending'); + try { + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => + new Response( + JSON.stringify({ + ok: false, + error: 'denied', + data: { sessionWorkContext }, + }), + { status: 200 } + ) + ), + spawnImpl: wizardProcess(wizardCalls), + }); + + expect(result).toMatchObject({ + ok: true, + skipped: 'context_pack_response_invalid', + sessionContext: { + activated: false, + reason: 'context_refresh_failed', + priorActivationCleared: true, + }, + }); + expect(existsSync(packPath)).toBe(false); + expect(existsSync(pendingPath)).toBe(false); + expect(wizardCalls).toHaveLength(1); + expect(wizardCalls[0].args).toEqual([ + 'sessions', + 'context', + 'clear', + '--cwd', + projectDir, + '--source-client', + 'opencode', + '--session-id', + 'session-1', + '--json', + ]); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does not spawn or persist a context above the Wizard ceiling', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const wizardCalls: Array> = []; + try { + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => + response({ + sessionWorkContext: { + ...sessionWorkContext, + padding: 'x'.repeat(5 * 1024), + }, + }) + ), + spawnImpl: wizardProcess(wizardCalls), + }); + + expect(result.sessionContext).toEqual({ + activated: false, + reason: 'context_invalid', + priorActivationCleared: true, + clearReason: 'wizard_cleared', + }); + expect((wizardCalls[0].args as string[])[2]).toBe('clear'); + expect( + existsSync(join(stateDirectory(projectDir), PENDING_CONTEXT_FILENAME)) + ).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does not accept exit zero without a valid exact-cwd Wizard acknowledgement', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + try { + for (const output of [ + '{"ready":true}', + (args: string[]) => + JSON.stringify({ + ackVersion: 'orgx-session-work-context-ack/v1', + activationVersion: 'orgx-session-work-context-activation/v2', + ready: true, + state: 'ready', + cwd: '/wrong/project', + sourceClient: 'opencode', + sessionId: 'session-1', + contextSha256: args[args.indexOf('--context-sha256') + 1], + }), + ]) { + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess([], 0, output), + }); + expect(result.sessionContext?.activated).toBe(false); + expect(['wizard_unverified', 'wizard_cwd_mismatch']).toContain( + result.sessionContext?.reason + ); + expect(result.sessionContext?.pendingPath).toBeTruthy(); + } + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('rejects a Wizard acknowledgement not bound to the exact session, source, digest, and contract', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const mutations = [ + { ackVersion: 'wrong' }, + { activationVersion: 'wrong' }, + { sourceClient: 'cursor' }, + { sessionId: 'another-session' }, + { contextSha256: '0'.repeat(64) }, + ]; + try { + for (const mutation of mutations) { + const wizardCalls: Array> = []; + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess(wizardCalls, 0, (args) => + JSON.stringify( + args.includes('clear') + ? { + ackVersion: 'orgx-session-work-context-ack/v1', + ready: false, + state: 'missing', + cleared: true, + cwd: projectDir, + sourceClient: 'opencode', + sessionId: 'session-1', + } + : { + ackVersion: 'orgx-session-work-context-ack/v1', + activationVersion: + 'orgx-session-work-context-activation/v2', + ready: true, + state: 'ready', + cwd: projectDir, + sourceClient: 'opencode', + sessionId: 'session-1', + contextSha256: args[args.indexOf('--context-sha256') + 1], + ...mutation, + } + ) + ), + }); + expect(result.sessionContext).toMatchObject({ + activated: false, + reason: 'wizard_unverified', + priorActivationCleared: true, + clearReason: 'wizard_cleared', + }); + expect(result.sessionContext?.pendingPath).toBeTruthy(); + expect( + wizardCalls.map((call) => (call.args as string[])[2]) + ).toEqual(['set', 'clear']); + } + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('bounds Wizard output and keeps the exact context pending', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + try { + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess([], 0, 'x'.repeat(MAX_WIZARD_OUTPUT_BYTES + 1)), + }); + expect(result.sessionContext?.reason).toBe('wizard_output_too_large'); + expect(result.sessionContext?.pendingPath).toBeTruthy(); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('clears prior cwd authority when the authoritative request is offline', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const pendingPath = join(stateDirectory(projectDir), PENDING_CONTEXT_FILENAME); + mkdirSync(stateDirectory(projectDir), { recursive: true }); + writeFileSync(pendingPath, 'prior-context'); + const wizardCalls: Array> = []; + const spawnImpl = wizardProcess(wizardCalls); + try { + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => { + throw new TypeError('offline'); + }), + spawnImpl, + }); + expect(result.reason).toBe('network_error'); + expect(result.sessionContext).toEqual({ + activated: false, + reason: 'context_refresh_failed', + priorActivationCleared: true, + clearReason: 'wizard_cleared', + }); + expect(existsSync(pendingPath)).toBe(false); + expect(wizardCalls[0].args).toEqual([ + 'sessions', + 'context', + 'clear', + '--cwd', + projectDir, + '--source-client', + 'opencode', + '--session-id', + 'session-1', + '--json', + ]); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('rejects a clear acknowledgement for a different native session', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + try { + const result = await clearSessionWorkContext({ + projectDir, + sessionId: 'session-1', + spawnImpl: wizardProcess([], 0, (args) => + JSON.stringify({ + ackVersion: 'orgx-session-work-context-ack/v1', + ready: false, + state: 'missing', + cleared: true, + cwd: args[args.indexOf('--cwd') + 1], + sourceClient: 'opencode', + sessionId: 'another-session', + }) + ), + }); + + expect(result).toEqual({ cleared: false, reason: 'wizard_unverified' }); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('replaces a destination symlink without overwriting its target', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const opencodeDir = stateDirectory(projectDir); + const victimPath = join(projectDir, 'victim.json'); + const packPath = join(opencodeDir, CONTEXT_PACK_FILENAME); + mkdirSync(opencodeDir, { recursive: true }); + writeFileSync(victimPath, 'do-not-overwrite'); + symlinkSync(victimPath, packPath); + try { + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess([]), + }); + expect(result.ok).toBe(true); + expect(readFileSync(victimPath, 'utf8')).toBe('do-not-overwrite'); + expect(JSON.parse(readFileSync(packPath, 'utf8')).data).toBeTruthy(); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('rejects a symlinked private state root without writing through it', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const outsideDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-outside-')); + symlinkSync(outsideDir, testStateRoot(projectDir)); + try { + const result = await hydrateContextPack({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess([]), + }); + expect(result).toMatchObject({ + ok: false, + skipped: 'context_pack_hydration_failed', + }); + expect(existsSync(join(outsideDir, CONTEXT_PACK_FILENAME))).toBe(false); + expect(existsSync(join(outsideDir, PENDING_CONTEXT_FILENAME))).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('rejects a state path whose existing symlink ancestor resolves into the project', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-context-pack-')); + const outsideDir = mkdtempSync(join(tmpdir(), 'orgx-opencode-outside-')); + const linkedProject = join(outsideDir, 'linked-project'); + const stateRoot = join(linkedProject, 'runtime-state'); + symlinkSync(projectDir, linkedProject); + try { + const result = await hydrateContextPackImpl({ + env: { ORGX_API_KEY: 'oxk_test', ORGX_TASK_ID: 'task-1' }, + projectDir, + sessionId: 'session-1', + stateRoot, + fetchImpl: vi.fn(async () => response({ sessionWorkContext })), + spawnImpl: wizardProcess([]), + }); + + expect(result).toMatchObject({ + ok: false, + skipped: 'context_pack_hydration_failed', + }); + expect(existsSync(join(projectDir, 'runtime-state'))).toBe(false); + } finally { + rmSync(outsideDir, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + } }); }); diff --git a/src/contextPackHydration.ts b/src/contextPackHydration.ts index 88909ea..6af49e9 100644 --- a/src/contextPackHydration.ts +++ b/src/contextPackHydration.ts @@ -1,29 +1,182 @@ /** - * Context-pack hydration (M adapter — OpenCode). + * Bounded, fail-open organizational-context hydration for OpenCode. * - * On session connect, fetches the compiled AgentContextPack for the active - * initiative (POST /api/client/context-pack) and writes .opencode/ - * orgx-context-pack.json (0600) so the agent starts briefed. Best-effort — never - * throws. The MCP backbone also returns the pack on first orgx_inspect call, so - * hydration is guaranteed either way. + * Network authority comes only from the launching environment. A successful + * response is retained as an inspectable pack, while receipt-ready session + * authority is activated only after the Wizard returns an exact-cwd JSON ack. */ -import { mkdir, writeFile, chmod } from 'node:fs/promises'; -import { join } from 'node:path'; +import { spawn as nodeSpawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { + chmod, + lstat, + mkdir, + realpath, + rename, + unlink, + writeFile, +} from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; -type Env = Record; +import { + MAX_SESSION_WORK_CONTEXT_BYTES, + activateSessionWorkContext, + canonicalJsonSha256, + canonicalSessionWorkContextJson, + clearSessionWorkContext, + type Env, + type SessionContextActivation, + type SessionContextClearance, + type SpawnLike, +} from './wizardContextBridge.js'; + +export { + MAX_SESSION_WORK_CONTEXT_BYTES, + MAX_WIZARD_OUTPUT_BYTES, + activateSessionWorkContext, + canonicalJsonSha256, + canonicalSessionWorkContextJson, + clearSessionWorkContext, + sessionWorkContextSha256, + type SessionContextActivation, + type SessionContextClearance, + type SpawnLike, +} from './wizardContextBridge.js'; + +export const CONTEXT_PACK_FILENAME = 'orgx-context-pack.json'; +export const PENDING_CONTEXT_FILENAME = + 'orgx-session-work-context.activation-pending.json'; +export const OPENCODE_CONTEXT_STATE_DIRECTORY = 'opencode-contexts'; +export const MAX_CONTEXT_PACK_RESPONSE_BYTES = 128 * 1024; +export const MAX_ADDITIONAL_CONTEXT_BYTES = 8 * 1024; + +const DEFAULT_BASE_URL = 'https://useorgx.com'; +const DEFAULT_TIMEOUT_MS = 3_000; +const MIN_TIMEOUT_MS = 250; +const MAX_TIMEOUT_MS = 10_000; + +const SCOPE_CONFIG = [ + { type: 'workspace', requestField: 'workspace_id', env: 'ORGX_WORKSPACE_ID' }, + { + type: 'initiative', + requestField: 'initiative_id', + env: 'ORGX_INITIATIVE_ID', + }, + { + type: 'workstream', + requestField: 'workstream_id', + env: 'ORGX_WORKSTREAM_ID', + }, + { type: 'task', requestField: 'task_id', env: 'ORGX_TASK_ID' }, +] as const; +const ANCHOR_PRIORITY = ['task', 'workstream', 'initiative', 'workspace'] as const; + +type AnchorType = (typeof SCOPE_CONFIG)[number]['type']; +type ScopeRequestField = (typeof SCOPE_CONFIG)[number]['requestField']; export interface ContextPackConfig { apiKey: string; baseUrl: string; - initiativeId: string; + scope: Partial>; + anchor: { type: AnchorType; id: string; requestField: ScopeRequestField }; +} + +export type ContextPackHydrationResult = { + ok: boolean; + skipped?: + | 'project_directory_unavailable' + | 'session_id_unavailable' + | 'context_pack_unconfigured' + | 'context_pack_request_failed' + | 'context_pack_response_too_large' + | 'context_pack_response_invalid' + | 'context_pack_hydration_failed'; + reason?: 'timeout' | 'network_error'; + status?: number; + contextPackPath?: string; + sessionContext?: SessionContextActivation; + additionalContext?: string; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function pickString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +function isLoopbackHost(hostname: string): boolean { + return ['localhost', '127.0.0.1', '::1', '[::1]'].includes( + hostname.toLowerCase() + ); +} + +/** Reject credentials, suffixes, non-HTTPS remote origins, and path redirects. */ +export function resolveSafeBaseUrl(value: string | undefined): string | null { + const candidate = pickString(value) ?? DEFAULT_BASE_URL; + try { + const parsed = new URL(candidate); + if ( + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + parsed.pathname !== '/' + ) { + return null; + } + if ( + parsed.protocol !== 'https:' && + !(parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname)) + ) { + return null; + } + return parsed.origin; + } catch { + return null; + } } export function resolveContextPackConfig(env: Env): ContextPackConfig | null { - const apiKey = env.ORGX_API_KEY?.trim(); - const baseUrl = env.ORGX_BASE_URL?.trim() || 'https://useorgx.com'; - const initiativeId = env.ORGX_INITIATIVE_ID?.trim(); - if (!apiKey || !initiativeId) return null; - return { apiKey, baseUrl, initiativeId }; + const apiKey = pickString(env.ORGX_API_KEY); + const baseUrl = resolveSafeBaseUrl(env.ORGX_BASE_URL); + const scope: Partial> = {}; + for (const field of SCOPE_CONFIG) { + const id = pickString(env[field.env]); + if (id) scope[field.requestField] = id; + } + const anchorType = ANCHOR_PRIORITY.find((type) => { + const field = SCOPE_CONFIG.find((candidate) => candidate.type === type); + return field ? Boolean(scope[field.requestField]) : false; + }); + const anchorField = SCOPE_CONFIG.find((field) => field.type === anchorType); + const anchorId = anchorField ? scope[anchorField.requestField] : undefined; + if (!apiKey || !baseUrl || !anchorField || !anchorId) return null; + return { + apiKey, + baseUrl, + scope, + anchor: { + type: anchorField.type, + id: anchorId, + requestField: anchorField.requestField, + }, + }; } export function buildContextPackRequest(config: ContextPackConfig): { @@ -31,39 +184,697 @@ export function buildContextPackRequest(config: ContextPackConfig): { init: RequestInit; } { return { - url: `${config.baseUrl.replace(/\/$/, '')}/api/client/context-pack`, + url: `${config.baseUrl}/api/v1/context-pack`, init: { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${config.apiKey}`, }, - body: JSON.stringify({ initiative_id: config.initiativeId }), + body: JSON.stringify(config.scope), + redirect: 'error', }, }; } -export async function hydrateContextPack(env: Env = process.env): Promise { +function boundedTimeout(value: string | undefined): number { + const configured = Number(value); + return Number.isFinite(configured) && + configured >= MIN_TIMEOUT_MS && + configured <= MAX_TIMEOUT_MS + ? Math.round(configured) + : DEFAULT_TIMEOUT_MS; +} + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +async function readBoundedResponseText( + response: Response, + maxBytes: number +): Promise { + const contentLength = Number(response.headers?.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > maxBytes) return null; + const reader = response.body?.getReader(); + if (!reader) { + const text = await response.text(); + return byteLength(text) <= maxBytes ? text : null; + } + const chunks: Buffer[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = Buffer.from(value); + total += chunk.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} + +async function writePrivateJson( + state: PrivateContextState, + filename: string, + value: unknown, + maxBytes = MAX_CONTEXT_PACK_RESPONSE_BYTES +): Promise { + const serialized = JSON.stringify(value); + if (byteLength(serialized) > maxBytes) throw new Error('bounded_json_too_large'); + const stateDir = await privateStateDirectory(state, true); + if (!stateDir) throw new Error('private_state_directory_unavailable'); + const path = join(stateDir, filename); + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + let committed = false; + try { + await writeFile(temporaryPath, serialized, { flag: 'wx', mode: 0o600 }); + await chmod(temporaryPath, 0o600); + await rename(temporaryPath, path); + committed = true; + await chmod(path, 0o600); + return path; + } catch (error) { + await unlink(temporaryPath).catch(() => undefined); + if (committed) await unlink(path).catch(() => undefined); + throw error; + } +} + +type PrivateContextState = { + env: Env; + projectDir: string; + sessionId: string; + stateRoot?: string; +}; + +function defaultContextStateRoot(env: Env): string | null { + const configuredWizardHome = pickString(env.ORGX_WIZARD_CONFIG_HOME); + if (configuredWizardHome) { + return isAbsolute(configuredWizardHome) + ? join(resolve(configuredWizardHome), OPENCODE_CONTEXT_STATE_DIRECTORY) + : null; + } + const configuredXdgHome = pickString(env.XDG_CONFIG_HOME); + const configHome = + configuredXdgHome && isAbsolute(configuredXdgHome) + ? resolve(configuredXdgHome) + : join(homedir(), '.config'); + return join(configHome, 'useorgx', 'wizard', OPENCODE_CONTEXT_STATE_DIRECTORY); +} + +/** + * Return the owner-local state directory for one native OpenCode session. + * The digest keeps cwd/session values out of filenames while preventing two + * sessions in the same repository from sharing mutable hydration state. + */ +export function resolvePrivateContextStateDirectory({ + env = process.env, + projectDir, + sessionId, + stateRoot, +}: { + env?: Env; + projectDir?: string; + sessionId?: string; + stateRoot?: string; +}): string | null { + const normalizedSessionId = pickString(sessionId); + if ( + !projectDir || + !isAbsolute(projectDir) || + !normalizedSessionId || + byteLength(normalizedSessionId) > 512 + ) { + return null; + } + const root = stateRoot + ? isAbsolute(stateRoot) + ? resolve(stateRoot) + : null + : defaultContextStateRoot(env); + if (!root || dirname(root) === root) return null; + const relativeToProject = relative(resolve(projectDir), root); + if ( + relativeToProject === '' || + (relativeToProject !== '..' && + !relativeToProject.startsWith(`..${sep}`) && + !isAbsolute(relativeToProject)) + ) { + return null; + } + const digest = createHash('sha256') + .update(resolve(projectDir)) + .update('\0') + .update(normalizedSessionId) + .digest('hex'); + return join(root, digest); +} + +function isPathWithin(parent: string, candidate: string): boolean { + const child = relative(parent, candidate); + return ( + child === '' || + (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)) + ); +} + +async function projectedRealPath(path: string): Promise { + let cursor = path; + const missing: string[] = []; + while (true) { + try { + return resolve(await realpath(cursor), ...missing.reverse()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const parent = dirname(cursor); + if (parent === cursor) throw error; + missing.push(basename(cursor)); + cursor = parent; + } + } +} + +async function ensurePrivateDirectory( + path: string, + create: boolean, + errorCode: string +): Promise { + if (create) { + try { + await mkdir(path, { recursive: true, mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + } + try { + const state = await lstat(path); + if (!state.isDirectory() || state.isSymbolicLink()) throw new Error(errorCode); + await chmod(path, 0o700); + return true; + } catch (error) { + if (!create && (error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function privateStateDirectory( + state: PrivateContextState, + create: boolean +): Promise { + const project = await lstat(state.projectDir); + if (!project.isDirectory() || project.isSymbolicLink()) { + throw new Error('unsafe_project_directory'); + } + const stateDir = resolvePrivateContextStateDirectory(state); + if (!stateDir) throw new Error('private_state_directory_unavailable'); + const stateRoot = resolve(stateDir, '..'); + const realProjectDir = await realpath(state.projectDir); + if (isPathWithin(realProjectDir, await projectedRealPath(stateRoot))) { + throw new Error('private_state_inside_project'); + } + if (!(await ensurePrivateDirectory(stateRoot, create, 'unsafe_private_state_root'))) { + return null; + } + if (isPathWithin(realProjectDir, await realpath(stateRoot))) { + throw new Error('private_state_inside_project'); + } + if ( + !(await ensurePrivateDirectory( + stateDir, + create, + 'unsafe_private_state_directory' + )) + ) { + return null; + } + return stateDir; +} + +async function removePrivateContextFile( + state: PrivateContextState, + filename: string +): Promise { + const stateDir = await privateStateDirectory(state, false); + if (!stateDir) return; + await unlink(join(stateDir, filename)).catch(() => undefined); +} + +async function removePendingContext(state: PrivateContextState): Promise { + await removePrivateContextFile(state, PENDING_CONTEXT_FILENAME); +} + +export async function persistPendingSessionWorkContext( + state: PrivateContextState, + context: Record +): Promise { + return writePrivateJson( + state, + PENDING_CONTEXT_FILENAME, + context, + MAX_SESSION_WORK_CONTEXT_BYTES + ); +} + +function truncateUtf8(value: string, maxBytes: number): string { + if (byteLength(value) <= maxBytes) return value; + const suffix = '\n[OrgX context truncated; inspect owner-local OrgX state.]'; + const budget = maxBytes - byteLength(suffix); + let text = ''; + for (const char of value) { + if (byteLength(text + char) > budget) break; + text += char; + } + return text + suffix; +} + +function additionalContextFor( + context: Record | null, + activation: SessionContextActivation +): string { + if (!context) { + const clearanceLine = activation.priorActivationCleared + ? 'Any prior exact-directory Wizard activation was cleared.' + : `Wizard could not verify removal of a prior activation (${activation.clearReason ?? 'unknown'}); do not rely on earlier session authority.`; + return [ + 'OrgX did not activate fresh receipt-ready session context.', + 'Refresh consequential state through OrgX before acting.', + clearanceLine, + ].join('\n'); + } + const activationLine = activation.activated + ? 'Wizard validated and activated this context for the exact current OpenCode session.' + : `Wizard activation is pending (${activation.reason}); use this as briefing only, not as proof of authority.`; + const clearanceLine = activation.activated + ? null + : activation.priorActivationCleared + ? 'Any prior authority lease for this exact OpenCode session was cleared.' + : `Wizard could not verify removal of a prior session lease (${activation.clearReason ?? 'unknown'}); do not rely on earlier authority.`; + return truncateUtf8( + [ + 'OrgX session context (producer-asserted; accepted references retain their own provenance):', + activationLine, + ...(clearanceLine ? [clearanceLine] : []), + 'The full compiled pack is retained in owner-only OrgX local state for inspection.', + JSON.stringify(context), + ].join('\n'), + MAX_ADDITIONAL_CONTEXT_BYTES + ); +} + +async function clearDefinitiveContext({ + contextPackPath, + env, + projectDir, + privateState, + reason, + spawnImpl, +}: { + contextPackPath: string; + env: Env; + projectDir: string; + privateState: PrivateContextState; + reason: 'not_returned' | 'context_invalid'; + spawnImpl: SpawnLike; +}): Promise { + await removePendingContext(privateState); + const clearance = await clearSessionWorkContext({ + projectDir, + sessionId: privateState.sessionId, + env, + spawnImpl, + }); + const sessionContext: SessionContextActivation = { + activated: false, + reason, + priorActivationCleared: clearance.cleared, + clearReason: clearance.reason, + }; + return { + ok: true, + contextPackPath, + sessionContext, + additionalContext: additionalContextFor(null, sessionContext), + }; +} + +async function clearUnverifiedContext({ + env, + projectDir, + privateState, + result, + spawnImpl, +}: { + env: Env; + projectDir: string; + privateState: PrivateContextState; + result: ContextPackHydrationResult; + spawnImpl: SpawnLike; +}): Promise { + await Promise.all([ + removePendingContext(privateState), + removePrivateContextFile(privateState, CONTEXT_PACK_FILENAME), + ]).catch(() => undefined); + const clearance = await clearSessionWorkContext({ + projectDir, + sessionId: privateState.sessionId, + env, + spawnImpl, + }); + const sessionContext: SessionContextActivation = { + activated: false, + reason: 'context_refresh_failed', + priorActivationCleared: clearance.cleared, + clearReason: clearance.reason, + }; + return { + ...result, + sessionContext, + additionalContext: additionalContextFor(null, sessionContext), + }; +} + +export async function hydrateContextPack({ + disabled = false, + env = process.env, + projectDir, + sessionId, + stateRoot, + fetchImpl = globalThis.fetch, + spawnImpl = nodeSpawn as unknown as SpawnLike, + now = new Date(), +}: { + disabled?: boolean; + env?: Env; + projectDir?: string; + sessionId?: string; + stateRoot?: string; + fetchImpl?: typeof fetch; + spawnImpl?: SpawnLike; + now?: Date; +} = {}): Promise { try { - const config = resolveContextPackConfig(env); - if (!config) return false; + if (!projectDir || !isAbsolute(projectDir)) { + return { ok: true, skipped: 'project_directory_unavailable' }; + } + if (!pickString(sessionId) || byteLength(pickString(sessionId)!) > 512) { + return { ok: true, skipped: 'session_id_unavailable' }; + } + const privateState: PrivateContextState = { + env, + projectDir, + sessionId: pickString(sessionId)!, + stateRoot, + }; + const config = disabled ? null : resolveContextPackConfig(env); + if (!config) { + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { ok: true, skipped: 'context_pack_unconfigured' }, + spawnImpl, + }); + } const { url, init } = buildContextPackRequest(config); - const res = await fetch(url, init); - if (!res.ok) return false; - const payload = (await res.json().catch(() => null)) as { data?: unknown } | null; - const data = payload?.data ?? null; - if (!data) return false; - const dir = join(process.cwd(), '.opencode'); - await mkdir(dir, { recursive: true }); - const out = join(dir, 'orgx-context-pack.json'); - await writeFile( - out, - JSON.stringify({ fetchedAt: new Date().toISOString(), data }, null, 2), - { mode: 0o600 } + const controller = new AbortController(); + const requestTimer = setTimeout( + () => controller.abort(), + boundedTimeout(env.ORGX_CONTEXT_PACK_TIMEOUT_MS) ); - await chmod(out, 0o600).catch(() => {}); - return true; + let response: Response; + let responseText: string | null; + try { + response = await fetchImpl(url, { ...init, signal: controller.signal }); + if (!response.ok) { + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { + ok: true, + skipped: 'context_pack_request_failed', + status: response.status, + }, + spawnImpl, + }); + } + responseText = await readBoundedResponseText( + response, + MAX_CONTEXT_PACK_RESPONSE_BYTES + ); + } catch (error) { + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { + ok: true, + skipped: 'context_pack_request_failed', + reason: + error instanceof Error && error.name === 'AbortError' + ? 'timeout' + : 'network_error', + }, + spawnImpl, + }); + } finally { + clearTimeout(requestTimer); + } + if (responseText === null) { + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { ok: true, skipped: 'context_pack_response_too_large' }, + spawnImpl, + }); + } + let payload: unknown; + try { + payload = JSON.parse(responseText); + } catch { + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { ok: true, skipped: 'context_pack_response_invalid' }, + spawnImpl, + }); + } + const data = + isRecord(payload) && payload.ok === true ? payload.data : undefined; + if (!isRecord(data)) { + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { ok: true, skipped: 'context_pack_response_invalid' }, + spawnImpl, + }); + } + + const contextPackPath = await writePrivateJson( + privateState, + CONTEXT_PACK_FILENAME, + { fetchedAt: now.toISOString(), data } + ); + const context = data.sessionWorkContext; + if (!isRecord(context)) { + return clearDefinitiveContext({ + contextPackPath, + env, + projectDir, + privateState, + reason: 'not_returned', + spawnImpl, + }); + } + if ( + context.schema_version !== 'orgx-session-work-context/v1' || + byteLength(JSON.stringify(context)) > MAX_SESSION_WORK_CONTEXT_BYTES + ) { + return clearDefinitiveContext({ + contextPackPath, + env, + projectDir, + privateState, + reason: 'context_invalid', + spawnImpl, + }); + } + + const activation = await activateSessionWorkContext({ + context, + projectDir, + sessionId: privateState.sessionId, + env, + spawnImpl, + }); + if (activation.activated) { + await removePendingContext(privateState); + } else { + const clearance = await clearSessionWorkContext({ + projectDir, + sessionId: privateState.sessionId, + env, + spawnImpl, + }); + activation.priorActivationCleared = clearance.cleared; + activation.clearReason = clearance.reason; + activation.pendingPath = await persistPendingSessionWorkContext( + privateState, + context + ); + } + return { + ok: true, + contextPackPath, + sessionContext: activation, + additionalContext: additionalContextFor(context, activation), + }; + } catch { + if (projectDir && isAbsolute(projectDir) && pickString(sessionId)) { + const privateState: PrivateContextState = { + env, + projectDir, + sessionId: pickString(sessionId)!, + stateRoot, + }; + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { ok: false, skipped: 'context_pack_hydration_failed' }, + spawnImpl, + }).catch(() => ({ + ok: false, + skipped: 'context_pack_hydration_failed', + })); + } + return { ok: false, skipped: 'context_pack_hydration_failed' }; + } +} + +/** + * Activate a context embedded in a trusted Gateway dispatch. Unlike the + * interactive hydrator above, this path performs no network fetch: the caller + * must validate the Gateway envelope and digest before invoking it. + */ +export async function activateProvidedSessionWorkContext({ + activationEnvelope, + context, + env = process.env, + projectDir, + sessionId, + stateRoot, + spawnImpl = nodeSpawn as unknown as SpawnLike, + now = new Date(), +}: { + activationEnvelope?: unknown; + context?: unknown; + env?: Env; + projectDir?: string; + sessionId?: string; + stateRoot?: string; + spawnImpl?: SpawnLike; + now?: Date; +} = {}): Promise { + try { + if (!projectDir || !isAbsolute(projectDir)) { + return { ok: true, skipped: 'project_directory_unavailable' }; + } + const normalizedId = pickString(sessionId); + if (!normalizedId || byteLength(normalizedId) > 512) { + return { ok: true, skipped: 'session_id_unavailable' }; + } + const privateState: PrivateContextState = { + env, + projectDir, + sessionId: normalizedId, + stateRoot, + }; + if ( + !isRecord(context) || + context.schema_version !== 'orgx-session-work-context/v1' || + byteLength(canonicalSessionWorkContextJson(context)) > + MAX_SESSION_WORK_CONTEXT_BYTES + ) { + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { ok: true, skipped: 'context_pack_response_invalid' }, + spawnImpl, + }); + } + const contextPackPath = await writePrivateJson( + privateState, + CONTEXT_PACK_FILENAME, + { + receivedAt: now.toISOString(), + source: 'orgx_gateway_dispatch', + data: { + ...(activationEnvelope + ? { sessionActivation: activationEnvelope } + : {}), + sessionWorkContext: context, + }, + } + ); + const activation = await activateSessionWorkContext({ + context, + projectDir, + sessionId: normalizedId, + env, + spawnImpl, + }); + if (activation.activated) { + await removePendingContext(privateState); + } else { + const clearance = await clearSessionWorkContext({ + projectDir, + sessionId: normalizedId, + env, + spawnImpl, + }); + activation.priorActivationCleared = clearance.cleared; + activation.clearReason = clearance.reason; + activation.pendingPath = await persistPendingSessionWorkContext( + privateState, + context + ); + } + return { + ok: true, + contextPackPath, + sessionContext: activation, + additionalContext: additionalContextFor(context, activation), + }; } catch { - return false; + if (projectDir && isAbsolute(projectDir) && pickString(sessionId)) { + const privateState: PrivateContextState = { + env, + projectDir, + sessionId: pickString(sessionId)!, + stateRoot, + }; + return clearUnverifiedContext({ + env, + projectDir, + privateState, + result: { ok: false, skipped: 'context_pack_hydration_failed' }, + spawnImpl, + }).catch(() => ({ + ok: false, + skipped: 'context_pack_hydration_failed', + })); + } + return { ok: false, skipped: 'context_pack_hydration_failed' }; } } diff --git a/src/continuityHealth.test.ts b/src/continuityHealth.test.ts index ba41663..e6050c1 100644 --- a/src/continuityHealth.test.ts +++ b/src/continuityHealth.test.ts @@ -32,7 +32,7 @@ describe('buildPluginContinuityHealth', () => { terminal_passive: true, events: [ 'session.created', - 'message.updated:user', + 'chat.message', 'tool.execute.before', 'tool.execute.after', 'permission.asked', diff --git a/src/continuityHealth.ts b/src/continuityHealth.ts index 2c5c9d9..c93065f 100644 --- a/src/continuityHealth.ts +++ b/src/continuityHealth.ts @@ -117,7 +117,7 @@ export async function buildPluginContinuityHealth({ const outboxHealth = outbox ?? (await inspectContinuityOutbox()); const hookEvents = [ 'session.created', - 'message.updated:user', + 'chat.message', 'tool.execute.before', 'tool.execute.after', 'permission.asked', diff --git a/src/gatewayProtocolBoundary.test.ts b/src/gatewayProtocolBoundary.test.ts new file mode 100644 index 0000000..134d311 --- /dev/null +++ b/src/gatewayProtocolBoundary.test.ts @@ -0,0 +1,125 @@ +import { + PeerClient, + type WebSocketEvent, + type WebSocketLike, +} from '@useorgx/orgx-gateway-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { OpenCodeDriver } from './OpenCodeDriver.js'; + +const DIGEST = `sha256:${'a'.repeat(64)}` as const; + +class TestSocket implements WebSocketLike { + readonly sent: unknown[] = []; + private readonly listeners = new Map< + 'open' | 'close' | 'error' | 'message', + Array<(event: WebSocketEvent) => void> + >(); + + addEventListener( + type: 'open' | 'close' | 'error' | 'message', + listener: (event: WebSocketEvent) => void + ): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + close(): void {} + + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + + emit(type: 'open' | 'close' | 'error' | 'message', event: WebSocketEvent): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} + +describe('Gateway SDK protocol boundary', () => { + it('fails protocol v2 before native session creation instead of emitting a mismatched terminal', async () => { + const socket = new TestSocket(); + const createClient = vi.fn(() => { + throw new Error('native OpenCode must not start for unsupported v2'); + }); + const driver = new OpenCodeDriver({ + createClient: createClient as never, + defaultDirectory: '/work/repo', + openCodeServerUrl: 'http://127.0.0.1:4096', + workspaceId: 'workspace-1', + workGraphOutboxPath: false, + }); + const peer = new PeerClient({ + baseUrl: 'wss://useorgx.test', + apiKey: 'oxk_test_only', + workspaceId: 'workspace-1', + pluginId: 'orgx-opencode-plugin', + protocolVersion: 1, + drivers: [driver], + reconnect: false, + webSocketFactory: () => socket, + }); + + peer.connect(); + socket.emit('open', {}); + socket.emit('message', { + data: JSON.stringify({ + kind: 'task.dispatch', + protocol_version: 2, + run_id: 'run-v2', + idempotency_key: 'key-v2', + timeout_seconds: 60, + task: { + title: 'unsupported proof-bearing dispatch', + driver: 'opencode', + }, + execution_envelope: { + schemaVersion: '1.0.0', + producer: { + actor: { type: 'service', id: 'orgx-gateway' }, + service: 'orgx-gateway', + serviceVersion: 'test', + }, + id: 'envelope-v2', + runId: 'run-v2', + attemptId: 'attempt-v2', + idempotencyKey: 'key-v2', + workRef: { + workspaceId: 'workspace-1', + initiativeId: 'initiative-1', + }, + missionId: 'mission-1', + missionContractDigest: DIGEST, + nodeId: 'node-1', + contextManifestDigest: DIGEST, + capabilityLeaseId: 'lease-1', + capabilityLeaseDigest: DIGEST, + runtimeProfileDigest: DIGEST, + qualityBarVersionId: 'quality-1', + skillVersionDigests: [], + toolManifestDigests: [], + budget: { + modelCostMicros: '0', + toolCostMicros: '0', + humanMinutes: 0, + }, + requestedAt: '2026-08-26T16:00:00.000Z', + digest: DIGEST, + }, + }), + }); + + await vi.waitFor(() => expect(socket.sent).toHaveLength(1)); + expect(socket.sent).toEqual([ + { + kind: 'task.failed', + run_id: 'run-v2', + reason: + 'OpenCode proof-bearing Gateway protocol v2 finalization is not supported', + recoverable: false, + }, + ]); + expect(createClient).not.toHaveBeenCalled(); + peer.disconnect(); + }); +}); diff --git a/src/peer.test.ts b/src/peer.test.ts index ccfaabe..0f86248 100644 --- a/src/peer.test.ts +++ b/src/peer.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const sdk = vi.hoisted(() => ({ options: undefined as Record | undefined, @@ -24,6 +24,10 @@ vi.mock('@useorgx/orgx-gateway-sdk', () => ({ import { startPeer, summarizeTransportError } from './peer.js'; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('startPeer', () => { beforeEach(() => { sdk.options = undefined; @@ -56,6 +60,118 @@ describe('startPeer', () => { await peer.stop(); expect(sdk.disconnect).toHaveBeenCalledOnce(); }); + + it('advertises exact activation and provider-observation heartbeat keys', async () => { + const requests: Array<{ + url: string; + body: Record; + redirect?: RequestRedirect; + }> = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body ?? '{}')), + redirect: init?.redirect, + }); + return new Response('{}', { status: 200 }); + }) + ); + const driver = { + id: 'opencode' as const, + detect: vi.fn(async () => ({ + installed: true, + authenticated: true, + version: '1.18.2', + })), + probe: vi.fn(async () => ({ + subscription_active: true, + session_alive: true, + })), + dispatch: vi.fn(), + cancel: vi.fn(), + }; + + const peer = await startPeer({ + apiKey: 'oxk_test_only', + workspaceId: 'workspace-test', + driver, + continuityOutbox: { + state: 'ready', + pending: 0, + dead_letters: 0, + last_replay_at: null, + }, + autoReplayWorkGraph: false, + }); + const presence = requests.find(({ url }) => + url.endsWith('/api/v1/gateway/heartbeat') + ); + + expect(presence?.body).toMatchObject({ + gateway_version: '0.1.0-alpha.15', + metadata: { + execution_provider: null, + execution_provider_id: null, + execution_provider_observed_at: null, + execution_auth_method: null, + capabilities: { + session_context_activation_v1: true, + session_context_acceptance_v1: true, + }, + }, + }); + expect(requests.every(({ redirect }) => redirect === 'error')).toBe(true); + + await peer.stop(); + }); + + it('rejects an unsafe OrgX base URL before opening a credentialed transport', async () => { + const driver = { + id: 'opencode', + detect: vi.fn(), + dispatch: vi.fn(), + cancel: vi.fn(), + }; + + await expect( + startPeer({ + apiKey: 'oxk_test_only', + workspaceId: 'workspace-test', + baseUrl: 'http://untrusted.example.test', + driver, + skipHeartbeat: true, + }) + ).rejects.toThrow('Unsafe OrgX base URL'); + expect(sdk.connect).not.toHaveBeenCalled(); + }); + + it('redacts and bounds terminal close reasons before logging', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const driver = { + id: 'opencode', + detect: vi.fn(), + dispatch: vi.fn(), + cancel: vi.fn(), + }; + const peer = await startPeer({ + apiKey: 'oxk_test_only', + workspaceId: 'workspace-test', + driver, + skipHeartbeat: true, + }); + + (sdk.options?.onClose as ((code: number, reason: string) => void) | undefined)?.( + 4401, + `Bearer oxk_private_secret ${'x'.repeat(1_000)}` + ); + + const logged = JSON.stringify(warn.mock.calls); + expect(logged).not.toContain('oxk_private_secret'); + expect(logged.length).toBeLessThan(800); + await peer.stop(); + }); }); describe('summarizeTransportError', () => { diff --git a/src/peer.ts b/src/peer.ts index efbaf1c..504de22 100644 --- a/src/peer.ts +++ b/src/peer.ts @@ -12,16 +12,25 @@ * }); */ -import { PeerClient, type Driver } from '@useorgx/orgx-gateway-sdk'; +import { + PeerClient, + type Driver, + type PeerClientConfig, +} from '@useorgx/orgx-gateway-sdk'; import { readFile } from 'fs/promises'; import { resolve } from 'path'; import { fileURLToPath } from 'url'; import { OpenCodeDriver } from './OpenCodeDriver.js'; +import { + ActivationAcceptanceBroker, + createActivationObservingWebSocketFactory, +} from './activationAcceptance.js'; import { buildPluginContinuityHealth, type ContinuityOutboxHealth, } from './continuityHealth.js'; +import { resolveSafeBaseUrl } from './contextPackHydration.js'; import { capturePluginException, initializePluginSentry, @@ -80,6 +89,10 @@ export type StartPeerOptions = { workspaceId: string; /** Default: https://useorgx.com */ baseUrl?: string; + /** Authoritative native OpenCode server URL from the plugin runtime. */ + openCodeServerUrl?: string; + /** Authoritative native OpenCode project directory from the plugin runtime. */ + openCodeDirectory?: string; /** Override for tests. */ driver?: Driver; /** Skip the license heartbeat (tests). */ @@ -94,6 +107,10 @@ export type StartPeerOptions = { continuityOutbox?: ContinuityOutboxHealth; /** Disable terminal replay for tests or intentionally offline installs. */ autoReplayWorkGraph?: boolean; + /** Gateway WebSocket override for deterministic transport tests. */ + webSocketFactory?: PeerClientConfig['webSocketFactory']; + /** Maximum wait for the Gateway's durable activation acceptance. */ + activationAcceptanceTimeoutMs?: number; }; export type StartedPeer = { @@ -101,9 +118,17 @@ export type StartedPeer = { }; export async function startPeer(opts: StartPeerOptions): Promise { - const baseUrl = opts.baseUrl ?? 'https://useorgx.com'; + const baseUrl = resolveSafeBaseUrl(opts.baseUrl); + if (!baseUrl) { + throw new Error( + 'Unsafe OrgX base URL; use credential-free HTTPS or loopback HTTP' + ); + } const manifest = await loadManifest(); initializePluginSentry(manifest.version); + const activationAcceptance = new ActivationAcceptanceBroker( + opts.activationAcceptanceTimeoutMs + ); const replayWorkGraph = opts.autoReplayWorkGraph === false ? undefined @@ -116,9 +141,22 @@ export async function startPeer(opts: StartPeerOptions): Promise { const driver = opts.driver ?? new OpenCodeDriver({ + openCodeServerUrl: opts.openCodeServerUrl, + defaultDirectory: opts.openCodeDirectory, skillRules: async () => fetchSkillRules(baseUrl, opts), workGraphOutboxPath: opts.workGraphOutboxPath, replayWorkGraph, + orgxApiKey: opts.apiKey, + orgxBaseUrl: baseUrl, + workspaceId: opts.workspaceId, + orgxEnv: process.env, + awaitActivationAcceptance: (expectation) => + activationAcceptance.waitForAcceptance(expectation), + cancelActivationAcceptance: (runId) => + activationAcceptance.rejectRun( + runId, + 'OrgX dispatch ended before context activation acceptance' + ), }); let transportOnline = false; @@ -134,6 +172,10 @@ export async function startPeer(opts: StartPeerOptions): Promise { installationId: opts.installationId ?? defaultInstallationId(), protocolVersion: GATEWAY_PROTOCOL_VERSION, drivers: [driver], + webSocketFactory: createActivationObservingWebSocketFactory( + activationAcceptance, + opts.webSocketFactory + ), onOpen: () => { transportOnline = true; // eslint-disable-next-line no-console @@ -146,7 +188,10 @@ export async function startPeer(opts: StartPeerOptions): Promise { onClose: (code, reason) => { transportOnline = false; // eslint-disable-next-line no-console - console.warn('[orgx-opencode-plugin] closed', { code, reason }); + console.warn('[orgx-opencode-plugin] closed', { + code, + reason: redactTransportText(reason), + }); }, onError: (err) => { const safeError = summarizeTransportError(err); @@ -194,6 +239,9 @@ export async function startPeer(opts: StartPeerOptions): Promise { if (presenceTimer) clearInterval(presenceTimer); if (licenseHeartbeatTimer) clearInterval(licenseHeartbeatTimer); transportOnline = false; + activationAcceptance.rejectAll( + 'OrgX peer stopped before context activation acceptance' + ); client.disconnect(); }, }; @@ -245,8 +293,11 @@ async function postPresenceHeartbeat( endpoint: opts.mcpEndpoint, outbox: opts.continuityOutbox, }); + const providerLease = + driver instanceof OpenCodeDriver ? driver.executionProviderLease() : null; const r = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/gateway/heartbeat`, { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.apiKey}`, @@ -270,6 +321,16 @@ async function postPresenceHeartbeat( transportOnline && detected.installed === true && authenticated, auth_status: authState, auth_method: null, + execution_provider: providerLease?.provider ?? null, + execution_provider_id: providerLease?.providerId ?? null, + execution_provider_observed_at: providerLease?.observedAt ?? null, + // The official OpenCode SDK does not expose whether an opaque stored + // provider credential is OAuth or an API key. + capabilities: { + session_context_activation_v1: true, + session_context_acceptance_v1: true, + }, + execution_auth_method: null, probe_version: detected.version ?? null, continuity_health: continuityHealth, }, @@ -287,6 +348,7 @@ async function postLicenseHeartbeat( ): Promise { const r = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/licenses/heartbeat`, { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.apiKey}`, @@ -300,7 +362,7 @@ async function postLicenseHeartbeat( }), }); if (!r.ok) { - throw new Error(`heartbeat ${r.status}: ${await r.text().catch(() => '')}`); + throw new Error(`heartbeat ${r.status}`); } } @@ -324,6 +386,7 @@ async function fetchSkillRules( `${baseUrl.replace(/\/$/, '')}/api/v1/plan-skills?workspace_id=${encodeURIComponent(opts.workspaceId)}`, { method: 'GET', + redirect: 'error', headers: { Authorization: `Bearer ${opts.apiKey}` }, } ); diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 6ef28a3..623737b 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; import { createOrgXOpenCodePlugin } from './plugin'; +import { + clearRuntimeSessionHydration, + publishRuntimeSessionHydration, +} from './runtimeSessionContext'; type PluginHooks = { + 'experimental.chat.system.transform': ( + input: { sessionID?: string }, + output: { system: string[] } + ) => Promise; 'chat.message': ( input: { sessionID: string; messageID?: string }, output: { parts: Array> } ) => Promise; - event: (input: { event: { type?: string } }) => Promise; + event: (input: { + event: Record & { type: string }; + }) => Promise; 'tool.execute.before': (input: Record) => Promise; 'tool.execute.after': (input: Record) => Promise; }; @@ -21,14 +31,23 @@ function createLogger() { } async function loadHooks( - opts: Parameters[0] + opts: Parameters[0], + input: Record = { + directory: '/work/repo', + serverUrl: new URL('http://localhost:4096'), + } ): Promise { const plugin = createOrgXOpenCodePlugin({ ...(opts ?? {}), bridgeSessionSummary: opts?.bridgeSessionSummary ?? vi.fn(async () => ({ ok: true })), + hydrateContextPack: + opts?.hydrateContextPack ?? vi.fn(async () => ({ ok: true })), + clearSessionWorkContext: + opts?.clearSessionWorkContext ?? + vi.fn(async () => ({ cleared: true, reason: 'wizard_cleared' })), }); - return (await plugin({} as never)) as PluginHooks; + return (await plugin(input as never)) as PluginHooks; } describe('OrgXOpenCodePlugin', () => { @@ -62,6 +81,8 @@ describe('OrgXOpenCodePlugin', () => { apiKey: 'oxk_test', workspaceId: 'workspace-123', baseUrl: 'https://example.org', + openCodeServerUrl: 'http://localhost:4096/', + openCodeDirectory: '/work/repo', }); expect(logger.log).toHaveBeenCalledWith( '[orgx-opencode-plugin] native OpenCode plugin peer started' @@ -86,6 +107,310 @@ describe('OrgXOpenCodePlugin', () => { expect(startPeer).toHaveBeenCalledTimes(1); }); + it('hydrates exactly once per native session against the OpenCode project directory', async () => { + const hydrateContextPack = vi.fn(async () => ({ ok: true } as const)); + const startPeer = vi.fn(async () => ({ stop: vi.fn() })); + const hooks = await loadHooks({ + startPeer, + hydrateContextPack, + logger: createLogger(), + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_GATEWAY_KEY: 'oxk_alias', + ORGX_WORKSPACE_ID: 'workspace-123', + ORGX_TASK_ID: 'task-456', + }, + }); + + const created = { + type: 'session.created', + properties: { info: { id: 'session-1' } }, + }; + await hooks.event({ event: created }); + await hooks.event({ event: created }); + await hooks.event({ event: { type: 'server.connected' } }); + + expect(hydrateContextPack).toHaveBeenCalledTimes(1); + expect(hydrateContextPack).toHaveBeenCalledWith({ + disabled: false, + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_BASE_URL: 'https://useorgx.com', + ORGX_WORKSPACE_ID: 'workspace-123', + ORGX_TASK_ID: 'task-456', + }, + projectDir: '/work/repo', + sessionId: 'session-1', + }); + }); + + it('waits for bounded context activation before session.created completes', async () => { + let finishHydration: (() => void) | undefined; + const hydrateContextPack = vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + finishHydration = () => resolve({ ok: true }); + }) + ); + const hooks = await loadHooks({ + hydrateContextPack, + startPeer: vi.fn(async () => ({ stop: vi.fn() })), + logger: createLogger(), + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + }, + }); + let completed = false; + + const connecting = hooks + .event({ + event: { + type: 'session.created', + properties: { info: { id: 'session-1' } }, + }, + }) + .then(() => { + completed = true; + }); + await vi.waitFor(() => expect(hydrateContextPack).toHaveBeenCalledTimes(1)); + expect(completed).toBe(false); + + finishHydration?.(); + await connecting; + expect(completed).toBe(true); + }); + + it('injects compiled context into every model request without duplicating one output', async () => { + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + additionalContext: 'accepted OrgX decision context', + } as const)); + const hooks = await loadHooks({ + hydrateContextPack, + startPeer: vi.fn(async () => ({ stop: vi.fn() })), + logger: createLogger(), + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + }, + }); + const first = { system: ['native system'] }; + const second = { system: ['native system'] }; + + await hooks['experimental.chat.system.transform']( + { sessionID: 'session-1' }, + first + ); + await hooks['experimental.chat.system.transform']( + { sessionID: 'session-1' }, + second + ); + await hooks['experimental.chat.system.transform']( + { sessionID: 'session-1' }, + second + ); + + expect(first.system).toEqual([ + 'native system', + 'accepted OrgX decision context', + ]); + expect(second.system).toEqual([ + 'native system', + 'accepted OrgX decision context', + ]); + expect(hydrateContextPack).toHaveBeenCalledTimes(1); + }); + + it('lets exact driver scope replace an earlier ambient session hydration', async () => { + const sessionId = 'runtime-scoped-session'; + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + additionalContext: 'ambient workspace context', + } as const)); + const hooks = await loadHooks({ + hydrateContextPack, + logger: createLogger(), + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + }, + }); + try { + await hooks.event({ + event: { + type: 'session.created', + properties: { info: { id: sessionId } }, + }, + }); + publishRuntimeSessionHydration('/work/repo', sessionId, { + ok: true, + additionalContext: 'exact dispatched task context', + sessionContext: { + activated: true, + reason: 'wizard_activated', + }, + }); + const output = { system: ['native system'] }; + + await hooks['experimental.chat.system.transform']( + { sessionID: sessionId }, + output + ); + + expect(output.system).toEqual([ + 'native system', + 'exact dispatched task context', + ]); + expect(output.system).not.toContain('ambient workspace context'); + } finally { + clearRuntimeSessionHydration('/work/repo', sessionId); + } + }); + + it('does not hydrate or inject into sessionless internal agent generation', async () => { + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + additionalContext: 'must remain session-bound', + } as const)); + const hooks = await loadHooks({ + hydrateContextPack, + logger: createLogger(), + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + }, + }); + const output = { system: ['native system'] }; + + await hooks['experimental.chat.system.transform']({}, output); + + expect(hydrateContextPack).not.toHaveBeenCalled(); + expect(output.system).toEqual(['native system']); + }); + + it('isolates hydration caches by native session and drops a deleted session', async () => { + const hydrateContextPack = vi.fn(async () => ({ ok: true } as const)); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + } as const)); + const hooks = await loadHooks({ + hydrateContextPack, + clearSessionWorkContext, + logger: createLogger(), + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + }, + }); + const sessionEvent = (type: string, id: string) => ({ + type, + properties: { info: { id } }, + }); + + await hooks.event({ event: sessionEvent('session.created', 'session-a') }); + await hooks.event({ event: sessionEvent('session.created', 'session-b') }); + await hooks.event({ event: sessionEvent('session.deleted', 'session-a') }); + await hooks.event({ event: sessionEvent('session.created', 'session-a') }); + + expect( + hydrateContextPack.mock.calls.map(([call]) => call.sessionId) + ).toEqual(['session-a', 'session-b', 'session-a']); + expect(clearSessionWorkContext).toHaveBeenCalledWith({ + env: { + ORGX_WORKSPACE_ID: 'workspace-123', + }, + projectDir: '/work/repo', + sessionId: 'session-a', + }); + }); + + it('waits for in-flight hydration before clearing the terminal session lease', async () => { + let finishHydration: (() => void) | undefined; + const hydrateContextPack = vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + finishHydration = () => resolve({ ok: true }); + }) + ); + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + } as const)); + const hooks = await loadHooks({ + hydrateContextPack, + clearSessionWorkContext, + logger: createLogger(), + env: {}, + }); + const properties = { info: { id: 'session-race' } }; + + const creating = hooks.event({ + event: { type: 'session.created', properties }, + }); + await vi.waitFor(() => expect(hydrateContextPack).toHaveBeenCalledTimes(1)); + const deleting = hooks.event({ + event: { type: 'session.deleted', properties }, + }); + await Promise.resolve(); + expect(clearSessionWorkContext).not.toHaveBeenCalled(); + + finishHydration?.(); + await Promise.all([creating, deleting]); + expect(clearSessionWorkContext).toHaveBeenCalledWith({ + env: {}, + projectDir: '/work/repo', + sessionId: 'session-race', + }); + }); + + it('does not send a credential-bearing base override to any network path', async () => { + const startPeer = vi.fn(async () => ({ stop: vi.fn() })); + const hydrateContextPack = vi.fn(async () => ({ ok: true } as const)); + const bridgeSessionSummary = vi.fn(async () => ({ ok: true })); + const logger = createLogger(); + const env = { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + ORGX_BASE_URL: 'https://user:secret@example.test?token=private', + }; + const hooks = await loadHooks({ + startPeer, + hydrateContextPack, + bridgeSessionSummary, + logger, + env, + }); + + await hooks.event({ + event: { + type: 'session.created', + properties: { info: { id: 'session-private' } }, + }, + }); + await hooks.event({ event: { type: 'server.connected' } }); + + expect(startPeer).not.toHaveBeenCalled(); + expect(hydrateContextPack).toHaveBeenCalledWith({ + disabled: true, + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_BASE_URL: undefined, + ORGX_WORKSPACE_ID: 'workspace-123', + }, + projectDir: '/work/repo', + sessionId: 'session-private', + }); + expect(bridgeSessionSummary.mock.calls[0][0].env).toEqual({ + ORGX_WORKSPACE_ID: 'workspace-123', + }); + expect(env).toEqual({ ORGX_WORKSPACE_ID: 'workspace-123' }); + expect(logger.warn).toHaveBeenCalledWith( + '[orgx-opencode-plugin] native plugin loaded, but ORGX_BASE_URL is not a credential-free HTTPS or loopback HTTP URL' + ); + }); + it('warns once when required env config is missing', async () => { const startPeer = vi.fn(async () => ({ stop: vi.fn() })); const logger = createLogger(); diff --git a/src/plugin.ts b/src/plugin.ts index d560e94..386c8d3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,9 +1,22 @@ +import { resolve } from 'node:path'; + import type { Plugin } from '@opencode-ai/plugin'; import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import type { StartedPeer, StartPeerOptions } from './peer.js'; -import { hydrateContextPack } from './contextPackHydration.js'; +import { + MAX_ADDITIONAL_CONTEXT_BYTES, + clearSessionWorkContext, + hydrateContextPack, + resolveSafeBaseUrl, + type ContextPackHydrationResult, + type SessionContextClearance, +} from './contextPackHydration.js'; import { capturePluginException } from './sentry.js'; +import { + clearRuntimeSessionHydration, + readRuntimeSessionHydration, +} from './runtimeSessionContext.js'; import { bridgeOpenCodeQuestions, parseQuestionRequest, @@ -15,12 +28,55 @@ type StartPeer = (opts: StartPeerOptions) => Promise; type Env = Record; type Logger = Pick; type BridgeSessionSummary = typeof bridgeOpenCodeSessionSummary; +type HydrateContextPack = (input: { + disabled?: boolean; + env?: Env; + projectDir?: string; + sessionId?: string; +}) => Promise; +type ClearSessionWorkContext = (input: { + env?: Env; + projectDir?: string; + sessionId?: string; +}) => Promise; + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function nativeSessionId(value: unknown): string | undefined { + const root = record(value); + const properties = record(root.properties); + const info = record(properties.info); + for (const candidate of [ + root.sessionID, + root.session_id, + properties.sessionID, + properties.session_id, + info.sessionID, + info.session_id, + info.id, + ]) { + if (typeof candidate !== 'string') continue; + const normalized = candidate.trim(); + if (normalized) return normalized; + } + return undefined; +} + +function contextHydrationKey(projectDir: string, sessionId: string): string { + return `${resolve(projectDir)}\0${sessionId}`; +} export type CreateOrgXOpenCodePluginOptions = { startPeer?: StartPeer; env?: Env; logger?: Logger; bridgeSessionSummary?: BridgeSessionSummary; + hydrateContextPack?: HydrateContextPack; + clearSessionWorkContext?: ClearSessionWorkContext; }; export function createOrgXOpenCodePlugin( @@ -29,31 +85,53 @@ export function createOrgXOpenCodePlugin( const start = opts.startPeer ?? defaultStartPeer; const env = opts.env ?? process.env; const apiKey = captureGatewayCredential(env); + const rawBaseUrl = env.ORGX_BASE_URL; + const safeBaseUrl = resolveSafeBaseUrl(rawBaseUrl); + const invalidBaseUrl = Boolean(rawBaseUrl?.trim()) && !safeBaseUrl; + if (invalidBaseUrl) delete env.ORGX_BASE_URL; const logger = opts.logger ?? console; const bridgeSessionSummary = opts.bridgeSessionSummary ?? bridgeOpenCodeSessionSummary; + const hydrate = opts.hydrateContextPack ?? hydrateContextPack; + const clearContext = + opts.clearSessionWorkContext ?? clearSessionWorkContext; let peer: Promise | null = null; + const contextHydrations = new Map< + string, + Promise + >(); let warnedMissingConfig = false; let warnedMissingAttentionConfig = false; const activeAttention = new Set(); - async function startIfConfigured() { + async function startIfConfigured( + openCodeServerUrl: string, + openCodeDirectory: string + ) { if (peer) return; const workspaceId = env.ORGX_WORKSPACE_ID; - const baseUrl = env.ORGX_BASE_URL; + const baseUrl = safeBaseUrl ?? undefined; - if (!apiKey || !workspaceId) { + if (!apiKey || !workspaceId || invalidBaseUrl) { if (!warnedMissingConfig) { logger.warn( - '[orgx-opencode-plugin] native plugin loaded, but ORGX_API_KEY and ORGX_WORKSPACE_ID are required to connect' + invalidBaseUrl + ? '[orgx-opencode-plugin] native plugin loaded, but ORGX_BASE_URL is not a credential-free HTTPS or loopback HTTP URL' + : '[orgx-opencode-plugin] native plugin loaded, but ORGX_API_KEY and ORGX_WORKSPACE_ID are required to connect' ); warnedMissingConfig = true; } return; } - peer = start({ apiKey, workspaceId, baseUrl }); + peer = start({ + apiKey, + workspaceId, + baseUrl, + openCodeServerUrl, + openCodeDirectory, + }); try { await peer; logger.log('[orgx-opencode-plugin] native OpenCode plugin peer started'); @@ -64,6 +142,38 @@ export function createOrgXOpenCodePlugin( } } + async function hydrateProjectContext( + projectDir: string, + sessionId: string + ): Promise { + const runtimeHydration = readRuntimeSessionHydration( + projectDir, + sessionId + ); + if (runtimeHydration) return runtimeHydration; + const key = contextHydrationKey(projectDir, sessionId); + const existing = contextHydrations.get(key); + if (existing) return existing; + const hydration = hydrate({ + disabled: invalidBaseUrl, + env: { + ...env, + ORGX_API_KEY: apiKey, + ORGX_BASE_URL: safeBaseUrl ?? undefined, + }, + projectDir, + sessionId, + }).catch((error) => { + capturePluginException(error, { stage: 'context_pack_hydration' }); + logger.warn( + `[orgx-opencode-plugin] context hydration unavailable: ${formatError(error)}` + ); + return { ok: false, skipped: 'context_pack_hydration_failed' } as const; + }); + contextHydrations.set(key, hydration); + return hydration; + } + return async (input) => { const capture = async (nativeEvent: string, payload: unknown) => { try { @@ -82,7 +192,28 @@ export function createOrgXOpenCodePlugin( }; return { + 'experimental.chat.system.transform': async (chatInput, output) => { + const sessionId = nativeSessionId(chatInput); + if (!sessionId) return; + const result = await hydrateProjectContext(input.directory, sessionId); + const additionalContext = result.additionalContext; + if ( + typeof additionalContext !== 'string' || + !additionalContext || + Buffer.byteLength(additionalContext, 'utf8') > + MAX_ADDITIONAL_CONTEXT_BYTES + ) { + return; + } + if (!output.system.includes(additionalContext)) { + output.system.push(additionalContext); + } + }, 'chat.message': async (messageInput, output) => { + const sessionId = nativeSessionId(messageInput); + if (sessionId) { + await hydrateProjectContext(input.directory, sessionId); + } const prompt = output.parts .filter( (part): part is typeof part & { type: 'text'; text: string } => @@ -101,12 +232,38 @@ export function createOrgXOpenCodePlugin( }); }, event: async ({ event }) => { + const sessionId = nativeSessionId(event); + if (event.type === 'session.created' && sessionId) { + await hydrateProjectContext(input.directory, sessionId); + } await capture(event.type, event); if (event.type === 'server.connected') { - await startIfConfigured(); - // M adapter: hydrate the context pack (best-effort, never throws). - void hydrateContextPack({ ...env, ORGX_API_KEY: apiKey }); + await startIfConfigured( + input.serverUrl.toString(), + resolve(input.directory) + ); + } + if (event.type === 'session.deleted' && sessionId) { + const key = contextHydrationKey(input.directory, sessionId); + await contextHydrations.get(key); + const clearance = await clearContext({ + env, + projectDir: input.directory, + sessionId, + }).catch((error) => { + capturePluginException(error, { + stage: 'session_context_clear', + }); + return { cleared: false, reason: 'wizard_unavailable' } as const; + }); + if (!clearance.cleared) { + logger.warn( + `[orgx-opencode-plugin] session context clear unverified: ${clearance.reason}` + ); + } + contextHydrations.delete(key); + clearRuntimeSessionHydration(input.directory, sessionId); } if (env.ORGX_REMOTE_ATTENTION !== '1') return; @@ -114,7 +271,7 @@ export function createOrgXOpenCodePlugin( if (!questionRequest || activeAttention.has(questionRequest.id)) return; const initiativeId = env.ORGX_INITIATIVE_ID; - if (!apiKey || !initiativeId) { + if (!apiKey || !initiativeId || !safeBaseUrl) { if (!warnedMissingAttentionConfig) { logger.warn( '[orgx-opencode-plugin] remote attention requires ORGX_API_KEY and ORGX_INITIATIVE_ID' @@ -135,7 +292,7 @@ export function createOrgXOpenCodePlugin( initiativeId, runId: env.ORGX_RUN_ID, workstreamId: env.ORGX_WORKSTREAM_ID, - baseUrl: env.ORGX_BASE_URL, + baseUrl: safeBaseUrl, reply: async (answers) => { await nativeClient.question.reply({ requestID: questionRequest.id, @@ -158,12 +315,20 @@ export function createOrgXOpenCodePlugin( .finally(() => activeAttention.delete(questionRequest.id)); }, 'tool.execute.before': async (toolInput, output) => { + const sessionId = nativeSessionId(toolInput); + if (sessionId) { + await hydrateProjectContext(input.directory, sessionId); + } await capture('tool.execute.before', { ...toolInput, args: output?.args, }); }, 'tool.execute.after': async (toolInput) => { + const sessionId = nativeSessionId(toolInput); + if (sessionId) { + await hydrateProjectContext(input.directory, sessionId); + } await capture('tool.execute.after', toolInput); }, }; diff --git a/src/runtimeSessionContext.ts b/src/runtimeSessionContext.ts new file mode 100644 index 0000000..6d2b25b --- /dev/null +++ b/src/runtimeSessionContext.ts @@ -0,0 +1,54 @@ +import { resolve } from 'node:path'; + +import type { ContextPackHydrationResult } from './contextPackHydration.js'; + +const MAX_RUNTIME_SESSIONS = 256; +const runtimeSessions = new Map(); + +function key(projectDir: string, sessionId: string): string { + return `${resolve(projectDir)}\0${sessionId.trim()}`; +} + +/** + * Bridge a driver-resolved runtime scope into native plugin hooks in the same + * process. This takes precedence over ambient process-scope hydration. + */ +export function publishRuntimeSessionHydration( + projectDir: string, + sessionId: string, + result: ContextPackHydrationResult +): void { + const sessionKey = key(projectDir, sessionId); + runtimeSessions.delete(sessionKey); + runtimeSessions.set(sessionKey, result); + while (runtimeSessions.size > MAX_RUNTIME_SESSIONS) { + const oldest = runtimeSessions.keys().next().value; + if (typeof oldest !== 'string') break; + runtimeSessions.delete(oldest); + } +} + +export function readRuntimeSessionHydration( + projectDir: string, + sessionId: string +): ContextPackHydrationResult | undefined { + return runtimeSessions.get(key(projectDir, sessionId)); +} + +/** Prevent a cached ambient pack from regaining authority after failure. */ +export function blockRuntimeSessionHydration( + projectDir: string, + sessionId: string +): void { + publishRuntimeSessionHydration(projectDir, sessionId, { + ok: false, + skipped: 'context_pack_hydration_failed', + }); +} + +export function clearRuntimeSessionHydration( + projectDir: string, + sessionId: string +): void { + runtimeSessions.delete(key(projectDir, sessionId)); +} diff --git a/src/sessionSummaryBridge.test.ts b/src/sessionSummaryBridge.test.ts index 2206ef2..6a04219 100644 --- a/src/sessionSummaryBridge.test.ts +++ b/src/sessionSummaryBridge.test.ts @@ -20,7 +20,7 @@ describe('OpenCode session summary bridge', () => { canonicalOpenCodeEvent('message.updated', { properties: { info: { role: 'user' } }, }) - ).toBe('UserPromptSubmit'); + ).toBeNull(); expect( canonicalOpenCodeEvent('message.updated', { properties: { info: { role: 'assistant' } }, @@ -28,6 +28,46 @@ describe('OpenCode session summary bridge', () => { ).toBeNull(); }); + it('captures one prompt for the native chat.message then message.updated lifecycle', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orgx-opencode-bridge-')); + const hookPath = join(dir, 'orgx-session-summary.mjs'); + await writeFile(hookPath, 'export async function main() {}\n', 'utf8'); + const main = vi.fn(async () => ({ ok: true })); + try { + const promptResult = await bridgeOpenCodeSessionSummary({ + nativeEvent: 'chat.message', + payload: { + sessionID: 'session-1', + messageID: 'message-1', + prompt: 'One native user prompt.', + }, + directory: '/work/repo', + hookPath, + importHook: async () => ({ main }), + }); + const updateResult = await bridgeOpenCodeSessionSummary({ + nativeEvent: 'message.updated', + payload: { + properties: { + info: { id: 'message-1', sessionID: 'session-1', role: 'user' }, + }, + }, + directory: '/work/repo', + hookPath, + importHook: async () => ({ main }), + }); + + expect(promptResult).toMatchObject({ + ok: true, + canonical_event: 'UserPromptSubmit', + }); + expect(updateResult).toEqual({ ok: true, skipped: 'unsupported_event' }); + expect(main).toHaveBeenCalledTimes(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('keeps bounded user intent while dropping tool arguments/results and errors', () => { const result = sanitizeOpenCodePayload( { @@ -41,7 +81,8 @@ describe('OpenCode session summary bridge', () => { output: 'private output', error: 'private error', }, - '/work/repo' + '/work/repo', + { ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded' } ); expect(result).toEqual({ session_id: 'session-1', @@ -69,6 +110,33 @@ describe('OpenCode session summary bridge', () => { expect(serialized).toContain('Implement the verified work ledger.'); }); + it('defaults to metadata-only capture and honors explicit bounded consent', () => { + const payload = { + sessionID: 'session-consent', + prompt: 'Only retain this when bounded capture is enabled.', + }; + + expect(sanitizeOpenCodePayload(payload, '/work/repo').prompt).toBeUndefined(); + expect( + sanitizeOpenCodePayload(payload, '/work/repo', { + ORGX_SESSION_WORK_EPISODE_CAPTURE: '1', + }).prompt + ).toBe('Only retain this when bounded capture is enabled.'); + }); + + it('bounds explicitly enabled prompt capture to 600 Unicode characters', () => { + const prompt = `${'a'.repeat(599)}🧠${'b'.repeat(100)}`; + + const sanitized = sanitizeOpenCodePayload( + { sessionID: 'session-bounded', prompt }, + '/work/repo', + { ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded' } + ); + + expect(Array.from(sanitized.prompt as string)).toHaveLength(600); + expect(sanitized.prompt).toBe(`${'a'.repeat(599)}🧠`); + }); + it('delegates to Wizard and starts fallback delivery for a run end', async () => { const dir = mkdtempSync(join(tmpdir(), 'orgx-opencode-bridge-')); const hookPath = join(dir, 'orgx-session-summary.mjs'); @@ -97,7 +165,6 @@ describe('OpenCode session summary bridge', () => { argv: [ '--event=RunEnd', '--source_client=opencode', - '--work_episode_capture=bounded', ], env: { PATH: process.env.PATH }, stdinText: JSON.stringify({ @@ -117,6 +184,43 @@ describe('OpenCode session summary bridge', () => { } }); + it('leaves Work Episode consent to the explicit Wizard environment setting', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orgx-opencode-bridge-')); + const hookPath = join(dir, 'orgx-session-summary.mjs'); + await writeFile(hookPath, 'export async function main() {}\n', 'utf8'); + const main = vi.fn(async () => ({ ok: true })); + try { + await bridgeOpenCodeSessionSummary({ + nativeEvent: 'chat.message', + payload: { + sessionID: 'session-consent', + prompt: 'Retain this bounded intent.', + }, + directory: '/work/repo', + hookPath, + env: { + PATH: process.env.PATH, + ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded', + }, + importHook: async () => ({ main }), + }); + + expect(main).toHaveBeenCalledTimes(1); + expect(main.mock.calls[0][0].argv).toEqual([ + '--event=UserPromptSubmit', + '--source_client=opencode', + ]); + expect(main.mock.calls[0][0].env.ORGX_SESSION_WORK_EPISODE_CAPTURE).toBe( + 'bounded' + ); + expect(JSON.parse(main.mock.calls[0][0].stdinText).prompt).toBe( + 'Retain this bounded intent.' + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('keeps an offline run queued without starting fallback delivery', async () => { const dir = mkdtempSync(join(tmpdir(), 'orgx-opencode-bridge-')); const hookPath = join(dir, 'orgx-session-summary.mjs'); diff --git a/src/sessionSummaryBridge.ts b/src/sessionSummaryBridge.ts index 78f01a8..fc44714 100644 --- a/src/sessionSummaryBridge.ts +++ b/src/sessionSummaryBridge.ts @@ -22,6 +22,8 @@ const EVENT_MAP: Record = { 'tool.execute.failed': 'PostToolUseFailure', }; +const MAX_CAPTURED_PROMPT_CHARACTERS = 600; + function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -44,6 +46,19 @@ function duration(...values: unknown[]): number | undefined { return value === undefined ? undefined : Math.max(0, Math.round(value)); } +function workEpisodeCaptureEnabled(value: string | undefined): boolean { + return ['bounded', 'on', 'true', '1'].includes( + String(value ?? '').trim().toLowerCase() + ); +} + +function boundedPrompt(value: unknown): string | undefined { + const prompt = string(value); + return prompt + ? Array.from(prompt).slice(0, MAX_CAPTURED_PROMPT_CHARACTERS).join('') + : undefined; +} + function safeActionDescriptor( root: Record, properties: Record, @@ -93,20 +108,16 @@ function safeActionDescriptor( export function canonicalOpenCodeEvent( nativeEvent: string, - payload?: unknown + _payload?: unknown ): string | null { - if (nativeEvent === 'message.updated') { - const properties = record(record(payload).properties); - const info = record(properties.info); - return info.role === 'user' ? 'UserPromptSubmit' : null; - } return EVENT_MAP[nativeEvent] ?? null; } /** Keep bounded intent/lineage plus metadata admitted by the Wizard hook. */ export function sanitizeOpenCodePayload( payload: unknown, - directory: string + directory: string, + env: Env = process.env ): Record { const root = record(payload); const properties = record(root.properties); @@ -134,7 +145,9 @@ export function sanitizeOpenCodePayload( tool_use_id: string(root.callID, properties.callID), duration_ms: duration(root.duration_ms, root.duration, properties.duration), permission_mode: string(root.permission, properties.permission), - prompt: string(root.prompt, root.message, properties.prompt), + prompt: workEpisodeCaptureEnabled(env.ORGX_SESSION_WORK_EPISODE_CAPTURE) + ? boundedPrompt(string(root.prompt, root.message, properties.prompt)) + : undefined, root_session_id: string(root.rootSessionID, root.root_session_id), parent_session_id: string( root.parentSessionID, @@ -231,11 +244,10 @@ export async function bridgeOpenCodeSessionSummary({ argv: [ `--event=${canonicalEvent}`, '--source_client=opencode', - '--work_episode_capture=bounded', ...(queueDir ? [`--queue_dir=${queueDir}`] : []), ], env, - stdinText: JSON.stringify(sanitizeOpenCodePayload(payload, directory)), + stdinText: JSON.stringify(sanitizeOpenCodePayload(payload, directory, env)), }); const fallbackDeliveryTriggered = result.queued === true && result.delivery_triggered !== true diff --git a/src/wizardContextBridge.ts b/src/wizardContextBridge.ts new file mode 100644 index 0000000..c7d159b --- /dev/null +++ b/src/wizardContextBridge.ts @@ -0,0 +1,410 @@ +import { spawn as nodeSpawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { isAbsolute, resolve } from 'node:path'; + +export type Env = Record; + +export const MAX_SESSION_WORK_CONTEXT_BYTES = 4 * 1024; +export const MAX_WIZARD_OUTPUT_BYTES = 16 * 1024; + +const DEFAULT_TIMEOUT_MS = 3_000; +const MIN_TIMEOUT_MS = 250; +const MAX_TIMEOUT_MS = 10_000; +const SOURCE_CLIENT = 'opencode'; +const ACK_VERSION = 'orgx-session-work-context-ack/v1'; +const ACTIVATION_VERSION = 'orgx-session-work-context-activation/v2'; +const WIZARD_ENV_ALLOWLIST = new Set([ + 'APPDATA', + 'CI', + 'ComSpec', + 'DO_NOT_TRACK', + 'DSH_HOME', + 'FORCE_COLOR', + 'HOME', + 'LANG', + 'LC_ALL', + 'LOCALAPPDATA', + 'NO_COLOR', + 'ORGX_TELEMETRY_DISABLED', + 'ORGX_WIZARD_CONFIG_HOME', + 'ORGX_WIZARD_DISABLE_KEYTAR', + 'ORGX_WIZARD_HOOK_OUTBOX', + 'ORGX_WIZARD_HOOK_OUTBOX_MAX_BYTES', + 'ORGX_WIZARD_HOOK_SPOOL', + 'PATH', + 'PATHEXT', + 'SystemRoot', + 'TEMP', + 'TMP', + 'TMPDIR', + 'USERPROFILE', + 'WINDIR', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', +]); + +export type SessionContextActivation = { + activated: boolean; + reason: + | 'wizard_activated' + | 'wizard_rejected' + | 'wizard_unavailable' + | 'wizard_timeout' + | 'wizard_unverified' + | 'wizard_cwd_mismatch' + | 'wizard_output_too_large' + | 'context_refresh_failed' + | 'context_invalid' + | 'not_returned'; + pendingPath?: string; + priorActivationCleared?: boolean; + clearReason?: string; +}; + +export type SessionContextClearance = { + cleared: boolean; + reason: + | 'wizard_cleared' + | 'wizard_already_clear' + | 'wizard_rejected' + | 'wizard_unavailable' + | 'wizard_timeout' + | 'wizard_unverified' + | 'wizard_cwd_mismatch' + | 'wizard_output_too_large' + | 'project_directory_unavailable'; +}; + +type SpawnedChild = { + stdin?: { + end: (value: string) => void; + once?: (event: 'error', listener: () => void) => unknown; + } | null; + stdout?: { + on?: (event: 'data', listener: (chunk: Uint8Array | string) => void) => unknown; + } | null; + once?: { + (event: 'error', listener: () => void): unknown; + (event: 'close', listener: (code: number | null) => void): unknown; + }; + kill?: () => unknown; +}; + +export type SpawnLike = ( + command: string, + args: string[], + options: { + env: Env; + stdio: ['pipe', 'pipe', 'ignore']; + windowsHide: true; + } +) => SpawnedChild; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +function normalizedSessionId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized && byteLength(normalized) <= 512 ? normalized : null; +} + +function canonicalJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value + .filter((entry) => entry !== undefined) + .map((entry) => canonicalJsonValue(entry)); + } + if (isRecord(value)) { + const sorted: Record = Object.create(null); + for (const key of Object.keys(value).sort()) { + if (value[key] !== undefined) sorted[key] = canonicalJsonValue(value[key]); + } + return sorted; + } + return value; +} + +export function canonicalSessionWorkContextJson(context: unknown): string { + const canonical = canonicalJsonValue(context); + if (canonical === undefined) throw new TypeError('context is undefined'); + return JSON.stringify(canonical); +} + +export function sessionWorkContextSha256(context: unknown): string { + return canonicalJsonSha256(context); +} + +export function canonicalJsonSha256(value: unknown): string { + return createHash('sha256') + .update(canonicalSessionWorkContextJson(value)) + .digest('hex'); +} + +function boundedTimeout(value: string | undefined): number { + const configured = Number(value); + return Number.isFinite(configured) && + configured >= MIN_TIMEOUT_MS && + configured <= MAX_TIMEOUT_MS + ? Math.round(configured) + : DEFAULT_TIMEOUT_MS; +} + +export function credentialFreeWizardEnvironment(env: Env): Env { + const childEnv: Env = {}; + for (const name of WIZARD_ENV_ALLOWLIST) { + if (typeof env[name] === 'string') childEnv[name] = env[name]; + } + return childEnv; +} + +function parseActivationAcknowledgement( + stdout: string, + projectDir: string, + sessionId: string, + contextSha256: string +): SessionContextActivation { + try { + const value: unknown = JSON.parse(stdout); + if ( + !isRecord(value) || + value.ackVersion !== ACK_VERSION || + value.activationVersion !== ACTIVATION_VERSION || + value.ready !== true || + value.state !== 'ready' || + value.sourceClient !== SOURCE_CLIENT || + value.sessionId !== sessionId || + value.contextSha256 !== contextSha256 + ) { + return { activated: false, reason: 'wizard_unverified' }; + } + if ( + typeof value.cwd !== 'string' || + !isAbsolute(value.cwd) || + value.cwd !== projectDir + ) { + return { activated: false, reason: 'wizard_cwd_mismatch' }; + } + return { activated: true, reason: 'wizard_activated' }; + } catch { + return { activated: false, reason: 'wizard_unverified' }; + } +} + +function parseClearAcknowledgement( + stdout: string, + projectDir: string, + sessionId: string +): SessionContextClearance { + try { + const value: unknown = JSON.parse(stdout); + if ( + !isRecord(value) || + value.ackVersion !== ACK_VERSION || + value.ready !== false || + value.state !== 'missing' || + value.sourceClient !== SOURCE_CLIENT || + value.sessionId !== sessionId + ) { + return { cleared: false, reason: 'wizard_unverified' }; + } + if ( + typeof value.cwd !== 'string' || + !isAbsolute(value.cwd) || + value.cwd !== projectDir + ) { + return { cleared: false, reason: 'wizard_cwd_mismatch' }; + } + return { + cleared: true, + reason: value.cleared === true ? 'wizard_cleared' : 'wizard_already_clear', + }; + } catch { + return { cleared: false, reason: 'wizard_unverified' }; + } +} + +function runWizardJsonCommand({ + args, + env, + failure, + input = '', + parseSuccess, + projectDir, + spawnImpl, +}: { + args: string[]; + env: Env; + failure: (reason: SessionContextActivation['reason']) => T; + input?: string; + parseSuccess: (stdout: string, projectDir: string) => T; + projectDir: string; + spawnImpl: SpawnLike; +}): Promise { + return new Promise((resolveResult) => { + let settled = false; + let outputBytes = 0; + const output: Buffer[] = []; + let timer: ReturnType | undefined; + const finish = (result: T) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolveResult(result); + }; + + let child: SpawnedChild; + try { + child = spawnImpl('orgx-wizard', args, { + env: credentialFreeWizardEnvironment(env), + stdio: ['pipe', 'pipe', 'ignore'], + windowsHide: true, + }); + if (!child) throw new Error('wizard unavailable'); + } catch { + finish(failure('wizard_unavailable')); + return; + } + + child.once?.('error', () => finish(failure('wizard_unavailable'))); + child.stdout?.on?.('data', (chunk) => { + const buffer = Buffer.from(chunk); + outputBytes += buffer.byteLength; + if (outputBytes > MAX_WIZARD_OUTPUT_BYTES) { + child.kill?.(); + finish(failure('wizard_output_too_large')); + return; + } + output.push(buffer); + }); + child.once?.('close', (code) => { + if (code !== 0) { + finish(failure('wizard_rejected')); + return; + } + finish(parseSuccess(Buffer.concat(output).toString('utf8'), projectDir)); + }); + child.stdin?.once?.('error', () => finish(failure('wizard_unavailable'))); + if (!child.stdin) { + finish(failure('wizard_unavailable')); + return; + } + timer = setTimeout(() => { + child.kill?.(); + finish(failure('wizard_timeout')); + }, boundedTimeout(env.ORGX_SESSION_CONTEXT_ACTIVATION_TIMEOUT_MS)); + + try { + child.stdin.end(input); + } catch { + finish(failure('wizard_unavailable')); + } + }); +} + +export async function activateSessionWorkContext({ + context, + projectDir, + sessionId, + env = process.env, + spawnImpl = nodeSpawn as unknown as SpawnLike, +}: { + context?: unknown; + projectDir?: string; + sessionId?: string; + env?: Env; + spawnImpl?: SpawnLike; +} = {}): Promise { + const normalizedId = normalizedSessionId(sessionId); + if ( + !isRecord(context) || + context.schema_version !== 'orgx-session-work-context/v1' || + !projectDir || + !isAbsolute(projectDir) || + !normalizedId + ) { + return { activated: false, reason: 'context_invalid' }; + } + const contextJson = canonicalSessionWorkContextJson(context); + if (byteLength(contextJson) > MAX_SESSION_WORK_CONTEXT_BYTES) { + return { activated: false, reason: 'context_invalid' }; + } + const normalizedProjectDir = resolve(projectDir); + const contextSha256 = createHash('sha256').update(contextJson).digest('hex'); + return runWizardJsonCommand({ + args: [ + 'sessions', + 'context', + 'set', + '--file', + '-', + '--cwd', + normalizedProjectDir, + '--source-client', + SOURCE_CLIENT, + '--session-id', + normalizedId, + '--context-sha256', + contextSha256, + '--json', + ], + env, + failure: (reason) => ({ activated: false, reason }), + input: contextJson, + parseSuccess: (stdout, cwd) => + parseActivationAcknowledgement( + stdout, + cwd, + normalizedId, + contextSha256 + ), + projectDir: normalizedProjectDir, + spawnImpl, + }); +} + +export async function clearSessionWorkContext({ + projectDir, + sessionId, + env = process.env, + spawnImpl = nodeSpawn as unknown as SpawnLike, +}: { + projectDir?: string; + sessionId?: string; + env?: Env; + spawnImpl?: SpawnLike; +} = {}): Promise { + const normalizedId = normalizedSessionId(sessionId); + if (!projectDir || !isAbsolute(projectDir) || !normalizedId) { + return { cleared: false, reason: 'project_directory_unavailable' }; + } + const normalizedProjectDir = resolve(projectDir); + return runWizardJsonCommand({ + args: [ + 'sessions', + 'context', + 'clear', + '--cwd', + normalizedProjectDir, + '--source-client', + SOURCE_CLIENT, + '--session-id', + normalizedId, + '--json', + ], + env, + failure: (reason) => ({ + cleared: false, + reason: reason as SessionContextClearance['reason'], + }), + parseSuccess: (stdout, cwd) => + parseClearAcknowledgement(stdout, cwd, normalizedId), + projectDir: normalizedProjectDir, + spawnImpl, + }); +} diff --git a/tests/fixtures/gatewaySessionContextActivation.v1.json b/tests/fixtures/gatewaySessionContextActivation.v1.json new file mode 100644 index 0000000..88cc879 --- /dev/null +++ b/tests/fixtures/gatewaySessionContextActivation.v1.json @@ -0,0 +1,83 @@ +{ + "schema_version": "orgx-gateway-session-context-activation/v1", + "source_client": "opencode", + "session_activation": { + "schema_version": "orgx-session-activation/v1", + "scope": { + "workspace_id": "00000000-0000-4000-8000-000000000001", + "initiative_id": "00000000-0000-4000-8000-000000000002", + "workstream_id": "00000000-0000-4000-8000-000000000003", + "task_id": "00000000-0000-4000-8000-000000000004" + }, + "work_context": { + "schema_version": "orgx-session-work-context/v1", + "provenance": "producer_asserted", + "intent": { + "summary": "Continue the accepted OrgX task.", + "acceptance_criteria": [ + "The exact activation is acknowledged before execution." + ], + "constraints": ["Do not start without the server acceptance frame."] + }, + "authority": { + "mode": "explicit", + "status": "granted", + "scope": { + "actions": ["implement", "verify"], + "resources": [], + "systems": ["orgx"] + }, + "constraints": [] + }, + "cost": { + "availability": "not_observed" + }, + "artifact_refs": [], + "evidence_refs": [] + }, + "compaction": { + "compacted": false, + "summary_truncated": false, + "omitted_counts": { + "authoritative_decisions": 0, + "open_risks": 0, + "acceptance_criteria": 0, + "artifact_refs": 0, + "evidence_refs": 0, + "active_constraints": 0, + "pending_expectations": 0, + "applied_learnings": 0, + "recent_receipt_refs": 0 + }, + "source_capsule": { + "id": "capsule-1", + "content_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "generated_at": "2026-08-26T12:00:00.000Z", + "projection_consistency": "best_effort_multi_read", + "omitted_counts": { + "authoritative_decisions": 0, + "applied_learnings": 0, + "pending_expectations": 0, + "open_risks": 0, + "recent_receipt_refs": 0 + }, + "source_completeness": { + "authoritative_decisions": { + "status": "complete", + "candidates_unvalidated": 0 + }, + "current_intent": { + "status": "complete", + "candidates_unvalidated": 0 + }, + "recent_receipt_refs": { + "status": "complete", + "candidates_unvalidated": 0 + } + } + } + } + }, + "context_sha256": "sha256:67bb73b5e76105a0fc86e6399cb1a90f2fc929ca8e0b7fee288782660ebc6601", + "activation_sha256": "sha256:9fc9376b4a3112bb2e2b3ec46e044ca60a1832126545357df52a9abddb7815a8" +} From 1d880769fe7fb2245ea80b957b7ae71ad6e48c8f Mon Sep 17 00:00:00 2001 From: hopeatina Date: Wed, 26 Aug 2026 13:17:27 -0500 Subject: [PATCH 2/4] fix: canonicalize Windows session cwd --- src/OpenCodeDriver.ts | 14 ++++---- src/contextPackHydration.ts | 57 ++++++++++++++++---------------- src/hostPath.test.ts | 47 +++++++++++++++++++++++++++ src/hostPath.ts | 37 +++++++++++++++++++++ src/plugin.ts | 63 +++++++++++++++++++++++------------- src/runtimeSessionContext.ts | 18 +++++++---- src/wizardContextBridge.ts | 26 ++++++--------- 7 files changed, 184 insertions(+), 78 deletions(-) create mode 100644 src/hostPath.test.ts create mode 100644 src/hostPath.ts diff --git a/src/OpenCodeDriver.ts b/src/OpenCodeDriver.ts index a936092..7918445 100644 --- a/src/OpenCodeDriver.ts +++ b/src/OpenCodeDriver.ts @@ -9,7 +9,7 @@ import { promises as fs } from 'node:fs'; import { homedir, platform } from 'node:os'; -import { isAbsolute, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { createOpencodeClient, @@ -46,6 +46,7 @@ import { } from './runtimeSessionContext.js'; import { recordWorkGraphEvent } from './workGraphOutbox.js'; import type { ActivationAcceptanceExpectation } from './activationAcceptance.js'; +import { normalizeAbsoluteHostPath } from './hostPath.js'; type Env = Record; type DriverContext = { @@ -236,10 +237,11 @@ export class OpenCodeDriver implements Driver { }; return; } - if ( - task.repo_path !== undefined && - (typeof task.repo_path !== 'string' || !isAbsolute(task.repo_path)) - ) { + const normalizedRepoPath = + task.repo_path === undefined + ? null + : normalizeAbsoluteHostPath(task.repo_path); + if (task.repo_path !== undefined && !normalizedRepoPath) { yield { kind: 'task.failed', run_id: context.run_id, @@ -249,7 +251,7 @@ export class OpenCodeDriver implements Driver { return; } const directory = resolve( - task.repo_path ?? this.opts.defaultDirectory ?? process.cwd() + normalizedRepoPath ?? this.opts.defaultDirectory ?? process.cwd() ); let active: ActiveRun | undefined; let startedAt = new Date().toISOString(); diff --git a/src/contextPackHydration.ts b/src/contextPackHydration.ts index 6af49e9..8fb1660 100644 --- a/src/contextPackHydration.ts +++ b/src/contextPackHydration.ts @@ -38,6 +38,7 @@ import { type SessionContextClearance, type SpawnLike, } from './wizardContextBridge.js'; +import { normalizeAbsoluteHostPath } from './hostPath.js'; export { MAX_SESSION_WORK_CONTEXT_BYTES, @@ -303,9 +304,9 @@ export function resolvePrivateContextStateDirectory({ stateRoot?: string; }): string | null { const normalizedSessionId = pickString(sessionId); + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); if ( - !projectDir || - !isAbsolute(projectDir) || + !normalizedProjectDir || !normalizedSessionId || byteLength(normalizedSessionId) > 512 ) { @@ -317,7 +318,7 @@ export function resolvePrivateContextStateDirectory({ : null : defaultContextStateRoot(env); if (!root || dirname(root) === root) return null; - const relativeToProject = relative(resolve(projectDir), root); + const relativeToProject = relative(normalizedProjectDir, root); if ( relativeToProject === '' || (relativeToProject !== '..' && @@ -327,7 +328,7 @@ export function resolvePrivateContextStateDirectory({ return null; } const digest = createHash('sha256') - .update(resolve(projectDir)) + .update(normalizedProjectDir) .update('\0') .update(normalizedSessionId) .digest('hex'); @@ -578,8 +579,9 @@ export async function hydrateContextPack({ spawnImpl?: SpawnLike; now?: Date; } = {}): Promise { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); try { - if (!projectDir || !isAbsolute(projectDir)) { + if (!normalizedProjectDir) { return { ok: true, skipped: 'project_directory_unavailable' }; } if (!pickString(sessionId) || byteLength(pickString(sessionId)!) > 512) { @@ -587,7 +589,7 @@ export async function hydrateContextPack({ } const privateState: PrivateContextState = { env, - projectDir, + projectDir: normalizedProjectDir, sessionId: pickString(sessionId)!, stateRoot, }; @@ -595,7 +597,7 @@ export async function hydrateContextPack({ if (!config) { return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: true, skipped: 'context_pack_unconfigured' }, spawnImpl, @@ -614,7 +616,7 @@ export async function hydrateContextPack({ if (!response.ok) { return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: true, @@ -631,7 +633,7 @@ export async function hydrateContextPack({ } catch (error) { return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: true, @@ -649,7 +651,7 @@ export async function hydrateContextPack({ if (responseText === null) { return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: true, skipped: 'context_pack_response_too_large' }, spawnImpl, @@ -661,7 +663,7 @@ export async function hydrateContextPack({ } catch { return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: true, skipped: 'context_pack_response_invalid' }, spawnImpl, @@ -672,7 +674,7 @@ export async function hydrateContextPack({ if (!isRecord(data)) { return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: true, skipped: 'context_pack_response_invalid' }, spawnImpl, @@ -689,7 +691,7 @@ export async function hydrateContextPack({ return clearDefinitiveContext({ contextPackPath, env, - projectDir, + projectDir: normalizedProjectDir, privateState, reason: 'not_returned', spawnImpl, @@ -702,7 +704,7 @@ export async function hydrateContextPack({ return clearDefinitiveContext({ contextPackPath, env, - projectDir, + projectDir: normalizedProjectDir, privateState, reason: 'context_invalid', spawnImpl, @@ -711,7 +713,7 @@ export async function hydrateContextPack({ const activation = await activateSessionWorkContext({ context, - projectDir, + projectDir: normalizedProjectDir, sessionId: privateState.sessionId, env, spawnImpl, @@ -720,7 +722,7 @@ export async function hydrateContextPack({ await removePendingContext(privateState); } else { const clearance = await clearSessionWorkContext({ - projectDir, + projectDir: normalizedProjectDir, sessionId: privateState.sessionId, env, spawnImpl, @@ -739,16 +741,16 @@ export async function hydrateContextPack({ additionalContext: additionalContextFor(context, activation), }; } catch { - if (projectDir && isAbsolute(projectDir) && pickString(sessionId)) { + if (normalizedProjectDir && pickString(sessionId)) { const privateState: PrivateContextState = { env, - projectDir, + projectDir: normalizedProjectDir, sessionId: pickString(sessionId)!, stateRoot, }; return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: false, skipped: 'context_pack_hydration_failed' }, spawnImpl, @@ -785,8 +787,9 @@ export async function activateProvidedSessionWorkContext({ spawnImpl?: SpawnLike; now?: Date; } = {}): Promise { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); try { - if (!projectDir || !isAbsolute(projectDir)) { + if (!normalizedProjectDir) { return { ok: true, skipped: 'project_directory_unavailable' }; } const normalizedId = pickString(sessionId); @@ -795,7 +798,7 @@ export async function activateProvidedSessionWorkContext({ } const privateState: PrivateContextState = { env, - projectDir, + projectDir: normalizedProjectDir, sessionId: normalizedId, stateRoot, }; @@ -807,7 +810,7 @@ export async function activateProvidedSessionWorkContext({ ) { return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: true, skipped: 'context_pack_response_invalid' }, spawnImpl, @@ -829,7 +832,7 @@ export async function activateProvidedSessionWorkContext({ ); const activation = await activateSessionWorkContext({ context, - projectDir, + projectDir: normalizedProjectDir, sessionId: normalizedId, env, spawnImpl, @@ -838,7 +841,7 @@ export async function activateProvidedSessionWorkContext({ await removePendingContext(privateState); } else { const clearance = await clearSessionWorkContext({ - projectDir, + projectDir: normalizedProjectDir, sessionId: normalizedId, env, spawnImpl, @@ -857,16 +860,16 @@ export async function activateProvidedSessionWorkContext({ additionalContext: additionalContextFor(context, activation), }; } catch { - if (projectDir && isAbsolute(projectDir) && pickString(sessionId)) { + if (normalizedProjectDir && pickString(sessionId)) { const privateState: PrivateContextState = { env, - projectDir, + projectDir: normalizedProjectDir, sessionId: pickString(sessionId)!, stateRoot, }; return clearUnverifiedContext({ env, - projectDir, + projectDir: normalizedProjectDir, privateState, result: { ok: false, skipped: 'context_pack_hydration_failed' }, spawnImpl, diff --git a/src/hostPath.test.ts b/src/hostPath.test.ts new file mode 100644 index 0000000..5b376f8 --- /dev/null +++ b/src/hostPath.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeAbsoluteHostPath } from './hostPath'; + +describe('normalizeAbsoluteHostPath', () => { + it('normalizes a drive-qualified Windows path and uppercases its drive', () => { + expect( + normalizeAbsoluteHostPath('c:\\work\\repo\\..\\orgx', 'win32') + ).toBe('C:\\work\\orgx'); + }); + + it('accepts and normalizes a Windows UNC path', () => { + expect( + normalizeAbsoluteHostPath( + '\\\\server\\share\\repo\\..\\orgx', + 'win32' + ) + ).toBe('\\\\server\\share\\orgx'); + }); + + it.each(['\\repo', '/repo', 'c:repo', '\\\\server', '//server'])( + 'rejects the non-qualified Windows path %s', + (candidate) => { + expect(normalizeAbsoluteHostPath(candidate, 'win32')).toBeNull(); + } + ); + + it.each([ + '\\\\?\\C:\\repo', + '\\\\.\\C:\\repo', + '\\\\??\\C:\\repo', + '//?/C:/repo', + '//./C:/repo', + ])('rejects the Windows device namespace path %s', (candidate) => { + expect(normalizeAbsoluteHostPath(candidate, 'win32')).toBeNull(); + }); + + it('keeps POSIX absolute-path behavior', () => { + expect(normalizeAbsoluteHostPath('/work/repo/../orgx', 'linux')).toBe( + '/work/orgx' + ); + expect(normalizeAbsoluteHostPath('/work/orgx/', 'darwin')).toBe( + '/work/orgx' + ); + expect(normalizeAbsoluteHostPath('work/orgx', 'linux')).toBeNull(); + }); +}); diff --git a/src/hostPath.ts b/src/hostPath.ts new file mode 100644 index 0000000..03c14e0 --- /dev/null +++ b/src/hostPath.ts @@ -0,0 +1,37 @@ +import { platform as runtimePlatform } from 'node:os'; +import { posix, win32 } from 'node:path'; + +const WINDOWS_DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_ABSOLUTE = /^[\\/]{2}(?![?.][\\/])[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/; +const WINDOWS_DEVICE_NAMESPACE = /^[\\/]{1,2}(?:[?.][\\/]|\?\?[\\/])/; + +/** + * Canonicalize an absolute path using the selected host's path contract. + * + * Node considers drive-root-relative and device namespace paths absolute on + * Windows. Neither identifies a stable repository cwd, so exact-session + * identity accepts only drive-qualified paths and conventional UNC shares. + */ +export function normalizeAbsoluteHostPath( + value: unknown, + hostPlatform: NodeJS.Platform = runtimePlatform() +): string | null { + if (typeof value !== 'string' || value.trim().length === 0) return null; + + if (hostPlatform !== 'win32') { + return posix.isAbsolute(value) ? posix.resolve(value) : null; + } + + if ( + WINDOWS_DEVICE_NAMESPACE.test(value) || + (!WINDOWS_DRIVE_ABSOLUTE.test(value) && + !WINDOWS_UNC_ABSOLUTE.test(value)) + ) { + return null; + } + + const normalized = win32.resolve(value); + return normalized.replace(/^([a-z]):/, (_, drive: string) => + `${drive.toUpperCase()}:` + ); +} diff --git a/src/plugin.ts b/src/plugin.ts index 386c8d3..fe0d961 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,3 @@ -import { resolve } from 'node:path'; - import type { Plugin } from '@opencode-ai/plugin'; import { createOpencodeClient } from '@opencode-ai/sdk/v2'; @@ -23,6 +21,7 @@ import { } from './attentionBridge.js'; import { captureGatewayCredential } from './childProcessEnv.js'; import { bridgeOpenCodeSessionSummary } from './sessionSummaryBridge.js'; +import { normalizeAbsoluteHostPath } from './hostPath.js'; type StartPeer = (opts: StartPeerOptions) => Promise; type Env = Record; @@ -66,8 +65,14 @@ function nativeSessionId(value: unknown): string | undefined { return undefined; } -function contextHydrationKey(projectDir: string, sessionId: string): string { - return `${resolve(projectDir)}\0${sessionId}`; +function contextHydrationKey( + projectDir: string, + sessionId: string +): string | null { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); + return normalizedProjectDir + ? `${normalizedProjectDir}\0${sessionId}` + : null; } export type CreateOrgXOpenCodePluginOptions = { @@ -146,12 +151,16 @@ export function createOrgXOpenCodePlugin( projectDir: string, sessionId: string ): Promise { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); + if (!normalizedProjectDir) { + return { ok: true, skipped: 'project_directory_unavailable' }; + } const runtimeHydration = readRuntimeSessionHydration( - projectDir, + normalizedProjectDir, sessionId ); if (runtimeHydration) return runtimeHydration; - const key = contextHydrationKey(projectDir, sessionId); + const key = contextHydrationKey(normalizedProjectDir, sessionId)!; const existing = contextHydrations.get(key); if (existing) return existing; const hydration = hydrate({ @@ -161,7 +170,7 @@ export function createOrgXOpenCodePlugin( ORGX_API_KEY: apiKey, ORGX_BASE_URL: safeBaseUrl ?? undefined, }, - projectDir, + projectDir: normalizedProjectDir, sessionId, }).catch((error) => { capturePluginException(error, { stage: 'context_pack_hydration' }); @@ -175,12 +184,14 @@ export function createOrgXOpenCodePlugin( } return async (input) => { + const exactProjectDir = normalizeAbsoluteHostPath(input.directory); + const projectDir = exactProjectDir ?? input.directory; const capture = async (nativeEvent: string, payload: unknown) => { try { await bridgeSessionSummary({ nativeEvent, payload, - directory: input.directory, + directory: projectDir, env, }); } catch (error) { @@ -195,7 +206,7 @@ export function createOrgXOpenCodePlugin( 'experimental.chat.system.transform': async (chatInput, output) => { const sessionId = nativeSessionId(chatInput); if (!sessionId) return; - const result = await hydrateProjectContext(input.directory, sessionId); + const result = await hydrateProjectContext(projectDir, sessionId); const additionalContext = result.additionalContext; if ( typeof additionalContext !== 'string' || @@ -212,7 +223,7 @@ export function createOrgXOpenCodePlugin( 'chat.message': async (messageInput, output) => { const sessionId = nativeSessionId(messageInput); if (sessionId) { - await hydrateProjectContext(input.directory, sessionId); + await hydrateProjectContext(projectDir, sessionId); } const prompt = output.parts .filter( @@ -234,22 +245,28 @@ export function createOrgXOpenCodePlugin( event: async ({ event }) => { const sessionId = nativeSessionId(event); if (event.type === 'session.created' && sessionId) { - await hydrateProjectContext(input.directory, sessionId); + await hydrateProjectContext(projectDir, sessionId); } await capture(event.type, event); if (event.type === 'server.connected') { - await startIfConfigured( - input.serverUrl.toString(), - resolve(input.directory) - ); + if (!exactProjectDir) { + logger.warn( + '[orgx-opencode-plugin] native plugin requires an absolute project directory' + ); + } else { + await startIfConfigured( + input.serverUrl.toString(), + exactProjectDir + ); + } } if (event.type === 'session.deleted' && sessionId) { - const key = contextHydrationKey(input.directory, sessionId); - await contextHydrations.get(key); + const key = contextHydrationKey(projectDir, sessionId); + if (key) await contextHydrations.get(key); const clearance = await clearContext({ env, - projectDir: input.directory, + projectDir, sessionId, }).catch((error) => { capturePluginException(error, { @@ -262,8 +279,8 @@ export function createOrgXOpenCodePlugin( `[orgx-opencode-plugin] session context clear unverified: ${clearance.reason}` ); } - contextHydrations.delete(key); - clearRuntimeSessionHydration(input.directory, sessionId); + if (key) contextHydrations.delete(key); + clearRuntimeSessionHydration(projectDir, sessionId); } if (env.ORGX_REMOTE_ATTENTION !== '1') return; @@ -284,7 +301,7 @@ export function createOrgXOpenCodePlugin( activeAttention.add(questionRequest.id); const nativeClient = createOpencodeClient({ baseUrl: input.serverUrl.toString(), - directory: input.directory, + directory: projectDir, }); void bridgeOpenCodeQuestions({ request: questionRequest, @@ -317,7 +334,7 @@ export function createOrgXOpenCodePlugin( 'tool.execute.before': async (toolInput, output) => { const sessionId = nativeSessionId(toolInput); if (sessionId) { - await hydrateProjectContext(input.directory, sessionId); + await hydrateProjectContext(projectDir, sessionId); } await capture('tool.execute.before', { ...toolInput, @@ -327,7 +344,7 @@ export function createOrgXOpenCodePlugin( 'tool.execute.after': async (toolInput) => { const sessionId = nativeSessionId(toolInput); if (sessionId) { - await hydrateProjectContext(input.directory, sessionId); + await hydrateProjectContext(projectDir, sessionId); } await capture('tool.execute.after', toolInput); }, diff --git a/src/runtimeSessionContext.ts b/src/runtimeSessionContext.ts index 6d2b25b..b25fe02 100644 --- a/src/runtimeSessionContext.ts +++ b/src/runtimeSessionContext.ts @@ -1,12 +1,15 @@ -import { resolve } from 'node:path'; - import type { ContextPackHydrationResult } from './contextPackHydration.js'; +import { normalizeAbsoluteHostPath } from './hostPath.js'; const MAX_RUNTIME_SESSIONS = 256; const runtimeSessions = new Map(); -function key(projectDir: string, sessionId: string): string { - return `${resolve(projectDir)}\0${sessionId.trim()}`; +function key(projectDir: string, sessionId: string): string | null { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); + const normalizedSessionId = sessionId.trim(); + return normalizedProjectDir && normalizedSessionId + ? `${normalizedProjectDir}\0${normalizedSessionId}` + : null; } /** @@ -19,6 +22,7 @@ export function publishRuntimeSessionHydration( result: ContextPackHydrationResult ): void { const sessionKey = key(projectDir, sessionId); + if (!sessionKey) return; runtimeSessions.delete(sessionKey); runtimeSessions.set(sessionKey, result); while (runtimeSessions.size > MAX_RUNTIME_SESSIONS) { @@ -32,7 +36,8 @@ export function readRuntimeSessionHydration( projectDir: string, sessionId: string ): ContextPackHydrationResult | undefined { - return runtimeSessions.get(key(projectDir, sessionId)); + const sessionKey = key(projectDir, sessionId); + return sessionKey ? runtimeSessions.get(sessionKey) : undefined; } /** Prevent a cached ambient pack from regaining authority after failure. */ @@ -50,5 +55,6 @@ export function clearRuntimeSessionHydration( projectDir: string, sessionId: string ): void { - runtimeSessions.delete(key(projectDir, sessionId)); + const sessionKey = key(projectDir, sessionId); + if (sessionKey) runtimeSessions.delete(sessionKey); } diff --git a/src/wizardContextBridge.ts b/src/wizardContextBridge.ts index c7d159b..4554159 100644 --- a/src/wizardContextBridge.ts +++ b/src/wizardContextBridge.ts @@ -1,6 +1,7 @@ import { spawn as nodeSpawn } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { isAbsolute, resolve } from 'node:path'; + +import { normalizeAbsoluteHostPath } from './hostPath.js'; export type Env = Record; @@ -183,11 +184,8 @@ function parseActivationAcknowledgement( ) { return { activated: false, reason: 'wizard_unverified' }; } - if ( - typeof value.cwd !== 'string' || - !isAbsolute(value.cwd) || - value.cwd !== projectDir - ) { + const acknowledgedCwd = normalizeAbsoluteHostPath(value.cwd); + if (!acknowledgedCwd || acknowledgedCwd !== projectDir) { return { activated: false, reason: 'wizard_cwd_mismatch' }; } return { activated: true, reason: 'wizard_activated' }; @@ -213,11 +211,8 @@ function parseClearAcknowledgement( ) { return { cleared: false, reason: 'wizard_unverified' }; } - if ( - typeof value.cwd !== 'string' || - !isAbsolute(value.cwd) || - value.cwd !== projectDir - ) { + const acknowledgedCwd = normalizeAbsoluteHostPath(value.cwd); + if (!acknowledgedCwd || acknowledgedCwd !== projectDir) { return { cleared: false, reason: 'wizard_cwd_mismatch' }; } return { @@ -321,11 +316,11 @@ export async function activateSessionWorkContext({ spawnImpl?: SpawnLike; } = {}): Promise { const normalizedId = normalizedSessionId(sessionId); + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); if ( !isRecord(context) || context.schema_version !== 'orgx-session-work-context/v1' || - !projectDir || - !isAbsolute(projectDir) || + !normalizedProjectDir || !normalizedId ) { return { activated: false, reason: 'context_invalid' }; @@ -334,7 +329,6 @@ export async function activateSessionWorkContext({ if (byteLength(contextJson) > MAX_SESSION_WORK_CONTEXT_BYTES) { return { activated: false, reason: 'context_invalid' }; } - const normalizedProjectDir = resolve(projectDir); const contextSha256 = createHash('sha256').update(contextJson).digest('hex'); return runWizardJsonCommand({ args: [ @@ -380,10 +374,10 @@ export async function clearSessionWorkContext({ spawnImpl?: SpawnLike; } = {}): Promise { const normalizedId = normalizedSessionId(sessionId); - if (!projectDir || !isAbsolute(projectDir) || !normalizedId) { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); + if (!normalizedProjectDir || !normalizedId) { return { cleared: false, reason: 'project_directory_unavailable' }; } - const normalizedProjectDir = resolve(projectDir); return runWizardJsonCommand({ args: [ 'sessions', From 9324f759e2b7673191862ce0b8203fda86e9da6a Mon Sep 17 00:00:00 2001 From: hopeatina Date: Sat, 29 Aug 2026 12:51:53 -0500 Subject: [PATCH 3/4] fix: preserve OpenCode provider leases --- README.md | 9 ++- package-lock.json | 8 +- package.json | 2 +- src/OpenCodeDriver.test.ts | 27 ++++++- src/OpenCodeDriver.ts | 17 +++- src/gatewayProtocolBoundary.test.ts | 120 +++++++++++++++++++++++++++- 6 files changed, 171 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f0f4794..ca05c4d 100644 --- a/README.md +++ b/README.md @@ -198,9 +198,12 @@ HTTP routes: `session.abort` cancels only the session mapped to that OrgX run. The terminal `AssistantMessage.providerID` must exactly match the session's -provider lease. Anthropic and OpenAI are reported from that field rather than -guessed from configuration, and the terminal source subtype remains -`user_managed`. Presence reports the latest observed provider in +native provider lease. The terminal receipt then echoes the immutable Gateway +route attribution—including `provider_id: null` when the route deliberately +left the user-managed provider unknown—rather than silently replacing it with +a later observation. A non-null route provider ID must match the native lease +before the session can run, and the terminal source subtype remains +`user_managed`. Presence separately reports the latest observed provider in `execution_provider`, `execution_provider_id`, and `execution_provider_observed_at`. `execution_auth_method` remains `null` because the official SDK does not distinguish an opaque stored OAuth diff --git a/package-lock.json b/package-lock.json index 30a4951..72c309f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@opencode-ai/sdk": "^1.18.2", "@sentry/node": "10.65.0", - "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#90fbab66eaedeb27f5f34b0ac4101c5ca4f5e173" + "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#c4d1eecf94fc9d9a607a8c60bc7ab166486e8910" }, "bin": { "orgx-opencode-plugin": "dist/cli.js", @@ -1330,9 +1330,9 @@ } }, "node_modules/@useorgx/orgx-gateway-sdk": { - "version": "0.1.0-alpha.10", - "resolved": "git+ssh://git@github.com/useorgx/orgx-gateway-sdk.git#90fbab66eaedeb27f5f34b0ac4101c5ca4f5e173", - "integrity": "sha512-61gDMjQkxj2Qyyzz5CqhywOOuES+XLuB1zAaBPpvlAc4BAPTJGk3Z5tJrSktahcQOn93Uzf0ZjxquduwmVQcUQ==", + "version": "0.1.0-alpha.11", + "resolved": "git+ssh://git@github.com/useorgx/orgx-gateway-sdk.git#c4d1eecf94fc9d9a607a8c60bc7ab166486e8910", + "integrity": "sha512-qQ1cLZmNHEuR2VxEHrd+2eq3Sos92O7/1LzbloZN7dkGTyPUEgs2UZbqEAZZXYXp+ANZAaxOtzio59UlVn8/+g==", "license": "MIT" }, "node_modules/@vitest/expect": { diff --git a/package.json b/package.json index 8184f9a..e95a7ad 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "dependencies": { "@opencode-ai/sdk": "^1.18.2", "@sentry/node": "10.65.0", - "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#90fbab66eaedeb27f5f34b0ac4101c5ca4f5e173" + "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#c4d1eecf94fc9d9a607a8c60bc7ab166486e8910" }, "devDependencies": { "@opencode-ai/plugin": "^1.18.2", diff --git a/src/OpenCodeDriver.test.ts b/src/OpenCodeDriver.test.ts index 92ab88f..e077683 100644 --- a/src/OpenCodeDriver.test.ts +++ b/src/OpenCodeDriver.test.ts @@ -458,7 +458,8 @@ describe('OpenCodeDriver official SDK boundary', () => { kind: 'task.completed', outcome_kind: 'awaiting_review', tokens_used: 3400, - provider: 'anthropic', + provider: 'other', + provider_id: null, source_sub_type: 'user_managed', }); expect(fixture.eventAbortSignals).toHaveLength(1); @@ -484,6 +485,7 @@ describe('OpenCodeDriver official SDK boundary', () => { expect(messages.at(-1)).toMatchObject({ kind: 'task.completed', provider: 'openai', + provider_id: 'openai', source_sub_type: 'user_managed', }); expect(driver.executionProviderLease()).toMatchObject({ @@ -506,6 +508,24 @@ describe('OpenCodeDriver official SDK boundary', () => { }); }); + it('rejects execution attribution that omits the leased provider ID', async () => { + const fixture = createSdkFixture(); + const messages = await collect(driverWithFixture(fixture), { + execution_attribution: { + provider: 'other', + source_sub_type: 'user_managed', + observed_at: '2026-08-26T16:00:00.000Z', + }, + repo_path: PROJECT_DIR, + }); + + expect(fixture.create).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + kind: 'task.failed', + reason: 'OrgX dispatch execution attribution is invalid', + }); + }); + it('rejects a non-null provider lease that disagrees with the native session', async () => { const fixture = createSdkFixture(); const messages = await collect(driverWithFixture(fixture), { @@ -518,6 +538,11 @@ describe('OpenCodeDriver official SDK boundary', () => { kind: 'task.failed', reason: 'OpenCode session provider does not match execution attribution', }); + expect( + messages.some((message) => + (message as { kind?: string }).kind === 'task.completed' + ) + ).toBe(false); }); it('consumes the exact Gateway activation and acknowledges it before prompting', async () => { diff --git a/src/OpenCodeDriver.ts b/src/OpenCodeDriver.ts index 7918445..e010bb3 100644 --- a/src/OpenCodeDriver.ts +++ b/src/OpenCodeDriver.ts @@ -91,6 +91,13 @@ type GatewayExecutionAttribution = { sourceSubType: 'user_managed'; observedAt: string; }; +type OpenCodeTaskCompletedMessage = Extract< + DriverOutboundMessage, + { kind: 'task.completed' } +> & { + /** Immutable provider identifier leased by the Gateway execution route. */ + provider_id: string | null; +}; type OpenCodeEvent = | { kind: 'tool_call'; tool: string; summary: string; ref?: string } | { kind: 'file_edit'; path: string; summary: string; diff_ref?: string } @@ -425,7 +432,7 @@ export class OpenCodeDriver implements Driver { first_response_seen: Boolean(firstResponseAt), }); this.assertNotCancelled(context.run_id); - yield { + const completedMessage: OpenCodeTaskCompletedMessage = { kind: 'task.completed', run_id: context.run_id, // A completed model turn is not proof that OrgX accepted or shipped it. @@ -434,11 +441,17 @@ export class OpenCodeDriver implements Driver { first_response_at: firstResponseAt ?? startedAt, completed_at: new Date().toISOString(), tokens_used: event.tokens_used, - provider: providerKind(event.provider), + // Echo the immutable route lease rather than re-projecting the + // terminal model event. A null provider_id is meaningful: the + // Gateway deliberately routed against an unknown user-managed + // provider and must receive that exact lease back. + provider: executionAttribution.provider, + provider_id: executionAttribution.providerId, source_sub_type: executionAttribution.sourceSubType, source_driver: 'opencode', cost_estimate_cents: 0, }; + yield completedMessage; await this.replayWorkGraph(); return; } diff --git a/src/gatewayProtocolBoundary.test.ts b/src/gatewayProtocolBoundary.test.ts index 134d311..0929b78 100644 --- a/src/gatewayProtocolBoundary.test.ts +++ b/src/gatewayProtocolBoundary.test.ts @@ -1,5 +1,6 @@ import { PeerClient, + type Driver, type WebSocketEvent, type WebSocketLike, } from '@useorgx/orgx-gateway-sdk'; @@ -27,8 +28,14 @@ class TestSocket implements WebSocketLike { close(): void {} + constructor(private readonly rejectKind?: string) {} + send(data: string): void { - this.sent.push(JSON.parse(data)); + const message = JSON.parse(data) as { kind?: string }; + if (message.kind === this.rejectKind) { + throw new Error(`test socket rejected ${this.rejectKind}`); + } + this.sent.push(message); } emit(type: 'open' | 'close' | 'error' | 'message', event: WebSocketEvent): void { @@ -36,7 +43,118 @@ class TestSocket implements WebSocketLike { } } +function completedDriver(providerId: string | null): Driver { + return { + id: 'opencode', + detect: vi.fn(async () => ({ installed: true, authenticated: true })), + probe: vi.fn(async () => ({ + subscription_active: true, + session_alive: true, + })), + cancel: vi.fn(async () => undefined), + async *dispatch(_task, context) { + yield { + kind: 'task.completed', + run_id: context.run_id, + outcome_kind: 'awaiting_review', + started_at: '2026-08-29T13:00:00.000Z', + completed_at: '2026-08-29T13:00:01.000Z', + tokens_used: 100, + provider: providerId === null ? 'other' : 'openai', + provider_id: providerId, + source_sub_type: 'user_managed', + source_driver: 'opencode', + cost_estimate_cents: 0, + }; + }, + }; +} + +function emitV1Dispatch(socket: TestSocket, runId: string): void { + socket.emit('message', { + data: JSON.stringify({ + kind: 'task.dispatch', + run_id: runId, + idempotency_key: `key-${runId}`, + timeout_seconds: 60, + task: { title: 'echo provider lease', driver: 'opencode' }, + }), + }); +} + describe('Gateway SDK protocol boundary', () => { + it.each([ + ['openai', 'openai'], + [null, 'other'], + ] as const)( + 'preserves the exact provider_id lease %s on the WebSocket terminal', + async (providerId, provider) => { + const socket = new TestSocket(); + const peer = new PeerClient({ + baseUrl: 'wss://useorgx.test', + apiKey: 'oxk_test_only', + workspaceId: 'workspace-1', + pluginId: 'orgx-opencode-plugin', + protocolVersion: 1, + drivers: [completedDriver(providerId)], + reconnect: false, + webSocketFactory: () => socket, + }); + + peer.connect(); + socket.emit('open', {}); + emitV1Dispatch(socket, `run-${provider}`); + + await vi.waitFor(() => expect(socket.sent).toHaveLength(1)); + expect(socket.sent[0]).toMatchObject({ + kind: 'task.completed', + provider, + provider_id: providerId, + source_sub_type: 'user_managed', + source_driver: 'opencode', + }); + peer.disconnect(); + } + ); + + it('preserves provider_id in the HTTP recovery receipt when the terminal socket send fails', async () => { + const socket = new TestSocket('task.completed'); + const requests: Array<{ url: string; body: Record }> = []; + const peer = new PeerClient({ + baseUrl: 'wss://useorgx.test', + apiKey: 'oxk_test_only', + workspaceId: 'workspace-1', + pluginId: 'orgx-opencode-plugin', + protocolVersion: 1, + drivers: [completedDriver('openai')], + reconnect: false, + webSocketFactory: () => socket, + fetch: vi.fn(async (input, init) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body ?? '{}')), + }); + return new Response('{}', { status: 200 }); + }), + }); + + peer.connect(); + socket.emit('open', {}); + emitV1Dispatch(socket, 'run-recovery'); + + await vi.waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toEqual({ + url: 'https://useorgx.test/api/v1/runs/run-recovery/receipt', + body: expect.objectContaining({ + provider: 'openai', + provider_id: 'openai', + source_sub_type: 'user_managed', + source_driver: 'opencode', + }), + }); + peer.disconnect(); + }); + it('fails protocol v2 before native session creation instead of emitting a mismatched terminal', async () => { const socket = new TestSocket(); const createClient = vi.fn(() => { From f43477d580a26371770922c723af0263b7a06285 Mon Sep 17 00:00:00 2001 From: hopeatina Date: Sat, 29 Aug 2026 13:11:16 -0500 Subject: [PATCH 4/4] fix: separate observed provider attribution --- README.md | 5 ++++- package-lock.json | 6 +++--- package.json | 2 +- src/OpenCodeDriver.test.ts | 2 ++ src/OpenCodeDriver.ts | 3 +++ src/gatewayProtocolBoundary.test.ts | 9 ++++++--- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ca05c4d..1a69e1a 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,10 @@ route attribution—including `provider_id: null` when the route deliberately left the user-managed provider unknown—rather than silently replacing it with a later observation. A non-null route provider ID must match the native lease before the session can run, and the terminal source subtype remains -`user_managed`. Presence separately reports the latest observed provider in +`user_managed`. The separate `observed_provider_id` terminal field records the +provider learned from that exact native session, so a first-run unknown route +can become an attributed observation without rewriting its routing history. +Presence separately reports the latest observed provider in `execution_provider`, `execution_provider_id`, and `execution_provider_observed_at`. `execution_auth_method` remains `null` because the official SDK does not distinguish an opaque stored OAuth diff --git a/package-lock.json b/package-lock.json index 72c309f..b33c3ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@opencode-ai/sdk": "^1.18.2", "@sentry/node": "10.65.0", - "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#c4d1eecf94fc9d9a607a8c60bc7ab166486e8910" + "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#0d2b73c8e0117d4185976f4309aa8d6c412d500b" }, "bin": { "orgx-opencode-plugin": "dist/cli.js", @@ -1331,8 +1331,8 @@ }, "node_modules/@useorgx/orgx-gateway-sdk": { "version": "0.1.0-alpha.11", - "resolved": "git+ssh://git@github.com/useorgx/orgx-gateway-sdk.git#c4d1eecf94fc9d9a607a8c60bc7ab166486e8910", - "integrity": "sha512-qQ1cLZmNHEuR2VxEHrd+2eq3Sos92O7/1LzbloZN7dkGTyPUEgs2UZbqEAZZXYXp+ANZAaxOtzio59UlVn8/+g==", + "resolved": "git+ssh://git@github.com/useorgx/orgx-gateway-sdk.git#0d2b73c8e0117d4185976f4309aa8d6c412d500b", + "integrity": "sha512-2qKRgNZaE4KpvHwwhEfrKEKJPNyr5bPhk6RarLhvMaes8dGgxbY5CX5+zPooc7LzBaqD5/rm20kSCw8IYOH6nw==", "license": "MIT" }, "node_modules/@vitest/expect": { diff --git a/package.json b/package.json index e95a7ad..a9c9430 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "dependencies": { "@opencode-ai/sdk": "^1.18.2", "@sentry/node": "10.65.0", - "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#c4d1eecf94fc9d9a607a8c60bc7ab166486e8910" + "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#0d2b73c8e0117d4185976f4309aa8d6c412d500b" }, "devDependencies": { "@opencode-ai/plugin": "^1.18.2", diff --git a/src/OpenCodeDriver.test.ts b/src/OpenCodeDriver.test.ts index e077683..d9118f9 100644 --- a/src/OpenCodeDriver.test.ts +++ b/src/OpenCodeDriver.test.ts @@ -460,6 +460,7 @@ describe('OpenCodeDriver official SDK boundary', () => { tokens_used: 3400, provider: 'other', provider_id: null, + observed_provider_id: 'anthropic', source_sub_type: 'user_managed', }); expect(fixture.eventAbortSignals).toHaveLength(1); @@ -486,6 +487,7 @@ describe('OpenCodeDriver official SDK boundary', () => { kind: 'task.completed', provider: 'openai', provider_id: 'openai', + observed_provider_id: 'openai', source_sub_type: 'user_managed', }); expect(driver.executionProviderLease()).toMatchObject({ diff --git a/src/OpenCodeDriver.ts b/src/OpenCodeDriver.ts index e010bb3..c5dcc12 100644 --- a/src/OpenCodeDriver.ts +++ b/src/OpenCodeDriver.ts @@ -97,6 +97,8 @@ type OpenCodeTaskCompletedMessage = Extract< > & { /** Immutable provider identifier leased by the Gateway execution route. */ provider_id: string | null; + /** Provider identifier observed from the exact native OpenCode session. */ + observed_provider_id: string | null; }; type OpenCodeEvent = | { kind: 'tool_call'; tool: string; summary: string; ref?: string } @@ -447,6 +449,7 @@ export class OpenCodeDriver implements Driver { // provider and must receive that exact lease back. provider: executionAttribution.provider, provider_id: executionAttribution.providerId, + observed_provider_id: active.providerLease?.providerId ?? null, source_sub_type: executionAttribution.sourceSubType, source_driver: 'opencode', cost_estimate_cents: 0, diff --git a/src/gatewayProtocolBoundary.test.ts b/src/gatewayProtocolBoundary.test.ts index 0929b78..c6f6e5e 100644 --- a/src/gatewayProtocolBoundary.test.ts +++ b/src/gatewayProtocolBoundary.test.ts @@ -62,6 +62,7 @@ function completedDriver(providerId: string | null): Driver { tokens_used: 100, provider: providerId === null ? 'other' : 'openai', provider_id: providerId, + observed_provider_id: providerId ?? 'anthropic', source_sub_type: 'user_managed', source_driver: 'opencode', cost_estimate_cents: 0, @@ -110,6 +111,7 @@ describe('Gateway SDK protocol boundary', () => { kind: 'task.completed', provider, provider_id: providerId, + observed_provider_id: providerId ?? 'anthropic', source_sub_type: 'user_managed', source_driver: 'opencode', }); @@ -126,7 +128,7 @@ describe('Gateway SDK protocol boundary', () => { workspaceId: 'workspace-1', pluginId: 'orgx-opencode-plugin', protocolVersion: 1, - drivers: [completedDriver('openai')], + drivers: [completedDriver(null)], reconnect: false, webSocketFactory: () => socket, fetch: vi.fn(async (input, init) => { @@ -146,8 +148,9 @@ describe('Gateway SDK protocol boundary', () => { expect(requests[0]).toEqual({ url: 'https://useorgx.test/api/v1/runs/run-recovery/receipt', body: expect.objectContaining({ - provider: 'openai', - provider_id: 'openai', + provider: 'other', + provider_id: null, + observed_provider_id: 'anthropic', source_sub_type: 'user_managed', source_driver: 'opencode', }),