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-pending-child-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Keep unfinished Assistant continuation children out of all public session lists and block direct actions until preparation completes.
5 changes: 5 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,8 @@ A continued session shows a distinct Terminal-not-started pane. Start Terminal
launches its coding agent in that same Studio workspace only after an explicit
click. Failed starts retain the session for retry, immediate exits show the real
exit state, and stale responses cannot override End or a newer selected session.

Unfinished continuation children remain internal to allocation recovery. General
session lists, history, runtime/lifecycle events and direct session actions use
the same preparation visibility boundary; direct Terminal start cannot bypass it.
Restored pending children are hidden before the server starts accepting requests.
46 changes: 46 additions & 0 deletions packages/harness/src/core/assistant-state-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,52 @@ it("orders lifecycle-only updates with observations and clears disabled projecti
expect(stopHost).toHaveBeenCalledOnce();
expect(stopLifecycle).toHaveBeenCalledOnce();
});
it("keeps private continuation runtimes and lifecycle headers out of every snapshot", () => {
let visible = false;
let changed!: () => void;
const publish = vi.fn();
const projection = createAssistantStateProjection(
{
getAssistantState: () => ({
...runtime,
sessions: [
{
harnessSessionId: state.harnessSessionId,
conversationId: "ses_private",
activity: "idle",
pendingPermissions: 0,
pendingQuestions: 0,
freshness: "current",
},
],
}),
subscribeAssistantState: () => () => {},
},
{
snapshot: () => [state],
subscribe: (fn) => {
changed = fn;
return () => {};
},
},
publish,
() => visible,
);
expect(projection.get()).toMatchObject({ sessions: [], lifecycles: [] });
changed();
expect(publish.mock.lastCall?.[0]).toMatchObject({
sessions: [],
lifecycles: [],
});
visible = true;
changed();
expect(publish.mock.lastCall?.[0]).toMatchObject({
sessions: [{ harnessSessionId: state.harnessSessionId }],
lifecycles: [state],
});
projection.dispose();
});

it.each([
[{ ...state, lease: "private" }],
[{ ...state, execution: "enabled" }],
Expand Down
8 changes: 7 additions & 1 deletion packages/harness/src/core/assistant-state-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function createAssistantStateProjection(
host: Pick<OpenCodeHost, "getAssistantState" | "subscribeAssistantState">,
lifecycle: Pick<AssistantLifecycleCoordinator, "snapshot" | "subscribe">,
publish: (snapshot: AssistantStateSnapshot) => void,
isVisible: (id: string) => boolean = () => true,
) {
let revision = 0;
const get = (): AssistantStateSnapshot => {
Expand All @@ -15,11 +16,16 @@ export function createAssistantStateProjection(
return {
...runtime,
revision,
sessions: runtime.sessions.filter((state) =>
isVisible(state.harnessSessionId),
),
lifecycles: runtime.enabled
? lifecycle
.snapshot()
.filter(
(state) => state.revision > 0 || state.lifecycle === "ending",
(state) =>
isVisible(state.harnessSessionId) &&
(state.revision > 0 || state.lifecycle === "ending"),
)
: [],
};
Expand Down
36 changes: 36 additions & 0 deletions packages/harness/src/server/assistant-lifecycle-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,12 @@ it("shares retained context across Continue, later Send and Resume while Termina
input,
);
const childId = continued.session.id as string;
expect((await json("/api/state")).sessions).toContainEqual(
expect.objectContaining({ id: childId }),
);
expect(await json("/api/sessions")).toContainEqual(
expect.objectContaining({ id: childId }),
);
await vi.waitFor(() =>
expect(events).toContainEqual(
expect.objectContaining({
Expand Down Expand Up @@ -815,6 +821,35 @@ it("hides an allocated but unprepared child across reboot and blocks attach befo
expect(failed.status).toBe(503);
const receipt = (await stores.continuations.read(binding, operationId))!;
expect(receipt.phase).toBe("associated");
const assertPrivate = async () => {
const state = await json("/api/state");
expect(JSON.stringify(state)).not.toContain(receipt.childStudioId);
expect(await json("/api/sessions")).not.toContainEqual(
expect.objectContaining({ id: receipt.childStudioId }),
);
for (const [suffix, method, body] of [
["/terminal/start", "POST", {}],
["/resume", "POST", {}],
["/restart-mcp", "POST", {}],
["/workflow", "PATCH", { workflowPath: null }],
["/input", "POST", { text: "must not run" }],
["/record", "GET", undefined],
["/assistant/record", "GET", undefined],
["", "DELETE", undefined],
] as const)
expect(
(
await request(
`/api/sessions/${receipt.childStudioId}${suffix}`,
method,
body,
)
).status,
`${method} ${suffix}`,
).toBe(404);
expect(JSON.stringify(events)).not.toContain(receipt.childStudioId);
};
await assertPrivate();
expect(events).not.toContainEqual(
expect.objectContaining({
type: "session.status",
Expand All @@ -828,6 +863,7 @@ it("hides an allocated but unprepared child across reboot and blocks attach befo
studio = undefined;
const before = launches.length;
await boot(activated);
await assertPrivate();
expect(
(await entries()).map(({ harnessSessionId }) => harnessSessionId),
).toEqual([parentId]);
Expand Down
30 changes: 25 additions & 5 deletions packages/harness/src/server/index.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 WebSocket visibility remains implicit

/ws/terminal trusts internal session rows without checking continuation visibility. Pending children currently lack PTYs, but future runtime changes can bypass the shared boundary.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,9 @@ export const startServer = async (
} satisfies Partial<Record<HarnessKind, HarnessAdapter>>);

const bus = new EventBus();
// Synchronous event projections consult this cache; all public admission reads
// refresh it from durable receipts, and allocation marks it before publication.
const hiddenAssistantSessions = new Set<string>();
const canvasWatcher = new CanvasWatcherManager({
onChange: (harnessSessionId) =>
bus.publish({ type: "canvas.reload", harnessSessionId }),
Expand Down Expand Up @@ -2013,7 +2016,7 @@ export const startServer = async (
const rescannedOnStart = new Set<string>();

sessionManager.onStatusChange((session) => {
bus.publish({ type: "session.status", session });
if (!hiddenAssistantSessions.has(session.id)) bus.publish({ type: "session.status", session });
if (session.status === "running") {
canvasWatcher.start(session.id, session.cwd);
workspaceWatcher.start(session.id, session.cwd);
Expand Down Expand Up @@ -3555,6 +3558,15 @@ export const startServer = async (
const assistantSessions = new AssistantSessionStore(statePaths.root);
const assistantRecords = new AssistantRecordStore(statePaths.root);
const assistantContinuations = new AssistantContinuationStore(statePaths.root);
const isAssistantSessionVisible = async (id: string): Promise<boolean> => {
const visible = await assistantContinuations.canAttach(id);
if (visible) hiddenAssistantSessions.delete(id);
else hiddenAssistantSessions.add(id);
return visible;
};
const restoredSessionIds = sessionManager.list().map((session) => session.id);
for (let offset = 0; offset < restoredSessionIds.length; offset += 8)
await Promise.all(restoredSessionIds.slice(offset, offset + 8).map(isAssistantSessionVisible));
const recordCaptures = new WeakMap<HostedOpenCode, AssistantRecordCapture>();
const authorizeAssistantWorkspace = async (id: string) => {
const session = sessionManager.get(id);
Expand Down Expand Up @@ -3594,13 +3606,14 @@ export const startServer = async (
authorize: authorizeAssistantWorkspace,
});
const openCodeAssociations = new OpenCodeAssociations(assistantSessions);
const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations, canAttach: (id) => assistantContinuations.canAttach(id) });
const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations, canAttach: isAssistantSessionVisible });
const assistantEnd = new AssistantEndCoordinator({ store: assistantSessions, lifecycle: assistantLifecycle, sessionManager });
// Hydrate lifecycle headers without launching runtimes. A corrupt entry fails
// closed when selected rather than preventing unrelated sessions from loading.
await Promise.allSettled(sessionManager.list().map((session) => assistantLifecycle.describe(session.id)));
const assistantProjection = createAssistantStateProjection(openCodeHost, assistantLifecycle, (snapshot) =>
bus.publish({ type: "assistant.state", snapshot }),
(id) => !hiddenAssistantSessions.has(id),
);
const getAssistantState = assistantProjection.get;
const assistantContext = createAssistantContextRuntime({
Expand All @@ -3623,7 +3636,13 @@ export const startServer = async (
const assistantContinue = new AssistantContinuation({
store: assistantContinuations,
records: assistantRecords,
sessions: sessionManager,
sessions: {
get: (id) => sessionManager.get(id),
allocateDormant: async (id, input) => {
await isAssistantSessionVisible(input.childSessionId);
return sessionManager.allocateDormant(id, input);
},
},
associations: assistantSessions,
lifecycle: assistantLifecycle,
native: new AssistantContinuationNative((hosted) => openCodeHost.assertCurrent(hosted)),
Expand Down Expand Up @@ -3673,7 +3692,7 @@ export const startServer = async (
res.setHeader("Cache-Control", "no-store");
res.json(assistantAccess.getBrowserState());
});
const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle, isVisible: (id) => assistantContinuations.canAttach(id) });
const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle, isVisible: isAssistantSessionVisible });
app.use("/api", createAssistantLifecycleRouter({
bootToken: options.bootToken,
history: assistantHistory,
Expand All @@ -3690,12 +3709,13 @@ export const startServer = async (
app.use("/api", createAssistantRecordsRouter({
bootToken: options.bootToken,
store: assistantRecords,
authorize: authorizeAssistantHistory,
authorize: async (id) => (await isAssistantSessionVisible(id)) ? authorizeAssistantHistory(id) : null,
history: assistantHistory,
}));
app.use(
"/api",
createRestRouter({
isSessionVisible: isAssistantSessionVisible,
Comment on lines 3717 to +3718

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Pending children bypass action admission

A caller knowing a pending child ID can invoke macro and canvas actions. Those routers bypass isSessionVisible and trust the private registry row.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

getAssistantState,
endSession: (id) => assistantEnd.end(id),
sessionManager,
Expand Down
33 changes: 28 additions & 5 deletions packages/harness/src/server/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ async function agentHoldsConversation(
}

export interface RestRouterOptions {
/** Keep unfinished continuation allocations private without removing internal registry rows. */
isSessionVisible?: (id: string) => Promise<boolean>;
getAssistantState?: () => AssistantStateSnapshot;
/** Complete selected-session End, including native and Terminal cleanup.
* Uses the route's boot authorization independently of Assistant grant expiry. */
Expand Down Expand Up @@ -355,6 +357,27 @@ export function createRestRouter(options: RestRouterOptions): Router {
router.post("/sessions", express.json({ limit: CREATE_SESSION_JSON_LIMIT_BYTES }));
router.use(express.json({ limit: JSON_BODY_LIMIT_BYTES }));

const listVisibleSessions = async (): Promise<HarnessSession[]> => {
const sessions = sessionManager.list();
if (!options.isSessionVisible) return sessions;
const visible: HarnessSession[] = [];
for (let offset = 0; offset < sessions.length; offset += 8) {
const page = sessions.slice(offset, offset + 8);
const accepted = await Promise.all(page.map((session) => options.isSessionVisible!(session.id)));
visible.push(...page.filter((_session, index) => accepted[index]));
}
return visible;
};
router.use("/sessions/:id", async (req, res, next) => {
try {
if (options.isSessionVisible && sessionManager.get(req.params.id) && !(await options.isSessionVisible(req.params.id))) {
res.status(404).json({ error: "session not found" });
return;
}
next();
} catch (error) { next(error); }
});

router.get("/state", async (_req, res, next) => {
try {
const settings = await loadSettings(options.settingsPath);
Expand All @@ -367,7 +390,7 @@ export function createRestRouter(options: RestRouterOptions): Router {
telemetryOptIn: settings.telemetryOptIn,
// Absent === opted-in: light product analytics is on by default.
productAnalyticsOptIn: settings.productAnalyticsOptIn !== false,
sessions: sessionManager.list(),
sessions: await listVisibleSessions(),
workflows: await listWorkflows(),
...(options.listWorkspaceScopes
? { workspaceScopes: await options.listWorkspaceScopes() }
Expand Down Expand Up @@ -593,8 +616,9 @@ export function createRestRouter(options: RestRouterOptions): Router {
}
});

router.get("/sessions", (_req, res) => {
res.json(sessionManager.list());
router.get("/sessions", async (_req, res, next) => {
try { res.json(await listVisibleSessions()); }
catch (error) { next(error); }
});

router.get("/sessions/history", async (req, res, next) => {
Expand Down Expand Up @@ -630,8 +654,7 @@ export function createRestRouter(options: RestRouterOptions): Router {

// Registry entries win over transcript-scanned history for the same
// agent session — they carry live status the transcript can't know.
const registryRows = sessionManager
.list()
const registryRows = (await listVisibleSessions())
.filter(
(session) => session.cwd === cwd && session.agentSessionId != null,
);
Expand Down
Loading