From 12046f522bca29ded24425be71b856993b4997f7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 13 Oct 2026 14:28:40 -0500 Subject: [PATCH] Fix saved Codex setup credentials and onboarding reload recovery --- doc/plans/2026-10-06-default-agent-runner.md | 42 +++++++ maps/agent-access-map/onboarding.md | 9 ++ maps/agent-access-map/runs-adapters.md | 10 ++ maps/feature-map/onboarding.md | 7 ++ maps/feature-map/runs-adapters.md | 9 ++ .../native-runtime/setup-readiness.test.ts | 111 ++++++++++++++++- .../native-runtime/setup-readiness.ts | 60 +++++++-- ui/src/components/OnboardingWizard.test.tsx | 117 +++++++++++++++++- ui/src/components/OnboardingWizard.tsx | 20 ++- 9 files changed, 368 insertions(+), 17 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 13a460f9b55..e1cc1fff162 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -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) diff --git a/maps/agent-access-map/onboarding.md b/maps/agent-access-map/onboarding.md index 485350eacbf..27e9aba1cc0 100644 --- a/maps/agent-access-map/onboarding.md +++ b/maps/agent-access-map/onboarding.md @@ -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. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 71244852466..eefb111c6e3 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -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 diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md index b6fd3ccc692..104f1d4e252 100644 --- a/maps/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -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. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index bb54cef8281..cd95d7be105 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -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. diff --git a/server/src/services/native-runtime/setup-readiness.test.ts b/server/src/services/native-runtime/setup-readiness.test.ts index e4738ca6bcf..08b30f66f98 100644 --- a/server/src/services/native-runtime/setup-readiness.test.ts +++ b/server/src/services/native-runtime/setup-readiness.test.ts @@ -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 | 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 }); } diff --git a/server/src/services/native-runtime/setup-readiness.ts b/server/src/services/native-runtime/setup-readiness.ts index 654cd391c50..10c33f3e4d6 100644 --- a/server/src/services/native-runtime/setup-readiness.ts +++ b/server/src/services/native-runtime/setup-readiness.ts @@ -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 { + 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; 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>; 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." }, diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 62ecc79e22d..9f4dd00fc58 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -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(), })); +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( + , + )); + } 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 diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index ab6c7c2fd89..833012c8d12 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -781,6 +781,7 @@ function OnboardingWizardInner({ // changes, and takes whatever the step is at that moment. const initialStepRef = useRef(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,