Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/assistant-history-workspace-aliases.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
124 changes: 122 additions & 2 deletions packages/harness/src/core/assistant-history-access.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<void>),
}));
vi.mock("node:fs/promises", async (original) => {
const actual = await original<typeof import("node:fs/promises")>();
return {
...actual,
realpath: async (...args: Parameters<typeof actual.realpath>) => {
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",
Expand Down Expand Up @@ -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<void>((resolve) => {
enter = resolve;
});
const held = new Promise<void>((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!,
Expand Down
16 changes: 14 additions & 2 deletions packages/harness/src/core/assistant-history-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ export function assistantHistoryAccess(options: {
authorize: (id: string) => Promise<OpenCodeWorkspace | null>;
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)
Expand All @@ -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,
Expand All @@ -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 });
}
99 changes: 97 additions & 2 deletions packages/harness/src/core/assistant-history.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"))
Expand Down
Loading
Loading