Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andluke 798b546e73 fix(omp): release closed sessions retained by mount notices
Keep per-session notice identity without keeping the session and its runtime reachable from the client-wide ledger.

Co-authored-by: luke <luopeihuan@gmail.com>
2026-11-03 11:23:44 +00:00
luke 6301490a07 fix(omp): suppress repeated mount notices 2026-11-03 17:41:10 +08:00
paseo-bot[bot] 16f688b726 test(server): stop pending setup before removing its fixture (#8624)
* test(server): diagnose recurring Windows setup timeout

* test(server): stop pending setup before removing its fixture

A test deadline does not cancel background setup. Abort and join it before deleting its working directory, and cover teardown with a deliberately live setup child. Keep the existing subdirectory shell assertions and timeout.

* test(server): preserve setup readiness filesystem errors

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 09:30:30 +00:00
paseo-bot[bot]andHomicideZero b507fc1986 fix(server): return 404 for missing well-known metadata (#8622)
* fix(server): return 404 for unserved well-known paths

The daemon web UI middleware fell back to the SPA's index.html for every
unknown GET, including /.well-known/* URIs. MCP clients that run OAuth
discovery (RFC 9728 protected resource metadata, RFC 8414 authorization
server metadata) received a 200 text/html body, treated it as malformed
metadata and retried instead of concluding the server has no OAuth.

Well-known paths now serve a real file from the dist directory if one
exists and otherwise return a JSON 404. Other paths keep the SPA fallback.

* fix(server): verify metadata routing through the daemon

Retain the contributed well-known routing fix with named resolver options and a full daemon regression that fails when metadata requests receive app HTML.

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>

---------

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 07:53:13 +00:00
Mohamed Boudra e546497a21 fix(workspaces): keep merged pull requests visible
* fix(workspaces): keep merged pull requests visible

A remote update before merge can leave the checkout behind the PR head. Match terminal requests by branch for display and require an exact fresh head match before automatic archive.

* fix(forges): retain pull requests targeting a fork

A fork checkout can have its own pull request as well as one targeting its parent. Compare both candidates before choosing which request to display.

* fix(forges): rank closed drafts as terminal

GitHub retains the draft flag after closing a pull request. Selection must use the request state so a closed draft does not override an open or exact-head request.
2026-11-03 13:43:02 +07:00
29 changed files with 1216 additions and 584 deletions
+8 -12
View File
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
Current change-request lookup matches the branch and repository for both open and
terminal requests. Prefer an open request, then a terminal request at the local
HEAD, then the most recently updated terminal request. A reused branch can show
its previous request until a new one opens. Auto-archive separately requires an
observed open-to-merged transition and a fresh exact match between checkout HEAD
and the PR head, along with the existing workspace safety checks.
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Keep checkout HEAD in adapter cache and poll identities so checkout changes
refresh the facts used by archive eligibility.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
@@ -111,6 +111,11 @@ import {
mapOmpRpcUiPermissionRequest,
} from "./rpc-ui-permission-mapper.js";
import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js";
import {
OmpMountNoticeLedger,
OmpMountNoticeLedgers,
parseOmpMountNotice,
} from "./mount-notice.js";
const OMP_PROVIDER = "omp";
const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = {
@@ -208,6 +213,10 @@ interface OmpAgentSessionOptions {
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
providerIdleDeadlineMs?: number;
/** Shared by every session of the client so a relaunched agent keeps its ledger. */
mountNotices: OmpMountNoticeLedgers;
/** The Paseo agent whose projection this session feeds; absent for direct client use. */
agentId?: string;
/**
* When false (resumed sessions), replayed session events are dropped until
* the first prompt or agent_start so history is not re-emitted as live
@@ -620,6 +629,8 @@ class OmpHistorySession implements AgentSession {
private readonly sessionFile: string,
provider: AgentProvider,
private readonly bridgedTools: ReadonlyMap<string, OmpBridgedToolIdentity>,
private readonly mountNotices: OmpMountNoticeLedgers,
private readonly agentId: string | undefined,
) {
this.provider = provider;
}
@@ -641,6 +652,8 @@ class OmpHistorySession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
// This history seeds a fresh projection that holds no live mount notices.
if (this.agentId) this.mountNotices.seedFromHistory(this.agentId, this);
yield* streamOmpHistory({
sessionFile: this.sessionFile,
provider: this.provider,
@@ -718,6 +731,11 @@ export class OmpAgentSession implements AgentSession {
private readonly subagentIndex = new OmpSubagentIndex();
private readonly subagentCardTracker: OmpSubagentCardTracker;
private lastTodoItem: Extract<AgentTimelineItem, { type: "todo" }> | null = null;
private readonly mountNotices: OmpMountNoticeLedgers;
private readonly agentId: string | undefined;
private readonly sessionMountNotices = new OmpMountNoticeLedger();
// The client-wide ledger outlives this session; retain identity without its runtime resources.
private readonly mountNoticeDeliverer = {};
private state: OmpSessionState;
private currentModeId: string | null;
private runtimeDead = false;
@@ -753,6 +771,8 @@ export class OmpAgentSession implements AgentSession {
this.currentModeId = options.currentModeId ?? null;
this.logger = options.logger;
this.live = options.live ?? true;
this.mountNotices = options.mountNotices;
this.agentId = options.agentId;
this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler();
this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS;
this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler();
@@ -999,6 +1019,10 @@ export class OmpAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
// This history seeds a fresh projection: it keeps the mount notices this session already
// delivered into it, and none from earlier sessions.
if (this.agentId) this.mountNotices.seedFromHistory(this.agentId, this.mountNoticeDeliverer);
else this.sessionMountNotices.retainDeliveredBy(this.mountNoticeDeliverer);
yield* streamOmpHistory({
sessionFile: this.state.sessionFile,
runtimeSession: this.runtimeSession,
@@ -1014,6 +1038,23 @@ export class OmpAgentSession implements AgentSession {
}
}
private mountNoticeLedger(): OmpMountNoticeLedger {
return this.agentId ? this.mountNotices.forAgent(this.agentId) : this.sessionMountNotices;
}
/**
* Drops a mount notice whose devices already have that state in the projected timeline.
* A notice no subscriber can receive is not recorded, so it cannot hide a later one.
*/
private isRedundantMountNotice(event: OmpRuntimeEvent): boolean {
const notice = parseOmpMountNotice(event);
if (!notice || this.subscribers.size === 0) return false;
const ledger = this.mountNoticeLedger();
if (!ledger.announces(notice)) return true;
ledger.recordDelivered(notice, this.mountNoticeDeliverer);
return false;
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
await this.refreshState();
return this.runtimeInfoFromState();
@@ -1170,6 +1211,9 @@ export class OmpAgentSession implements AgentSession {
await this.runtimeSession.branch(target);
await this.refreshState();
this.activeToolCalls.clear();
// A conversation rewind replaces the whole projection, rows this session delivered included.
if (this.agentId) this.mountNotices.forget(this.agentId);
else this.sessionMountNotices.clear();
}
async canStopRuntime(): Promise<boolean> {
@@ -1718,6 +1762,10 @@ export class OmpAgentSession implements AgentSession {
}
return true;
}
if (this.isRedundantMountNotice(event)) {
this.logger.debug({ event }, "Dropped OMP mount notice already shown in this timeline");
return true;
}
const mappedEvent = mapOmpRuntimeEventToTimelineItem(event);
if (!mappedEvent.handled) {
return false;
@@ -2246,6 +2294,7 @@ export class OmpAgentClient implements AgentClient {
private readonly usagePollScheduler?: OmpUsagePollScheduler;
private readonly providerIdleDeadlineMs?: number;
private readonly runtime?: OmpRuntime;
private readonly mountNotices = new OmpMountNoticeLedgers();
constructor(options: OmpAgentClientOptions) {
const runtimeSettings = mergeOmpRuntimeSettings(
@@ -2346,6 +2395,8 @@ export class OmpAgentClient implements AgentClient {
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
providerIdleDeadlineMs: this.providerIdleDeadlineMs,
mountNotices: this.mountNotices,
agentId: launchContext?.agentId,
});
} catch (error) {
await hostTools?.close();
@@ -2375,6 +2426,8 @@ export class OmpAgentClient implements AgentClient {
sessionFile,
this.provider,
persistenceMetadata.bridgedTools,
this.mountNotices,
launchContext?.agentId,
);
}
@@ -2417,6 +2470,8 @@ export class OmpAgentClient implements AgentClient {
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
providerIdleDeadlineMs: this.providerIdleDeadlineMs,
mountNotices: this.mountNotices,
agentId: launchContext?.agentId,
live: false,
});
} catch (error) {
@@ -0,0 +1,495 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setImmediate as waitForImmediate } from "node:timers/promises";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type {
AgentPersistenceHandle,
AgentSession,
AgentStreamEvent,
AgentTimelineItem,
} from "../../agent-sdk-types.js";
import { AgentManager, type AgentManagerEvent } from "../../agent-manager.js";
import { OmpAgentClient } from "./agent.js";
import { parseOmpMountNotice } from "./mount-notice.js";
import type { OmpRuntimeEvent } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
const AGENT_ID = "8b0c7a52-6f4e-4c4b-9f55-6a5f0f2f6b11";
const BROWSER_AND_LINEAR = "xd://: mounted browser, mcp__linear__search";
type NoticeLevel = "info" | "warning" | "error";
function notice(
message: string,
options: { level?: NoticeLevel; source?: string | null } = {},
): OmpRuntimeEvent {
const source = options.source === undefined ? "xdev" : options.source;
return {
type: "notice",
level: options.level ?? "info",
message,
...(source === null ? {} : { source }),
};
}
function notifications(items: readonly AgentTimelineItem[]): string[] {
return items.flatMap((item) => (item.type === "notification" ? [item.message] : []));
}
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
});
async function writeSessionFile(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-mount-notice-"));
cleanups.push(() => rm(directory, { recursive: true, force: true }));
const sessionFile = join(directory, "session.jsonl");
const entries = [
{ type: "session", id: "root", parentId: null },
{ type: "message", id: "u1", parentId: "root", message: { role: "user", content: "hi" } },
];
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"), "utf8");
return sessionFile;
}
function persistenceHandle(sessionFile: string): AgentPersistenceHandle {
return {
provider: "omp",
sessionId: "omp-session",
nativeHandle: sessionFile,
metadata: { cwd: process.cwd() },
};
}
async function drain(history: AsyncGenerator<AgentStreamEvent>): Promise<void> {
for await (const _event of history) {
// Consumed the way the manager seeds a projection.
}
}
async function closeQuietly(manager: AgentManager, agentId: string): Promise<void> {
await manager.closeAgent(agentId).catch(() => undefined);
}
/** One Paseo agent whose OMP process the daemon relaunches, as idle collection and reload do. */
class MountNoticeHarness {
readonly omp = new FakeOmp();
readonly client = new OmpAgentClient({ logger: createTestLogger(), runtime: this.omp });
private readonly events: AgentStreamEvent[] = [];
private session: AgentSession | null = null;
constructor(private readonly sessionFile: string) {}
handle(): AgentPersistenceHandle {
return persistenceHandle(this.sessionFile);
}
async relaunch(): Promise<void> {
await this.session?.close();
const session = await this.client.resumeSession(this.handle(), undefined, {
agentId: AGENT_ID,
});
this.session = session;
session.subscribe((event) => this.events.push(event));
cleanups.push(() => session.close());
}
async announce(event: OmpRuntimeEvent | string): Promise<void> {
this.omp.latestSession().emit(typeof event === "string" ? notice(event) : event);
await waitForImmediate();
}
/** The current session seeds a fresh projection from provider history. */
async seedFromHistory(): Promise<void> {
if (!this.session) throw new Error("No OMP session");
await drain(this.session.streamHistory());
}
async readArchivedHistory(agentId = AGENT_ID): Promise<void> {
const history = await this.client.resumeSession(
this.handle(),
undefined,
{ agentId },
{
purpose: "history",
},
);
await drain(history.streamHistory());
await history.close();
}
notifications(): string[] {
return notifications(
this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : [])),
);
}
}
async function harness(): Promise<MountNoticeHarness> {
const omp = new MountNoticeHarness(await writeSessionFile());
await omp.relaunch();
return omp;
}
describe("parseOmpMountNotice", () => {
test("reads mounted and unmounted devices", () => {
expect(parseOmpMountNotice(notice("xd://: mounted a, b; unmounted c"))).toEqual({
mounted: ["a", "b"],
unmounted: ["c"],
});
});
test("accepts OMP builds that omit the notice source", () => {
expect(parseOmpMountNotice(notice("xd://: unmounted a", { source: null }))).toEqual({
mounted: [],
unmounted: ["a"],
});
});
test("leaves other notices alone", () => {
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { source: "btw" }))).toBeNull();
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { level: "warning" }))).toBeNull();
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { level: "error" }))).toBeNull();
expect(parseOmpMountNotice(notice("xd://: rescanned devices"))).toBeNull();
expect(parseOmpMountNotice(notice("xd://: mounted a, "))).toBeNull();
expect(parseOmpMountNotice(notice("Session saved"))).toBeNull();
});
});
describe("OMP xd:// mount notices", () => {
test("retaining announced mounts does not retain closed OMP sessions", () => {
const fixture = fileURLToPath(new URL("./test-utils/mount-notice-memory.ts", import.meta.url));
const output = execFileSync(process.execPath, ["--expose-gc", "--import", "tsx", fixture], {
encoding: "utf8",
timeout: 20_000,
});
expect(JSON.parse(output)).toEqual({
closedWithoutNoticeRetained: false,
closedWithNoticeRetained: false,
noticesAfterRelaunch: 1,
});
});
test("a relaunched OMP process does not repeat a mount set the timeline already shows", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
await omp.relaunch();
await omp.announce(BROWSER_AND_LINEAR);
await omp.relaunch();
await omp.announce("xd://: mounted mcp__linear__search");
await omp.announce("xd://: mounted browser");
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
});
test("a changed mount set is shown as OMP reported it", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
await omp.relaunch();
const withGithub = "xd://: mounted browser, mcp__linear__search, mcp__github__search";
await omp.announce(withGithub);
await omp.announce("xd://: unmounted browser");
await omp.relaunch();
await omp.announce("xd://: unmounted browser");
expect(omp.notifications()).toEqual([
BROWSER_AND_LINEAR,
withGithub,
"xd://: unmounted browser",
]);
});
test("a device mounted again after an unmount is shown", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
await omp.announce("xd://: unmounted browser");
await omp.announce("xd://: mounted browser");
expect(omp.notifications()).toEqual([
BROWSER_AND_LINEAR,
"xd://: unmounted browser",
"xd://: mounted browser",
]);
});
test("identical ordinary notices are all shown", async () => {
const omp = await harness();
await omp.announce(notice("Session saved", { source: "session-persistence" }));
await omp.relaunch();
await omp.announce(notice("Session saved", { source: "session-persistence" }));
await omp.announce(notice(BROWSER_AND_LINEAR, { source: "btw-history" }));
await omp.announce(notice(BROWSER_AND_LINEAR, { source: "btw-history" }));
expect(omp.notifications()).toEqual([
"Session saved",
"Session saved",
BROWSER_AND_LINEAR,
BROWSER_AND_LINEAR,
]);
});
test("identical xd:// warnings and errors are all shown", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
for (const level of ["warning", "error"] as const) {
await omp.announce(notice(BROWSER_AND_LINEAR, { level }));
await omp.relaunch();
await omp.announce(notice(BROWSER_AND_LINEAR, { level }));
}
expect(omp.notifications()).toEqual([
BROWSER_AND_LINEAR,
BROWSER_AND_LINEAR,
BROWSER_AND_LINEAR,
BROWSER_AND_LINEAR,
BROWSER_AND_LINEAR,
]);
});
test("a projection seeded from history drops mounts earlier sessions showed", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
await omp.relaunch();
await omp.seedFromHistory();
await omp.announce(BROWSER_AND_LINEAR);
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR, BROWSER_AND_LINEAR]);
});
test("a projection seeded from history keeps mounts its own session already showed", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
await omp.seedFromHistory();
await omp.relaunch();
await omp.announce(BROWSER_AND_LINEAR);
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
});
test("reading the agent's archived history drops the mounts it no longer shows", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
await omp.readArchivedHistory();
await omp.relaunch();
await omp.announce(BROWSER_AND_LINEAR);
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR, BROWSER_AND_LINEAR]);
});
test("reading another agent's history of the same session file keeps this agent's mounts", async () => {
const omp = await harness();
await omp.announce(BROWSER_AND_LINEAR);
await omp.readArchivedHistory("2d1e4f7a-0c3b-4d8e-a9f6-5b7c8d9e0f12");
await omp.relaunch();
await omp.announce(BROWSER_AND_LINEAR);
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
});
test("a notice nobody received does not hide the next one", async () => {
const omp = new MountNoticeHarness(await writeSessionFile());
const unobserved = await omp.client.resumeSession(omp.handle(), undefined, {
agentId: AGENT_ID,
});
cleanups.push(() => unobserved.close());
await omp.announce(BROWSER_AND_LINEAR);
await unobserved.close();
await omp.relaunch();
await omp.announce(BROWSER_AND_LINEAR);
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
});
test("an approval mode change keeps the mount set the timeline already shows", async () => {
const omp = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime: omp });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
cleanups.push(() => session.close());
const items: AgentTimelineItem[] = [];
session.subscribe((event) => {
if (event.type === "timeline") items.push(event.item);
});
omp.latestSession().emit(notice(BROWSER_AND_LINEAR));
await waitForImmediate();
await session.setMode("ask");
omp.latestSession().emit(notice(BROWSER_AND_LINEAR));
await waitForImmediate();
expect(omp.recordedLaunches).toHaveLength(2);
expect(notifications(items)).toEqual([BROWSER_AND_LINEAR]);
});
});
/** A daemon AgentManager driving one OMP agent through FakeOmp. */
class DaemonTimelineHarness {
readonly omp = new FakeOmp();
readonly manager = new AgentManager({
clients: {
omp: new OmpAgentClient({
logger: createTestLogger(),
runtime: this.omp,
// FakeOmp supplies the runtime; the manager still checks command availability.
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
}),
},
logger: createTestLogger(),
});
readonly live: Array<{
epoch: string | undefined;
seq: number | undefined;
item: AgentTimelineItem;
}> = [];
constructor(readonly agentId: string) {
cleanups.push(() => closeQuietly(this.manager, agentId));
this.manager.subscribe(
(event: AgentManagerEvent) => {
if (event.type === "agent_stream" && event.event.type === "timeline") {
this.live.push({ epoch: event.epoch, seq: event.seq, item: event.event.item });
}
},
{ agentId, replayState: false },
);
}
/** What the agent loader does when a chat opens an agent the daemon has not loaded. */
async load(handle: AgentPersistenceHandle): Promise<void> {
await this.manager.resumeAgentFromPersistence(handle, undefined, this.agentId);
await this.manager.hydrateTimelineFromProvider(this.agentId);
}
async announce(event: OmpRuntimeEvent | string): Promise<void> {
this.omp.latestSession().emit(typeof event === "string" ? notice(event) : event);
await waitForImmediate();
}
async prompt(text: string): Promise<void> {
await this.manager.appendTimelineItem(this.agentId, { type: "user_message", text });
}
fetchAll() {
return this.manager.fetchTimeline(this.agentId, { direction: "tail", limit: 0 });
}
fetchedNotifications(): string[] {
return notifications(this.fetchAll().rows.map((row) => row.item));
}
}
describe("OMP mount notices in the daemon timeline", () => {
test("live delivery and fetched history agree after idle collection relaunches OMP", async () => {
const daemon = new DaemonTimelineHarness(AGENT_ID);
await daemon.load(persistenceHandle(await writeSessionFile()));
await daemon.announce(BROWSER_AND_LINEAR);
await daemon.prompt("first prompt");
const handle = daemon.manager.getAgent(AGENT_ID)?.persistence;
if (!handle) throw new Error("OMP agent has no persistence handle");
const epoch = daemon.fetchAll().epoch;
const historyRows = daemon.fetchAll().rows.length - 2;
await daemon.manager.closeAgent(AGENT_ID);
await daemon.load(handle);
await daemon.announce(BROWSER_AND_LINEAR);
await daemon.announce(notice("Session saved", { source: null }));
await daemon.prompt("second prompt");
await daemon.manager.flush();
const fetched = daemon.fetchAll();
expect(daemon.omp.recordedLaunches.at(-1)?.session).toBe(handle.nativeHandle);
expect(fetched.epoch).toBe(epoch);
expect(fetched.rows.slice(historyRows).map((row) => row.item)).toEqual([
{ type: "notification", level: "info", message: BROWSER_AND_LINEAR },
expect.objectContaining({ type: "user_message", text: "first prompt" }),
{ type: "notification", level: "info", message: "Session saved" },
expect.objectContaining({ type: "user_message", text: "second prompt" }),
]);
const liveRows = daemon.live.filter((entry) => entry.epoch === epoch);
expect(liveRows.map((entry) => entry.item)).toEqual(
fetched.rows.slice(historyRows).map((row) => row.item),
);
expect(liveRows.map((entry) => entry.seq)).toEqual(
fetched.rows.slice(historyRows).map((row) => row.seqEnd),
);
const seqs = fetched.rows.map((row) => row.seqEnd);
expect(seqs).toEqual(seqs.map((_seq, index) => index + 1));
expect(fetched.hasOlder).toBe(false);
expect(fetched.hasNewer).toBe(false);
const afterFirstPrompt = daemon.manager.fetchTimeline(AGENT_ID, {
direction: "after",
cursor: { epoch, seq: historyRows + 2 },
limit: 0,
});
expect(afterFirstPrompt.gap).toBe(false);
expect(afterFirstPrompt.rows.map((row) => row.item)).toEqual(
fetched.rows.slice(historyRows + 2).map((row) => row.item),
);
});
test("a mount notice that lands before history seeding still suppresses the next relaunch", async () => {
const daemon = new DaemonTimelineHarness(AGENT_ID);
const handle = persistenceHandle(await writeSessionFile());
await daemon.manager.resumeAgentFromPersistence(handle, undefined, AGENT_ID);
await daemon.announce(BROWSER_AND_LINEAR);
await daemon.manager.hydrateTimelineFromProvider(AGENT_ID);
await daemon.manager.closeAgent(AGENT_ID);
await daemon.load(handle);
await daemon.announce(BROWSER_AND_LINEAR);
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
});
test("a conversation rewind starts a projection that shows the mount set once", async () => {
const daemon = new DaemonTimelineHarness(AGENT_ID);
const handle = persistenceHandle(await writeSessionFile());
await daemon.load(handle);
await daemon.announce(BROWSER_AND_LINEAR);
const userMessage = daemon
.fetchAll()
.rows.map((row) => row.item)
.find((item) => item.type === "user_message");
if (userMessage?.type !== "user_message" || !userMessage.messageId) {
throw new Error("History has no user message to rewind to");
}
await daemon.manager.rewind(AGENT_ID, userMessage.messageId, "conversation");
expect(daemon.fetchedNotifications()).toEqual([]);
await daemon.manager.closeAgent(AGENT_ID);
await daemon.load(handle);
await daemon.announce(BROWSER_AND_LINEAR);
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
});
test("reloading from disk starts a projection that shows the mount set once", async () => {
const daemon = new DaemonTimelineHarness(AGENT_ID);
await daemon.load(persistenceHandle(await writeSessionFile()));
await daemon.announce(BROWSER_AND_LINEAR);
const epoch = daemon.fetchAll().epoch;
await daemon.manager.reloadAgentSession(AGENT_ID, undefined, { rehydrateFromDisk: true });
await daemon.manager.hydrateTimelineFromProvider(AGENT_ID);
await daemon.announce(BROWSER_AND_LINEAR);
await daemon.manager.reloadAgentSession(AGENT_ID);
await daemon.announce(BROWSER_AND_LINEAR);
expect(daemon.fetchAll().epoch).not.toBe(epoch);
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
});
});
@@ -0,0 +1,132 @@
import { OmpNoticeEventSchema } from "./rpc-types.js";
// OMP 18.x announces device mounts as an info notice `xd://: mounted a, b; unmounted c` with
// source `xdev`. Each OMP process diffs against the tools it had at startup, so every relaunch of
// the same session (idle collection, reload, mode change) announces again every MCP server that
// connects after OMP's startup wait. A relaunch never reports a device that went missing while
// no process ran, and has no RPC that lists mounted devices, so a device that vanishes and later
// remounts in a new process looks identical to one that was present all along.
const MOUNT_NOTICE_PREFIX = "xd://: ";
const MOUNT_NOTICE_SOURCE = "xdev";
const MOUNT_NOTICE_LEVEL = "info";
const MOUNTED_PREFIX = "mounted ";
const UNMOUNTED_PREFIX = "unmounted ";
export interface OmpMountNotice {
mounted: readonly string[];
unmounted: readonly string[];
}
function parseNames(list: string): string[] | null {
const names = list.split(",").map((name) => name.trim());
return names.every((name) => name.length > 0) ? names : null;
}
/** Reads an OMP `notice` event as an xd:// mount change, or null for any other notice. */
export function parseOmpMountNotice(event: unknown): OmpMountNotice | null {
const parsed = OmpNoticeEventSchema.safeParse(event);
if (!parsed.success) return null;
const { level, message, source } = parsed.data;
if (level !== MOUNT_NOTICE_LEVEL) return null;
if (source !== undefined && source !== MOUNT_NOTICE_SOURCE) return null;
if (!message.startsWith(MOUNT_NOTICE_PREFIX)) return null;
const mounted: string[] = [];
const unmounted: string[] = [];
for (const segment of message.slice(MOUNT_NOTICE_PREFIX.length).split(";")) {
const part = segment.trim();
let target: string[];
let names: string[] | null;
if (part.startsWith(UNMOUNTED_PREFIX)) {
target = unmounted;
names = parseNames(part.slice(UNMOUNTED_PREFIX.length));
} else if (part.startsWith(MOUNTED_PREFIX)) {
target = mounted;
names = parseNames(part.slice(MOUNTED_PREFIX.length));
} else {
return null;
}
if (!names) return null;
target.push(...names);
}
return mounted.length > 0 || unmounted.length > 0 ? { mounted, unmounted } : null;
}
type OmpMountState = "mounted" | "unmounted";
/** The session that delivered a timeline row. A ledger keeps only rows its projection holds. */
export type OmpMountNoticeDeliverer = object;
interface OmpAnnouncedMount {
state: OmpMountState;
deliverer: OmpMountNoticeDeliverer;
}
/** The last xd:// state one agent's projected timeline shows for each device. */
export class OmpMountNoticeLedger {
private readonly announced = new Map<string, OmpAnnouncedMount>();
/** Reports whether the notice changes any device state the projection shows. */
announces(notice: OmpMountNotice): boolean {
return (
notice.mounted.some((name) => this.announced.get(name)?.state !== "mounted") ||
notice.unmounted.some((name) => this.announced.get(name)?.state !== "unmounted")
);
}
/** Records a row the deliverer added to the projection; it shows every device it names. */
recordDelivered(notice: OmpMountNotice, deliverer: OmpMountNoticeDeliverer): void {
for (const name of notice.mounted) this.announced.set(name, { state: "mounted", deliverer });
for (const name of notice.unmounted) {
this.announced.set(name, { state: "unmounted", deliverer });
}
}
/**
* Keeps only rows the deliverer added. A session streams provider history only to seed a fresh
* projection, which holds that history plus the rows this session already delivered into it.
*/
retainDeliveredBy(deliverer: OmpMountNoticeDeliverer): void {
for (const [name, mount] of this.announced) {
if (mount.deliverer !== deliverer) this.announced.delete(name);
}
}
clear(): void {
this.announced.clear();
}
get size(): number {
return this.announced.size;
}
}
/**
* Mount ledgers keyed by Paseo agent id. The agent's retained projection outlives one OMP
* process and one Paseo session, so its ledger must too.
*/
export class OmpMountNoticeLedgers {
private readonly byAgentId = new Map<string, OmpMountNoticeLedger>();
forAgent(agentId: string): OmpMountNoticeLedger {
let ledger = this.byAgentId.get(agentId);
if (!ledger) {
ledger = new OmpMountNoticeLedger();
this.byAgentId.set(agentId, ledger);
}
return ledger;
}
/** The agent's whole projection is about to be replaced from provider history. */
forget(agentId: string): void {
this.byAgentId.delete(agentId);
}
/** The agent's projection is being seeded from provider history by this deliverer. */
seedFromHistory(agentId: string, deliverer: OmpMountNoticeDeliverer): void {
const ledger = this.byAgentId.get(agentId);
if (!ledger) return;
ledger.retainDeliveredBy(deliverer);
if (ledger.size === 0) this.byAgentId.delete(agentId);
}
}
@@ -0,0 +1,60 @@
import { setImmediate as nextTask } from "node:timers/promises";
import pino from "pino";
import { OmpAgentClient } from "../agent.js";
import { FakeOmp } from "./fake-omp.js";
if (!global.gc) throw new Error("Run with --expose-gc");
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: pino({ level: "silent" }), runtime });
const notices: string[] = [];
const notice = {
type: "notice",
level: "info",
source: "xdev",
message: "xd://: mounted browser",
} as const;
const agentId = "a7969ccf-b200-429a-a36a-e61ac01e59ab";
async function closedSession(announce: boolean, id: string) {
const session = await client.createSession(
{ provider: "omp", cwd: process.cwd() },
{ agentId: id },
);
session.subscribe((event) => {
if (event.type === "timeline" && event.item.type === "notification") {
notices.push(event.item.message);
}
});
if (announce) runtime.latestSession().emit(notice);
await nextTask();
const persistence = session.describePersistence();
if (!persistence) throw new Error("Session has no persistence handle");
await session.close();
return { reference: new WeakRef(session), persistence };
}
const withoutNotice = await closedSession(false, "cd39924e-3984-4d10-b2ec-8a4e4ba029f5");
const withNotice = await closedSession(true, agentId);
// Move to another job before collecting: WeakRef targets survive their creation job.
for (let i = 0; i < 5; i++) {
await nextTask();
global.gc();
}
const retained = {
closedWithoutNoticeRetained: withoutNotice.reference.deref() !== undefined,
closedWithNoticeRetained: withNotice.reference.deref() !== undefined,
};
const resumed = await client.resumeSession(withNotice.persistence, undefined, { agentId });
try {
resumed.subscribe((event) => {
if (event.type === "timeline" && event.item.type === "notification") {
notices.push(event.item.message);
}
});
runtime.latestSession().emit(notice);
await nextTask();
console.log(JSON.stringify({ ...retained, noticesAfterRelaunch: notices.length }));
} finally {
await resumed.close();
}
@@ -30,6 +30,7 @@ function createPullRequest(
state: "open",
baseRefName: "main",
headRefName: "feature",
headSha: "same-head",
isMerged: true,
...overrides,
};
@@ -46,6 +47,7 @@ function createSnapshot(overrides?: {
repoRoot: "/tmp/repo",
mainRepoRoot: "/tmp/repo",
currentBranch: "feature",
headSha: "same-head",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -346,6 +348,25 @@ afterEach(() => {
});
describe("archiveIfSafe", () => {
test("denies archive when checkout facts do not establish safety", async () => {
for (const git of [{ headSha: undefined }, { behindOfOrigin: 1 }, { isDirty: null }]) {
const harness = createHarness({ snapshot: createSnapshot({ git }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
}
const harness = createHarness({
snapshot: createSnapshot({ pullRequest: createPullRequest({ headSha: undefined }) }),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("keeps the workspace active when the merged PR head differs from checkout", async () => {
const harness = createHarness({ snapshot: createSnapshot({ git: { headSha: "local-head" } }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -59,13 +59,21 @@ export async function archiveIfSafe(input: {
const cwd = snapshot.cwd;
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest?.isMerged) {
if (
!pullRequest?.isMerged ||
!snapshot.git.headSha ||
snapshot.git.headSha !== pullRequest.headSha ||
snapshot.forge.error
) {
return;
}
if (snapshot.git.isDirty === true) {
if (snapshot.git.isDirty !== false) {
return;
}
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
if (
(snapshot.git.aheadOfOrigin !== null && snapshot.git.aheadOfOrigin !== 0) ||
(snapshot.git.behindOfOrigin !== null && snapshot.git.behindOfOrigin !== 0)
) {
return;
}
@@ -161,7 +161,12 @@ async function createWorkspaceJourney() {
};
}
async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
async function createUnobservedWorkspace(journeyOptions?: {
archiveBarrier?: Promise<void>;
changeBeforeArchiveRead?: boolean;
}) {
const { archiveBarrier, changeBeforeArchiveRead: shouldChange = false } = journeyOptions ?? {};
let changeBeforeArchiveRead = shouldChange;
const { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
const cwd = worktree.worktreePath;
run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
@@ -173,8 +178,10 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
runner: async () => ({ stdout: "", stderr: "" }),
});
let observation: PullRequestObservation | null = null;
let remoteHeadSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
const reads: Array<string | undefined> = [];
const archives: string[] = [];
let archiveDecisions = 0;
let active = true;
const service = new WorkspaceGitServiceImpl({
logger,
@@ -186,6 +193,7 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
return {
status: observation && {
...observation,
headSha: remoteHeadSha,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
@@ -202,15 +210,29 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
logger,
paseoHome,
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
workspaceGitService: service,
workspaceGitService: {
onSnapshotUpdated: service.onSnapshotUpdated.bind(service),
getSnapshot: async (
snapshotCwd: string,
readOptions: { force: true; afterInFlight?: boolean; reason: string },
) => {
if (changeBeforeArchiveRead && readOptions.reason === "auto-archive-on-merge") {
changeBeforeArchiveRead = false;
writeFileSync(path.join(cwd, "README.md"), "changed before archive\n");
run(cwd, ["add", "README.md"]);
run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "new commit"]);
}
return service.getSnapshot(snapshotCwd, readOptions);
},
},
getAutoArchivedChangeRequestUrl: async () => null,
listActiveWorkspaces: async () =>
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
} as unknown as AutoArchiveOnMergeOptions;
setupAutoArchiveOnMerge(options, {
resolvePath: path.resolve,
archiveIfSafe: async (input) =>
archiveIfSafe({
archiveIfSafe: async (input) => {
await archiveIfSafe({
...input,
deps: {
isPaseoOwnedWorktreeCwd,
@@ -227,13 +249,19 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
};
},
},
}),
});
archiveDecisions++;
},
});
return {
archives,
archiveDecisions: () => archiveDecisions,
reads,
metrics: () => service.getMetrics(),
isActive: () => active,
advanceRemoteHead: () => {
remoteHeadSha = "a".repeat(40);
},
makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"),
commitUnpushed: () => {
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
@@ -270,12 +298,33 @@ afterEach(async () => {
});
describe("workspace auto-archive behavior", () => {
test("rechecks checkout changes made after the merge notification", async () => {
const workspace = await createUnobservedWorkspace({ changeBeforeArchiveRead: true });
await workspace.observe("open");
await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(workspace.archives).toEqual([]);
expect(workspace.isActive()).toBe(true);
});
test("keeps a merged PR visible and the workspace active when its remote head advances", async () => {
const workspace = await createUnobservedWorkspace();
const open = await workspace.observe("open");
workspace.advanceRemoteHead();
const merged = await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(merged.forge.pullRequest?.url).toBe(open.forge.pullRequest?.url);
expect(merged.forge.pullRequest?.headSha).not.toBe(merged.git.headSha);
expect(workspace.isActive()).toBe(true);
expect(workspace.archives).toEqual([]);
});
test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => {
let releaseArchive!: () => void;
const archiveBarrier = new Promise<void>((resolve) => {
releaseArchive = resolve;
});
const workspace = await createUnobservedWorkspace(archiveBarrier);
const workspace = await createUnobservedWorkspace({ archiveBarrier });
try {
await workspace.observe("open");
expect(workspace.archives).toEqual([]);
@@ -292,10 +341,10 @@ describe("workspace auto-archive behavior", () => {
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual(["workspace-under-test"]);
// Confirmation reads the completed snapshot cache; it does not shell out again.
expect(workspace.reads).toEqual([
"workspace-journey",
"workspace-journey",
"auto-archive-on-merge",
"workspace-journey",
]);
} finally {
@@ -90,6 +90,11 @@ test("fans one fresh observation out to every workspace attached to its exact cw
await finished;
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(getSnapshot).toHaveBeenCalledWith("/repo/worktree/.", {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
expect(calls).toEqual([
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
@@ -63,6 +63,8 @@ export function setupAutoArchiveOnMerge(
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
try {
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
} catch (error) {
@@ -84,6 +84,23 @@ describe("daemon web UI bootstrap", () => {
}
});
test("returns 404 for missing metadata while serving web UI deep links", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: true,
webUi: { enabled: true, distDir },
});
const origin = `http://127.0.0.1:${daemonHandle.port}`;
const metadata = await fetch(`${origin}/.well-known/oauth-protected-resource/mcp/agents`);
expect(metadata.status).toBe(404);
expect(metadata.headers.get("content-type")).toBe("application/json; charset=utf-8");
expect(await metadata.json()).toEqual({ error: "Not found" });
const deepLink = await fetch(`${origin}/h/example/agent/123`);
expect(deepLink.status).toBe(200);
expect(await deepLink.text()).toContain("<body>app</body>");
});
test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => {
daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false });
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
@@ -224,7 +224,7 @@ async function fetchApi(url, init) {
return new Response(JSON.stringify({ account: { uuid: second ? 'claude-two' : 'claude-one' }, organization: { uuid: second ? 'org-two' : 'org-one' } }));
}
if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 });
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-opencode-db': 23, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } }));
}
export default function contribute(server) {
@@ -270,31 +270,6 @@ export default function contribute(server) {
(await client.listUsageReports({ reportIds: ["codex:chatgpt-one"], forceRefresh: true }))
.reports[0];
expect((await refresh())?.report.windows[0]?.usedPct).toBe(22);
const openCodeDatabase = path.join(path.dirname(openCodePath), "opencode.db");
const openCodeDb = new DatabaseSync(openCodeDatabase);
openCodeDb.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
// OpenCode's v1 migration leaves active NULL and does not update auth.json.
openCodeDb.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, NULL, 1, 1)").run(
"migrated-login",
"openai",
"OpenAI",
JSON.stringify({
type: "oauth",
access: "fixture-opencode-db",
expires: Date.now() + 60_000,
metadata: { accountID: "chatgpt-one" },
}),
);
openCodeDb.close();
const rediscover = async () =>
(await client.listUsageReports({ forceRefresh: true })).reports.find(
(entry) => entry.id === "codex:chatgpt-one",
);
expect((await rediscover())!.report.windows[0]!.usedPct).toBe(23);
await rm(openCodeDatabase);
expect((await rediscover())!.report.windows[0]!.usedPct).toBe(22);
await rm(openCodePath);
expect((await refresh())?.report.windows[0]?.usedPct).toBe(33);
await json(piPath, {
+27
View File
@@ -187,6 +187,33 @@ describe("daemon web UI route module", () => {
expect(res.body).toContain("app");
});
test.each([
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp/agents",
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
])("returns a JSON 404 instead of the SPA for missing %s", async (requestPath) => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", requestPath);
expect(res.status).toBe(404);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(JSON.parse(res.body)).toEqual({ error: "Not found" });
});
test("serves well-known files that exist in the dist directory", async () => {
await mkdir(path.join(distDir, ".well-known"), { recursive: true });
await writeFile(path.join(distDir, ".well-known", "security.json"), '{"ok":true}');
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/.well-known/security.json");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(res.body).toBe('{"ok":true}');
});
test("serves static assets", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
+24 -2
View File
@@ -15,6 +15,13 @@ function isExcludedPath(requestPath: string): boolean {
return EXCLUDED_PATHS.has(requestPath);
}
// RFC 8615 well-known URIs are machine-read metadata. Clients such as MCP OAuth
// discovery (RFC 9728, RFC 8414) need a 404 for documents the daemon does not
// serve; the SPA's index.html with a 200 reads as malformed metadata.
function isWellKnownPath(requestPath: string): boolean {
return requestPath === "/.well-known" || requestPath.startsWith("/.well-known/");
}
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
@@ -71,7 +78,14 @@ interface ResolvedTarget {
isIndexHtml: boolean;
}
function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null {
interface ResolveTargetFileOptions {
distDir: string;
requestPath: string;
spaFallback: boolean;
}
function resolveTargetFile(options: ResolveTargetFileOptions): ResolvedTarget | null {
const { distDir, requestPath, spaFallback } = options;
const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
let filePath = path.join(distDir, safePath);
@@ -82,6 +96,9 @@ function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget
const finalStat = safeStat(filePath);
if (!finalStat?.isFile()) {
if (!spaFallback) {
return null;
}
filePath = path.join(distDir, "index.html");
const fallbackStat = safeStat(filePath);
if (!fallbackStat?.isFile()) {
@@ -190,8 +207,13 @@ interface ServeWebUiFileOptions {
function serveWebUiFile(options: ServeWebUiFileOptions): void {
const { distDir, requestPath, label, req, res } = options;
const target = resolveTargetFile(distDir, requestPath);
const wellKnown = isWellKnownPath(requestPath);
const target = resolveTargetFile({ distDir, requestPath, spaFallback: !wellKnown });
if (!target) {
if (wellKnown) {
res.status(404).json({ error: "Not found" });
return;
}
res.status(404).end();
return;
}
@@ -155,6 +155,7 @@ export interface WorkspaceGitRuntimeSnapshot {
repoRoot: string | null;
mainRepoRoot: string | null;
currentBranch: string | null;
headSha?: string;
remoteUrl: string | null;
isPaseoOwnedWorktree: boolean;
isDirty: boolean | null;
@@ -185,6 +186,7 @@ export interface WorkspaceGitRuntimeSnapshot {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
@@ -320,6 +322,7 @@ export type WorkspaceGitSnapshotOptions =
| {
force: true;
includeForge?: boolean;
afterInFlight?: boolean;
reason: string;
};
@@ -3250,7 +3253,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
refreshWorktree: true,
includeForge: options?.includeForge ?? true,
reason: options?.reason ?? defaultReason,
queueIfBusy: false,
queueIfBusy: force && options?.afterInFlight === true,
movedRemoteRefs: new Set(),
};
}
@@ -3550,6 +3553,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: refreshWorktree
@@ -550,14 +550,17 @@ function createGitHubPrRemoteRepo() {
describe("runWorktreeSetupInBackground", () => {
const cleanupPaths: string[] = [];
let stopSetup: (() => Promise<void>) | undefined;
afterEach(() => {
afterEach(async () => {
await stopSetup?.();
stopSetup = undefined;
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("runs setup from an exact workspace subdirectory", async () => {
function startSubdirectorySetup(command: string) {
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
cleanupPaths.push(tempDir);
const repoDir = path.join(tempDir, "source");
@@ -578,13 +581,14 @@ describe("runWorktreeSetupInBackground", () => {
path.join(workspaceCwd, "paseo.json"),
JSON.stringify({
worktree: {
setup: ["pwd > setup-cwd.txt"],
setup: [command],
},
}),
);
const paseoHome = path.join(tempDir, ".paseo");
await runWorktreeSetupInBackground(
const controller = new AbortController();
const completion = runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForWorkspaceId: async () => {},
@@ -603,10 +607,56 @@ describe("runWorktreeSetupInBackground", () => {
worktreePath: worktree.worktreePath,
workspaceCwd,
},
controller.signal,
);
const stop = async () => {
controller.abort();
await completion;
};
stopSetup = stop;
return { tempDir, workspaceCwd, worktreePath: worktree.worktreePath, completion, stop };
}
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
test("runs setup from an exact workspace subdirectory", async () => {
const setup = startSubdirectorySetup("pwd > setup-cwd.txt");
await setup.completion;
expect(existsSync(path.join(setup.workspaceCwd, "setup-cwd.txt"))).toBe(true);
expect(existsSync(path.join(setup.worktreePath, "setup-cwd.txt"))).toBe(false);
});
test("teardown stops unfinished setup before removing its workspace", async ({
onTestFinished,
}) => {
const setup = startSubdirectorySetup(
`node -e "require('fs').writeFileSync('setup-pid.txt', String(process.pid)); setInterval(()=>{},1000)"`,
);
let pid = 0;
const probeChild = () => process.kill(pid, 0);
onTestFinished(async () => {
try {
expect(pid).toBeGreaterThan(0);
expect(existsSync(setup.tempDir)).toBe(false);
expect(probeChild).toThrow(expect.objectContaining({ code: "ESRCH" }));
} finally {
// Keep a failing cleanup regression from leaking its deliberately live child.
await setup.stop();
}
});
await expect
.poll(
() => {
try {
pid = Number(readFileSync(path.join(setup.workspaceCwd, "setup-pid.txt"), "utf8"));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
pid = 0;
}
return pid;
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
});
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
@@ -128,6 +128,7 @@ export interface CurrentPullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
@@ -2064,7 +2064,7 @@ describe("createGiteaService", () => {
]);
});
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
@@ -2086,7 +2086,7 @@ describe("createGiteaService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({ number: 8, state: "merged" });
});
it("returns null when no PR matches the current branch", async () => {
+10 -1
View File
@@ -162,6 +162,7 @@ const GiteaPrListItemSchema = z
updated: z.string().optional(),
labels: z.string().optional(),
ci: z.string().optional(),
headSha: z.string().optional(),
})
.passthrough();
@@ -1010,6 +1011,7 @@ function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): Gite
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
base: item.base.ref,
head,
headSha: item.head.sha,
updated: item.updated_at,
labels: item.labels.map((label) => label.name).join(","),
};
@@ -1082,6 +1084,7 @@ function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestSt
state,
baseRefName: item.base ?? "",
headRefName: stripHeadOwner(item.head),
...(item.headSha ? { headSha: item.headSha } : {}),
isMerged: state === "merged",
mergeable: mapGiteaMergeable(item),
checks: [],
@@ -1653,6 +1656,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
input.headSha !== undefined &&
item.head.sha === input.headSha,
) ??
candidates[0] ??
null;
return match ? currentPullRequestApiToListItem(match) : null;
}
@@ -1718,7 +1722,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
combined,
actionTasks,
);
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
return applyGiteaChecks(
{ ...status, headSha: pr.headSha },
combined,
actionTasks,
actionRequiredRunIds,
);
} catch {
return status;
}
@@ -23,7 +23,7 @@ const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
@@ -3219,7 +3219,7 @@ describe("ForgeService", () => {
});
});
it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => {
it("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const runner = createRunner([
currentPullRequestJson({
@@ -3243,8 +3243,9 @@ describe("ForgeService", () => {
expect(status?.state).toBe("merged");
});
it("does not attach a stale merged PR after a same-name branch advances", async () => {
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
const stale = currentPullRequestJson({
headRepositoryOwner: { login: "parentOwner" },
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
@@ -3265,12 +3266,105 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({ state: "merged" });
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it("prefers an exact terminal head and otherwise the most recently updated terminal PR", async () => {
const owner = { login: "forkOwner" };
const terminal = ({
number,
headRefOid,
updatedAt,
state,
}: {
number: number;
headRefOid: string;
updatedAt: string;
state: string;
}) =>
JSON.parse(
currentPullRequestJson({
number,
headRefOid,
updatedAt,
state,
mergedAt: state === "MERGED" ? updatedAt : null,
headRepositoryOwner: owner,
}),
);
const older = terminal({
number: 41,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
state: "MERGED",
});
const newer = terminal({
number: 42,
headRefOid: "new",
updatedAt: "2026-07-02T00:00:00Z",
state: "CLOSED",
});
for (const [headSha, expected] of [
["old", 41],
["other", 42],
] as const) {
const runner = createScriptedRunner([JSON.stringify(older), JSON.stringify([older, newer])]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha,
});
expect(status?.number).toBe(expected);
}
});
it("prefers an exact merged PR over a closed draft", async () => {
const owner = { login: "forkOwner" };
const closedDraft = JSON.parse(
currentPullRequestJson({
number: 1,
state: "CLOSED",
isDraft: true,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const merged = JSON.parse(
currentPullRequestJson({
number: 2,
state: "MERGED",
mergedAt: "2026-07-02T00:00:00Z",
headRefOid: "exact",
updatedAt: "2026-07-02T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const runner = createScriptedRunner([
JSON.stringify(closedDraft),
JSON.stringify([closedDraft, merged]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha: "exact",
});
expect(status).toMatchObject({ number: 2, state: "merged" });
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
@@ -3313,6 +3407,84 @@ describe("ForgeService", () => {
expect(status).toMatchObject({ number: 43, state: "open" });
});
it("keeps a merged PR targeting the fork itself", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "checkout-head",
headRepositoryOwner: { login: "alice" },
}),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
"[]",
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "checkout-head",
});
expect(status).toMatchObject({ number: 7, repoOwner: "alice", state: "merged" });
});
it("compares the fork's terminal PR against parent PRs by state, head and update time", async () => {
const local = JSON.parse(
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
updatedAt: "2026-07-17T12:00:00Z",
headRefOid: "local-head",
headRepositoryOwner: { login: "alice" },
}),
);
for (const [state, headSha, updatedAt, expected] of [
["OPEN", "other", "2026-07-16T12:00:00Z", 8],
["CLOSED", "other", "2026-07-18T12:00:00Z", 7],
["CLOSED", "other", "2026-07-18T12:00:00Z", 8],
] as const) {
const parent = {
...local,
number: 8,
url: "https://github.com/acme/app/pull/8",
state,
mergedAt: null,
headRefOid: headSha,
updatedAt,
};
const runner = createScriptedRunner([
JSON.stringify(local),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
JSON.stringify([{ number: 8 }]),
JSON.stringify(parent),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: state === "OPEN" || expected === 7 ? "local-head" : "neither",
});
expect(status?.number).toBe(expected);
}
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
+26 -20
View File
@@ -346,6 +346,7 @@ const CurrentPullRequestStatusSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
@@ -524,7 +525,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -640,6 +641,7 @@ const BatchPollPrNodeSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().catch(""),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
reviewDecision: z.unknown().optional(),
@@ -744,6 +746,7 @@ fragment PaseoPollPullRequest on PullRequest {
baseRefName
headRefName
headRefOid
updatedAt
mergedAt
mergeable
reviewDecision
@@ -1118,6 +1121,7 @@ interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus;
headSha?: string;
headRepositoryOwner?: string;
updatedAt?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
@@ -2968,7 +2972,7 @@ async function resolveCurrentPullRequestView(options: {
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch) {
if (viewMatch?.status.state === "open") {
return viewMatch.status;
}
@@ -2980,12 +2984,13 @@ async function resolveCurrentPullRequestView(options: {
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner) {
return null;
return viewMatch?.status ?? null;
}
if (parentOwner && parentName) {
return resolveForkPullRequestView({
...options,
forkOwner,
viewMatch,
repo: `${parentOwner}/${parentName}`,
});
}
@@ -2998,7 +3003,7 @@ async function resolveCurrentPullRequestView(options: {
run: options.run,
});
const match = pickPullRequestCandidate({
candidates,
candidates: viewMatch ? [...candidates, viewMatch] : candidates,
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner,
@@ -3019,6 +3024,7 @@ async function resolveForkPullRequestView(options: {
headRef: string;
headSha?: string;
forkOwner: string;
viewMatch: ResolvedPullRequestCandidate | null;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
@@ -3034,12 +3040,15 @@ async function resolveForkPullRequestView(options: {
),
);
const match = pickPullRequestCandidate({
candidates: candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
),
candidates: [
...candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate =>
candidate !== null && candidate.headRepositoryOwner === options.forkOwner,
),
...(options.viewMatch ? [options.viewMatch] : []),
],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.forkOwner,
});
return match?.status ?? null;
}
@@ -3337,6 +3346,7 @@ function toBatchCurrentPullRequestItem(
baseRefName: node.baseRefName,
headRefName: node.headRefName,
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
updatedAt: node.updatedAt,
mergedAt: node.mergedAt ?? null,
...(statusCheckRollup ? { statusCheckRollup } : {}),
reviewDecision: node.reviewDecision,
@@ -3357,6 +3367,7 @@ function toCurrentPullRequestCandidate(
const headSha = item.headRefOid;
return {
status,
updatedAt: item.updatedAt,
...(headSha ? { headSha } : {}),
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
@@ -3380,12 +3391,6 @@ function pickPullRequestCandidate(options: {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
return false;
}
if (!options.headRepositoryOwner) {
return true;
}
@@ -3411,17 +3416,17 @@ function comparePullRequestCandidatePreference(
if (leftExact !== rightExact) {
return leftExact ? -1 : 1;
}
return 0;
return (
(right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") ||
(right.status.number ?? 0) - (left.status.number ?? 0)
);
}
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
if (status.state === "open" || status.isDraft) {
if (status.state === "open") {
return 0;
}
if (status.state === "merged") {
return 1;
}
return 2;
return 1;
}
type GitHubCloneProtocol = "https" | "ssh";
@@ -3969,6 +3974,7 @@ function toCurrentPullRequestStatus(
state,
baseRefName: item.baseRefName,
headRefName: item.headRefName || fallbackHeadRefName,
...(item.headRefOid ? { headSha: item.headRefOid } : {}),
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
@@ -357,14 +357,16 @@ describe("createGitLabService", () => {
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
});
it("does not attach the latest historical merge request after a reused branch advances", async () => {
it("keeps a merged MR when its remote head advances beyond the checkout", async () => {
const stale = {
...OPEN_MR,
state: "merged",
source_branch: "dev",
sha: "1111111111111111111111111111111111111111",
};
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
const { service, calls } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [stale] : stale)),
);
await expect(
service.getCurrentPullRequestStatus({
@@ -372,8 +374,8 @@ describe("createGitLabService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
expect(calls).toEqual([currentMrListArgs("dev")]);
).resolves.toMatchObject({ state: "merged" });
expect(calls[0]).toEqual(currentMrListArgs("dev"));
});
it("looks up a numeric current branch through the source-branch list filter", async () => {
@@ -728,6 +728,7 @@ function toCurrentPullRequestStatus(
state: mapMergeRequestState(mr.state),
baseRefName: mr.target_branch,
headRefName: mr.source_branch,
...(mr.sha ? { headSha: mr.sha } : {}),
isMerged: mr.state === "merged" || mr.merged_at != null,
isDraft: mr.draft ?? mr.work_in_progress ?? false,
mergeable: mapMergeable(mr),
@@ -987,6 +988,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
(mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
candidates[0] ??
null;
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
@@ -4223,6 +4223,7 @@ export interface PullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
@@ -78,27 +78,6 @@ async function database(path: string) {
db.close();
}
async function opencodeDatabase(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
return db;
}
function opencodeOauth(access: string, expires: number, accountId?: string) {
return JSON.stringify({
type: "oauth",
methodID: "chatgpt-headless",
access,
refresh: "fixture-refresh",
expires,
...(accountId ? { metadata: { accountID: accountId } } : {}),
});
}
test("Pi uses its own OAuth identity and re-reads rotated tokens without writing", async () => {
const path = join(home, ".pi", "agent", "auth.json");
await json(path, {
@@ -281,269 +260,3 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
),
).rejects.toThrow("login store no longer exists");
});
function opencodeUsageResponse() {
return Response.json({
plan_type: "plus",
rate_limit: {
primary_window: { used_percent: 30, limit_window_seconds: 18000 },
secondary_window: { used_percent: 81, limit_window_seconds: 604800 },
},
});
}
test("OpenCode v2 database login replaces a stale v1 file login", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
await json(join(data, "opencode", "auth.json"), {
openai: { type: "oauth", access: "fixture-expired", expires: 500 },
});
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{ route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } },
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "db-account" });
const report = await fetchUsage(
inputs[0]!,
async (_url, init) => {
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer fixture-db");
expect(headers.get("ChatGPT-Account-Id")).toBe("db-account");
return opencodeUsageResponse();
},
lookup(env),
);
expect(report).toMatchObject({
status: "available",
planLabel: "plus",
windows: [
{ id: "five_hour", usedPct: 30 },
{ id: "weekly", usedPct: 81 },
],
});
});
test("OpenCode v2 database login with an expired token is unavailable without a request", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-expired", 500, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const [input] = await logins(lookup(env));
expect(
await fetchUsage(
input!,
async () => {
throw new Error("must not fetch an expired token");
},
lookup(env),
),
).toEqual({
status: "unavailable",
problem: { kind: "expired", expiresAt: new Date(500).toISOString(), refreshedBy: "opencode" },
});
});
test("OpenCode v2 database is unchanged after its login is read", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const before = await readFile(databasePath);
const [input] = await logins(lookup(env));
await fetchUsage(input!, async () => opencodeUsageResponse(), lookup(env));
expect(await readFile(databasePath)).toEqual(before);
});
test("OpenCode v2 database login is re-read after the stored token rotates", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-first", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const [input] = await logins(lookup(env));
const { DatabaseSync } = await import("node:sqlite");
const writer = new DatabaseSync(databasePath);
writer
.prepare("UPDATE credential SET value = ? WHERE id = 'cred_1'")
.run(opencodeOauth("fixture-rotated", 2000, "db-account"));
writer.close();
const report = await fetchUsage(
input!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-rotated");
return opencodeUsageResponse();
},
lookup(env),
);
expect(report.status).toBe("available");
});
test.each([
["there is no v2 database", null],
[
"the v2 database holds no OpenAI login",
{ integration: "zenai", value: JSON.stringify({ type: "key", key: "fixture-key" }) },
],
[
"the v2 OpenAI login is not active",
{
integration: "openai",
value: opencodeOauth("fixture-inactive", 2000, "db-account"),
active: 0,
},
],
])("OpenCode v1 file login stays when %s", async (_name, row) => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const file = join(data, "opencode", "auth.json");
await json(file, { openai: { type: "oauth", access: "fixture-file", expires: 2000 } });
if (row) {
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
row.integration,
"OpenAI",
row.value,
null,
"chatgpt-headless",
"active" in row ? row.active : 1,
1,
1,
);
db.close();
}
expect(await logins(lookup(env))).toEqual([{ route: { store: "opencode", path: file } }]);
});
test.each(["absolute", "relative"])(
"OpenCode v2 database path honours %s OPENCODE_DB",
async (kind) => {
const data = join(home, "data");
const databasePath = join(data, "opencode", "custom-opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
expect(
await logins(
lookup({
XDG_DATA_HOME: data,
OPENCODE_DB: kind === "absolute" ? databasePath : "custom-opencode.db",
}),
),
).toEqual([{ route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } }]);
},
);
test.each([false, true])(
"OpenCode v2 selects its current login after legacy migration (explicit selection: %s)",
async (selected) => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
const insert = db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
// The v1 migration leaves active NULL; OpenCode uses creation order until a login is selected.
insert.run(
"older",
"openai",
"OpenAI",
opencodeOauth("fixture-older", 2000, "older-account"),
null,
"chatgpt-headless",
selected ? 1 : null,
1,
3,
);
insert.run(
"newer",
"openai",
"OpenAI",
opencodeOauth("fixture-newer", 2000, "newer-account"),
null,
"chatgpt-headless",
null,
2,
2,
);
db.close();
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{
route: {
store: "opencode-db",
path: databasePath,
credentialId: selected ? "older" : "newer",
},
},
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({
key: selected ? "older-account" : "newer-account",
});
},
);
@@ -27,22 +27,6 @@ export const oauthSchema = z.object({
const rowSchema = z.object({ id: z.number().int().positive(), data: z.string() });
export type Route = z.infer<typeof routeSchema>;
export interface OpencodeOauthLogin {
id: string;
access: string;
accountId?: string;
expires?: number;
}
const opencodeRowSchema = z.object({ id: z.string(), value: z.string() });
const opencodeColumnSchema = z.object({ name: z.string() });
const opencodeCredentialSchema = z.object({
type: z.literal("oauth"),
access: z.string().min(1),
expires: z.number().optional(),
metadata: z.object({ accountID: z.string().min(1).optional() }).optional(),
});
export async function readJson(path: string): Promise<unknown> {
try {
return JSON.parse(await readFile(path, "utf8"));
@@ -63,20 +47,6 @@ export function piAuthPath(lookup: StoreLookup): string {
return join(directory, "auth.json");
}
export interface OpencodeStorePaths {
file: string;
database: string;
}
export function opencodeStorePaths(env: NodeJS.ProcessEnv, home: string): OpencodeStorePaths {
const dataHome = env.XDG_DATA_HOME || join(home, ".local", "share");
const directory = join(dataHome, "opencode");
return {
file: join(directory, "auth.json"),
database: resolve(directory, env.OPENCODE_DB || "opencode.db"),
};
}
function ompDatabasePath(lookup: StoreLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
@@ -148,61 +118,6 @@ export function readOmp(route: Extract<Route, { store: "omp" }>, lookup: StoreLo
return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null;
}
function parseCredential(value: string): unknown | null {
try {
return JSON.parse(value);
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
/**
* OpenCode v2 keeps its logins in its own database and no longer writes the v1
* credential file, so the file alone cannot describe the active ChatGPT login.
*/
export function readOpencodeOauth(path: string, lookup: StoreLookup = {}): OpencodeOauthLogin[] {
const sqlite = (lookup.sqlite ?? builtinSqlite)();
if (!sqlite || !existsSync(path)) return [];
let db: Database | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
const columns = db
.prepare("PRAGMA table_info(credential)")
.all()
.map((value) => opencodeColumnSchema.safeParse(value));
if (!columns.some((column) => column.success && column.data.name === "integration_id")) {
return [];
}
const rows = db
.prepare(
// Migrated v1 rows have no active flag. OpenCode selects the newest
// created login until an explicit selection pins another one.
"SELECT id, value FROM credential WHERE integration_id = ? AND (active = 1 OR active IS NULL) ORDER BY active DESC, time_created DESC, id DESC LIMIT 1",
)
.all("openai");
return rows.flatMap((value) => {
const row = opencodeRowSchema.safeParse(value);
if (!row.success) return [];
const credential = opencodeCredentialSchema.safeParse(parseCredential(row.data.value));
if (!credential.success) return [];
const accountId = credential.data.metadata?.accountID;
return [
{
id: row.data.id,
access: credential.data.access,
...(accountId ? { accountId } : {}),
...(credential.data.expires !== undefined ? { expires: credential.data.expires } : {}),
},
];
});
} catch {
return [];
} finally {
db?.close();
}
}
export async function readHarness(
route: Extract<Route, { path: string }>,
lookup: StoreLookup = {},
@@ -230,72 +230,6 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => {
}
});
test("OpenCode v2 database login reports usage while the v1 file login expired", async () => {
const directory = await mkdtemp(join(tmpdir(), "usage-opencode-v2-"));
try {
const data = join(directory, "data");
await mkdir(join(data, "opencode"), { recursive: true });
await writeFile(
join(data, "opencode", "auth.json"),
JSON.stringify({ openai: { type: "oauth", access: "fixture-expired", expires: 500 } }),
);
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(join(data, "opencode", "opencode.db"));
db.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
JSON.stringify({
type: "oauth",
methodID: "chatgpt-headless",
access: "fixture-db",
expires: 2000,
metadata: { accountID: "db-account" },
}),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const lookup = {
home: directory,
env: { XDG_DATA_HOME: data },
platform: "linux" as const,
now: () => 1000,
};
const accounts = await discover({ kind: "global" }, lookup);
expect(accounts.map((account) => account.input)).toEqual([
{
route: {
store: "opencode-db",
path: join(data, "opencode", "opencode.db"),
credentialId: "cred_1",
},
},
]);
const report = await fetchUsage(
inputSchema.parse(accounts[0]!.input),
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-db");
return Response.json({
plan_type: "plus",
rate_limit: { primary_window: { used_percent: 30, limit_window_seconds: 18000 } },
});
},
lookup,
);
expect(report).toMatchObject({ status: "available", planLabel: "plus" });
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function accountFor(input: CodexUsageInput) {
const accounts = await discover(
{ kind: "global" },
+17 -51
View File
@@ -1,12 +1,4 @@
import {
discoverOmp,
opencodeStorePaths,
piAuthPath,
readHarness,
readJson,
readOpencodeOauth,
type StoreLookup,
} from "./stores.js";
import { discoverOmp, piAuthPath, readHarness, readJson, type StoreLookup } from "./stores.js";
import { homedir } from "node:os";
import { join } from "node:path";
import {
@@ -96,33 +88,19 @@ function globalRoutes(lookup: StoreLookup): CodexUsageInput[] {
const candidates: CodexUsageInput[] = [...new Set(paths)].map((path) => ({
route: { store: "codex", path },
}));
candidates.push(...opencodeRoutes(env, home, lookup));
candidates.push(
{
route: {
store: "opencode",
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
},
},
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ route })),
);
return candidates;
}
/**
* OpenCode v2 stores its logins in a database and leaves the v1 credential file
* untouched, so a file login can be stale while the database login is current.
* The database wins whenever it holds an OpenAI login; the file stays the
* source for v1 installations that have no such database.
*/
function opencodeRoutes(
env: NodeJS.ProcessEnv,
home: string,
lookup: StoreLookup,
): CodexUsageInput[] {
const { file, database } = opencodeStorePaths(env, home);
const logins = readOpencodeOauth(database, lookup);
if (!logins.length) return [{ route: { store: "opencode", path: file } }];
return logins.map((login) => ({
route: { store: "opencode-db", path: database, credentialId: login.id },
}));
}
function sessionRoutes(
scope: Extract<UsageScope, { kind: "session" }>,
lookup: StoreLookup,
@@ -141,7 +119,14 @@ function sessionRoutes(
if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }];
if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route }));
if (scope.provider === "opencode")
return opencodeRoutes(scope.env, lookup.home ?? homedir(), lookup);
return [
{
route: {
store: "opencode",
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
},
},
];
return [];
}
@@ -150,14 +135,6 @@ export async function readAuth(
lookup: StoreLookup = {},
): Promise<Auth | null> {
const route = input.route;
if (route.store === "opencode-db") {
const login = readOpencodeOauth(route.path, lookup).find(
(candidate) => candidate.id === route.credentialId,
);
return login
? { token: login.access, accountId: login.accountId, expires: login.expires }
: null;
}
if (route.store !== "codex") {
const oauth = await readHarness(route, lookup);
return oauth
@@ -214,7 +191,7 @@ export async function fetchUsage(
): Promise<UsageReport> {
const auth = await readAuth(input, lookup);
if (!auth) throw new Error("Codex login store no longer exists");
const refreshedBy = refreshCommand(input.route.store);
const refreshedBy = input.route.store;
if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)())
return unavailable({
kind: "expired",
@@ -320,17 +297,6 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab
}
function harnessLabel(input: CodexUsageInput): string {
const labels = {
codex: "Codex",
opencode: "OpenCode",
"opencode-db": "OpenCode",
pi: "Pi",
omp: "OMP",
};
const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" };
return labels[input.route.store];
}
/** The CLI that refreshes the login behind each store. */
function refreshCommand(store: CodexUsageInput["route"]["store"]): string {
return store === "opencode-db" ? "opencode" : store;
}
@@ -2,13 +2,6 @@ import { z } from "zod";
export const routeSchema = z.discriminatedUnion("store", [
z.object({ store: z.enum(["codex", "opencode", "pi"]), path: z.string().min(1) }).strict(),
z
.object({
store: z.literal("opencode-db"),
path: z.string().min(1),
credentialId: z.string().min(1),
})
.strict(),
z
.object({
store: z.literal("omp"),