From 778233bca23177e71da8a0c262e2f94b95d11549 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 22:48:03 +0000 Subject: [PATCH] fix(harness): fence assistant action results by binding --- .changeset/assistant-action-authority.md | 5 ++++ packages/harness/docs/assistant-history.md | 4 +++ .../src/server/assistant-lifecycle.test.ts | 25 +++++++++++++++++++ .../harness/src/server/assistant-lifecycle.ts | 6 +++-- 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 .changeset/assistant-action-authority.md diff --git a/.changeset/assistant-action-authority.md b/.changeset/assistant-action-authority.md new file mode 100644 index 000000000..0f8aff4e2 --- /dev/null +++ b/.changeset/assistant-action-authority.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Discard Assistant inspection and Resume responses after an authorized binding changes in the same workspace. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 39befffd1..7a788190d 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -154,3 +154,7 @@ Resume and Continue. Attach includes verified public continuation provenance and rechecks its lease after receipt reads. Pending children stay out of history and cannot attach, including after restart; only prepared children publish a session event. Runtime activation remains an explicit capability from its existing owner. + +Inspection and Resume responses recheck the complete opaque history scope after +asynchronous work. A changed project, account, harness or native binding invalidates +the result even when the workspace path and lifecycle revision are unchanged. diff --git a/packages/harness/src/server/assistant-lifecycle.test.ts b/packages/harness/src/server/assistant-lifecycle.test.ts index 2bd6224c0..fcadb7059 100644 --- a/packages/harness/src/server/assistant-lifecycle.test.ts +++ b/packages/harness/src/server/assistant-lifecycle.test.ts @@ -43,6 +43,7 @@ const savedEntry = { history: "partial", nativeResume: "unchecked", recordRevision: 7, + continuationScope: "a".repeat(64), }; const session = { id: "studio-a", @@ -235,6 +236,30 @@ it("discards Resume when a newer lifecycle or authorization wins before the resp expect(response.status).toBe(403); expect(await response.text()).not.toContain("private"); }); +it.each([ + ["inspect", "b".repeat(64)], + ["inspect", undefined], + ["resume", "b".repeat(64)], + ["resume", undefined], +] as const)( + "discards %s results when the authorized binding changes in the same workspace (%s)", + async (action, continuationScope) => { + entry.mockResolvedValueOnce(savedEntry).mockResolvedValueOnce({ + ...savedEntry, + continuationScope, + lifecycle: action === "resume" ? attachment.lifecycle : lifecycle, + }); + const response = await post(action, { + expectedRevision: 4, + ...(action === "resume" ? { operationId: op } : {}), + }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: openCodeTransportFailure("lifecycle_changed"), + }); + }, +); + it("projects Continue provenance without receipt inputs or accepted-reference authority", async () => { const response = await post("continue", { expectedRevision: 4, diff --git a/packages/harness/src/server/assistant-lifecycle.ts b/packages/harness/src/server/assistant-lifecycle.ts index 776550db3..55026f19e 100644 --- a/packages/harness/src/server/assistant-lifecycle.ts +++ b/packages/harness/src/server/assistant-lifecycle.ts @@ -146,7 +146,8 @@ export function createAssistantLifecycleRouter(options: { if ( !current || current.lifecycle.revision !== parsed.data.expectedRevision || - current.cwd !== entry.cwd + current.cwd !== entry.cwd || + current.continuationScope !== entry.continuationScope ) throw changed(); res.json({ @@ -165,12 +166,13 @@ export function createAssistantLifecycleRouter(options: { request.expectedRevision, request.operationId, ); - const session = options.getSession(id); const current = await options.history.entry(id); + const session = options.getSession(id); if ( !session || !current || current.cwd !== entry.cwd || + current.continuationScope !== entry.continuationScope || current.lifecycle.revision !== attachment.lifecycle.revision || current.lifecycle.lifecycle !== "open" )