Fix saved Codex setup credentials and onboarding reload recovery

This commit is contained in:
Dotta committed 2026-10-13 14:28:40 -05:00
1 parent e301c68d64
commit 12046f522b
9 files changed
+368 -17

No files matched your search

@@ -1,5 +1,47 @@
# Runner defaults: staged delivery
## Current Codex merge checkpoint — 2026-10-13
Canonical branch: `codex/codex-runner-default`. Delivery PR:
[#15683](https://github.com/paperclipai/paperclip/pull/15683).
The runtime/install prerequisite #16028 and release repair #16149 are merged.
The finish line remains ordinary native Codex onboarding, checked task/follow-up,
saved-agent preservation, exact-candidate installation and required checks, then
a normal protected merge. Production deployment and default promotion remain
outside this merge campaign.
Published `e301c68d645eabd9fa7ae3a9450a460f6d2d0439` passes required CI with
52 successes and two conditional skips. Its fresh review finds one linked-login
setup defect. Literal executable source `6c0eca871616a110954553d6ff971e7655c85f93`
passes [remote package qualification](https://github.com/paperclipai/paperclip/actions/runs/38445419339),
including all four producers and clean Apple Silicon, Intel Mac and Linux ARM64
consumers. Fresh installed JavaScript initializes migration0339. The existing
Mac app upgrades while preserving three full agent configurations, eight
historical runs and saved account summaries, with no provider work during upgrade.
The [managed6c attempt](https://github.com/paperclipai/paperclip/actions/runs/38448001365)
passes the saved environment probe, native account/model hello, native hire and
campaign bounds. A legitimate reload then returns onboarding to step one before
Get started. Both setup leases and fixture cleanup pass; no task/artifact/follow-up
is reached. Preserve this failed attempt. The production draft-restoration fix
passes192 existing/new controls and independent source review, including delayed
loading, explicit fresh starts, old runner choices and company/account boundaries.
The managed fixture keeps its reload assertion. The separately scoped linked-login
fix stages checked credentials only for isolated setup and keeps the general
no-follow guard, refresh target and bounded retirement contract. Its original
source passes both regular-file controls and fails both linked-file controls
after the real lower materializer omits authentication. The matching treatment
passes69 setup controls and12 unchanged lower controls, server no-emit and
independent source review. Private copies retire only after confirmed cleanup;
an uncertain close retains owned state and remains a failure.
Next action: integrate exact master
`393d1c410862aa93561548e0a39feec3b89df6ff` with its migration0340 and plugin lifecycle
changes, preserve the Storybook catalog, and freeze one candidate. Require fresh
CI/review, affected installed-package and managed first-task proof before ready
or merge. Historical receipts keep their literal sources. The following
checkpoints are retained history, not the current merge state.
## Production preparation checkpoint — 2026-10-13
The runtime prerequisite [#16028](https://github.com/paperclipai/paperclip/pull/16028)
+9
View File
@@ -200,3 +200,12 @@ Scoped source review of [draft restoration](../../ui/src/components/OnboardingWi
preserves harness/runner intent and invalidates obsolete setup-test results after
a selection change. Browser draft continuity is not API or live-agent evidence,
and opening a saved Legacy agent does not apply new-agent defaults.
The initial matching route opening preserves the authorized saved wizard step
on bare and company-prefixed reloads. Explicit new-company or changed-company
openings retain their navigation override and clear foreign company resource
IDs. [Existing component controls](../../ui/src/components/OnboardingWizard.test.tsx)
exercise these presentation and account boundaries. This scoped correction adds
no server draft API, agent grant or proof of live actor authorization. The
managed first-task eval still reloads before Get started and remains a separate
live acceptance gate.
+10
View File
@@ -91,6 +91,16 @@ or Legacy CLI check cannot replace that runtime evidence. Missing dependencies,
credentials, invalid models and incompatible settings fail without silently
changing runners. Account login remains board/owner-bound.
For a linked Codex subscription, setup checks the resolved credential owner and
stages a private probe copy before local or remote isolation. The general native
home materializer retains its no-follow symlink guard. Refresh still targets the
checked account source, and confirmed bounded retirement removes its private
copy. Unconfirmed retirement retains the existing cleanup block and owned state.
[Existing fake-credential setup controls](../../server/src/services/native-runtime/setup-readiness.test.ts)
exercise linked/regular sources and failure cleanup. This adds no credential-read
API, agent login authority or cross-company account access. Its source-only
evidence does not qualify a live permitted/denied agent operation.
Verification for this scope remains `not-run`: use an admitted agent REST/CLI
validation path, assert missing grants and cross-company targets/accounts are
denied, then prove the useful task/follow-up separately. `hire_agent` inherits its
+7
View File
@@ -100,6 +100,13 @@ recover the harness and runner, and a changed harness or runner invalidates stal
test results. Existing Legacy agents remain Legacy when their onboarding/setup
configuration is reopened; this journey does not migrate them.
Reload both `/onboarding` and the company-prefixed route after the first agent is
saved but before Get started. The authorized draft must retain its current step
and that agent's execution choice. An explicit new-company opening still starts
fresh. Opening another company must clear the previous company's resource IDs;
an unauthorized account must not restore its draft. Existing component controls
cover these boundaries; they do not substitute for the managed first-task eval.
### `cli-setup`
Automated: [CLI onboarding](../../cli/src/__tests__/onboard.test.ts) and [isolated test drive](../../cli/src/__tests__/test-drive.test.ts) cover their setup contracts; they do not establish that every model account is usable.
+9
View File
@@ -59,6 +59,15 @@ existing agent, make an unrelated edit, and confirm both its persisted runner an
next run stay unchanged. Use [hiring/import preservation](./agents.md#hire-agent)
for approval and portability controls.
Test native Codex with a saved subscription whose auth file is a managed link,
as well as a regular file and an API-key account. Setup must seed the isolated
probe from the checked account without exposing credentials or changing its
saved runner. Dangling or incorrectly owned credentials must fail clearly. The
private probe copy must be removed after confirmed retirement following success,
failure or cancellation. Unconfirmed shutdown remains a cleanup failure.
Existing setup controls use fake credentials; real account execution remains
a separate acceptance requirement.
### `run-history`
Automated: [live run routes](../../server/src/__tests__/agent-live-run-routes.test.ts) and [run event sequencing](../../server/src/__tests__/heartbeat-run-event-sequencing.test.ts) cover run data contracts.
@@ -1,8 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
const { prepareIsolatedCodexHome } = await import(new URL("../../../../packages/paperclip-runner/src/drivers/runtime-context-materializer.ts", import.meta.url).href);
const { execute, nativeProbe, selectedRunner, selectedRemoteRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), selectedRemoteRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() }));
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: execute }));
vi.mock("./native-ssh-command-runner.js", () => ({ createNativeSshCommandRunner: sshRunner }));
@@ -99,6 +100,109 @@ describe("Codex selected native account verification", () => {
const sshContext = { companyId: "company", adapterType: "paperclip_runner", config: {}, executionTarget: {
kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/qa-workspace", spec: { host: "qa-host", username: "qa-user", port: 22, remoteCwd: "/qa-workspace", remoteWorkspacePath: "/qa-workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true },
} };
it.each(["local", "remote"].flatMap(transport => [false, true].map(linked => ({ transport, linked }))))
("seeds the selected subscription before isolated $transport setup (linked=$linked)", async ({ transport, linked }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-selected-auth-")));
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" }, last_refresh: "2026-01-01T00:00:00.000Z" });
const stored = join(home, linked ? "account-store.json" : "auth.json");
let seeded = false;
try {
await writeFile(stored, auth, { mode: 0o600 });
if (linked) await symlink(stored, join(home, "auth.json"));
await writeFile(join(home, "config.toml"), 'cli_auth_credentials_store = "file"\n[mcp_servers.host_only]\ncommand = "untrusted-host-tool"\n', { mode: 0o600 });
await writeFile(join(home, "unassigned-private-file"), "must-not-copy");
nativeProbe.mockImplementation(async input => {
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
const codexHome = join(input.runtimeDirectory, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome, sourceCodexHome: input.transportOptions.sourceCodexHome,
apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY });
const privateAuth = await readFile(join(codexHome, "auth.json"), "utf8").catch(() => null);
seeded = privateAuth === auth;
expect((await readFile(join(codexHome, "config.toml"), "utf8"))).not.toContain("untrusted-host-tool");
if (!seeded) throw new Error("authentication required");
if (linked) {
const source = join(input.runtimeDirectory, "codex-credential-source");
expect(input.transportOptions.sourceCodexHome).toBe(source);
expect((await stat(source)).mode & 0o777).toBe(0o700);
expect((await stat(join(source, "auth.json"))).mode & 0o777).toBe(0o600);
expect((await readdir(source)).sort()).toEqual(["auth.json", "config.toml"]);
}
if (transport === "remote") {
await remoteLauncher.mock.calls[0][0].ensureArtifact();
const upload = sshExecute.mock.calls.find(([value]) => value.args?.[1]?.includes("/codex-home/auth.json"))?.[0];
expect(upload).toBeDefined();
expect(Buffer.from(upload!.stdin, "base64").toString("utf8")).toBe(auth);
expect(upload!.args[1]).toContain("chmod 600");
}
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: "/untrusted-configured-home", _PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET: "spoofed-auth" } }, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("pass");
expect(seeded).toBe(true);
expect(await readFile(stored, "utf8")).toBe(auth);
expect(JSON.stringify(result)).not.toContain("fixture-access");
await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" });
expect(execute).not.toHaveBeenCalled();
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => ["dangling", "foreign-owner", "unsafe-mode", "oversize"].map(mode => ({ transport, mode }))))
("rejects $mode linked subscription before $transport provider admission", async ({ transport, mode }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-invalid-link-")));
const stored = join(home, "account-store.json");
let owner: ReturnType<typeof vi.spyOn> | undefined;
try {
if (mode !== "dangling") await writeFile(stored, mode === "oversize" ? "x".repeat(65_537) : '{"tokens":{"account_id":"fixture-account"}}', { mode: 0o600 });
await symlink(stored, join(home, "auth.json"));
if (mode === "unsafe-mode") await chmod(stored, 0o644);
if (mode === "foreign-owner") owner = vi.spyOn(process, "getuid").mockReturnValue(process.getuid!() + 1);
nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol"));
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(nativeProbe).not.toHaveBeenCalled();
expect(sshExecute).not.toHaveBeenCalled();
if (mode === "dangling") expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
} finally { owner?.mockRestore(); await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"])("redacts checked file credentials from %s native probe errors without exporting them to env", async transport => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-file-redaction-")));
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" } });
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
throw new Error("authentication failed: " + auth + " fixture-access fixture-refresh");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(JSON.stringify(result)).not.toMatch(/fixture-account|fixture-access|fixture-refresh|fixture-id/);
expect(result.checks[0].message).toContain("[REDACTED]");
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => [false, true].map(confirmed => ({ transport, confirmed }))))
("settles private linked credentials only after confirmed canceled $transport setup (confirmed=$confirmed)", async ({ transport, confirmed }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-linked-cancel-")));
const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}';
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
expect(await readFile(join(input.transportOptions.sourceCodexHome, "auth.json"), "utf8")).toBe(auth);
if (confirmed) await input.onCleanupConfirmed();
throw new Error("Native provider setup canceled");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
expect((await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol")).status).toBe("fail");
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
if (confirmed) await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" });
else expect(await readFile(join(root, "codex-credential-source", "auth.json"), "utf8")).toBe(auth);
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["linux-arm64", "linux-x64"])("uses task staging and the selected %s SSH daemon without a provider pack", async target => {
const selectedBinary = `/fixture/bin/${target}/paperclip-runnerd`;
selectedRemoteRunner.mockResolvedValue(selectedBinary);
@@ -162,7 +266,8 @@ describe("Codex selected native account verification", () => {
const home = await mkdtemp(join(tmpdir(), "native-ssh-managed-account-"));
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
try {
await writeFile(join(home, "auth.json"), auth("qa-account", "old", 120_000), { mode: 0o600 });
await writeFile(join(home, "account-store.json"), auth("qa-account", "old", 120_000), { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
const refreshed = auth(mode === "another-account" ? "different-account" : "qa-account", "new", 60_000);
sshExecute.mockImplementation(async input => ({ exitCode: input.args?.[2] === "paperclip-native-refresh" && mode === "read-failure" ? 1 : 0, timedOut: false,
stdout: input.args?.[2] === "paperclip-native-refresh" ? Buffer.from(refreshed).toString("base64") : "", stderr: "" }));
@@ -182,7 +287,7 @@ describe("Codex selected native account verification", () => {
expect(JSON.stringify(result)).not.toContain("access-new");
const read = sshExecute.mock.calls.find(([input]) => input.args?.[2] === "paperclip-native-refresh")![0];
expect(read.command).toBe("sh"); expect(read.args[1]).toContain("head -c 65537"); expect(read.args[1]).toContain("test ! -L");
expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(home);
expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(join(nativeProbe.mock.calls[0][0].runtimeDirectory, "codex-credential-source"));
if (mode === "read-failure") expect(result.checks[0].message).toContain("refresh handoff unavailable");
if (mode === "uncertain-close") expect(result.checks[0].message).toContain("teardown was not confirmed");
} finally { await rm(home, { recursive: true, force: true }); }
@@ -1,4 +1,5 @@
import { mkdtemp, realpath, rm, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { lstat, mkdir, mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
@@ -28,6 +29,41 @@ function redactNativeProbeMessage(message: string, environment: Record<string, s
return message.slice(0, 2000);
}
async function prepareProbeCodexSource(runtimeDirectory: string, sourceCodexHome: string, authJson?: string): Promise<string> {
if (authJson === undefined) return sourceCodexHome;
let config: Buffer | undefined;
const source = await open(join(sourceCodexHome, "config.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
.catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT" || error.code === "ELOOP") return null; throw error; });
if (source) {
try {
const metadata = await source.stat();
if (metadata.isFile()) {
const bytes = Buffer.alloc(1024 * 1024 + 1);
let size = 0;
while (size < bytes.length) {
const result = await source.read(bytes, size, bytes.length - size, size);
if (!result.bytesRead) break;
size += result.bytesRead;
}
if (size > 1024 * 1024) throw new Error("The selected Codex configuration exceeds its size limit.");
config = bytes.subarray(0, size);
}
} finally { await source.close(); }
}
const credentialSource = join(runtimeDirectory, "codex-credential-source");
let created = false;
try {
await mkdir(credentialSource, { mode: 0o700 });
created = true;
await writeFile(join(credentialSource, "auth.json"), authJson, { mode: 0o600, flag: "wx" });
if (config) await writeFile(join(credentialSource, "config.toml"), config, { mode: 0o600, flag: "wx" });
return credentialSource;
} catch (error) {
if (created) await rm(credentialSource, { recursive: true, force: true });
throw error;
}
}
async function probeRemoteNativeCodex(options: {
context: AdapterEnvironmentTestContext;
model: string | null;
@@ -35,6 +71,7 @@ async function probeRemoteNativeCodex(options: {
environment: Record<string, string>;
sourceCodexHome: string;
credentialRefreshAuthPath?: string;
sourceAuthJson?: string;
timeoutMs: number;
}) {
const target = options.context.executionTarget;
@@ -66,6 +103,7 @@ async function probeRemoteNativeCodex(options: {
if (result.timedOut || result.exitCode !== 0) throw new Error(message);
};
const runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
const sourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, options.sourceCodexHome, options.sourceAuthJson);
let remoteDirectoryCreated = false;
let remoteCaBundlePath: string | undefined;
let signalSpawned!: () => void;
@@ -74,7 +112,7 @@ async function probeRemoteNativeCodex(options: {
workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs,
transportOptions: {
externallySandboxed: target.transport === "sandbox",
runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome: options.sourceCodexHome,
runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome,
runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory,
prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` },
readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }),
@@ -155,6 +193,7 @@ export async function testNativeRunnerAuthentication(context: AdapterEnvironment
const effort = context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort;
const timeoutMs = remote ? 90_000 : 45_000;
let runtimeDirectory: string | undefined;
let sourceAuthJson: string | undefined;
try {
if (provider === "codex") {
const { buildNativeProviderEnvironment } = await import("./native-session-executor.js");
@@ -162,24 +201,31 @@ export async function testNativeRunnerAuthentication(context: AdapterEnvironment
}
let credentialRefreshAuthPath: string | undefined;
if (sourceCodexHome && !environment.OPENAI_API_KEY?.trim() && !environment.PAPERCLIP_AI_PROVIDER_KEY?.trim()) {
let linked = false;
try {
credentialRefreshAuthPath = await realpath(join(sourceCodexHome, "auth.json"));
const source = await stat(credentialRefreshAuthPath);
const authPath = join(sourceCodexHome, "auth.json");
linked = (await lstat(authPath)).isSymbolicLink();
const resolvedAuthPath = await realpath(authPath);
const source = await stat(resolvedAuthPath);
if (!source.isFile() || source.uid !== process.getuid?.()) throw new Error("Invalid Codex source credential owner.");
if (linked) sourceAuthJson = await readLocalAiCredentialFile(resolvedAuthPath);
credentialRefreshAuthPath = resolvedAuthPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
if (linked) throw new Error("Codex authentication required: the selected credential link is unavailable.");
}
}
let receipt: Awaited<ReturnType<typeof probeNativeRunnerEnvironment>>;
if (context.executionTarget?.kind === "remote") {
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, timeoutMs,
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, sourceAuthJson, timeoutMs,
...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) });
} else {
runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
const probeSourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, sourceCodexHome ?? "", sourceAuthJson);
receipt = await probeNativeRunnerEnvironment({ runtimeDirectory, provider, model, environment, timeoutMs,
...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}),
onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); },
transportOptions: { runnerBinary: resolvePaperclipRunnerBinary(), sourceCodexHome: sourceCodexHome ?? "", externallySandboxed: false },
transportOptions: { runnerBinary: resolvePaperclipRunnerBinary(), sourceCodexHome: probeSourceCodexHome, externallySandboxed: false },
...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) });
@@ -191,7 +237,7 @@ export async function testNativeRunnerAuthentication(context: AdapterEnvironment
return { adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_passed`, level: "info",
message: `The native ${provider} runtime verified the selected account and model in ${remote ? "the selected environment" : "the Paperclip host"}.` }] };
} catch (error) {
const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", environment);
const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", { ...environment, ...(sourceAuthJson ? { [authKey]: sourceAuthJson } : {}) });
const authentication = /(?:invalid (?:api[- ]?key|auth(?:entication)? token)|authentication (?:failed|required)|unauthenticated|unauthorized|not authenticated|please (?:log|sign) in|not logged in|\b(?:401|403)\b)/i.test(message);
return { adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_${authentication ? "auth_required" : /timed out/i.test(message) ? "timeout" : "failed"}`, level: "error", message,
hint: "Check the selected account, model access, and native runtime prerequisites, then retry. Legacy runner is available explicitly in Advanced." },
+115 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, StrictMode } from "react";
import type { LocalAiLoginStatus } from "@paperclipai/shared";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -171,8 +171,10 @@ const mockAdapterRegistry = vi.hoisted(() => ({
disabled: new Set<string>(),
}));
const mockRouterState = vi.hoisted(() => ({ pathname: "/" }));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/", search: "", hash: "", state: null }),
useLocation: () => ({ pathname: mockRouterState.pathname, search: "", hash: "", state: null }),
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));
@@ -356,6 +358,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockSecretsApi.removeUserSecretDefinition.mockResolvedValue({ ok: true });
window.localStorage.clear();
mockRouterState.pathname = "/";
mockDialog.onboardingOpen = true;
mockDialog.onboardingOptions = {};
mockDialog.onboardingRouteDismissed = false;
@@ -1258,6 +1261,116 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
});
describe("restored route progress", () => {
function savedReview(runner = "auto") {
return { step: 5, companyName: "Saved Co", createdCompanyId: "c1", createdCompanyPrefix: "SC",
createdAgentId: "saved-agent", agentName: "Saved lead", adapterType: "codex_local", runner,
model: "gpt-5.6-sol", credentialMode: "api" };
}
async function openSavedReview(pathname: string, runner = "auto", options: { strict?: boolean; companiesLoading?: boolean; dialogStep?: number } = {}) {
const draft = savedReview(runner);
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(draft));
mockRouterState.pathname = pathname;
mockDialog.onboardingOpen = options.dialogStep !== undefined;
mockDialog.onboardingOptions = options.dialogStep === undefined ? {} : { initialStep: options.dialogStep, companyId: "c1" };
const companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" },
{ id: "c2", name: "Other Co", issuePrefix: "OTHER" }];
mockCompany.companies = companies;
mockCompany.loading = options.companiesLoading ?? false;
mockCompaniesApi.list.mockResolvedValue(companies);
const handles = render();
if (options.strict) {
await act(async () => handles.root.render(
<QueryClientProvider client={handles.queryClient}><StrictMode><OnboardingWizard /></StrictMode></QueryClientProvider>,
));
} else await renderWizardView(handles.root, handles.queryClient);
for (let tick = 0; tick < 8; tick++) await flushReact();
return { ...handles, draft };
}
it.each([
["/onboarding", "auto"], ["/SC/onboarding", "auto"],
["/onboarding", "legacy"], ["/SC/onboarding", "legacy"],
])("restores the authorized review draft after route reload %s with %s runner", async (pathname, runner) => {
const { root, draft } = await openSavedReview(pathname, runner);
try {
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true);
expect(document.body.textContent).not.toContain("What is the name of your organization?");
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
expect(persisted).toMatchObject(draft);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
it("keeps restored review progress when initial effects replay in StrictMode", async () => {
const { root, draft } = await openSavedReview("/SC/onboarding", "auto", { strict: true });
try {
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true);
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft);
} finally { await act(async () => root.unmount()); }
});
it("waits for route company loading before deciding the initial restored step", async () => {
const { root, queryClient, draft } = await openSavedReview("/SC/onboarding", "auto", { companiesLoading: true });
try {
mockCompany.loading = false;
await renderWizardView(root, queryClient);
for (let tick = 0; tick < 8; tick++) await flushReact();
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true);
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft);
} finally { await act(async () => root.unmount()); }
});
it("does not restore another company's review into a different route", async () => {
const { root } = await openSavedReview("/OTHER/onboarding");
try {
expect(document.querySelector("#onboarding-agent-name")).not.toBeNull();
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
expect(persisted.createdCompanyId).toBe("c2");
expect(persisted.createdAgentId).toBeNull();
expect(persisted.step).toBe(3);
} finally { await act(async () => root.unmount()); }
});
it("does not bind an unmatched company prefix to the restored company", async () => {
const { root } = await openSavedReview("/MISSING/onboarding");
try {
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
expect(persisted.step).toBe(1);
expect(persisted.createdCompanyId).toBeNull();
expect(persisted.createdAgentId).toBeNull();
expect(persisted.companyName).toBe("");
} finally { await act(async () => root.unmount()); }
});
it.each([2, 3])("honors explicit prefilled-company dialog step %s on initial mount", async initialStep => {
const { root } = await openSavedReview("/SC/onboarding", "auto", { dialogStep: initialStep });
try {
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
expect(persisted.step).toBe(initialStep);
expect(persisted.createdCompanyId).toBe("c1");
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(false);
} finally { await act(async () => root.unmount()); }
});
it("honors an explicit dialog step instead of resuming the route draft", async () => {
const { root, queryClient } = await openSavedReview("/SC/onboarding");
try {
mockDialog.onboardingOpen = true;
mockDialog.onboardingOptions = { companyId: "c2", initialStep: 3 };
await renderWizardView(root, queryClient);
await flushReact();
expect(document.querySelector("#onboarding-agent-name")).not.toBeNull();
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
expect(persisted.createdCompanyId).toBe("c2");
expect(persisted.createdAgentId).toBeNull();
expect(persisted.step).toBe(3);
} finally { await act(async () => root.unmount()); }
});
});
it("re-syncs a restored draft once companies resolve asynchronously (companies start empty/loading)", async () => {
// Regression for the initializer-only restore bug: the inner wizard's
// ~20 useState(saved?.x ?? default) initializers only read `saved` on
+15 -5
View File
@@ -781,6 +781,7 @@ function OnboardingWizardInner({
// changes, and takes whatever the step is at that moment.
const initialStepRef = useRef<Step | undefined>(undefined);
initialStepRef.current = effectiveOnboardingOptions.initialStep;
const openingRequestRef = useRef<{ open: boolean; companyId: string | null; hasOpened: boolean } | null>(null);
// Reset the route-dismissed flag when navigating to a different path.
useEffect(() => {
@@ -820,20 +821,29 @@ function OnboardingWizardInner({
// Navigating from one company's onboarding path to another re-decides the
// step; the same request re-deriving a fresher value does not.
useEffect(() => {
const routeCompanyId = effectiveOnboardingOptions.companyId ?? null;
const previousRequest = openingRequestRef.current;
openingRequestRef.current = { open: effectiveOnboardingOpen, companyId: routeCompanyId,
hasOpened: previousRequest?.hasOpened === true || effectiveOnboardingOpen };
if (!effectiveOnboardingOpen) return;
// If explicit options are provided, they take precedence over saved state
if (initialStepRef.current) {
if (previousRequest?.open && previousRequest.companyId === routeCompanyId) return;
const initialRequest = previousRequest?.hasOpened !== true;
const resumesRouteDraft = initialRequest && !onboardingOpen && saved !== null
&& typeof saved.step === "number" && saved.step >= 1 && saved.step <= 5
&& (routeCompanyId ? routeCompanyId === saved.createdCompanyId : !companyPrefix);
if (initialStepRef.current && !resumesRouteDraft) {
setStep(initialStepRef.current);
setEntryStep(initialStepRef.current);
}
const routeCompanyId = effectiveOnboardingOptions.companyId ?? null;
if (routeCompanyId) {
// Claim ownership only when the route *introduces* a company. A route
// that merely names the one already in hand - the wizard created it,
// then the user navigated to that company's onboarding path - has not
// supplied anything, so it must not take ownership of it. Otherwise
// navigating on to `/onboarding` would clear work the wizard did.
if (routeCompanyId !== createdCompanyIdRef.current) {
if (routeCompanyId !== createdCompanyIdRef.current
|| (initialRequest && typeof saved?.createdCompanyId === "string"
&& routeCompanyId !== saved.createdCompanyId)) {
setCreatedCompanyId(routeCompanyId);
clearCompanyScopedState();
}
@@ -857,7 +867,7 @@ function OnboardingWizardInner({
routeCompanyIdRef.current = routeCompanyId;
return;
}
if (routeCompanyIdRef.current) {
if (routeCompanyIdRef.current || (initialRequest && companyPrefix && saved?.createdCompanyId)) {
// The route named a company and now does not - the user navigated from
// an existing company's onboarding to `/onboarding`, or to a prefix that
// matches nothing. Drop it. Keeping it leaves the wizard showing step 1,