perf(app): row-grained replica cache persistence (#3755)

* feat(hub): guide starter setup (#3651)

* feat(hub): guide setup with provider IDs

* fix(hub): preserve login setup state

* feat(hub): select starter agent runtime

Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.

* fix(hub): stage starter runtime choices

Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.

* fix(hub): scope runtime choices to workspace

Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.

* fix(server): stop short start gates from undercutting provider startup budgets (#3621)

* fix(server): stop short start gates from undercutting provider startup budgets

Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.

A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.

Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.

Related to #3571.

* test(server): drive the run start budget through a real AgentManager

The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.

Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.

* fix(server): describe agent run start timeouts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(relay): validate handshake keys (#3669)

* fix(files): keep large source previews responsive (#3665)

Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.

* fix(hub): make guided setup choices actionable (#3677)

Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.

* Keep released agent SDK versions reproducible (#3678)

Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.

* feat(app): refine agent profile creation (#3680)

Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.

* fix(app): restore native composer auto-growth (#3681)

Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.

* docs(hub): document zero-friction onboarding (#3657)

Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.

* Surface workspace changes from agent panes (#3682)

* feat(app): refine workspace pane actions

Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.

* feat(app): improve access to workspace changes

* Expand workspace panel actions and keyboard navigation (#3685)

* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.

* Restore compact and stable chat timelines (#3716)

Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.

* perf(app): defer replica persistence until user inactivity

Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.

* Give every pane a browser-style New tab (#3715)

* feat(app): replace empty panes with New tabs

Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.

* test(app): count content tabs in subagent integration

* fix(app): preserve workspace shortcut ownership

* fix(app): keep compact model options scrollable

The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.

* fix(app): order new tab launcher actions by workflow

* fix(app): show the New agent shortcut

The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.

* fix(app): prioritize side panel launcher actions

Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation.

* fix(app): align terminal profiles header

* docs: add 0.5.0-beta.5 changelog

* test(app): align new tab launcher expectations

* chore(release): cut 0.5.0-beta.5

* fix: update lockfile signatures and Nix hash [skip ci]

* fix(app): make compact git actions menu-only

Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu.

* fix(app): keep pinned project actions reachable (#3722)

* fix(app): keep pinned project actions reachable

Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available.

Co-authored-by: Agent Op <agentop718@gmail.com>

* test(app): verify pinned project workspace context

---------

Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(app): stop a string SVG transform from crashing the app (#3711)

* fix(app): stop a string SVG transform from crashing the app

A root <Svg> is a real React Native view, so react-native-svg rewrites a
*string* `transform` into RN style syntax in `extractTransformSvgView`,
using a PEG parser whose grammar only accepts SVG transform functions.
Upstream lets that parser's SyntaxError escape, so any string it does not
understand takes the whole app down during render:

  SyntaxError: Expected transform functions but "n" found.
  "found": "n", "location": { "start": { "offset": 0 } }

`none` reproduces that message character for character; so does any other
CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling
extractor in that file already catches the same error and degrades, so
the asymmetry is the bug: a string that is merely ignored on a <Path>
crashes on an <Svg>.

We feed that path untrusted input. The daemon scans a project for
favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts)
and ships it to the client, where ProjectIconImage hands it to SvgCss.
Running the library's own XML parser over `<svg style="transform:none">`
yields exactly `transform: "none"` on the root, which is why the crash
starts once a host connects and the project icons render, with no
terminal involved.

Note this is *not* reachable from an RN-style array transform: the
extractor returns arrays untouched and only parses strings, so
`style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter
and the checks ring) was never a factor.

Patch the three builds a consumer can resolve - Metro takes the package's
`react-native` field (src/*.ts), Node and Vitest take main/module - and
register react-native-svg with the postinstall patch runner so the patch
actually applies. The new test pins all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ

* test(app): cover parsed root SVG transforms

Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(pairing): warn that pairing links grant daemon access (#3734)

A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed.

* fix(app): preserve timeline retry backoff (#3736)

Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action.

* Open the tab chooser beside the pointer (#3735)

* feat(app): open the tab chooser beside the pointer

Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.

* test(app): follow the anchored tab chooser

The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.

* fix(app): restore composer height lifecycle (#3740)

Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.

* fix(server): stop quota fetcher from writing provider credentials (#3442)

* fix(server): recover already-settled interrupts (#3742)

* fix(server): recover already-settled interrupts

Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection.

* fix(codex): fence interruption during turn startup

Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work.

* docs(changelog): prepare 0.5.0 stable release

* chore(release): cut 0.5.0

* fix: update lockfile signatures and Nix hash [skip ci]

* fix(app): stabilize native composer resizing

JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.

* docs(changelog): prepare 0.5.1 stable release

* chore(release): cut 0.5.1

* fix: update lockfile signatures and Nix hash [skip ci]

* perf(app): move replica cache validation to restore

Add an observational live measurement harness for the row-store migration baseline. Trust already-shaped cache values on persist while retaining full schema validation at the restore boundary.

* feat(app): add replica cache row store

Ship the platform storage engines behind a dark interface so the cache migration can wire them separately. Both engines share a contract, including atomic writes and destructive schema-version resets.

* feat(app): switch replica cache to row storage

Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction.

* fix(app): harden replica row persistence

Serialize pending batches at write execution time so retries cannot overwrite newer rows or resurrect removed hosts. Reject oversized hosts before storage writes and preserve unchanged directory identities so routine reconciliation stays row-grained.

---------

Co-authored-by: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Yuri Gui <yugui923@users.noreply.github.com>
Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dan Berindei <dan.berindei@gmail.com>
This commit is contained in:
Mohamed Boudra
2026-08-24 20:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 5 BrianAguilarWasco paseo-ai[bot] Yuri Gui Agent Op Omer Cohen Dan Berindei
parent 43d27328e4
commit b12daabe35
232 changed files with 10258 additions and 3287 deletions
@@ -1061,6 +1061,7 @@ test("isolated rewind falls back from steering to the normal replacement path",
activeTurnBehavior: "steer",
runOptions: { clientMessageId: "rewind-client" },
});
await manager.waitForAgentRunStart(agentId);
expect(session.interruptCount).toBe(2);
expect(session.startPrompts).toContain("/rewind submitted-message-id");
expect(manager.getTimeline(agentId)).toContainEqual(
@@ -7163,6 +7164,63 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist
);
});
test("acknowledged cancellation settles a pending run before it has a turn id", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cancel-pending-start-"));
const startEntered = deferred<void>();
let rejectStart!: (error: Error) => void;
const stalledStart = new Promise<{ turnId: string }>((_resolve, reject) => {
rejectStart = reject;
});
class PendingStartSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
startEntered.resolve();
return await stalledStart;
}
override async interrupt(): Promise<void> {}
}
class PendingStartClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new PendingStartSession(config);
}
}
const manager = new AgentManager({
clients: { codex: new PendingStartClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
idFactory: () => "00000000-0000-4000-8000-000000000132",
});
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const firstRun = manager.runAgent(agent.id, "start a turn");
void firstRun.catch(() => undefined);
await startEntered.promise;
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
expect(manager.hasInFlightRun(agent.id)).toBe(true);
await expect(manager.cancelAgentRun(agent.id)).resolves.toEqual({ status: "settled" });
expect(manager.hasInFlightRun(agent.id)).toBe(false);
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
rejectStart(new Error("released canceled start"));
await expect(firstRun).rejects.toThrow("released canceled start");
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
} finally {
rejectStart(new Error("test cleanup"));
if (manager.getAgent("00000000-0000-4000-8000-000000000132")) {
await manager.closeAgent("00000000-0000-4000-8000-000000000132");
}
rmSync(workdir, { recursive: true, force: true });
}
});
test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-"));
const storagePath = join(workdir, "agents");
@@ -66,7 +66,11 @@ import {
AgentStreamCoalescer,
} from "./agent-stream-coalescer.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { AgentRunState, type ForegroundTurnWaiter } from "./agent-run-state.js";
import {
AgentRunState,
type ForegroundTurnWaiter,
type PendingForegroundRun,
} from "./agent-run-state.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
@@ -2141,6 +2145,37 @@ export class AgentManager {
});
}
private async startPendingForegroundTurn(params: {
agent: ActiveManagedAgent;
agentId: string;
pendingRun: PendingForegroundRun;
prompt: AgentPromptInput;
options?: AgentRunOptions;
}): Promise<string> {
const { agent, agentId, pendingRun, prompt, options } = params;
try {
const result = await agent.session.startTurn(prompt, options);
if (pendingRun.settled) {
throw new Error(`Agent ${agentId} run was canceled before its turn started`);
}
return result.turnId;
} catch (error) {
if (pendingRun.settled) {
throw error;
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,
@@ -2185,21 +2220,13 @@ export class AgentManager {
const streamForwarder = async function* streamForwarder(this: AgentManager) {
let turnId: string;
let turnStream: ReturnType<AgentRunState["createTurnStream"]> | null = null;
try {
const result = await agent.session.startTurn(prompt, options);
turnId = result.turnId;
} catch (error) {
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
turnId = await this.startPendingForegroundTurn({
agent,
agentId,
pendingRun,
prompt,
options,
});
if (isReplacement) {
agent.pendingReplacement = false;
@@ -2719,6 +2746,17 @@ export class AgentManager {
turnId: run.turnId,
});
await run.settledPromise;
} else if (settlement === "timed_out" && run.kind === "foreground") {
this.logger.warn(
{ agentId, kind: run.kind },
"cancelAgentRun: acknowledged pending turn still active after timeout, clearing it",
);
this.runs.settleForegroundRun(agentId, run.token);
if (!agent.pendingReplacement) {
agent.lifecycle = "idle";
this.touchUpdatedAt(agent);
this.emitState(agent);
}
} else if (settlement === "timed_out" && run.kind === "autonomous") {
this.logger.warn(
{ agentId, kind: run.kind },
@@ -1,5 +1,9 @@
import { expect, it, test, vi } from "vitest";
import pino, { type Logger } from "pino";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
@@ -8,8 +12,15 @@ import {
formatSystemNotificationPrompt,
isSystemInjectedEnvelope,
setupFinishNotification,
waitForAgentRunStartWithTimeout,
} from "./agent-prompt.js";
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import type {
AgentClient,
AgentRunResult,
AgentSession,
AgentStreamEvent,
} from "./agent-sdk-types.js";
interface CapturedLogger {
logger: Logger;
@@ -520,3 +531,234 @@ it("does not notify archived callers", async () => {
expect(streamAgentSpy).not.toHaveBeenCalled();
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
});
// Deliberately independent literals rather than the production constants these tests
// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests
// green if that constant were shortened back under a provider's startup budget.
const EXPECTED_RUN_START_BUDGET_MS = 60_000;
// The slowest provider startup budget the run-start wait has to sit outside of today
// (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS).
const SLOWEST_PROVIDER_STARTUP_BUDGET_MS = 30_000;
const RUN_START_TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsSessionListing: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
/**
* Provider session whose turn start is held open for a configurable span, so the real
* AgentManager run-state transition (pendingRun.started -> lifecycle "running" ->
* agent_state) is what the run-start wait observes. `startDelayMs: null` never starts.
*/
class SlowStartAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = RUN_START_TEST_CAPABILITIES;
readonly id = randomUUID();
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private releaseStartTurn!: () => void;
private readonly released = new Promise<void>((resolve) => {
this.releaseStartTurn = resolve;
});
constructor(private readonly startDelayMs: number | null) {}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
/** Teardown hook so a never-starting turn cannot wedge the suite. */
release(): void {
this.releaseStartTurn();
}
async startTurn(): Promise<{ turnId: string }> {
await new Promise<void>((resolve) => {
if (this.startDelayMs !== null) {
setTimeout(resolve, this.startDelayMs);
}
void this.released.then(resolve);
});
const turnId = "turn-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}, 0);
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
pushEvent(event: AgentStreamEvent): void {
for (const callback of this.subscribers) {
callback(event);
}
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
class SlowStartAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = RUN_START_TEST_CAPABILITIES;
readonly sessions: SlowStartAgentSession[] = [];
constructor(private readonly startDelayMs: number | null) {}
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(): Promise<AgentSession> {
const session = new SlowStartAgentSession(this.startDelayMs);
this.sessions.push(session);
return session;
}
async fetchCatalog() {
return { models: [], modes: [] };
}
async resumeSession(): Promise<AgentSession> {
return await this.createSession();
}
}
/**
* Real AgentManager driving a real agent, so the run-start wait exercises the production
* run-state and agent_state subscription path rather than a replaced method.
*/
async function createRunStartScenario(startDelayMs: number | null): Promise<{
agentManager: AgentManager;
agentId: string;
startRun: () => Promise<void>;
cleanup: () => Promise<void>;
}> {
const workdir = mkdtempSync(join(tmpdir(), "agent-run-start-budget-"));
const client = new SlowStartAgentClient(startDelayMs);
const agentManager = new AgentManager({
clients: { codex: client },
logger: createTestLogger(),
});
const snapshot = await agentManager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
let drained: Promise<void> = Promise.resolve();
return {
agentManager,
agentId: snapshot.id,
// streamAgent registers the pending run synchronously, so the wait always observes it.
startRun: async () => {
const run = agentManager.streamAgent(snapshot.id, "start the run");
drained = (async () => {
for await (const _event of run) {
// Drain whatever the turn produces.
}
})().catch(() => undefined);
},
cleanup: async () => {
// Release any turn still held open, then close. The drain is deliberately not
// awaited: depending on how far the turn got, the stream ends either from the
// release or from the close, and teardown must not depend on which.
for (const session of client.sessions) {
session.release();
}
await agentManager.closeAgent(snapshot.id).catch(() => undefined);
void drained;
rmSync(workdir, { recursive: true, force: true });
},
};
}
test("waiting for a run start outlasts the slowest provider startup budget", async () => {
// A provider is still allowed to be starting here, so the outer wait must not abort it.
const scenario = await createRunStartScenario(SLOWEST_PROVIDER_STARTUP_BUDGET_MS + 5_000);
vi.useFakeTimers();
try {
await scenario.startRun();
const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId);
let settled = false;
const markSettled = () => {
settled = true;
};
void wait.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(SLOWEST_PROVIDER_STARTUP_BUDGET_MS);
expect(settled).toBe(false);
expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).not.toBe("running");
await vi.advanceTimersByTimeAsync(5_000);
await expect(wait).resolves.toBeUndefined();
expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).toBe("running");
} finally {
vi.useRealTimers();
await scenario.cleanup();
}
});
test("waiting for a run start still gives up at the run start budget", async () => {
const scenario = await createRunStartScenario(null);
vi.useFakeTimers();
try {
await scenario.startRun();
const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId);
const rejection = expect(wait).rejects.toThrow(
"codex run did not start within 60 seconds (phase: run start)",
);
let settled = false;
const markSettled = () => {
settled = true;
};
void wait.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(EXPECTED_RUN_START_BUDGET_MS - 1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(settled).toBe(true);
} finally {
vi.useRealTimers();
await scenario.cleanup();
}
});
@@ -212,14 +212,35 @@ export interface StartCreatedAgentInitialPromptParams {
logger: Logger;
}
const AGENT_RUN_START_TIMEOUT_MS = 15_000;
/**
* Outer bound on a run reaching "started" after dispatch.
*
* This wraps provider startup, so it MUST stay larger than the slowest provider's own
* startup budget — otherwise it aborts a start the provider was still allowed to be
* working on, and the provider's budget can never apply. OpenCode is the slowest today:
* up to 30s for the server to boot (OPENCODE_SERVER_STARTUP_TIMEOUT_MS) and then a
* session.create on the same budget, so this is deliberately set well above 30s.
*
* Not derived from the provider constant on purpose: this module is provider-agnostic
* and must not depend on a specific provider's internals.
*/
const AGENT_RUN_START_TIMEOUT_MS = 60_000;
export async function waitForAgentRunStartWithTimeout(
agentManager: AgentManager,
agentId: string,
): Promise<void> {
const provider = agentManager.getAgent(agentId)?.provider ?? "provider";
const startAbort = new AbortController();
const startTimeout = setTimeout(() => startAbort.abort("timeout"), AGENT_RUN_START_TIMEOUT_MS);
const startTimeout = setTimeout(
() =>
startAbort.abort(
new Error(
`${provider} run did not start within ${AGENT_RUN_START_TIMEOUT_MS / 1000} seconds (phase: run start)`,
),
),
AGENT_RUN_START_TIMEOUT_MS,
);
try {
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
@@ -653,6 +653,11 @@ export interface AgentSession {
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
describePersistence(): AgentPersistenceHandle | null;
/**
* Resolve once every foreground turn that predates this call can no longer run or become active.
* Calling while already idle is a successful no-op. Reject only when foreground ownership is
* still uncertain.
*/
interrupt(): Promise<void>;
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
@@ -111,6 +111,62 @@ test("unexpected Codex app-server exit fails the active run and agent", async ()
}
});
test("replacement self-heals when Codex reports that the tracked turn is already idle", async () => {
const workdir = mkdtempSync(join(tmpdir(), "codex-already-idle-replace-"));
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({
__jsonRpcError: { code: -32600, message: "no active turn to interrupt" },
}),
});
const manager = new AgentManager({
clients: { codex: new ProcessExitCodexClient([appServer]) },
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, modeId: "auto", model: "gpt-5.4" },
undefined,
{ workspaceId: undefined },
);
agentId = agent.id;
const firstRun = manager.runAgent(agent.id, "keep working");
const firstTurnStart = await appServer.waitForTurnStart();
appServer.startsTurn({
threadId: String(firstTurnStart.threadId),
turnId: "native-turn-already-idle",
});
await manager.waitForAgentRunStart(agent.id);
const replacementStream = await manager.replaceAgentRun(agent.id, "replacement prompt");
const replacementEvents = (async () => {
const events = [];
for await (const event of replacementStream) {
events.push(event);
}
return events;
})();
await expect
.poll(() => appServer.requests().filter((message) => message.method === "turn/start").length)
.toBe(2);
appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn-replacement" });
appServer.completeTurn({ threadId: "thread-1" });
await expect(firstRun).resolves.toMatchObject({ canceled: true });
await expect(replacementEvents).resolves.toContainEqual(
expect.objectContaining({ type: "turn_completed" }),
);
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
} finally {
if (agentId && manager.getAgent(agentId)) {
await manager.closeAgent(agentId);
}
rmSync(workdir, { recursive: true, force: true });
}
});
test("intentional agent close stays clean while a Codex turn is active", async () => {
const workdir = mkdtempSync(join(tmpdir(), "codex-process-close-"));
const appServer = createFakeCodexAppServer();
@@ -3354,6 +3354,34 @@ describe("Codex app-server provider", () => {
}
});
test("treats Codex already having no active turn as an acknowledged interrupt", async () => {
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({
__jsonRpcError: { code: -32600, message: "no active turn to interrupt" },
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Wait for the child.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-already-idle" });
await expect(session.interrupt()).resolves.toBeUndefined();
appServer.completeTurn();
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("waits for Codex to identify an accepted turn before interrupting it", async () => {
const interruptedTurns: unknown[] = [];
const appServer = createFakeCodexAppServer({
@@ -3386,7 +3414,7 @@ describe("Codex app-server provider", () => {
}
});
test("does not interrupt after the accepted turn terminates before identification", async () => {
test("acknowledges interruption when the accepted turn terminates before identification", async () => {
const interruptedTurns: unknown[] = [];
const appServer = createFakeCodexAppServer({
"turn/interrupt": async (params) => {
@@ -3407,9 +3435,7 @@ describe("Codex app-server provider", () => {
const interruptPromise = session.interrupt();
appServer.completeTurn();
await expect(interruptPromise).rejects.toThrow(
"Cannot interrupt Codex before turn/started identifies the active turn",
);
await expect(interruptPromise).resolves.toBeUndefined();
await resultPromise;
expect(interruptedTurns).toEqual([]);
appServer.assertNoErrors();
@@ -3418,7 +3444,7 @@ describe("Codex app-server provider", () => {
}
});
test("rejects an interrupt before Codex initializes the thread", async () => {
test("acknowledges interruption before Codex initializes a thread", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
@@ -3427,13 +3453,53 @@ describe("Codex app-server provider", () => {
async () => appServer.child,
);
await expect(session.interrupt()).rejects.toThrow(
"Cannot interrupt Codex before the active thread is initialized",
);
await expect(session.interrupt()).resolves.toBeUndefined();
await session.close();
});
test("cancels a cold start before it can issue a native turn", async () => {
const threadStart = deferred<{ thread: { id: string } }>();
const appServer = createFakeCodexAppServer({
"thread/start": () => threadStart.promise,
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const resultPromise = session.run("Start after the delayed thread.");
try {
await appServer.waitForRequest("thread/start");
let interruptSettled = false;
const interruptPromise = session.interrupt().then(() => {
interruptSettled = true;
return undefined;
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(interruptSettled).toBe(false);
threadStart.resolve({ thread: { id: "thread-1" } });
await expect(interruptPromise).resolves.toBeUndefined();
await expect(resultPromise).rejects.toThrow("interrupted before reaching Codex");
expect(appServer.requests()).not.toContainEqual(
expect.objectContaining({ method: "turn/start" }),
);
appServer.assertNoErrors();
} finally {
threadStart.resolve({ thread: { id: "thread-1" } });
const nativeStart = await appServer.waitForTurnStart().catch(() => null);
if (nativeStart) {
appServer.startsTurn({ threadId: "thread-1", turnId: "cleanup-turn" });
appServer.completeTurn();
}
await resultPromise.catch(() => undefined);
await session.close();
}
});
test("interrupts an autonomous Codex turn identified by live notifications", async () => {
const session = createSession();
const requests: Array<{ method: string; params: unknown }> = [];
@@ -857,6 +857,14 @@ function isDefinitiveCodexSteerRejection(error: unknown): boolean {
);
}
function isCodexAlreadyIdleInterrupt(error: unknown): boolean {
return (
error instanceof CodexAppServerRpcError &&
error.code === -32600 &&
error.message === "no active turn to interrupt"
);
}
// Codex app-server API response types
interface CodexReasoningEffortEntry {
reasoningEffort?: string;
@@ -3227,6 +3235,11 @@ export class CodexAppServerAgentSession implements AgentSession {
promise: Promise<string | null>;
resolve: (turnId: string | null) => void;
} | null = null;
private pendingForegroundStart: {
promise: Promise<void>;
resolve: () => void;
cancelRequested: boolean;
} | null = null;
private client: CodexAppServerClient | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
@@ -4081,10 +4094,20 @@ export class CodexAppServerAgentSession implements AgentSession {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.activeForegroundTurnId) {
if (this.activeForegroundTurnId || this.pendingForegroundStart) {
throw new Error("A foreground turn is already active");
}
let resolveStart!: () => void;
const pendingStart = {
promise: new Promise<void>((resolve) => {
resolveStart = resolve;
}),
resolve: () => resolveStart(),
cancelRequested: false,
};
this.pendingForegroundStart = pendingStart;
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
@@ -4129,6 +4152,9 @@ export class CodexAppServerAgentSession implements AgentSession {
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
if (pendingStart.cancelRequested) {
throw new Error("Codex turn start was interrupted before reaching Codex");
}
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
return { turnId };
} catch (error) {
@@ -4137,6 +4163,11 @@ export class CodexAppServerAgentSession implements AgentSession {
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
throw error;
} finally {
if (this.pendingForegroundStart === pendingStart) {
this.pendingForegroundStart = null;
}
pendingStart.resolve();
}
}
@@ -4617,7 +4648,19 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async interrupt(): Promise<void> {
const pendingStart = this.pendingForegroundStart;
if (pendingStart) {
pendingStart.cancelRequested = true;
await pendingStart.promise;
}
if (!this.client || !this.currentThreadId) {
if (
!this.activeForegroundTurnId &&
!this.currentTurnId &&
!this.pendingForegroundTurnIdentification
) {
return;
}
throw new Error("Cannot interrupt Codex before the active thread is initialized");
}
let turnId = this.currentTurnId;
@@ -4632,17 +4675,31 @@ export class CodexAppServerAgentSession implements AgentSession {
) {
turnId = await pendingIdentification.promise;
}
if (!turnId && !this.activeForegroundTurnId && !this.currentTurnId) {
return;
}
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
}
await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
try {
await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
} catch (error) {
if (!isCodexAlreadyIdleInterrupt(error)) {
throw error;
}
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.currentTurnId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
this.pendingForegroundTurnIdentification = null;
}
}
async close(): Promise<void> {
@@ -343,6 +343,81 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true });
}, 60_000);
test("creates a session when session.create needs more than ten seconds", async () => {
vi.useFakeTimers();
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
const slowCreate = createTestDeferred<void>();
openCode.sessionCreateImplementation = async () => {
await slowCreate.promise;
return { data: { id: "session-1" } };
};
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const creation = client.createSession(buildConfig(cwd));
let settled = false;
const markSettled = () => {
settled = true;
};
void creation.then(markSettled, markSettled);
// Under CPU contention a per-directory bootstrap has been measured well past ten
// seconds, which the previous budget failed outright.
await vi.advanceTimersByTimeAsync(20_000);
expect(settled).toBe(false);
slowCreate.resolve();
const session = await creation;
expect(session.provider).toBe("opencode");
expect(openCode.calls.sessionCreate).toHaveLength(1);
await session.close();
} finally {
vi.useRealTimers();
slowCreate.resolve();
rmSync(cwd, { recursive: true, force: true });
}
});
test("bounds session.create at the server startup budget", async () => {
vi.useFakeTimers();
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
openCode.sessionCreateImplementation = () => new Promise<never>(() => undefined);
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const creation = client.createSession(buildConfig(cwd));
const rejection = expect(creation).rejects.toThrow("OpenCode session.create timed out");
let settled = false;
const markSettled = () => {
settled = true;
};
void creation.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(EXPECTED_STARTUP_BUDGET_MS - 1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(settled).toBe(true);
expect(runtime.acquisitions.at(-1)?.releaseCount).toBe(1);
} finally {
vi.useRealTimers();
rmSync(cwd, { recursive: true, force: true });
}
});
test("archives and unarchives the durable native session through client hooks", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
@@ -1396,10 +1396,15 @@ export class OpenCodeAgentClient implements AgentClient {
});
try {
// Creating the first session for a directory is part of OpenCode coming up, so it
// shares the server startup budget instead of a shorter one that fails agent
// creation on contended cold starts.
const response = await withTimeout(
client.session.create({ directory: openCodeConfig.cwd }),
10_000,
"OpenCode session.create timed out after 10s",
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
`OpenCode session.create timed out after ${Math.round(
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
)}s`,
);
if (response.error) {
@@ -145,6 +145,7 @@ export class TestOpenCodeClient {
sessionCommandEvents: unknown[] = [idleEvent()];
sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionCreateImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
sessionDeleteResponse: OpenCodeResponse = {};
sessionChildrenResponses: OpenCodeResponse[] = [];
sessionChildrenImplementation:
@@ -294,6 +295,9 @@ export class TestOpenCodeClient {
},
create: async (parameters: unknown) => {
this.calls.sessionCreate.push(parameters);
if (this.sessionCreateImplementation) {
return await this.sessionCreateImplementation(parameters);
}
return this.sessionCreateResponse;
},
delete: async (parameters: unknown) => {
@@ -82,6 +82,11 @@ function decodeCiphertext(text: string): ArrayBuffer {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
// Fixed transport key for an unsupported peer public key.
const UNSUPPORTED_PEER_TRANSPORT_KEY = Uint8Array.from(
Buffer.from("351f86faa3b988468a850122b65b0acece9c4826806aeee63de9c0da2bd7f91e", "hex"),
);
function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown {
const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
return JSON.parse(plaintext);
@@ -161,35 +166,48 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
}
async function waitForCapturedLog(
lines: string[],
predicate: (line: string) => boolean,
timeout = 1000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (lines.some(predicate)) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for captured log. Recent logs:\n${lines.slice(-20).join("")}`);
}
(shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => {
let relayPort: number;
let relayProcess: ChildProcess | null = null;
let relayStdoutLines: string[] = [];
const startRelay = async () => {
const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
relayStdoutLines = [];
relayPort = await getAvailablePort();
const relayDir = path.resolve(process.cwd(), "../relay");
relayProcess = spawn(
"npx",
[
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
const relayArgs = [
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
];
if (options.useLocalRelay) {
relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
}
relayProcess = spawn("npx", relayArgs, {
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
@@ -364,6 +382,122 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}
}, 90000);
test("daemon closes a relay client that sends an unsupported handshake key", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay({ useLocalRelay: true });
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId } = decodeOfferFromFragmentUrl(offerUrl);
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
);
let receivedServerInfo = false;
const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected");
const outcome = await new Promise<
{ type: "closed"; code: number; reason: string } | { type: "errored"; error: Error }
>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("timed out waiting for daemon to reject unsupported handshake key"));
}, 20_000);
ws.on("open", async () => {
try {
await waitForCapturedLog(lines, isRelayDataConnectedLog);
ws.send(
JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(new Uint8Array(32)),
}),
);
ws.send(
encodeCiphertext(
encrypt(
UNSUPPORTED_PEER_TRANSPORT_KEY,
JSON.stringify({
type: "hello",
clientId: "cid_relay_unsupported_key",
clientType: "cli",
protocolVersion: 1,
}),
),
),
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
ws.on("message", (data) => {
try {
const parsed = WSOutboundMessageSchema.parse(
parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()),
);
if (
parsed.type === "session" &&
parsed.message.type === "status" &&
parsed.message.payload?.status === "server_info"
) {
receivedServerInfo = true;
clearTimeout(timeout);
reject(
new Error("daemon sent server_info after accepting an unsupported handshake key"),
);
}
} catch {
// Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion.
}
});
ws.on("close", (code, reason) => {
clearTimeout(timeout);
resolve({ type: "closed", code, reason: reason.toString() });
});
ws.on("error", (error) => {
clearTimeout(timeout);
resolve({ type: "errored", error });
});
});
expect(receivedServerInfo).toBe(false);
if (outcome.type === "closed") {
expect(outcome.code).toBeGreaterThan(0);
} else {
expect(outcome.error).toBeInstanceOf(Error);
}
const isRejectedHandshakeLog = (line: string) =>
line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key");
await waitForCapturedLog(lines, isRejectedHandshakeLog);
expect(lines.some(isRejectedHandshakeLog)).toBe(true);
} finally {
await daemon.close();
await stopRelay();
}
}, 90000);
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
@@ -372,6 +372,30 @@ describe("WorkspaceFilesSession", () => {
]);
});
test("rejects an over-budget file before opening a binary transfer", async () => {
const cwd = makeDir("workspace-files-read-budget-");
writeFileSync(join(cwd, "notes.txt"), "hello world");
const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true });
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: "notes.txt",
mode: "file",
requestId: "req-read-budget",
acceptBinary: true,
maxBytes: 5,
});
expect(binary).toEqual([]);
expect(emitted).toEqual([
expect.objectContaining({
type: "file_explorer_response",
payload: expect.objectContaining({ error: "File is too large to display" }),
}),
]);
});
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
const cwd = makeDir("workspace-files-large-binary-");
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
@@ -263,6 +263,12 @@ export class WorkspaceFilesSession {
source,
);
} else {
if (request.maxBytes) {
const file = await getDownloadableFileInfo({ root: cwd, relativePath: requestedPath });
if (file.size > request.maxBytes) {
throw new Error("File is too large to display");
}
}
if (request.acceptBinary && this.host.hasBinaryChannel()) {
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
await this.host.emitBinary(
@@ -22,7 +22,6 @@ import {
const execFileAsync = promisify(execFile);
const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000;
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
const ClaudeCredentialsSchema = z.object({
@@ -71,21 +70,14 @@ const ClaudeUsageResponseSchema = z.object({
.nullish(),
});
const ClaudeTokenRefreshSchema = z.object({
access_token: z.string().optional(),
refresh_token: z.string().optional(),
});
type ClaudeCredentials = z.infer<typeof ClaudeCredentialsSchema>;
type ClaudeUsageResponse = z.infer<typeof ClaudeUsageResponseSchema>;
type ClaudeTokenRefresh = z.infer<typeof ClaudeTokenRefreshSchema>;
type ClaudeLimit = z.infer<typeof ClaudeLimitSchema>;
const SCOPED_WEEKLY_KIND = "weekly_scoped";
interface ClaudeCredentialRecord {
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
filePath: string | null;
}
interface ClaudeQuotaProviderOptions {
@@ -370,30 +362,13 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
return unavailableUsage(this);
}
const { oauth, filePath } = credentials;
const { oauth } = credentials;
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
let resp = await this.callClaudeApi(oauth.accessToken);
const resp = await this.callClaudeApi(oauth.accessToken);
if (resp === "NEEDS_AUTH") {
if (!filePath || !oauth.refreshToken) {
return unavailableUsage(this);
}
const refreshed = await this.refreshClaudeToken(oauth.refreshToken);
if (!refreshed?.access_token) {
return unavailableUsage(this);
}
await this.saveClaudeCredentials(filePath, {
...oauth,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? oauth.refreshToken,
});
resp = await this.callClaudeApi(refreshed.access_token);
if (resp === "NEEDS_AUTH") {
return unavailableUsage(this);
}
// Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md.
return unavailableUsage(this);
}
const scoped = reconcileScopedLimits(
@@ -469,7 +444,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
);
const oauth = creds.claudeAiOauth;
if (oauth?.accessToken) {
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: credPath };
return { oauth: { ...oauth, accessToken: oauth.accessToken } };
}
} catch {
// Fall through to the macOS Keychain below.
@@ -480,7 +455,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials());
const oauth = creds.success ? creds.data.claudeAiOauth : undefined;
if (oauth?.accessToken) {
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: null };
return { oauth: { ...oauth, accessToken: oauth.accessToken } };
}
}
@@ -499,38 +474,4 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
return ClaudeUsageResponseSchema.parse(await res.json());
}
private async refreshClaudeToken(refreshToken: string): Promise<ClaudeTokenRefresh | null> {
const res = await fetchProviderApi(
this.fetchApi,
"https://platform.claude.com/v1/oauth/token",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLAUDE_CLIENT_ID,
scope: "user:profile user:inference user:sessions:claude_code user:mcp_servers",
}),
},
);
if (!res.ok) return null;
return ClaudeTokenRefreshSchema.parse(await res.json());
}
private async saveClaudeCredentials(
credPath: string,
oauth: ClaudeCredentials["claudeAiOauth"],
): Promise<void> {
try {
const existing = ClaudeCredentialsSchema.parse(
JSON.parse(await fs.readFile(credPath, "utf8")),
);
existing.claudeAiOauth = oauth;
await fs.writeFile(credPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
} catch {
// Non-fatal; Claude Code can refresh again on its own next time.
}
}
}
@@ -18,8 +18,6 @@ import {
windowFromUsedPct,
} from "../usage.js";
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const CodexAuthSchema = z.object({
tokens: z
.object({
@@ -58,20 +56,9 @@ const CodexUsageResponseSchema = z.object({
.nullish(),
});
const CodexTokenRefreshSchema = z.object({
access_token: z.string().optional(),
refresh_token: z.string().optional(),
});
type CodexAuth = z.infer<typeof CodexAuthSchema>;
type CodexWindow = z.infer<typeof CodexWindowSchema>;
type CodexUsageResponse = z.infer<typeof CodexUsageResponseSchema>;
type CodexTokenRefresh = z.infer<typeof CodexTokenRefreshSchema>;
interface CodexAuthRecord {
auth: CodexAuth;
path: string;
}
interface CodexQuotaProviderOptions {
logger: Logger;
@@ -102,30 +89,18 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
}
async fetchUsage(): Promise<ProviderUsage> {
const authRecord = await this.readCodexAuth();
const auth = authRecord?.auth;
const auth = await this.readCodexAuth();
const accessToken = auth?.tokens?.access_token;
if (!authRecord || !auth || !accessToken) {
if (!auth || !accessToken) {
return unavailableUsage(this);
}
const { refresh_token, account_id } = auth.tokens ?? {};
let resp = await this.callCodexApi(accessToken, account_id);
const { account_id } = auth.tokens ?? {};
const resp = await this.callCodexApi(accessToken, account_id);
if (resp === "NEEDS_AUTH") {
if (!refresh_token) {
return unavailableUsage(this);
}
const refreshed = await this.refreshCodexToken(refresh_token);
if (!refreshed?.access_token) {
return unavailableUsage(this);
}
await this.saveCodexAuth(authRecord.path, auth, refreshed);
resp = await this.callCodexApi(refreshed.access_token, account_id);
if (resp === "NEEDS_AUTH") {
return unavailableUsage(this);
}
// Read-only on credentials; the Codex CLI owns refresh. See docs/providers.md.
return unavailableUsage(this);
}
return this.toUsage(resp);
@@ -194,7 +169,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
};
}
private async readCodexAuth(): Promise<CodexAuthRecord | null> {
private async readCodexAuth(): Promise<CodexAuth | null> {
const candidates = [
...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []),
join(homedir(), ".config", "codex", "auth.json"),
@@ -204,7 +179,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
if (!existsSync(path)) continue;
try {
const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
if (auth.tokens?.access_token) return { auth, path };
if (auth.tokens?.access_token) return auth;
} catch {
continue;
}
@@ -236,39 +211,4 @@ export class CodexQuotaProvider implements ProviderUsageFetcher {
if (text.trim().startsWith("<")) return "NEEDS_AUTH";
return CodexUsageResponseSchema.parse(JSON.parse(text));
}
private async refreshCodexToken(refreshToken: string): Promise<CodexTokenRefresh | null> {
const params = new URLSearchParams({
grant_type: "refresh_token",
client_id: CODEX_CLIENT_ID,
refresh_token: refreshToken,
});
const res = await fetchProviderApi(this.fetchApi, "https://auth.openai.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
if (!res.ok) return null;
return CodexTokenRefreshSchema.parse(await res.json());
}
private async saveCodexAuth(
authPath: string,
original: CodexAuth,
refreshed: CodexTokenRefresh,
): Promise<void> {
try {
const updated: CodexAuth = {
...original,
tokens: {
...original.tokens,
access_token: refreshed.access_token ?? original.tokens?.access_token,
refresh_token: refreshed.refresh_token ?? original.tokens?.refresh_token,
},
};
await fs.writeFile(authPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
} catch {
// Non-fatal; the next call can refresh again.
}
}
}
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js";
@@ -15,9 +14,7 @@ import {
windowFromUsedPct,
} from "../usage.js";
const KIMI_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
const KIMI_TOKEN_URL = "https://auth.kimi.com/api/oauth/token";
const KimiUsageFieldsSchema = z.object({
limit: ApiOptionalStringSchema,
@@ -249,27 +246,8 @@ const KimiAuthSchema = z
})
.passthrough();
const KimiTokenRefreshSchema = z.object({
access_token: z.string(),
refresh_token: z.string().nullish(),
expires_in: ApiNumberSchema.nullish(),
scope: z.string().nullish(),
token_type: z.string().nullish(),
});
type KimiAuth = z.infer<typeof KimiAuthSchema>;
type KimiTokenRefresh = z.infer<typeof KimiTokenRefreshSchema>;
interface KimiCredentialRecord {
credentials: KimiAuth & { access_token: string };
filePath: string | null;
}
interface SaveRefreshedCredentialsOptions {
filePath: string;
refreshTokenUsed: string;
refreshed: KimiTokenRefresh;
}
type KimiCredentials = KimiAuth & { access_token: string };
interface KimiQuotaProviderOptions {
logger: Logger;
@@ -295,9 +273,10 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
const credentials = await this.readCredentials();
if (!credentials) return unavailableUsage(this);
const res = await this.fetchUsageResponse(credentials);
const res = await this.callUsageApi(credentials.access_token);
if (!res.ok) {
// Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md.
this.logger.debug({ status: res.status }, "Kimi usage fetch failed");
return unavailableUsage(this);
}
@@ -316,27 +295,6 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
};
}
private async fetchUsageResponse(record: KimiCredentialRecord): Promise<Response> {
const { credentials, filePath } = record;
const res = await this.callUsageApi(credentials.access_token);
if (res.status !== 401 || !filePath || !credentials.refresh_token) return res;
const latest = await this.readCredentialFile(filePath);
const retried =
latest?.access_token && latest.access_token !== credentials.access_token
? await this.callUsageApi(latest.access_token)
: res;
if (retried.status !== 401) return retried;
const refreshTokenUsed = latest?.refresh_token ?? credentials.refresh_token;
const refreshed = await this.refreshToken(refreshTokenUsed);
if (!refreshed) return retried;
await this.saveRefreshedCredentials({ filePath, refreshTokenUsed, refreshed });
return this.callUsageApi(refreshed.access_token);
}
private async callUsageApi(token: string): Promise<Response> {
return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, {
headers: {
@@ -346,95 +304,16 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
});
}
private async refreshToken(refreshToken: string): Promise<KimiTokenRefresh | null> {
const body = new URLSearchParams({
client_id: KIMI_CLIENT_ID,
grant_type: "refresh_token",
refresh_token: refreshToken,
});
const res = await fetchProviderApi(this.fetchApi, KIMI_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
this.logger.debug({ status: res.status }, "Kimi token refresh failed");
return null;
}
const parsed = KimiTokenRefreshSchema.safeParse(await res.json());
return parsed.success ? parsed.data : null;
}
private async saveRefreshedCredentials(options: SaveRefreshedCredentialsOptions): Promise<void> {
const { filePath, refreshTokenUsed, refreshed } = options;
const existing = await this.readCredentialFile(filePath);
if (!existing) return;
// The Kimi CLI owns this file too. If its refresh token no longer matches the one we
// just spent, the CLI rotated the credentials while our refresh was in flight and its
// copy is newer than ours — writing our merge would strand the CLI on dead tokens.
if (existing.refresh_token !== refreshTokenUsed) {
this.logger.debug("Kimi credentials rotated during refresh; keeping the file on disk");
return;
}
const expiresIn = refreshed.expires_in ?? existing.expires_in;
const merged: KimiAuth = {
...existing,
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token ?? existing.refresh_token,
expires_in: expiresIn,
expires_at:
expiresIn == null ? existing.expires_at : Math.floor(Date.now() / 1000) + expiresIn,
scope: refreshed.scope ?? existing.scope,
token_type: refreshed.token_type ?? existing.token_type,
};
const tempPath = join(
dirname(filePath),
`.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
);
try {
await fs.writeFile(tempPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
// writeFile above is async I/O that gives the CLI a window to rotate the file on
// its own. Re-read right before the rename and confirm nothing changed since the
// guard above — the check-then-write gap this closes is what the file-level guard
// couldn't cover, since it ran before the write, not right before the replace.
const beforeRename = await this.readCredentialFile(filePath);
if (!this.credentialsUnchangedSince(existing, beforeRename)) {
this.logger.debug("Kimi credentials rotated during refresh; discarding stale merge");
await fs.rm(tempPath, { force: true }).catch(() => undefined);
return;
}
await fs.rename(tempPath, filePath);
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist refreshed Kimi credentials");
await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
}
private credentialsUnchangedSince(baseline: KimiAuth, candidate: KimiAuth | null): boolean {
return (
candidate?.access_token === baseline.access_token &&
candidate?.refresh_token === baseline.refresh_token
);
}
private async readCredentials(): Promise<KimiCredentialRecord | null> {
private async readCredentials(): Promise<KimiCredentials | null> {
const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"];
if (environmentToken) {
return { credentials: { access_token: environmentToken }, filePath: null };
return { access_token: environmentToken };
}
for (const path of this.credentialPaths()) {
const credentials = await this.readCredentialFile(path);
if (credentials?.access_token) {
return {
credentials: { ...credentials, access_token: credentials.access_token },
filePath: path,
};
return { ...credentials, access_token: credentials.access_token };
}
}
return null;
@@ -1,6 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { promises as fsPromises } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -528,43 +527,27 @@ describe("real provider usage fetchers", () => {
expect(fetchApi).not.toHaveBeenCalled();
});
it("refreshes Claude access tokens on 401 and retries", async () => {
it("returns unavailable on 401 without refreshing or rewriting credentials", async () => {
writeClaudeCredentials(claudeHome, "at_expired", "rt_valid");
const credPath = join(claudeHome, ".credentials.json");
const before = readFileSync(credPath, "utf8");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
const endpoint = url.toString();
if (endpoint === "https://api.anthropic.com/api/oauth/usage") {
usageCalls += 1;
if (usageCalls === 1) return new Response(null, { status: 401 });
return jsonResponse(makeClaudeResponse());
}
if (endpoint === "https://platform.claude.com/v1/oauth/token") {
return jsonResponse({ access_token: "at_refreshed", refresh_token: "rt_new" });
return new Response(null, { status: 401 });
}
// The read-only fetcher must never hit the OAuth token endpoint.
throw new Error(`Unmocked: ${endpoint}`);
}) as never;
const result = await service().listUsage();
expect(findProvider(result, "claude").status).toBe("available");
expect(usageCalls).toBe(2);
});
it("returns unavailable Claude usage when 401 persists after refresh", async () => {
writeClaudeCredentials(claudeHome, "at_bad", "rt_bad");
fetchApi = mockFetch(
new Map([
["https://api.anthropic.com/api/oauth/usage", () => new Response(null, { status: 401 })],
[
"https://platform.claude.com/v1/oauth/token",
() => jsonResponse({ access_token: "at_still_bad", refresh_token: "rt_still_bad" }),
],
]),
);
const result = await service().listUsage();
expect(findProvider(result, "claude").status).toBe("unavailable");
expect(usageCalls).toBe(1);
// The credentials file must be left untouched for the Claude CLI to own.
expect(readFileSync(credPath, "utf8")).toBe(before);
});
it("does not refresh Claude tokens read from the macOS Keychain", async () => {
@@ -630,7 +613,6 @@ describe("real provider usage fetchers", () => {
"https://chatgpt.com/backend-api/wham/usage",
() => new Response("<html>Login</html>", { status: 200 }),
],
["https://auth.openai.com/oauth/token", () => new Response(null, { status: 401 })],
]),
);
@@ -639,42 +621,42 @@ describe("real provider usage fetchers", () => {
expect(findProvider(result, "codex").status).toBe("unavailable");
});
it("persists refreshed Codex tokens to the auth file that was read", async () => {
const alternateCodexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-alt-"));
process.env["CODEX_HOME"] = alternateCodexHome;
writeFileSync(join(alternateCodexHome, "auth.json"), JSON.stringify({ tokens: {} }));
writeCodexAuth(codexHome, "at_codex_stale", "rt_codex_valid");
let usageCalls = 0;
fetchApi = mockFetch(
new Map([
[
"https://chatgpt.com/backend-api/wham/usage",
() => {
usageCalls += 1;
if (usageCalls === 1) return new Response(null, { status: 401 });
return jsonResponse(makeCodexResponse());
},
],
[
"https://auth.openai.com/oauth/token",
() => jsonResponse({ access_token: "at_codex_fresh", refresh_token: "rt_codex_fresh" }),
],
]),
it("returns unavailable on 401 without refreshing or rewriting auth.json", async () => {
// Regression: the fetcher used to refresh the token and rewrite auth.json
// through a schema that dropped id_token (and OPENAI_API_KEY/last_refresh),
// leaving the file unparseable by the Codex CLI and forcing a re-login.
const authPath = join(codexHome, "auth.json");
writeFileSync(
authPath,
JSON.stringify({
OPENAI_API_KEY: null,
tokens: {
id_token: "id_codex",
access_token: "at_codex_stale",
refresh_token: "rt_codex_valid",
account_id: "acct_codex",
},
last_refresh: "2026-07-04T20:35:00Z",
}),
);
const before = readFileSync(authPath, "utf8");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
const endpoint = url.toString();
if (endpoint === "https://chatgpt.com/backend-api/wham/usage") {
usageCalls += 1;
return new Response(null, { status: 401 });
}
// The read-only fetcher must never hit the OAuth token endpoint.
throw new Error(`Unmocked: ${endpoint}`);
}) as never;
try {
const result = await service().listUsage();
const result = await service().listUsage();
const refreshedAuth = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8"));
const untouchedAuth = JSON.parse(readFileSync(join(alternateCodexHome, "auth.json"), "utf8"));
expect(findProvider(result, "codex").status).toBe("available");
expect(refreshedAuth.tokens.access_token).toBe("at_codex_fresh");
expect(refreshedAuth.tokens.refresh_token).toBe("rt_codex_fresh");
expect(untouchedAuth.tokens.access_token).toBeUndefined();
} finally {
rmSync(alternateCodexHome, { recursive: true, force: true });
}
expect(findProvider(result, "codex").status).toBe("unavailable");
expect(usageCalls).toBe(1);
// The auth file must be left byte-for-byte untouched for the Codex CLI to own.
expect(readFileSync(authPath, "utf8")).toBe(before);
});
it("fetches Copilot usage from COPILOT_TOKEN", async () => {
@@ -1103,181 +1085,27 @@ describe("real provider usage fetchers", () => {
expect(kimi.status).toBe("available");
});
it("persists refreshed Kimi tokens to the credential file that was read", async () => {
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired", {
preserved_field: "keep-me",
});
const authorization: Array<string | null> = [];
it("returns unavailable on 401 without refreshing or rewriting the credential file", async () => {
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired");
const credPath = kimiCredentialPath(join(homeDir, ".kimi-code"));
const before = readFileSync(credPath, "utf8");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
const endpoint = url.toString();
if (endpoint === "https://api.kimi.com/coding/v1/usages") {
usageCalls += 1;
authorization.push(
(init?.headers as Record<string, string> | undefined)?.Authorization ?? null,
);
if (usageCalls === 1) return new Response(null, { status: 401 });
return jsonResponse({ usage: { limit: "100", remaining: "74" } });
return new Response(null, { status: 401 });
}
if (endpoint === "https://auth.kimi.com/api/oauth/token") {
expect(init?.method).toBe("POST");
expect(init?.body?.toString()).toContain("grant_type=refresh_token");
expect(init?.body?.toString()).toContain("refresh_token=rt_kimi");
return jsonResponse({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_rotated",
expires_in: 900,
});
}
throw new Error(`Unmocked fetch: ${endpoint}`);
}) as never;
const result = await service({ kimiHomeDir: homeDir }).listUsage();
const persisted = JSON.parse(
readFileSync(kimiCredentialPath(join(homeDir, ".kimi-code")), "utf8"),
);
expect(findProvider(result, "kimi").status).toBe("available");
expect(authorization).toEqual(["Bearer at_kimi_expired", "Bearer at_kimi_fresh"]);
expect(persisted).toMatchObject({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_rotated",
expires_in: 900,
scope: "kimi-code",
preserved_field: "keep-me",
});
expect(persisted.expires_at).toBeGreaterThan(Date.now() / 1000);
});
it("prefers a Kimi token rewritten on disk over refreshing again", async () => {
const kimiHome = join(homeDir, ".kimi-code");
writeKimiCredentials(kimiHome, "at_kimi_old");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const endpoint = url.toString();
if (endpoint === "https://api.kimi.com/coding/v1/usages") {
usageCalls += 1;
if (usageCalls === 1) {
writeKimiCredentials(kimiHome, "at_kimi_rewritten");
return new Response(null, { status: 401 });
}
expect((init?.headers as Record<string, string> | undefined)?.Authorization).toBe(
"Bearer at_kimi_rewritten",
);
return jsonResponse({ usage: { limit: "100", remaining: "50" } });
}
throw new Error(`Unmocked fetch: ${endpoint}`);
// The read-only fetcher must never hit the OAuth token endpoint.
throw new Error(`Unmocked: ${endpoint}`);
}) as never;
const result = await service({ kimiHomeDir: homeDir }).listUsage();
expect(findProvider(result, "kimi").status).toBe("available");
expect(usageCalls).toBe(2);
});
it("keeps Kimi credentials rotated by the CLI during the refresh", async () => {
const kimiHome = join(homeDir, ".kimi-code");
writeKimiCredentials(kimiHome, "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
[
"https://auth.kimi.com/api/oauth/token",
() => {
writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" });
return jsonResponse({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_rotated",
expires_in: 900,
});
},
],
]),
);
await service({ kimiHomeDir: homeDir }).listUsage();
const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8"));
expect(persisted).toMatchObject({
access_token: "at_kimi_cli",
refresh_token: "rt_kimi_cli",
});
});
it("discards a refreshed Kimi merge if the CLI rotates credentials after the guard but before the rename", async () => {
const kimiHome = join(homeDir, ".kimi-code");
writeKimiCredentials(kimiHome, "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
[
"https://auth.kimi.com/api/oauth/token",
() =>
jsonResponse({
access_token: "at_kimi_fresh",
refresh_token: "rt_kimi_fresh",
expires_in: 900,
}),
],
]),
);
// The credential-file guard runs, passes, and only then does the CLI rotate the file
// — simulated by mutating the file as a side effect of the temp-file write, which is
// the async I/O step that sits between the guard read and the final rename.
const realWriteFile = fsPromises.writeFile;
const writeFileSpy = vi
.spyOn(fsPromises, "writeFile")
.mockImplementationOnce(async (...args: Parameters<typeof fsPromises.writeFile>) => {
writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" });
return realWriteFile(...args);
});
await service({ kimiHomeDir: homeDir }).listUsage();
const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8"));
expect(persisted).toMatchObject({
access_token: "at_kimi_cli",
refresh_token: "rt_kimi_cli",
});
writeFileSpy.mockRestore();
});
it("does not recreate a Kimi credential file deleted during the refresh", async () => {
const credentialPath = kimiCredentialPath(join(homeDir, ".kimi-code"));
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
[
"https://auth.kimi.com/api/oauth/token",
() => {
rmSync(credentialPath, { force: true });
return jsonResponse({ access_token: "at_kimi_fresh", expires_in: 900 });
},
],
]),
);
const result = await service({ kimiHomeDir: homeDir }).listUsage();
expect(findProvider(result, "kimi").status).toBe("unavailable");
expect(existsSync(credentialPath)).toBe(false);
});
it("returns unavailable Kimi usage when the token refresh is rejected", async () => {
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired");
fetchApi = mockFetch(
new Map([
["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })],
["https://auth.kimi.com/api/oauth/token", () => new Response(null, { status: 400 })],
]),
);
const result = await service({ kimiHomeDir: homeDir }).listUsage();
expect(findProvider(result, "kimi").status).toBe("unavailable");
expect(usageCalls).toBe(1);
// The credentials file must be left untouched for the Kimi CLI to own.
expect(readFileSync(credPath, "utf8")).toBe(before);
});
it("does not refresh Kimi tokens read from the environment", async () => {