mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Fix saved Codex setup credentials and onboarding reload recovery
This commit is contained in:
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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user