diff --git a/.changeset/assistant-history-workspace-aliases.md b/.changeset/assistant-history-workspace-aliases.md new file mode 100644 index 000000000..22b7f0b3b --- /dev/null +++ b/.changeset/assistant-history-workspace-aliases.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Verify workspace aliases across Assistant history, Resume, and mixed Terminal records while preserving canonical authority and refresh ordering. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index dd202933b..bae2910d9 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -218,3 +218,9 @@ Starting Terminal keeps the same conversation and pending request through host status updates, including readiness before the HTTP acknowledgement. Terminal focus stays selected, and settling the request shows the latest live or exited state. Navigation, account changes and End still invalidate stale operations. + +Workspace aliases use a server-verified pair of the Studio launch path and its +canonical record path. History, inspection, Resume and mixed Terminal records +keep both identities, and reject a changed or retargeted path. Refreshes through +aliases share canonical publication ordering, so an empty response clears stale +rows without hiding failures in a different workspace. diff --git a/packages/harness/src/core/assistant-history-access.test.ts b/packages/harness/src/core/assistant-history-access.test.ts index 9dcbc8fca..65aa20e0b 100644 --- a/packages/harness/src/core/assistant-history-access.test.ts +++ b/packages/harness/src/core/assistant-history-access.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { assistantHistoryAccess } from "./assistant-history-access.js"; @@ -9,14 +9,33 @@ import { nativeAuthorityScope, } from "./assistant-authority.js"; import type { AssistantGrant } from "./assistant-access.js"; +import { AssistantHistory } from "./assistant-history.js"; + +const filesystem = vi.hoisted(() => ({ + afterRealpath: null as null | (() => Promise), +})); +vi.mock("node:fs/promises", async (original) => { + const actual = await original(); + return { + ...actual, + realpath: async (...args: Parameters) => { + const path = await actual.realpath(...args); + await filesystem.afterRealpath?.(); + return path; + }, + }; +}); let root: string; afterEach(async () => { + filesystem.afterRealpath = null; if (root) await rm(root, { recursive: true, force: true }); }); it("resolves the host's same stable binding without native IO and rechecks authority after awaits", async () => { - root = await mkdtemp(join(tmpdir(), "assistant-history-access-")); + root = await realpath( + await mkdtemp(join(tmpdir(), "assistant-history-access-")), + ); let grant: AssistantGrant | null = { userId: "u", tenantId: "t", @@ -48,6 +67,107 @@ it("resolves the host's same stable binding without native IO and rechecks autho store, }); expect(await resolve("studio-a")).toEqual(saved); + let checks = 0; + const checked = Object.assign( + async (id: string) => { + const binding = await resolve(id); + checks++; + return binding; + }, + { captureAuthority: resolve.captureAuthority }, + ); + const session = { + id: "studio-a", + cwd: root, + title: "Private saved title", + harness: "claude-code" as const, + status: "exited" as const, + ready: false, + agentSessionId: null, + boundWorkflowPath: null, + createdAt: "2026-09-15T00:00:00.000Z", + lastActiveAt: "2026-09-15T00:00:00.000Z", + }; + const history = new AssistantHistory({ + sessions: { list: () => [session], get: () => session }, + authorize: checked, + records: { read: async () => null }, + lifecycle: { + describe: async () => ({ + version: 1, + harnessSessionId: session.id, + lifecycle: "ended", + execution: "paused", + revision: 1, + updatedAt: 1, + }), + }, + }); + for (const action of ["entry", "list"] as const) + for (const change of [ + "user", + "tenant", + "identity", + "expire", + "revoke", + "refresh", + ] as const) { + const before: AssistantGrant = { + ...grant!, + environment: { ...grant!.environment }, + }; + checks = 0; + let enter!: () => void, release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + filesystem.afterRealpath = async () => { + if (checks !== (action === "entry" ? 2 : 3)) return; + filesystem.afterRealpath = null; + enter(); + await held; + }; + const pending = + action === "entry" + ? history.entry(session.id) + : history.listWithWorkspace(root); + await entered; + if (change === "user") grant!.userId = "changed-user"; + if (change === "tenant") grant!.tenantId = "changed-tenant"; + if (change === "identity") grant!.identityRevision = "changed-revision"; + if (change === "expire") grant!.expiresAt = Date.now() - 1; + if (change === "revoke") grant = null; + if (change === "refresh") + grant = { ...before, expiresAt: Date.now() + 120000 }; + release(); + if (change === "refresh") expect(await pending).toBeTruthy(); + else await expect(pending).rejects.toThrow("access changed"); + grant = before; + } + const alias = join(root, "alias"), + other = join(root, "other"); + await symlink(root, alias, "junction"); + workspace.cwd = alias; + expect(await resolve("studio-a")).toEqual(saved); + const associate = store.associate.bind(store); + for (const change of ["raw", "target"]) { + vi.spyOn(store, "associate").mockImplementationOnce(async (...args) => { + const binding = await associate(...args); + if (change === "raw") workspace.cwd = root; + else { + await mkdir(other); + await rm(alias); + await symlink(other, alias, "junction"); + } + return binding; + }); + await expect(resolve("studio-a")).rejects.toThrow("access changed"); + workspace.cwd = alias; + } + workspace.cwd = root; authorize.mockImplementationOnce(async () => { grant = { ...grant!, diff --git a/packages/harness/src/core/assistant-history-access.ts b/packages/harness/src/core/assistant-history-access.ts index d116aaa92..8ec52323b 100644 --- a/packages/harness/src/core/assistant-history-access.ts +++ b/packages/harness/src/core/assistant-history-access.ts @@ -28,7 +28,16 @@ export function assistantHistoryAccess(options: { authorize: (id: string) => Promise; store: AssistantSessionStore; }) { - return async (id: string) => { + const captureAuthority = () => { + const captured = authority(options.access.get()); + if (!captured) + throw new OpenCodeAccessError("Assistant access is unavailable"); + return () => { + if (authority(options.access.get()) !== captured) + throw new OpenCodeAccessError("Assistant access changed"); + }; + }; + const resolve = async (id: string) => { const grant = options.access.get(); const captured = authority(grant); if (!grant || !captured) @@ -37,7 +46,8 @@ export function assistantHistoryAccess(options: { const workspace = await options.authorize(id); if (!workspace || workspace.harnessSessionId !== id) throw new OpenCodeAccessError("Workspace unavailable"); - const cwd = await realpath(workspace.cwd); + const workspaceCwd = workspace.cwd; + const cwd = await realpath(workspaceCwd); const key = { harnessSessionId: id, cwd, @@ -57,9 +67,11 @@ export function assistantHistoryAccess(options: { authority(options.access.get()) !== captured || !current || current.harnessSessionId !== id || + current.cwd !== workspaceCwd || currentCwd !== cwd ) throw new OpenCodeAccessError("Assistant access changed"); return binding; }; + return Object.assign(resolve, { captureAuthority }); } diff --git a/packages/harness/src/core/assistant-history.test.ts b/packages/harness/src/core/assistant-history.test.ts index 73d639a94..bc15f6387 100644 --- a/packages/harness/src/core/assistant-history.test.ts +++ b/packages/harness/src/core/assistant-history.test.ts @@ -1,4 +1,11 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; @@ -37,7 +44,7 @@ const record = (binding: AssistantAssociation): AssistantRecord => ({ limitations: [], }); beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), "assistant-discovery-")); + root = await realpath(await mkdtemp(join(tmpdir(), "assistant-discovery-"))); cwd = join(root, "project"); await mkdir(cwd); store = new AssistantSessionStore(root); @@ -189,6 +196,94 @@ it("retains discovery after native data disappears", async () => { ]); }); +it("proves a real query alias and each current launch spelling against the saved canonical binding", async () => { + const query = join(root, "query-alias"), + launch = join(root, "launch-alias"); + await symlink(cwd, query, "junction"); + await symlink(cwd, launch, "junction"); + sessions[0]!.cwd = launch; + const result = await history.listWithWorkspace(query); + expect(result.workspace).toEqual({ cwd: query, canonicalCwd: cwd }); + expect(result.entries).toHaveLength(2); + expect(result.entries[0]).toMatchObject({ + cwd, + workspace: { cwd: launch, canonicalCwd: cwd }, + history: "available", + }); + expect(result.entries[1]!.workspace).toEqual({ cwd, canonicalCwd: cwd }); + await rm(query); + await mkdir(query); + expect(await history.listWithWorkspace(query)).toEqual({ + workspace: { cwd: query, canonicalCwd: query }, + entries: [], + }); +}); + +it.each(["raw rebind", "launch retarget", "query retarget"])( + "fences %s during retained history IO", + async (change) => { + const alias = join(root, "alias"), + other = join(root, "other"); + await symlink(cwd, alias, "junction"); + await mkdir(other); + if (change !== "query retarget") sessions[0]!.cwd = alias; + const read = records.read.bind(records); + vi.spyOn(records, "read").mockImplementationOnce(async (binding) => { + const value = await read(binding); + if (change === "raw rebind") sessions[0]!.cwd = cwd; + else { + await rm(alias); + await symlink(other, alias, "junction"); + } + return value; + }); + await expect( + change === "query retarget" + ? history.listWithWorkspace(alias) + : history.entry("studio-a"), + ).rejects.toThrow(OpenCodeAccessError); + }, +); + +it("rechecks earlier entries' raw spelling after the rest of the list", async () => { + const alias = join(root, "alias"); + await symlink(cwd, alias, "junction"); + const original = authorize.getMockImplementation()!; + let calls = 0; + authorize.mockImplementation(async (id) => { + if (id === "studio-a" && ++calls === 3) sessions[0]!.cwd = alias; + return original(id); + }); + await expect(history.listWithWorkspace(cwd)).rejects.toThrow( + OpenCodeAccessError, + ); +}); + +it.each(["raw", "target"])( + "fences an earlier entry's %s change during final authorization of its sibling", + async (change) => { + const alias = join(root, "alias"), + other = join(root, "other"); + await symlink(cwd, alias, "junction"); + await mkdir(other); + sessions[0]!.cwd = alias; + let calls = 0; + authorize.mockImplementation(async (id) => { + if (id === "studio-b" && ++calls === 3) { + if (change === "raw") sessions[0]!.cwd = cwd; + else { + await rm(alias); + await symlink(other, alias, "junction"); + } + } + return bindings.get(id) ?? null; + }); + await expect(history.listWithWorkspace(cwd)).rejects.toThrow( + OpenCodeAccessError, + ); + }, +); + it("discards a read whose binding changes before it returns", async () => { authorize .mockResolvedValueOnce(bindings.get("studio-a")) diff --git a/packages/harness/src/core/assistant-history.ts b/packages/harness/src/core/assistant-history.ts index 90b5909d7..656fdf12c 100644 --- a/packages/harness/src/core/assistant-history.ts +++ b/packages/harness/src/core/assistant-history.ts @@ -1,7 +1,10 @@ import { realpath } from "node:fs/promises"; import { createHash } from "node:crypto"; import { isAbsolute } from "node:path"; -import type { AssistantHistoryEntry } from "../shared/assistant-history.js"; +import type { + AssistantHistoryEntry, + AssistantHistoryList, +} from "../shared/assistant-history.js"; import type { HarnessSession } from "../shared/types.js"; import type { AssistantRecordStore } from "./assistant-record-store.js"; import type { AssistantAssociation } from "./assistant-session-store.js"; @@ -13,7 +16,10 @@ interface Options { list(): HarnessSession[]; get(id: string): HarnessSession | undefined; }; - authorize: (id: string) => Promise; + authorize: ((id: string) => Promise) & { + /** Existing access owner supplies a synchronous fence for final filesystem awaits. */ + captureAuthority?: () => () => void; + }; records: Pick; lifecycle: Pick; /** Pending continuation children are not discoverable until preparation commits. */ @@ -32,13 +38,17 @@ export class AssistantHistory { entry: AssistantHistoryEntry; binding: AssistantAssociation; } | null> { + const assertAuthority = this.options.authorize.captureAuthority?.(); const session = this.options.sessions.get(id); if (!session) return null; + const workspaceCwd = session.cwd; + const project = JSON.stringify([session.harness, session.agentMapIdentity]); if (this.options.isVisible && !(await this.options.isVisible(id))) return null; - const project = JSON.stringify([session.harness, session.agentMapIdentity]); const binding = await this.options.authorize(id); if (!binding) return null; + if ((await realpath(workspaceCwd)) !== binding.cwd) + throw new OpenCodeAccessError("Workspace changed"); const lifecycle = await this.options.lifecycle.describe(id); let history: AssistantHistoryEntry["history"] = "unavailable"; let recordRevision: number | null = null; @@ -68,15 +78,18 @@ export class AssistantHistory { JSON.stringify(binding) ) throw new OpenCodeAccessError("Assistant binding changed"); - const current = this.options.sessions.get(id); if (this.options.isVisible && !(await this.options.isVisible(id))) return null; + const canonicalCwd = await realpath(workspaceCwd); + const current = this.options.sessions.get(id); if ( !current || - current.cwd !== session.cwd || + current.cwd !== workspaceCwd || + canonicalCwd !== binding.cwd || JSON.stringify([current.harness, current.agentMapIdentity]) !== project ) throw new OpenCodeAccessError("Workspace changed"); + assertAuthority?.(); return { binding, entry: { @@ -84,6 +97,7 @@ export class AssistantHistory { harnessSessionId: id, title: current.title, cwd: binding.cwd, + workspace: { cwd: workspaceCwd, canonicalCwd: binding.cwd }, createdAt: current.createdAt, updatedAt, lifecycle, @@ -111,6 +125,11 @@ export class AssistantHistory { } async list(cwd: string): Promise { + return (await this.listWithWorkspace(cwd)).entries; + } + + async listWithWorkspace(cwd: string): Promise { + const assertAuthority = this.options.authorize.captureAuthority?.(); if (!isAbsolute(cwd) || cwd.length > 4096) throw new OpenCodeAccessError("Workspace unavailable"); const canonical = await realpath(cwd); @@ -154,12 +173,29 @@ export class AssistantHistory { if (!binding || JSON.stringify(binding) !== JSON.stringify(previous)) throw new OpenCodeAccessError("Assistant access changed"); } - return entries - .map(({ entry }) => entry) - .sort( - (a, b) => - b.updatedAt.localeCompare(a.updatedAt) || - a.harnessSessionId.localeCompare(b.harnessSessionId), - ); + const canonicalCwds = await Promise.all([ + realpath(cwd), + ...entries.map(({ entry }) => realpath(entry.workspace!.cwd)), + ]); + if ( + canonicalCwds.some((path) => path !== canonical) || + entries.some( + ({ entry }) => + this.options.sessions.get(entry.harnessSessionId)?.cwd !== + entry.workspace!.cwd, + ) + ) + throw new OpenCodeAccessError("Workspace changed"); + assertAuthority?.(); + return { + workspace: { cwd, canonicalCwd: canonical }, + entries: entries + .map(({ entry }) => entry) + .sort( + (a, b) => + b.updatedAt.localeCompare(a.updatedAt) || + a.harnessSessionId.localeCompare(b.harnessSessionId), + ), + }; } } diff --git a/packages/harness/src/server/assistant-lifecycle.test.ts b/packages/harness/src/server/assistant-lifecycle.test.ts index fcadb7059..942715b9d 100644 --- a/packages/harness/src/server/assistant-lifecycle.test.ts +++ b/packages/harness/src/server/assistant-lifecycle.test.ts @@ -96,6 +96,7 @@ const post = ( }); beforeEach(async () => { entry.mockReset().mockResolvedValue(savedEntry); + session.cwd = "/workspace"; inspect.mockReset().mockResolvedValue({ nativeHistory: "available", nativeResume: "available", @@ -236,6 +237,62 @@ 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("returns the current raw/canonical proof on same-ID alias Resume", async () => { + const workspace = { cwd: "/alias", canonicalCwd: savedEntry.cwd }; + session.cwd = workspace.cwd; + entry + .mockResolvedValueOnce({ ...savedEntry, workspace }) + .mockResolvedValueOnce({ + ...savedEntry, + workspace, + lifecycle: attachment.lifecycle, + }); + const response = await post("resume", { + expectedRevision: 4, + operationId: op, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + workspace, + session: { id: "studio-a", cwd: "/alias" }, + }); +}); + +it.each(["inspect", "resume"])( + "rejects %s when a raw alias rebind leaves the canonical binding unchanged", + async (action) => { + entry + .mockResolvedValueOnce({ + ...savedEntry, + workspace: { cwd: "/alias-a", canonicalCwd: savedEntry.cwd }, + }) + .mockResolvedValueOnce({ + ...savedEntry, + workspace: { cwd: "/alias-b", canonicalCwd: savedEntry.cwd }, + lifecycle: action === "resume" ? attachment.lifecycle : lifecycle, + }); + session.cwd = "/alias-b"; + expect( + ( + await post(action, { + expectedRevision: 4, + ...(action === "resume" ? { operationId: op } : {}), + }) + ).status, + ).toBe(409); + }, +); + +it("rejects a same-ID Terminal cwd rebind after the last history read", async () => { + entry.mockResolvedValueOnce(savedEntry).mockImplementationOnce(async () => { + session.cwd = "/other"; + return { ...savedEntry, lifecycle: attachment.lifecycle }; + }); + expect( + (await post("resume", { expectedRevision: 4, operationId: op })).status, + ).toBe(409); +}); it.each([ ["inspect", "b".repeat(64)], ["inspect", undefined], diff --git a/packages/harness/src/server/assistant-lifecycle.ts b/packages/harness/src/server/assistant-lifecycle.ts index 55026f19e..cc976f4a0 100644 --- a/packages/harness/src/server/assistant-lifecycle.ts +++ b/packages/harness/src/server/assistant-lifecycle.ts @@ -17,6 +17,10 @@ import type { HarnessSession } from "../shared/types.js"; import type { AssistantContinuationView } from "../shared/assistant-continuation.js"; import { openCodeTransportFailure } from "../shared/opencode-errors.js"; import { createBootTokenMiddleware } from "./auth.js"; +import { + assistantHistoryMatches, + sameAssistantWorkspace, +} from "../shared/assistant-history.js"; const inspectionRequest = z .object({ expectedRevision: z.number().int().nonnegative().safe() }) @@ -146,7 +150,7 @@ export function createAssistantLifecycleRouter(options: { if ( !current || current.lifecycle.revision !== parsed.data.expectedRevision || - current.cwd !== entry.cwd || + !sameAssistantWorkspace(current, entry) || current.continuationScope !== entry.continuationScope ) throw changed(); @@ -171,13 +175,18 @@ export function createAssistantLifecycleRouter(options: { if ( !session || !current || - current.cwd !== entry.cwd || + !sameAssistantWorkspace(current, entry) || + !assistantHistoryMatches(current, session.id, session.cwd) || current.continuationScope !== entry.continuationScope || current.lifecycle.revision !== attachment.lifecycle.revision || current.lifecycle.lifecycle !== "open" ) throw changed(); - res.json({ session: sessionView(session), attachment }); + res.json({ + session: sessionView(session), + attachment, + ...(current.workspace ? { workspace: current.workspace } : {}), + }); } else { if (!options.continue) throw new OpenCodeTransportError( diff --git a/packages/harness/src/server/assistant-records.test.ts b/packages/harness/src/server/assistant-records.test.ts index 6e0ebed47..6951d377f 100644 --- a/packages/harness/src/server/assistant-records.test.ts +++ b/packages/harness/src/server/assistant-records.test.ts @@ -18,6 +18,10 @@ const binding: AssistantAssociation = { const authorize = vi.fn(), read = vi.fn(); const list = vi.fn(); +const history = { + list, + listWithWorkspace: undefined as undefined | ReturnType, +}; const request = (token = "boot") => fetch(`${origin}/sessions/studio-a/assistant/record`, { headers: { "X-Harness-Token": token }, @@ -28,13 +32,14 @@ beforeEach(async () => { list .mockReset() .mockResolvedValue([{ kind: "assistant", harnessSessionId: "studio-a" }]); + history.listWithWorkspace = undefined; const app = express(); app.use( createAssistantRecordsRouter({ bootToken: "boot", authorize, store: { read }, - history: { list }, + history, }), ); server = createServer(app); @@ -100,3 +105,18 @@ it("gates metadata listing and rejects ambiguous workspace queries", async () => expect(failed.status).toBe(503); expect(await failed.text()).not.toContain("private"); }); + +it("returns the verified query and canonical pair from the history authority", async () => { + const result = { + entries: [], + workspace: { cwd: "/alias", canonicalCwd: "/workspace" }, + }; + history.listWithWorkspace = vi.fn().mockResolvedValue(result); + const response = await fetch( + `${origin}/sessions/assistant-history?cwd=%2Falias`, + { headers: { "X-Harness-Token": "boot" } }, + ); + expect(await response.json()).toEqual(result); + expect(history.listWithWorkspace).toHaveBeenCalledWith("/alias"); + expect(list).not.toHaveBeenCalled(); +}); diff --git a/packages/harness/src/server/assistant-records.ts b/packages/harness/src/server/assistant-records.ts index 19a8aab67..3d91bb683 100644 --- a/packages/harness/src/server/assistant-records.ts +++ b/packages/harness/src/server/assistant-records.ts @@ -9,7 +9,8 @@ export function createAssistantRecordsRouter(options: { bootToken: string; authorize: (id: string) => Promise; store: Pick; - history?: Pick; + history?: Pick & + Partial>; }): Router { const router = express.Router(); router.use(createBootTokenMiddleware(options.bootToken)); @@ -25,7 +26,11 @@ export function createAssistantRecordsRouter(options: { return; } try { - res.json({ entries: await options.history!.list(req.query.cwd) }); + res.json( + options.history!.listWithWorkspace + ? await options.history!.listWithWorkspace(req.query.cwd) + : { entries: await options.history!.list(req.query.cwd) }, + ); } catch (error) { res .status(error instanceof OpenCodeAccessError ? 403 : 503) diff --git a/packages/harness/src/shared/assistant-history.ts b/packages/harness/src/shared/assistant-history.ts index 277a7f800..599622aa0 100644 --- a/packages/harness/src/shared/assistant-history.ts +++ b/packages/harness/src/shared/assistant-history.ts @@ -3,6 +3,50 @@ import type { AssistantSessionView, } from "./assistant-session.js"; import type { OpenCodeTransportFailure } from "./opencode-errors.js"; +import { z } from "zod"; + +const workspacePath = z + .string() + .min(1) + .max(4096) + .refine( + (value) => !value.includes("\0") && /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(value), + ); +/** Verified by the server against the current Studio binding, never inferred by the browser. */ +export const assistantHistoryWorkspaceSchema = z + .object({ + cwd: workspacePath, + canonicalCwd: workspacePath, + }) + .strict(); +export type AssistantHistoryWorkspace = z.infer< + typeof assistantHistoryWorkspaceSchema +>; + +export const assistantWorkspace = ( + entry: AssistantHistoryEntry, +): AssistantHistoryWorkspace => + entry.workspace ?? { cwd: entry.cwd, canonicalCwd: entry.cwd }; + +export const sameAssistantWorkspace = ( + a: AssistantHistoryEntry, + b: AssistantHistoryEntry, +): boolean => + a.cwd === b.cwd && assistantWorkspace(a).cwd === assistantWorkspace(b).cwd; + +export const assistantHistoryMatches = ( + entry: AssistantHistoryEntry | undefined, + studioId: string | undefined, + cwd: string, +): boolean => + !!entry && + entry.harnessSessionId === studioId && + assistantWorkspace(entry).cwd === cwd; + +export interface AssistantHistoryList { + workspace: AssistantHistoryWorkspace; + entries: AssistantHistoryEntry[]; +} /** Additive history entry: a Studio identity does not require a Terminal vendor ID. */ export interface AssistantHistoryEntry { @@ -10,6 +54,8 @@ export interface AssistantHistoryEntry { harnessSessionId: string; title: string; cwd: string; + /** Current launch spelling paired with the canonical record/native workspace. */ + workspace?: AssistantHistoryWorkspace; createdAt: string; updatedAt: string; lifecycle: AssistantLifecycle; diff --git a/packages/harness/web/e2e/assistant-history.spec.ts b/packages/harness/web/e2e/assistant-history.spec.ts index fc30e0dad..7f31d4409 100644 --- a/packages/harness/web/e2e/assistant-history.spec.ts +++ b/packages/harness/web/e2e/assistant-history.spec.ts @@ -117,6 +117,12 @@ const rows = async (page: Page) => { await page.getByTestId("past-sessions-trigger").hover(); }; async function setup(page: Page, mode = "valid") { + const workspace = + mode === "alias" ? { cwd, canonicalCwd: `/private${cwd}` } : undefined; + const savedEntry = (id: string) => + workspace + ? { ...entry(id), cwd: workspace.canonicalCwd, workspace } + : entry(id); const streams = new Set(); const events = createServer((req, res) => { res.writeHead(200, { @@ -162,6 +168,7 @@ async function setup(page: Page, mode = "valid") { }, }); const resumed = (id: string) => ({ + ...(workspace ? { workspace } : {}), session: { id, cwd, @@ -183,7 +190,7 @@ async function setup(page: Page, mode = "valid") { return route.fulfill({ json: { entry: { - ...entry(id), + ...savedEntry(id), nativeResume: probe.inspectState, ...(probe.inspectState !== "available" ? { @@ -286,9 +293,10 @@ async function setup(page: Page, mode = "valid") { } return route.fulfill({ json: { + ...(workspace ? { workspace } : {}), entries: [ - entry("assistant-only"), - entry("sess-leasing"), + savedEntry("assistant-only"), + savedEntry("sess-leasing"), ...(mode === "foreground" ? [entry("sess-boot")] : []), ], }, @@ -300,9 +308,11 @@ async function setup(page: Page, mode = "valid") { return; } const id = new URL(route.request().url()).pathname.split("/")[3]!; + const saved = record(mode === "foreign" ? "another-studio" : id); + if (workspace) saved.binding.cwd = workspace.canonicalCwd; return route.fulfill({ status: mode === "missing" ? 404 : 200, - json: { record: record(mode === "foreign" ? "another-studio" : id) }, + json: { record: saved }, }); }); page.on("request", (request) => { @@ -1032,3 +1042,135 @@ test("a Terminal summary in another directory remains separate from an Assistant page.getByRole("button", { name: "View Terminal history", exact: true }), ).toHaveCount(0); }); + +test("verified aliases preserve mixed history and same-ID paused Resume", async ({ + page, +}) => { + const probe = await setup(page, "alias"); + await rows(page); + await expect( + page.getByTestId("assistant-history-sess-leasing"), + ).toContainText("Terminal + Assistant"); + await expect(page.getByTestId("exited-session-sess-leasing")).toHaveCount(0); + await page.getByTestId("assistant-history-sess-leasing").click(); + await expect(page.getByTestId("assistant-transcript")).toContainText( + "Saved answer sess-leasing", + ); + await page + .getByRole("button", { name: "View Terminal history", exact: true }) + .click(); + await expect(page.getByTestId("dead-session-pane")).toBeVisible(); + await review(page, "sess-leasing"); + await checkResume(page).click(); + await resumeButton(page).click(); + await expect(page.getByText("Paused", { exact: true })).toBeVisible(); + await expect( + page.getByText("Native saved answer sess-leasing", { exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + "sess-leasing", + ); + expect( + probe.calls.filter((url) => /prompt_async|final-response/.test(url)), + ).toEqual([]); +}); + +test("an empty verified alias refresh clears its prior canonical entries", async ({ + page, +}) => { + await setup(page, "alias"); + await rows(page); + await expect( + page.getByTestId("assistant-history-assistant-only"), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await page.route("**/api/sessions/assistant-history?**", (route) => { + const requested = new URL(route.request().url()).searchParams.get("cwd")!; + return route.fulfill({ + json: { + entries: [], + workspace: { + cwd: requested, + canonicalCwd: requested === cwd ? `/private${cwd}` : requested, + }, + }, + }); + }); + await rows(page); + await expect( + page.getByTestId("assistant-history-assistant-only"), + ).toHaveCount(0); + await expect(page.getByTestId("assistant-history-sess-leasing")).toHaveCount( + 0, + ); + await expect(page.getByTestId("exited-session-sess-leasing")).toBeVisible(); +}); + +test("a slow sibling alias cannot replace a newer canonical history bucket", async ({ + page, +}) => { + await setup(page, "alias"); + await page.evaluate(async () => { + const url = performance + .getEntriesByType("resource") + .find( + (entry) => new URL(entry.name).pathname === "/src/lib/api.ts", + )!.name; + const { MockApi } = await import(url); + MockApi.prototype.sessionHistory = async () => []; + }); + const canonicalCwd = `/private${cwd}`; + const saved = (title: string) => ({ + ...entry("ordered-alias"), + title, + cwd: canonicalCwd, + workspace: { cwd, canonicalCwd }, + }); + let calls = 0, + held: Route | null = null; + const first = page.waitForResponse( + (response) => + new URL(response.url()).searchParams.get("cwd") === cwd && + response.url().includes("assistant-history"), + ); + await page.route("**/api/sessions/assistant-history?**", (route) => { + const directory = new URL(route.request().url()).searchParams.get("cwd")!; + if (directory === otherCwd) { + held = route; + return; + } + if (directory !== cwd) return route.fulfill({ json: { entries: [] } }); + return route.fulfill({ + json: { + workspace: { cwd, canonicalCwd }, + entries: [ + saved(++calls === 1 ? "Old alias history" : "Fresh alias history"), + ], + }, + }); + }); + await rows(page); + await expect.poll(() => held !== null && calls === 1).toBe(true); + await (await first).finished(); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await refreshDirectory(page, cwd); + await expect( + page.getByTestId("assistant-history-ordered-alias"), + ).toContainText("Fresh alias history"); + await held!.fulfill({ + json: { + workspace: { cwd: otherCwd, canonicalCwd }, + entries: [saved("Old alias history")], + }, + }); + await expect(page.getByText("Loading…", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("assistant-history-ordered-alias"), + ).toContainText("Fresh alias history"); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 477b743ba..6c4a05f9a 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -87,7 +87,7 @@ import { TemplatesPanel } from "./components/TemplatesPanel"; import { Terminal } from "./components/Terminal"; import { AssistantPane } from "./components/AssistantPane"; import { AssistantHistoryPane } from "./components/AssistantHistoryPane"; -import type { AssistantHistoryEntry } from "../../src/shared/assistant-history"; +import { assistantHistoryMatches, type AssistantHistoryEntry } from "../../src/shared/assistant-history"; import { AssistantHistoryActions, type AssistantResumeOperation } from "./components/AssistantHistoryActions"; import { AssistantContinueAction } from "./components/AssistantContinueAction"; import { DormantTerminalPane } from "./components/DormantTerminalPane"; @@ -750,9 +750,9 @@ export const App = (): JSX.Element => { const activeAssistantReview = assistantReview?.authority === harness.assistantHistoryAuthority && assistantReview.navigation === studioRestoreGenerationRef.current ? assistantReview : null; const reviewedAssistantTerminal = activeAssistantReview ? harness.state?.sessions.find((session) => - session.id === activeAssistantReview.entry.harnessSessionId && session.cwd === activeAssistantReview.entry.cwd) : undefined; + assistantHistoryMatches(activeAssistantReview.entry, session.id, session.cwd)) : undefined; const reviewedTerminalSummary = activeAssistantReview ? harness.history.find((summary) => - summary.harnessSessionId === activeAssistantReview.entry.harnessSessionId && summary.cwd === activeAssistantReview.entry.cwd) : undefined; + assistantHistoryMatches(activeAssistantReview.entry, summary.harnessSessionId, summary.cwd)) : undefined; // Template gallery opened from the command palette (browse is reachable // from anywhere, not only the add dialog / welcome panel entries). const [templatesOpen, setTemplatesOpen] = useState(false); diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index 7037108e5..cf4a1fec7 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -1,5 +1,5 @@ import { AssistantActivity } from "./AssistantActivity"; -import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history"; +import { assistantHistoryMatches, type AssistantHistoryEntry } from "../../../src/shared/assistant-history"; import type { AssistantProjection } from "../lib/assistant-state"; import { useCallback, @@ -678,12 +678,12 @@ export function WorkflowsRail({ ) && !registryAgentIds.has(summary.agentSessionId), ); const pastRows = [ - ...exitedSessions.filter((session) => assistantByStudio.get(session.id)?.cwd !== session.cwd).map((session) => ({ + ...exitedSessions.filter((session) => !assistantHistoryMatches(assistantByStudio.get(session.id), session.id, session.cwd)).map((session) => ({ kind: "exited" as const, at: session.lastActiveAt, session, })), - ...pastSummaries.filter((summary) => !summary.harnessSessionId || assistantByStudio.get(summary.harnessSessionId)?.cwd !== summary.cwd).map((summary) => ({ + ...pastSummaries.filter((summary) => !summary.harnessSessionId || !assistantHistoryMatches(assistantByStudio.get(summary.harnessSessionId), summary.harnessSessionId, summary.cwd)).map((summary) => ({ kind: "summary" as const, at: summary.lastActiveAt, summary, @@ -1191,8 +1191,8 @@ export function WorkflowsRail({ > {pastRows.map((row) => { if (row.kind === "assistant") { - const terminal = sessions.find((session) => session.id === row.entry.harnessSessionId && session.cwd === row.entry.cwd); - const summary = history.find((candidate) => candidate.harnessSessionId === row.entry.harnessSessionId && candidate.cwd === row.entry.cwd); + const terminal = sessions.find((session) => assistantHistoryMatches(row.entry, session.id, session.cwd)); + const summary = history.find((candidate) => assistantHistoryMatches(row.entry, candidate.harnessSessionId, candidate.cwd)); return ( @@ -38,7 +43,9 @@ const entrySchema = z .catch(undefined), }) .refine( - (entry) => entry.harnessSessionId === entry.lifecycle.harnessSessionId, + (entry) => + entry.harnessSessionId === entry.lifecycle.harnessSessionId && + (!entry.workspace || entry.workspace.canonicalCwd === entry.cwd), ); export function parseAssistantHistoryEntry( @@ -175,20 +182,44 @@ export async function readAssistantHistory( bootToken: string, signal: AbortSignal, ): Promise { + return (await readAssistantHistoryWorkspace(cwd, bootToken, signal)).entries; +} + +export async function readAssistantHistoryWorkspace( + cwd: string, + bootToken: string, + signal: AbortSignal, +): Promise { const value = await assistantHistoryRequest( `/api/sessions/assistant-history?cwd=${encodeURIComponent(cwd)}`, bootToken, signal, ); - const result = z.object({ entries: z.array(entrySchema) }).safeParse(value); - if (!result.success || result.data.entries.some((entry) => entry.cwd !== cwd)) + const result = z + .object({ + entries: z.array(entrySchema), + workspace: assistantHistoryWorkspaceSchema.optional(), + }) + .safeParse(value); + if (!result.success) + throw new Error("Assistant history could not be verified."); + const workspace = result.data.workspace ?? { cwd, canonicalCwd: cwd }; + if ( + workspace.cwd !== cwd || + result.data.entries.some( + (entry) => + entry.cwd !== workspace.canonicalCwd || + // Alias discovery requires an explicit current launch-path proof too. + (workspace.cwd !== workspace.canonicalCwd && !entry.workspace), + ) + ) throw new Error("Assistant history could not be verified."); const ids = new Set( result.data.entries.map((entry) => entry.harnessSessionId), ); if (ids.size !== result.data.entries.length) throw new Error("Assistant history could not be verified."); - return result.data.entries; + return { entries: result.data.entries, workspace }; } export async function readAssistantRecord( diff --git a/packages/harness/web/src/lib/assistant-history-workspace.test.ts b/packages/harness/web/src/lib/assistant-history-workspace.test.ts new file mode 100644 index 000000000..1102dfc64 --- /dev/null +++ b/packages/harness/web/src/lib/assistant-history-workspace.test.ts @@ -0,0 +1,216 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { + assistantHistoryMatches, + type AssistantHistoryEntry, +} from "../../../src/shared/assistant-history"; +import { + parseAssistantHistoryEntry, + readAssistantHistory, +} from "./assistant-history-client"; +import { + inspectAssistant, + resumeAssistantRequest, +} from "./assistant-resume-client"; +import { continueAssistantRequest } from "./assistant-continuation-client"; + +const operationId = "11111111-1111-4111-8111-111111111111"; +const workspace = { cwd: "/launch-alias", canonicalCwd: "/canonical" }; +const entry: AssistantHistoryEntry = { + kind: "assistant", + harnessSessionId: "studio-a", + title: "Saved", + cwd: workspace.canonicalCwd, + workspace, + createdAt: "2026-09-15T00:00:00.000Z", + updatedAt: "2026-09-15T00:00:00.000Z", + lifecycle: { + version: 1, + harnessSessionId: "studio-a", + revision: 2, + lifecycle: "ended", + execution: "paused", + updatedAt: 1, + }, + history: "available", + nativeResume: "available", + recordRevision: 1, + continuationScope: "a".repeat(64), +}; +const signal = () => new AbortController().signal; +const respond = (value: unknown) => + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(value), { + headers: { "content-type": "application/json" }, + }), + ), + ); +const resumed = () => ({ + workspace, + session: { + id: entry.harnessSessionId, + cwd: workspace.cwd, + title: entry.title, + harness: "claude-code", + agentSessionId: null, + boundWorkflowPath: null, + status: "exited", + ready: false, + createdAt: entry.createdAt, + lastActiveAt: entry.updatedAt, + }, + attachment: { + lease: operationId, + conversationId: "ses_saved", + lifecycle: { ...entry.lifecycle, lifecycle: "open", revision: 3 }, + }, +}); +afterEach(() => vi.unstubAllGlobals()); + +it("accepts verified query/launch aliases and keeps legacy exact-cwd compatibility", async () => { + respond({ + workspace: { cwd: "/query-alias", canonicalCwd: entry.cwd }, + entries: [entry], + }); + expect(await readAssistantHistory("/query-alias", "boot", signal())).toEqual([ + entry, + ]); + const legacy = { ...entry, workspace: undefined }; + respond({ entries: [legacy] }); + expect(await readAssistantHistory(entry.cwd, "boot", signal())).toEqual([ + legacy, + ]); +}); + +it.each([ + { + workspace: { cwd: "/another-query", canonicalCwd: entry.cwd }, + entries: [entry], + }, + { + workspace: { cwd: "/query-alias", canonicalCwd: "/foreign" }, + entries: [entry], + }, + { + workspace: { cwd: "/query-alias", canonicalCwd: entry.cwd }, + entries: [{ ...entry, workspace: undefined }], + }, + { + workspace: { cwd: "/query-alias", canonicalCwd: entry.cwd }, + entries: [ + { ...entry, workspace: { ...workspace, canonicalCwd: "/foreign" } }, + ], + }, + { + workspace: { cwd: "/query-alias", canonicalCwd: entry.cwd }, + entries: [{ ...entry, workspace: { cwd: null, canonicalCwd: entry.cwd } }], + }, + { entries: [entry] }, +])( + "rejects an absent, malformed or mismatched alias proof %#", + async (value) => { + respond(value); + await expect( + readAssistantHistory("/query-alias", "boot", signal()), + ).rejects.toThrow("could not be verified"); + }, +); + +it("only matches the exact Studio and current launch spelling, even if another row spells the canonical path", () => { + expect(assistantHistoryMatches(entry, "studio-a", workspace.cwd)).toBe(true); + expect(assistantHistoryMatches(entry, "studio-b", workspace.cwd)).toBe(false); + expect(assistantHistoryMatches(entry, "studio-a", entry.cwd)).toBe(false); + expect(assistantHistoryMatches(entry, "studio-a", "/other")).toBe(false); + expect( + parseAssistantHistoryEntry({ + ...entry, + workspace: { ...workspace, canonicalCwd: "/other" }, + }), + ).toBeNull(); +}); + +it("resumes through the exact verified pair without changing the requested operation tuple", async () => { + respond(resumed()); + expect( + (await resumeAssistantRequest(entry, operationId, "boot", signal())).session + .cwd, + ).toBe(workspace.cwd); + const request = vi.mocked(fetch).mock.calls[0]![1]!; + expect(JSON.parse(request.body as string)).toEqual({ + operationId, + expectedRevision: 2, + }); +}); + +it.each(["missing", "malformed", "canonical", "raw", "session"])( + "rejects a %s Resume workspace proof", + async (change) => { + const value: Record = resumed(); + if (change === "missing") delete value.workspace; + if (change === "malformed") value.workspace = { cwd: 1 }; + if (change === "canonical") + value.workspace = { ...workspace, canonicalCwd: "/foreign" }; + if (change === "raw") value.workspace = { ...workspace, cwd: entry.cwd }; + if (change === "session") + value.session = { ...resumed().session, cwd: entry.cwd }; + respond(value); + await expect( + resumeAssistantRequest(entry, operationId, "boot", signal()), + ).rejects.toThrow("could not be verified"); + }, +); + +it("rejects an inspection whose launch alias changes within the same canonical directory", async () => { + respond({ + entry: { ...entry, workspace: { ...workspace, cwd: "/new-alias" } }, + }); + await expect(inspectAssistant(entry, "boot", signal())).rejects.toThrow( + "could not be verified", + ); +}); + +it("verifies a Continue child against the source launch spelling without substituting its canonical path", async () => { + const child = resumed(); + child.session.id = "child"; + child.attachment.lifecycle.harnessSessionId = "child"; + const value = { + ...child, + session: { + ...child.session, + agentMapIdentity: { projectId: "p", userId: "u", sessionId: "child" }, + }, + continuation: { + operationId, + sourceSessionId: entry.harnessSessionId, + sourceRecordRevision: 1, + capturedAt: entry.updatedAt, + retainedTurns: 1, + omittedTurns: 0, + seed: { + conversationId: "ses_saved", + messageId: "msg_seed", + partId: "prt_seed", + text: "Seed", + sha256: createHash("sha256").update("Seed").digest("hex"), + }, + }, + }; + const request = { + operationId, + expectedRevision: 2, + expectedRecordRevision: 1, + }; + respond(value); + expect( + (await continueAssistantRequest(entry, request, "boot", signal())).session + .cwd, + ).toBe(workspace.cwd); + value.session.cwd = entry.cwd; + respond(value); + await expect( + continueAssistantRequest(entry, request, "boot", signal()), + ).rejects.toThrow("could not be verified"); +}); diff --git a/packages/harness/web/src/lib/assistant-resume-client.ts b/packages/harness/web/src/lib/assistant-resume-client.ts index a2862c779..66077d396 100644 --- a/packages/harness/web/src/lib/assistant-resume-client.ts +++ b/packages/harness/web/src/lib/assistant-resume-client.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import type { HarnessSession } from "@shared/types"; -import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history"; +import { + assistantHistoryMatches, + assistantHistoryWorkspaceSchema, + assistantWorkspace, + sameAssistantWorkspace, + type AssistantHistoryEntry, +} from "../../../src/shared/assistant-history"; import { parseAssistantLifecycle } from "../../../src/shared/assistant-session"; import { parseOpenCodeTransportFailure, @@ -99,7 +105,7 @@ export async function inspectAssistant( if ( !next || next.harnessSessionId !== entry.harnessSessionId || - next.cwd !== entry.cwd || + !sameAssistantWorkspace(next, entry) || next.lifecycle.revision !== entry.lifecycle.revision || next.nativeResume === "unchecked" ) @@ -121,12 +127,20 @@ export async function resumeAssistantRequest( signal, ); const session = assistantSessionSchema.safeParse(value.session); + const workspace = assistantHistoryWorkspaceSchema + .optional() + .safeParse(value.workspace); const attachment = object(value.attachment); const lifecycle = parseAssistantLifecycle(attachment.lifecycle); if ( !session.success || session.data.id !== entry.harnessSessionId || - session.data.cwd !== entry.cwd || + !assistantHistoryMatches(entry, session.data.id, session.data.cwd) || + !workspace.success || + (!!entry.workspace && !workspace.data) || + (workspace.data && + (workspace.data.cwd !== assistantWorkspace(entry).cwd || + workspace.data.canonicalCwd !== entry.cwd)) || (session.data.agentMapIdentity && session.data.agentMapIdentity.sessionId !== entry.harnessSessionId) || !lifecycle || diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index c10c7c9f0..8699b9593 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -1,6 +1,6 @@ import { AssistantStateOrder, type AssistantProjection } from "./assistant-state"; -import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history"; -import { readAssistantHistory } from "./assistant-history-client"; +import { assistantHistoryMatches, assistantWorkspace, type AssistantHistoryEntry, type AssistantHistoryList } from "../../../src/shared/assistant-history"; +import { readAssistantHistory, readAssistantHistoryWorkspace } from "./assistant-history-client"; import { resumeAssistantRequest } from "./assistant-resume-client"; import { continueAssistantRequest, type ContinueRequest } from "./assistant-continuation-client"; import { sameStudioAuthority, startTerminalRequest } from "./terminal-start-client"; @@ -447,7 +447,8 @@ export function useHarnessState(): HarnessStateHook { const [assistantHistory, setAssistantHistory] = useState<{ authority: string | null; entries: AssistantHistoryEntry[]; failedCwds: Set; versions: Map; - }>({ authority: null, entries: [], failedCwds: new Set(), versions: new Map() }); + scopes: Map; canonicalVersions: Map; + }>({ authority: null, entries: [], failedCwds: new Set(), versions: new Map(), scopes: new Map(), canonicalVersions: new Map() }); const endingSessions = useRef(new Set()); const [endingSessionIds, setEndingSessionIds] = useState>( new Set(), @@ -543,7 +544,7 @@ export function useHarnessState(): HarnessStateHook { // History fan-out bookkeeping — see loadHistory. Refs, not state: neither // affects the render, and both are read/written within a single call. const inFlightHistory = useRef>>( new Map(), ); @@ -1470,8 +1471,8 @@ export function useHarnessState(): HarnessStateHook { const request = Promise.allSettled([ api.sessionHistory(cwd), authority - ? readAssistantHistory(cwd, getBootToken(), AbortSignal.timeout(5000)) - : Promise.resolve([]), + ? readAssistantHistoryWorkspace(cwd, getBootToken(), AbortSignal.timeout(5000)) + : Promise.resolve({ entries: [], workspace: { cwd, canonicalCwd: cwd } }), ]).then(([terminal, assistant]) => ({ revision, terminal: terminal.status === "fulfilled" ? terminal.value : null, @@ -1506,6 +1507,8 @@ export function useHarnessState(): HarnessStateHook { const entries = new Map((sameAuthority ? prev.entries : []).map((entry) => [entry.harnessSessionId, entry])); const failedCwds = new Set(sameAuthority ? prev.failedCwds : []); const versions = new Map(sameAuthority ? prev.versions : []); + const scopes = new Map(sameAuthority ? prev.scopes : []); + const canonicalVersions = new Map(sameAuthority ? prev.canonicalVersions : []); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const cwd = unique[index]!, { revision, assistant } = result.value; @@ -1514,10 +1517,17 @@ export function useHarnessState(): HarnessStateHook { versions.set(cwd, revision); if (assistant === null) { failedCwds.add(cwd); return; } failedCwds.delete(cwd); - for (const [id, entry] of entries) if (entry.cwd === cwd) entries.delete(id); - for (const entry of assistant) entries.set(entry.harnessSessionId, entry); + const canonical = assistant.workspace.canonicalCwd, previous = scopes.get(cwd); + scopes.set(cwd, canonical); + if (previous && previous !== canonical && ![...scopes.values()].includes(previous)) + for (const [id, entry] of entries) if (entry.cwd === previous) entries.delete(id); + // Different aliases of one directory share the same publication fence. + if (revision <= (canonicalVersions.get(canonical) ?? -1)) return; + canonicalVersions.set(canonical, revision); + for (const [id, entry] of entries) if (entry.cwd === canonical) entries.delete(id); + for (const entry of assistant.entries) entries.set(entry.harnessSessionId, entry); }); - return { authority, entries: [...entries.values()], failedCwds, versions }; + return { authority, entries: [...entries.values()], failedCwds, versions, scopes, canonicalVersions }; }); } } finally { @@ -1850,7 +1860,7 @@ export function useHarnessState(): HarnessStateHook { const latest = assistantOrder.current().snapshot?.lifecycles?.find((row) => row.harnessSessionId === entry.harnessSessionId); if (latest && (latest.revision > result.lifecycle.revision || (latest.revision === result.lifecycle.revision && latest.lifecycle !== "open"))) return null; const currentSession = sessionsRef.current.find((row) => row.id === result.session.id); - if (currentSession && currentSession.cwd !== entry.cwd) return null; + if (currentSession && !assistantHistoryMatches(entry, currentSession.id, currentSession.cwd)) return null; // A newer bus row owns Terminal status. Resume only adds an absent Studio. setState((prev) => prev && !prev.sessions.some((row) => row.id === result.session.id) ? { ...prev, sessions: [...prev.sessions, result.session] } : prev); @@ -1872,7 +1882,7 @@ export function useHarnessState(): HarnessStateHook { const latest = assistantOrder.current().snapshot?.lifecycles?.find((row) => row.harnessSessionId === result.session.id); if (latest && (latest.revision > result.lifecycle.revision || (latest.revision === result.lifecycle.revision && latest.lifecycle !== "open"))) return null; const current = sessionsRef.current.find((row) => row.id === result.session.id); - if (current && (current.cwd !== entry.cwd || current.agentMapIdentity?.projectId !== result.session.agentMapIdentity?.projectId)) return null; + if (current && (current.cwd !== assistantWorkspace(entry).cwd || current.agentMapIdentity?.projectId !== result.session.agentMapIdentity?.projectId)) return null; setState((prev) => prev && !prev.sessions.some((row) => row.id === result.session.id) ? { ...prev, sessions: [...prev.sessions, result.session] } : prev); const revision = ++conversationRevealSequence.current; @@ -1886,7 +1896,7 @@ export function useHarnessState(): HarnessStateHook { if (!session || !authority || signal.aborted) return null; const entries = await readAssistantHistory(session.cwd, getBootToken(), signal); if (signal.aborted || authority !== assistantAuthorityKey() || sessionsRef.current.find((row) => row.id === id)?.cwd !== session.cwd) return null; - return entries.find((entry) => entry.harnessSessionId === id) ?? null; + return entries.find((entry) => assistantHistoryMatches(entry, id, session.cwd)) ?? null; }, [assistantAuthorityKey]); const startTerminal = useCallback(async (id: string, signal: AbortSignal, isCurrent: () => boolean): Promise => {