Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity
* origin/master: fix(onboarding): preserve draft through company refetch (#12735) feat(codex-local): give each Codex account its own home and path secret (#12709) feat(claude-local): add Claude Fable 5.1 support (#12730)
This commit is contained in:
commit
2ffcc0104a
|
|
@ -102,6 +102,7 @@
|
|||
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
|
||||
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
|
||||
"@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
|
||||
"@agentclientprotocol/claude-agent-acp@0.73.0": "patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
|
||||
"@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch"
|
||||
},
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
"probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.70.0",
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.73.0",
|
||||
"@anthropic-ai/sdk": "0.121.0",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code
|
|||
export const models = [
|
||||
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
|
||||
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
|
||||
{ id: "claude-fable-5-1", label: "Claude Fable 5.1" },
|
||||
{ id: "claude-fable-5", label: "Claude Fable 5" },
|
||||
{ id: "claude-mythos-5", label: "Claude Mythos 5" },
|
||||
{ id: "claude-opus-5", label: "Claude Opus 5" },
|
||||
|
|
|
|||
|
|
@ -514,6 +514,30 @@ describe("claude_local ACP lane", () => {
|
|||
expect(settings.permissions.allow).toEqual(expect.arrayContaining(["Bash(curl:*)", "Bash(env)"]));
|
||||
});
|
||||
|
||||
it("passes the exact configured Fable 5.1 ID through ANTHROPIC_MODEL on the ACP lane", async () => {
|
||||
const root = await makeTempRoot("paperclip-claude-acp-fable51-");
|
||||
const meta: AdapterInvocationMeta[] = [];
|
||||
const execute = createClaudeAcpExecutor({
|
||||
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
|
||||
});
|
||||
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
engine: "acp",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
model: "claude-fable-5-1",
|
||||
promptTemplate: "Do the assigned work.",
|
||||
},
|
||||
onMeta: async (payload: AdapterInvocationMeta) => {
|
||||
meta.push(payload);
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-fable-5-1");
|
||||
});
|
||||
|
||||
it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => {
|
||||
const root = await makeTempRoot("paperclip-claude-acp-remote-cwd-");
|
||||
const localCwd = path.join(root, "worktree");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import path from "node:path";
|
|||
|
||||
const effortFlagSupportCache = new Map<string, Promise<boolean | null>>();
|
||||
|
||||
export const CLAUDE_FABLE_5_1_MIN_CLI_VERSION = "2.1.251";
|
||||
|
||||
const CLAUDE_FABLE_5_1_MODEL_IDS = new Set([
|
||||
"claude-fable-5-1",
|
||||
"us.anthropic.claude-fable-5-1",
|
||||
]);
|
||||
|
||||
export function claudeCommandLooksLike(command: string, expected = "claude"): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
|
|
@ -33,6 +40,78 @@ function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | nul
|
|||
].join(":");
|
||||
}
|
||||
|
||||
export function minimumClaudeCliVersionForModel(model: string): string | null {
|
||||
return CLAUDE_FABLE_5_1_MODEL_IDS.has(model.trim())
|
||||
? CLAUDE_FABLE_5_1_MIN_CLI_VERSION
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseClaudeCliVersion(output: string): string | null {
|
||||
return output.match(/\b(\d+)\.(\d+)\.(\d+)\b/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function claudeCliVersionAtLeast(version: string, minimum: string): boolean {
|
||||
const parsedVersion = version.split(".").map(Number);
|
||||
const parsedMinimum = minimum.split(".").map(Number);
|
||||
if (
|
||||
parsedVersion.length !== 3 ||
|
||||
parsedMinimum.length !== 3 ||
|
||||
parsedVersion.some((part) => !Number.isInteger(part) || part < 0) ||
|
||||
parsedMinimum.some((part) => !Number.isInteger(part) || part < 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < parsedMinimum.length; index += 1) {
|
||||
if (parsedVersion[index] !== parsedMinimum[index]) {
|
||||
return parsedVersion[index] > parsedMinimum[index];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function probeClaudeCommandVersion(input: {
|
||||
runId: string;
|
||||
command: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}): Promise<string | null> {
|
||||
const version = await runAdapterExecutionTargetProcess(
|
||||
input.runId,
|
||||
input.target,
|
||||
input.command,
|
||||
["--version"],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: Math.max(1, Math.min(input.timeoutSec, 20)),
|
||||
graceSec: Math.max(1, Math.min(input.graceSec, 5)),
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (version.timedOut || version.exitCode !== 0) return null;
|
||||
return parseClaudeCliVersion(`${version.stdout}\n${version.stderr}`);
|
||||
}
|
||||
|
||||
export async function readClaudeCommandVersion(input: {
|
||||
runId: string;
|
||||
command: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}): Promise<string | null> {
|
||||
// Do not cache this probe: an operator may upgrade Claude Code while the
|
||||
// Paperclip server is running, and the next Test/run should recover without
|
||||
// requiring a server restart.
|
||||
return probeClaudeCommandVersion(input).catch(() => null);
|
||||
}
|
||||
|
||||
async function probeClaudeCommandSupportsEffortFlag(input: {
|
||||
runId: string;
|
||||
command: string;
|
||||
|
|
|
|||
|
|
@ -13,15 +13,17 @@ const {
|
|||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async (): Promise<RunProcessResult> => ({
|
||||
runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]): Promise<RunProcessResult> => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }),
|
||||
JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }),
|
||||
JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }),
|
||||
].join("\n"),
|
||||
stdout: args.includes("--version")
|
||||
? "2.1.251 (Claude Code)\n"
|
||||
: [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }),
|
||||
JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }),
|
||||
JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
pid: 123,
|
||||
startedAt: new Date().toISOString(),
|
||||
|
|
@ -76,12 +78,14 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
import { resetClaudeCliCapabilitiesCacheForTests } from "./cli-capabilities.js";
|
||||
|
||||
describe("claude remote execution", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetClaudeCliCapabilitiesCacheForTests();
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (!dir) continue;
|
||||
|
|
@ -403,4 +407,126 @@ describe("claude remote execution", () => {
|
|||
expect(result.errorCode).toBe("duplex_channel_lost");
|
||||
});
|
||||
|
||||
describe("CLI-lane model pass-through", () => {
|
||||
async function executeWithModel(prefix: string, config: Record<string, unknown>) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-model-passthrough",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "claude",
|
||||
...config,
|
||||
},
|
||||
context: {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
remoteCwd: "/remote/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const call = runChildProcess.mock.calls.find((candidate) =>
|
||||
(candidate[2] as string[]).includes("--print"),
|
||||
) as unknown as [string, string, string[]] | undefined;
|
||||
return { args: call?.[2] ?? [], result };
|
||||
}
|
||||
|
||||
it("passes the exact configured Fable 5.1 ID as --model on the CLI lane", async () => {
|
||||
const { args } = await executeWithModel("paperclip-claude-model-direct-", {
|
||||
model: "claude-fable-5-1",
|
||||
});
|
||||
|
||||
const modelFlag = args.indexOf("--model");
|
||||
expect(modelFlag).toBeGreaterThanOrEqual(0);
|
||||
expect(args[modelFlag + 1]).toBe("claude-fable-5-1");
|
||||
});
|
||||
|
||||
it("passes the Bedrock-native Fable 5.1 ID as --model under Bedrock auth", async () => {
|
||||
const { args } = await executeWithModel("paperclip-claude-model-bedrock-", {
|
||||
model: "us.anthropic.claude-fable-5-1",
|
||||
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
||||
});
|
||||
|
||||
const modelFlag = args.indexOf("--model");
|
||||
expect(modelFlag).toBeGreaterThanOrEqual(0);
|
||||
expect(args[modelFlag + 1]).toBe("us.anthropic.claude-fable-5-1");
|
||||
});
|
||||
|
||||
it("skips --model for a direct Anthropic ID under Bedrock auth", async () => {
|
||||
const { args } = await executeWithModel("paperclip-claude-model-bedrock-skip-", {
|
||||
model: "claude-fable-5-1",
|
||||
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
||||
});
|
||||
|
||||
expect(args).not.toContain("--model");
|
||||
});
|
||||
|
||||
it("rejects Fable 5.1 before launch when the CLI is older than 2.1.251", async () => {
|
||||
runChildProcess.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "2.1.247 (Claude Code)\n",
|
||||
stderr: "",
|
||||
pid: 123,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { args, result } = await executeWithModel("paperclip-claude-model-old-cli-", {
|
||||
model: "claude-fable-5-1",
|
||||
});
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(result.errorCode).toBe("claude_cli_version_incompatible");
|
||||
expect(result.errorMessage).toContain("requires Claude Code 2.1.251 or newer");
|
||||
expect(result.resultJson).toMatchObject({
|
||||
requiredClaudeCodeVersion: "2.1.251",
|
||||
detectedClaudeCodeVersion: "2.1.247",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves Fable compatibility to explicitly configured custom CLI wrappers", async () => {
|
||||
const { args, result } = await executeWithModel("paperclip-claude-model-wrapper-", {
|
||||
command: "/opt/paperclip/claude-wrapper",
|
||||
model: "claude-fable-5-1",
|
||||
});
|
||||
|
||||
expect(args).toContain("--model");
|
||||
expect(args).toContain("claude-fable-5-1");
|
||||
expect(result.errorCode).not.toBe("claude_cli_version_incompatible");
|
||||
expect(runChildProcess.mock.calls.some((call) =>
|
||||
(call[2] as string[]).includes("--version"),
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,7 +81,13 @@ import {
|
|||
resolveSharedClaudeConfigDir,
|
||||
writePaperclipClaudeMcpConfig,
|
||||
} from "./claude-config.js";
|
||||
import { claudeCommandSupportsEffortFlag } from "./cli-capabilities.js";
|
||||
import {
|
||||
claudeCliVersionAtLeast,
|
||||
claudeCommandLooksLike,
|
||||
claudeCommandSupportsEffortFlag,
|
||||
minimumClaudeCliVersionForModel,
|
||||
readClaudeCommandVersion,
|
||||
} from "./cli-capabilities.js";
|
||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { prepareClaudePromptBundle } from "./prompt-cache.js";
|
||||
|
|
@ -863,6 +869,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
const passesConfiguredModel = Boolean(
|
||||
model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model)),
|
||||
);
|
||||
|
||||
const buildClaudeArgs = (
|
||||
resumeSessionId: string | null,
|
||||
|
|
@ -879,7 +888,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
// For Bedrock: only pass --model when the ID is a Bedrock-native identifier
|
||||
// (e.g. "us.anthropic.*" or ARN). Anthropic-style IDs like "claude-opus-4-6" are invalid
|
||||
// on Bedrock, so skip them and let the CLI use its own configured model.
|
||||
if (model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model))) {
|
||||
if (passesConfiguredModel) {
|
||||
args.push("--model", model);
|
||||
}
|
||||
if (effectiveEffort) args.push("--effort", effectiveEffort);
|
||||
|
|
@ -1250,6 +1259,49 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
};
|
||||
|
||||
try {
|
||||
const minimumCliVersion = passesConfiguredModel && claudeCommandLooksLike(command, "claude")
|
||||
? minimumClaudeCliVersionForModel(model)
|
||||
: null;
|
||||
if (minimumCliVersion) {
|
||||
const detectedCliVersion = await readClaudeCommandVersion({
|
||||
runId,
|
||||
command,
|
||||
target: runtimeExecutionTarget,
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
});
|
||||
if (
|
||||
!detectedCliVersion ||
|
||||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
|
||||
) {
|
||||
const detected = detectedCliVersion
|
||||
? `detected ${detectedCliVersion}`
|
||||
: "could not determine the installed version";
|
||||
const errorMessage =
|
||||
`Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane; ${detected}. ` +
|
||||
"Upgrade Claude Code or restore the default ACP lane before retrying.";
|
||||
await onLog("stderr", `[paperclip] ${errorMessage}\n`);
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage,
|
||||
errorCode: "claude_cli_version_incompatible",
|
||||
provider: "anthropic",
|
||||
biller: isBedrockAuth(effectiveEnv) ? "aws_bedrock" : "anthropic",
|
||||
model,
|
||||
billingType,
|
||||
resultJson: {
|
||||
stopReason: "claude_cli_version_incompatible",
|
||||
requiredClaudeCodeVersion: minimumCliVersion,
|
||||
detectedClaudeCodeVersion: detectedCliVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
const sessionErrorKind =
|
||||
sessionId &&
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ const ANTHROPIC_API_VERSION = "2023-06-01";
|
|||
/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */
|
||||
const BEDROCK_MODELS: AdapterModel[] = [
|
||||
{ id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" },
|
||||
// Fable 5.1's documented geo inference ID carries no -v1 suffix, unlike earlier entries.
|
||||
{ id: "us.anthropic.claude-fable-5-1", label: "Bedrock Fable 5.1" },
|
||||
{ id: "us.anthropic.claude-fable-5-v1", label: "Bedrock Fable 5" },
|
||||
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
|
||||
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
|
||||
|
|
|
|||
|
|
@ -629,6 +629,39 @@ describe("claude CLI local hello probe hardening", () => {
|
|||
expect(JSON.stringify(spawnedEnv)).not.toContain("caller-proxy");
|
||||
});
|
||||
|
||||
it("warns without executing when runtime PATH selects a different local Claude executable", async () => {
|
||||
const runtimeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cli-runtime-path-"));
|
||||
const runtimeClaudePath = path.join(runtimeDir, "claude");
|
||||
await writeFile(runtimeClaudePath, "#!/bin/sh\nexit 0\n");
|
||||
await chmod(runtimeClaudePath, 0o755);
|
||||
|
||||
try {
|
||||
probeResult.value = { exitCode: 0, stdout: "2.1.251 (Claude Code)\n", stderr: "" };
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: "claude",
|
||||
model: "claude-fable-5-1",
|
||||
env: { PATH: runtimeDir },
|
||||
},
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.checks).toContainEqual(expect.objectContaining({
|
||||
code: "claude_cli_version_probe_mismatch",
|
||||
level: "warn",
|
||||
}));
|
||||
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("names the local host target on every result", async () => {
|
||||
probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" };
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|||
|
||||
import { testEnvironment } from "./test.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
|
||||
import { resetClaudeCliCapabilitiesCacheForTests } from "./cli-capabilities.js";
|
||||
|
||||
const sandboxTarget: AdapterExecutionTarget = {
|
||||
kind: "remote",
|
||||
|
|
@ -94,6 +95,7 @@ const loginRequiredStdout = [
|
|||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetClaudeCliCapabilitiesCacheForTests();
|
||||
});
|
||||
|
||||
describe("claude sandbox auth-missing check", () => {
|
||||
|
|
@ -137,3 +139,40 @@ describe("claude sandbox auth-missing check", () => {
|
|||
expect(result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claude CLI model compatibility check", () => {
|
||||
it("fails before the hello probe when Fable 5.1 is configured with an older CLI", async () => {
|
||||
probeResult.value = {
|
||||
exitCode: 0,
|
||||
stdout: "2.1.247 (Claude Code)\n",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: "claude",
|
||||
model: "claude-fable-5-1",
|
||||
},
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks).toContainEqual(expect.objectContaining({
|
||||
code: "claude_cli_version_incompatible",
|
||||
level: "error",
|
||||
detail: "Detected Claude Code 2.1.247.",
|
||||
}));
|
||||
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
|
||||
const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
|
||||
string,
|
||||
AdapterExecutionTarget,
|
||||
string,
|
||||
string[],
|
||||
];
|
||||
expect(versionCall[3]).toEqual(["--version"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
ensureAdapterExecutionTargetDirectory,
|
||||
runAdapterExecutionTargetProcess,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
detectClaudeLoginRequired,
|
||||
|
|
@ -23,7 +24,13 @@ import {
|
|||
isClaudeTransientUpstreamError,
|
||||
parseClaudeStreamJson,
|
||||
} from "./parse.js";
|
||||
import { claudeCommandLooksLike, claudeCommandSupportsEffortFlag } from "./cli-capabilities.js";
|
||||
import {
|
||||
claudeCliVersionAtLeast,
|
||||
claudeCommandLooksLike,
|
||||
claudeCommandSupportsEffortFlag,
|
||||
minimumClaudeCliVersionForModel,
|
||||
readClaudeCommandVersion,
|
||||
} from "./cli-capabilities.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { buildClaudeProbePermissionArgs } from "./permissions.js";
|
||||
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
|
||||
|
|
@ -47,6 +54,14 @@ function isNonEmpty(value: unknown): value is string {
|
|||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function localExecutablesMatch(
|
||||
trustedCommand: string | null,
|
||||
runtimeCommand: string | null,
|
||||
): boolean {
|
||||
if (!trustedCommand || !runtimeCommand) return false;
|
||||
return trustedCommand === runtimeCommand;
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
|
|
@ -130,8 +145,17 @@ export async function testEnvironment(
|
|||
})),
|
||||
);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
let localRuntimeCommand: string | null = null;
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
if (!targetIsRemote) {
|
||||
localRuntimeCommand = await resolveAdapterExecutionTargetCommandForLogs(
|
||||
command,
|
||||
target,
|
||||
cwd,
|
||||
runtimeEnv,
|
||||
);
|
||||
}
|
||||
checks.push({
|
||||
code: "claude_command_resolvable",
|
||||
level: "info",
|
||||
|
|
@ -214,7 +238,62 @@ export async function testEnvironment(
|
|||
check.code !== "claude_command_unresolvable" &&
|
||||
check.code !== "claude_managed_config_dir_failed",
|
||||
);
|
||||
if (canRunProbe) {
|
||||
let configuredModelIsCompatible = true;
|
||||
const configuredModel = asString(config.model, "").trim();
|
||||
const minimumCliVersion =
|
||||
claudeCommandLooksLike(command, "claude") &&
|
||||
(!hasBedrock || isBedrockModelId(configuredModel))
|
||||
? minimumClaudeCliVersionForModel(configuredModel)
|
||||
: null;
|
||||
const versionProbeCommand = localProbe?.command ?? (targetIsRemote ? command : null);
|
||||
const versionProbeMatchesRuntime = targetIsRemote || localExecutablesMatch(
|
||||
localProbe?.command ?? null,
|
||||
localRuntimeCommand,
|
||||
);
|
||||
if (
|
||||
canRunProbe &&
|
||||
minimumCliVersion &&
|
||||
versionProbeCommand &&
|
||||
!versionProbeMatchesRuntime
|
||||
) {
|
||||
configuredModelIsCompatible = false;
|
||||
checks.push({
|
||||
code: "claude_cli_version_probe_mismatch",
|
||||
level: "warn",
|
||||
message:
|
||||
"Skipped Fable 5.1 readiness probing because the runtime PATH selects a different Claude executable than the trusted local Test probe.",
|
||||
hint:
|
||||
"Ensure the runtime-selected Claude Code is 2.1.251 or newer. Execution will verify that exact executable before launch.",
|
||||
});
|
||||
} else if (canRunProbe && minimumCliVersion && versionProbeCommand) {
|
||||
const versionProbeEnv = localProbe?.env ?? env;
|
||||
const detectedCliVersion = await readClaudeCommandVersion({
|
||||
runId,
|
||||
command: versionProbeCommand,
|
||||
target,
|
||||
cwd,
|
||||
env: versionProbeEnv,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
});
|
||||
if (
|
||||
!detectedCliVersion ||
|
||||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
|
||||
) {
|
||||
configuredModelIsCompatible = false;
|
||||
checks.push({
|
||||
code: "claude_cli_version_incompatible",
|
||||
level: "error",
|
||||
message: `Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane.`,
|
||||
detail: detectedCliVersion
|
||||
? `Detected Claude Code ${detectedCliVersion}.`
|
||||
: "Could not determine the installed Claude Code version.",
|
||||
hint: "Upgrade Claude Code or restore the default ACP lane, then retry the Test.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canRunProbe && configuredModelIsCompatible) {
|
||||
if (!claudeCommandLooksLike(command, "claude")) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_skipped_custom_command",
|
||||
|
|
@ -233,7 +312,7 @@ export async function testEnvironment(
|
|||
hint: "Install the `claude` CLI on the Paperclip host, then retry the Test.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, "").trim();
|
||||
const model = configuredModel;
|
||||
const effort = asString(config.effort, "").trim();
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ const TOKEN_SENTINEL = "SENTINEL_TOKEN_XYZ";
|
|||
|
||||
// This suite proves the device-login credential promotion helper. The helper
|
||||
// runs an independent readiness check on the exact staged credential first, then
|
||||
// validates it with the export rules, then writes only the company-scoped
|
||||
// credential home and the company-scoped cache. It writes only while the session
|
||||
// validates it with the export rules, then writes this account's own home and,
|
||||
// as a fallback, the company default home. It writes only while the session
|
||||
// holds the sole active claim on the slot (the conditional check). It never
|
||||
// writes the instance-global host, and it never logs secret bytes.
|
||||
describe("device-login credential promotion", () => {
|
||||
|
|
@ -83,11 +83,7 @@ describe("device-login credential promotion", () => {
|
|||
return path.join(resolveManagedCodexHomeDir(env, companyId), "auth.json");
|
||||
}
|
||||
|
||||
async function readIfPresent(target: string): Promise<string | null> {
|
||||
return readFile(target, "utf8").catch(() => null);
|
||||
}
|
||||
|
||||
it("a failed readiness check rejects and writes neither the company home nor the cache", async () => {
|
||||
it("a failed readiness check rejects and writes neither the company home nor the account home", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const logs: string[] = [];
|
||||
|
|
@ -119,10 +115,10 @@ describe("device-login credential promotion", () => {
|
|||
expect(logs.join("\n")).not.toContain(ACCOUNT);
|
||||
});
|
||||
|
||||
it("a user login seeds an empty company home and cache slot for the new identity", async () => {
|
||||
it("a user login seeds this account's own home and the company default home", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
|
|
@ -131,22 +127,22 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(outcome).toBe("promoted");
|
||||
expect(result.outcome).toBe("promoted");
|
||||
|
||||
const homeAuth = await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8");
|
||||
expect(JSON.parse(homeAuth).tokens.account_id).toBe(ACCOUNT);
|
||||
const cacheAuth = await readFile(
|
||||
const accountAuth = await readFile(
|
||||
resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A),
|
||||
"utf8",
|
||||
);
|
||||
expect(JSON.parse(cacheAuth).tokens.account_id).toBe(ACCOUNT);
|
||||
expect(JSON.parse(accountAuth).tokens.account_id).toBe(ACCOUNT);
|
||||
// The instance-global host was never seeded.
|
||||
await expect(
|
||||
lstat(path.join(resolveSharedCodexHomeDir(env), "auth.json")),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("a strictly-newer same-identity login updates the company home", async () => {
|
||||
it("a strictly-newer login updates this account's own home", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
|
|
@ -158,7 +154,7 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "new" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
|
|
@ -167,13 +163,15 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(outcome).toBe("promoted");
|
||||
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
|
||||
expect(homeAuth.last_refresh).toBe(NEWER);
|
||||
expect(homeAuth.tokens.refresh_token).toContain("new");
|
||||
expect(result.outcome).toBe("promoted");
|
||||
const accountAuth = JSON.parse(
|
||||
await readFile(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A), "utf8"),
|
||||
);
|
||||
expect(accountAuth.last_refresh).toBe(NEWER);
|
||||
expect(accountAuth.tokens.refresh_token).toContain("new");
|
||||
});
|
||||
|
||||
it("an older same-identity login keeps the company home", async () => {
|
||||
it("an older login keeps this account's own home", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
|
|
@ -185,7 +183,7 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "older" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
|
|
@ -194,12 +192,14 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(outcome).toBe("kept");
|
||||
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
|
||||
expect(homeAuth.tokens.refresh_token).toContain("keep");
|
||||
expect(result.outcome).toBe("kept");
|
||||
const accountAuth = JSON.parse(
|
||||
await readFile(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A), "utf8"),
|
||||
);
|
||||
expect(accountAuth.tokens.refresh_token).toContain("keep");
|
||||
});
|
||||
|
||||
it("a different-identity login keeps the home and reports a foreign-identity outcome", async () => {
|
||||
it("promotion writes the account home for a second, different account", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
|
|
@ -211,7 +211,7 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: OTHER_ACCOUNT, lastRefresh: NEWER, marker: "other" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
|
|
@ -220,23 +220,19 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
// The home keeps the first identity. The login did not install the other
|
||||
// account, so the outcome is `kept_foreign_identity`, not a plain `kept`: the
|
||||
// caller must fail the session instead of a report of `authenticated`. The
|
||||
// other identity still lands in its own per-identity cache slot.
|
||||
expect(outcome).toBe("kept_foreign_identity");
|
||||
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
|
||||
expect(homeAuth.tokens.account_id).toBe(ACCOUNT);
|
||||
const otherCache = JSON.parse(
|
||||
// A second, different account gets its own home, so the second login
|
||||
// succeeds instead of failing behind the first account's home.
|
||||
expect(result.outcome).toBe("promoted");
|
||||
const otherAccountAuth = JSON.parse(
|
||||
await readFile(resolveCodexAuthCacheEntryPath(env, OTHER_ACCOUNT, COMPANY_A), "utf8"),
|
||||
);
|
||||
expect(otherCache.tokens.account_id).toBe(OTHER_ACCOUNT);
|
||||
expect(otherAccountAuth.tokens.account_id).toBe(OTHER_ACCOUNT);
|
||||
});
|
||||
|
||||
it("the cache off-switch skips the cache slot but still seeds the company home", async () => {
|
||||
it("promotion returns the account id and the account home path", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home, { PAPERCLIP_CODEX_AUTH_CACHE: "off" });
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const env = envFor(home);
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
|
|
@ -245,29 +241,231 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(outcome).toBe("promoted");
|
||||
// The company home was seeded.
|
||||
expect(await readIfPresent(companyHomeAuthPath(env, COMPANY_A))).toContain(ACCOUNT);
|
||||
// The cache slot was not written.
|
||||
await expect(
|
||||
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
|
||||
).rejects.toThrow();
|
||||
expect(result.accountId).toBe(ACCOUNT);
|
||||
expect(result.accountHomeDir).toBe(
|
||||
path.dirname(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
|
||||
);
|
||||
});
|
||||
|
||||
it("a cache write failure keeps the promotion successful and the company home durable", async () => {
|
||||
it("promotion reports accountHomeCreated true for a first login of an account", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
// Force the per-identity cache write to fail. Plant a regular file where the
|
||||
// cache expects the identity directory, so the private-directory guard throws
|
||||
// before the cache slot is written. The company home write runs first, so the
|
||||
// credential is already durable when the cache write fails.
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(result.outcome).toBe("promoted");
|
||||
expect(result.accountHomeCreated).toBe(true);
|
||||
});
|
||||
|
||||
it("promotion reports accountHomeCreated false for a repeat login of the same account", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "first" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "second" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(result.outcome).toBe("promoted");
|
||||
expect(result.accountHomeCreated).toBe(false);
|
||||
});
|
||||
|
||||
it("two concurrent promotions for the same account report only one accountHomeCreated true", async () => {
|
||||
// Two different logins for the SAME Codex account run their own
|
||||
// promotion slot, so they can promote at the same time. Without a lock
|
||||
// around the absence check and the directory creation, both calls could
|
||||
// see the directory as absent and both report `created: true`; a caller
|
||||
// that later deletes the directory on `created: true` would then delete
|
||||
// a home the other call's login still uses.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const [first, second] = await Promise.all([
|
||||
promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "racer-a" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
}),
|
||||
promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "racer-b" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
}),
|
||||
]);
|
||||
// Both racers still authenticate; only the timestamp-comparison outcome
|
||||
// (`promoted` vs. `kept`) can differ, because they carry the same
|
||||
// `lastRefresh` timestamp.
|
||||
expect(["promoted", "kept"]).toContain(first.outcome);
|
||||
expect(["promoted", "kept"]).toContain(second.outcome);
|
||||
const createdFlags = [first.accountHomeCreated, second.accountHomeCreated];
|
||||
expect(createdFlags.filter(Boolean)).toHaveLength(1);
|
||||
await expect(
|
||||
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("promotion keeps the company default home when it holds another account", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "first" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
const before = await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8");
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: OTHER_ACCOUNT, lastRefresh: NEWER, marker: "other" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
// The company default home fallback still names the first account: a
|
||||
// second account never clobbers it once some account has claimed it.
|
||||
expect(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("promotion seeds the company default home when it holds no usable credential", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(result.outcome).toBe("promoted");
|
||||
const homeAuth = await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8");
|
||||
expect(JSON.parse(homeAuth).tokens.account_id).toBe(ACCOUNT);
|
||||
});
|
||||
|
||||
it("promotion fails the login when the account identifier cannot become a handle", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await expect(
|
||||
promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: "acct 42", lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("a whitespace-padded account identifier never shares a home with, or reports as authenticated against, the unpadded account", async () => {
|
||||
// A distinct identifier with surrounding whitespace must reject, not
|
||||
// alias onto the unpadded account's home. Promote the unpadded account
|
||||
// first, then attempt a second login whose identifier differs only by a
|
||||
// leading space. The second login must fail, and it must never read or
|
||||
// report a "kept" outcome against the first account's home.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "original" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
await expect(
|
||||
promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ` ${ACCOUNT}`, lastRefresh: NEWER, marker: "padded" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
// The unpadded account's home still holds only the credential the first
|
||||
// login wrote. The padded login never read it, never wrote it, and never
|
||||
// received a "kept" outcome that would report it as authenticated.
|
||||
const accountAuth = JSON.parse(
|
||||
await readFile(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A), "utf8"),
|
||||
);
|
||||
expect(accountAuth.tokens.refresh_token).toContain("original");
|
||||
});
|
||||
|
||||
it("a broken account-home directory fails the whole login", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
// Force this account's own home write to fail. Plant a regular file where
|
||||
// the account expects its own directory, so the private-directory guard
|
||||
// throws before any write. That write is now fail-loud (it is the durable
|
||||
// result), so the whole login fails, and the best-effort company default
|
||||
// home fallback is never reached.
|
||||
const cacheDir = resolveCodexAuthCacheDir(env, COMPANY_A);
|
||||
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
|
||||
const entryPath = resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A);
|
||||
await writeFile(path.dirname(entryPath), "not-a-directory");
|
||||
|
||||
await expect(
|
||||
promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("a company default home write failure keeps the promotion successful and this account's own home durable", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
// Force the company default home write to fail. Plant a regular file where
|
||||
// the company home directory belongs, so mkdir fails. This account's own
|
||||
// home write already ran and is durable, so the login still succeeds.
|
||||
const companyHome = resolveManagedCodexHomeDir(env, COMPANY_A);
|
||||
await mkdir(path.dirname(companyHome), { recursive: true });
|
||||
await writeFile(companyHome, "not-a-directory");
|
||||
|
||||
const logs: string[] = [];
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
|
|
@ -279,14 +477,13 @@ describe("device-login credential promotion", () => {
|
|||
},
|
||||
});
|
||||
|
||||
// The company home holds a usable credential, so the promotion still reports
|
||||
// success rather than a failed login.
|
||||
expect(outcome).toBe("promoted");
|
||||
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
|
||||
expect(homeAuth.tokens.account_id).toBe(ACCOUNT);
|
||||
// The cache failure is observable and carries no secret bytes.
|
||||
expect(result.outcome).toBe("promoted");
|
||||
const accountAuth = JSON.parse(
|
||||
await readFile(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A), "utf8"),
|
||||
);
|
||||
expect(accountAuth.tokens.account_id).toBe(ACCOUNT);
|
||||
const haystack = logs.join("\n");
|
||||
expect(haystack).toContain("the per-identity cache write failed");
|
||||
expect(haystack).toContain("seeding the company default home failed");
|
||||
expect(haystack).not.toContain(TOKEN_SENTINEL);
|
||||
expect(haystack).not.toContain(ACCOUNT);
|
||||
});
|
||||
|
|
@ -324,7 +521,7 @@ describe("device-login credential promotion", () => {
|
|||
it("a promotion whose session is no longer the sole active owner writes nothing", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
|
|
@ -334,17 +531,17 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(outcome).toBe("not_sole_owner");
|
||||
expect(result.outcome).toBe("not_sole_owner");
|
||||
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
|
||||
await expect(
|
||||
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("an automatic background login never seeds an empty company slot", async () => {
|
||||
it("an automatic background login never seeds an empty home", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const outcome = await promoteDeviceLoginCredential({
|
||||
const result = await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
|
||||
companyId: COMPANY_A,
|
||||
// Not a user-initiated login.
|
||||
|
|
@ -354,7 +551,7 @@ describe("device-login credential promotion", () => {
|
|||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(outcome).toBe("background_skipped");
|
||||
expect(result.outcome).toBe("background_skipped");
|
||||
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
|
||||
await expect(
|
||||
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { toAccountHandle } from "@paperclipai/shared";
|
||||
import {
|
||||
ensureCodexAuthCacheEntryDir,
|
||||
isCodexAuthCacheEnabled,
|
||||
ensureCodexAuthCacheEntryDirExclusive,
|
||||
readSubscriptionAccountId,
|
||||
writeCodexAuthCacheEntry,
|
||||
} from "./codex-auth-cache.js";
|
||||
import { writeCredentialSeedOrNewer } from "./codex-auth-seed-write.js";
|
||||
import { codexHomeHasUsableAuth, resolveManagedCodexHomeDir } from "./codex-home.js";
|
||||
|
|
@ -18,13 +17,18 @@ import { assertUsableSubscriptionShape } from "./device-login-export.js";
|
|||
//
|
||||
// The helper never writes the instance-global host (`CODEX_HOME` or `~/.codex`),
|
||||
// because that source has no `companyId`. It writes two company-scoped targets:
|
||||
// the company credential home and the company per-identity cache slot. The
|
||||
// company home write is the first-login host rule: the cache vend never seeds an
|
||||
// empty host, so a first login must seed the company home directly.
|
||||
// this account's own home, and — only as a fallback for an agent with no bound
|
||||
// secret — the company default home.
|
||||
//
|
||||
// This account's own home is the durable result of a login: the write is fail
|
||||
// loud, and the caller names it with a company secret so any agent can bind to
|
||||
// it. The company default home is a best-effort fallback: it is seeded only the
|
||||
// first time any account logs in for the company (while it holds no usable
|
||||
// credential yet), and a write failure there never fails the login.
|
||||
//
|
||||
// Two decisions gate the write:
|
||||
// - Decision C: only a user-initiated login seeds the company slot. An
|
||||
// automatic background path never seeds an empty slot.
|
||||
// - Decision C: only a user-initiated login seeds a home. An automatic
|
||||
// background path never seeds one.
|
||||
// - Decision H: the helper writes only while the session still holds the sole
|
||||
// active claim on `(company_id, adapter_type)`. This is defense in depth over
|
||||
// the partial unique index, so a second session cannot race the same slot.
|
||||
|
|
@ -93,30 +97,45 @@ export async function checkStagedCredentialReadiness(
|
|||
/**
|
||||
* The promotion outcome.
|
||||
*
|
||||
* - `promoted`: the helper wrote the company home (a seed or a strictly-newer
|
||||
* same-identity update). The helper then tries the per-identity cache slot when
|
||||
* the cache is on. The cache write is best-effort: a cache failure keeps the
|
||||
* `promoted` outcome, because the company home is already durable.
|
||||
* - `kept`: the login carried the SAME identity as the company home, and the home
|
||||
* already held a newer same-identity credential, so the home was kept. This is a
|
||||
* successful authentication: a later run vends and uses the same account. The
|
||||
* helper still tries the best-effort cache write.
|
||||
* - `kept_foreign_identity`: the login carried a DIFFERENT identity than the
|
||||
* company home. The helper never clobbers an occupied home, so it kept the other
|
||||
* account and installed nothing durable for this login. The identity-anchored
|
||||
* vend reads the home identity first, so a later run can never select this
|
||||
* login. This is NOT a successful authentication; the caller must fail the
|
||||
* session instead of a report of `authenticated`.
|
||||
* - `promoted`: the helper wrote this account's own home (a seed for a first
|
||||
* login of this account, or a strictly-newer update for a repeat login). The
|
||||
* helper also seeds the company default home when it holds no usable
|
||||
* credential yet.
|
||||
* - `kept`: the login carried a credential that is not newer than what this
|
||||
* account's own home already holds, so the home was kept as-is. This is
|
||||
* still a successful authentication: a later run reads the same home.
|
||||
* - `not_sole_owner`: Decision H rejected the write. Nothing was written.
|
||||
* - `background_skipped`: Decision C rejected the write (an automatic background
|
||||
* path never seeds a company slot). Nothing was written.
|
||||
* path never seeds a home). Nothing was written.
|
||||
*/
|
||||
export type PromoteDeviceLoginCredentialOutcome =
|
||||
| "promoted"
|
||||
| "kept"
|
||||
| "kept_foreign_identity"
|
||||
| "not_sole_owner"
|
||||
| "background_skipped";
|
||||
export type PromoteDeviceLoginCredentialOutcome = "promoted" | "kept" | "not_sole_owner" | "background_skipped";
|
||||
|
||||
/**
|
||||
* The promotion result. `accountId` and `accountHomeDir` are set once the
|
||||
* credential's identity is known — every outcome below the handle-validation
|
||||
* step carries `accountId`; only `promoted` and `kept` reach the write step and
|
||||
* carry `accountHomeDir`.
|
||||
*
|
||||
* `accountHomeCreated` is true only when this account's own home directory was
|
||||
* absent right before this call created it. The check and the creation run
|
||||
* under one lock, so a concurrent promotion for the same account (a second
|
||||
* login for the same Codex account) never also reports `created: true` for a
|
||||
* directory the first call already made. A caller that must undo a later
|
||||
* failure (for example, a failed secret write) should remove the directory
|
||||
* only when `accountHomeCreated` is true, and should still re-check that no
|
||||
* company secret now names this account's home before it deletes: the
|
||||
* absence of a company secret at the time this call started is not proof
|
||||
* that this login is the only login that used the directory. A user can also
|
||||
* delete the secret and keep the account home, so a repeat login then reads
|
||||
* no secret. `accountHomeCreated` is false for every outcome that does not
|
||||
* reach the write step (`not_sole_owner`, `background_skipped`).
|
||||
*/
|
||||
export interface PromoteDeviceLoginCredentialResult {
|
||||
outcome: PromoteDeviceLoginCredentialOutcome;
|
||||
accountId: string | null;
|
||||
accountHomeDir: string | null;
|
||||
accountHomeCreated: boolean;
|
||||
}
|
||||
|
||||
export interface PromoteDeviceLoginCredentialInput {
|
||||
/** The exact staged credential bytes the login sandbox produced. */
|
||||
|
|
@ -167,13 +186,14 @@ function requireSafeCompanyId(companyId: string): string {
|
|||
|
||||
/**
|
||||
* Promotes a device-login credential into the company scope. The order is fixed:
|
||||
* readiness check, credential validation, Decision C, Decision H, then the writes.
|
||||
* The readiness check and the writes run while the caller still holds the active
|
||||
* claim, so a second session cannot race the same slot.
|
||||
* readiness check, credential validation, account-handle validation, Decision C,
|
||||
* Decision H, then the writes. The readiness check and the writes run while the
|
||||
* caller still holds the active claim, so a second session cannot race the same
|
||||
* slot.
|
||||
*/
|
||||
export async function promoteDeviceLoginCredential(
|
||||
input: PromoteDeviceLoginCredentialInput,
|
||||
): Promise<PromoteDeviceLoginCredentialOutcome> {
|
||||
): Promise<PromoteDeviceLoginCredentialResult> {
|
||||
const { authBytes, userInitiated, checkReadiness, isSoleActiveOwner, log } = input;
|
||||
const env = input.env ?? process.env;
|
||||
const companyId = requireSafeCompanyId(input.companyId);
|
||||
|
|
@ -191,84 +211,92 @@ export async function promoteDeviceLoginCredential(
|
|||
const accountId = readSubscriptionAccountId(authBytes);
|
||||
if (!accountId) {
|
||||
// The shape gate above already guarantees a subscription identity; this guard
|
||||
// keeps the account_id non-null for the cache key without a non-null cast.
|
||||
// keeps the account_id non-null for the handle conversion without a non-null
|
||||
// cast.
|
||||
throw new Error("device-login promotion: the credential has no subscription identity");
|
||||
}
|
||||
|
||||
// 3. Decision C: only a user-initiated login seeds the company slot.
|
||||
// 2b. Convert the identity into a safe account handle. The handle names both
|
||||
// this account's own home directory and its company secret, so a login
|
||||
// whose identity cannot form one must fail before any write.
|
||||
const accountHandle = toAccountHandle(accountId);
|
||||
if (!accountHandle) {
|
||||
throw new Error("device-login promotion: the account identifier cannot form a valid account handle");
|
||||
}
|
||||
|
||||
// 3. Decision C: only a user-initiated login seeds a home.
|
||||
if (!userInitiated) {
|
||||
await log("[paperclip] Codex device-login promotion: skipped (an automatic background login never seeds a company slot).");
|
||||
return "background_skipped";
|
||||
await log("[paperclip] Codex device-login promotion: skipped (an automatic background login never seeds a home).");
|
||||
return { outcome: "background_skipped", accountId, accountHomeDir: null, accountHomeCreated: false };
|
||||
}
|
||||
|
||||
// 4. Decision H: write only while the session still owns the active slot.
|
||||
const soleOwner = await isSoleActiveOwner();
|
||||
if (!soleOwner) {
|
||||
await log("[paperclip] Codex device-login promotion: skipped (the session no longer holds the sole active claim on the slot).");
|
||||
return "not_sole_owner";
|
||||
return { outcome: "not_sole_owner", accountId, accountHomeDir: null, accountHomeCreated: false };
|
||||
}
|
||||
|
||||
// 5a. First-login host rule: seed or update the company credential home. The
|
||||
// shared writer seeds an empty home and applies a strictly-newer
|
||||
// same-identity update; it keeps a newer same-identity or a different
|
||||
// identity. It never touches the instance-global host.
|
||||
const companyHome = resolveManagedCodexHomeDir(env, companyId);
|
||||
await mkdir(companyHome, { recursive: true, mode: PRIVATE_DIR_MODE });
|
||||
const companyHomeAuthPath = path.join(companyHome, AUTH_FILE_NAME);
|
||||
const homeOutcome = await writeCredentialSeedOrNewer({
|
||||
// 5a. This account's own home is the durable result of a login: each account
|
||||
// handle addresses exactly one home, so this write can never collide with
|
||||
// a different identity, and a write failure here fails the whole
|
||||
// promotion (fail loud, unlike the company default home fallback below).
|
||||
// Two different logins can promote the same account at the same time
|
||||
// (each login owns its own promotion slot), so the absence check and the
|
||||
// directory creation run inside one lock: only the caller that truly
|
||||
// finds the directory absent gets `created: true`. A plain
|
||||
// check-then-create sequence here would let two concurrent callers for
|
||||
// the same account both see the directory as absent and both believe
|
||||
// they created it.
|
||||
const { entryPath: accountHomeAuthPath, created: accountHomeCreated } =
|
||||
await ensureCodexAuthCacheEntryDirExclusive(env, accountHandle, companyId);
|
||||
const accountHomeDir = path.dirname(accountHomeAuthPath);
|
||||
const accountHomeOutcome = await writeCredentialSeedOrNewer({
|
||||
sourceBytes: authBytes,
|
||||
destinationPath: companyHomeAuthPath,
|
||||
destinationPath: accountHomeAuthPath,
|
||||
seedIfDestAbsent: true,
|
||||
log,
|
||||
writtenLine: "[paperclip] Codex device-login promotion: wrote the company credential home at mode 0600.",
|
||||
keptLine: "[paperclip] Codex device-login promotion: kept the company credential home (the login is not a seed or a strictly-newer same-identity credential).",
|
||||
tempPrefix: "auth.json.promotion-home",
|
||||
writtenLine: "[paperclip] Codex device-login promotion: wrote this account's own home at mode 0600.",
|
||||
keptLine: "[paperclip] Codex device-login promotion: kept this account's own home (the login is not a seed or a strictly-newer credential).",
|
||||
tempPrefix: "auth.json.promotion-account-home",
|
||||
errorLabel: "codex device-login promotion",
|
||||
env,
|
||||
});
|
||||
|
||||
// A kept home has two very different meanings. The writer keeps the home when
|
||||
// it already holds a newer SAME-identity credential (a genuine success: a later
|
||||
// run vends and uses the same account), and it also keeps the home when the home
|
||||
// holds a DIFFERENT identity (the writer never clobbers an occupied home). The
|
||||
// second case installed nothing durable for this login: the home still holds the
|
||||
// other account, and the identity-anchored vend reads the home identity first,
|
||||
// so it can never select this login. Compare the login identity with the kept
|
||||
// home identity, so the caller can fail the session instead of a report of
|
||||
// `authenticated`. A home that this helper cannot read as the same identity is
|
||||
// treated as a foreign identity; the caller fails closed.
|
||||
let foreignIdentityKeep = false;
|
||||
if (homeOutcome === "kept") {
|
||||
const homeBytes = await readFile(companyHomeAuthPath).catch(() => null);
|
||||
const homeAccountId = homeBytes ? readSubscriptionAccountId(homeBytes) : null;
|
||||
foreignIdentityKeep = homeAccountId !== accountId;
|
||||
}
|
||||
|
||||
// 5b. Record the credential in its per-identity company cache slot, so a later
|
||||
// run can vend a strictly-newer copy. The cache write respects the
|
||||
// off-switch. It is company-scoped and per identity, so it never crosses a
|
||||
// company boundary and never clobbers a different identity.
|
||||
//
|
||||
// The company home write above already made the credential durable, so a
|
||||
// later run authenticates from the home even when the cache slot is absent.
|
||||
// The cache is only a vend optimization. So a cache write failure (a
|
||||
// permission error, a full disk, or a lock timeout) must not fail the
|
||||
// promotion, or the operator sees a failed login for a credential that is
|
||||
// already usable. Log the failure and keep the home outcome; a later run
|
||||
// re-seeds the cache slot from the durable home.
|
||||
if (isCodexAuthCacheEnabled(env)) {
|
||||
// 5b. Company default home fallback, for an agent with no bound secret. Seed
|
||||
// it only the first time any account logs in for the company, i.e. only
|
||||
// while it holds no usable credential yet; a login for a second account
|
||||
// must never touch it once some account has claimed it. This write is
|
||||
// best-effort: this account's own home above is already durable, so a
|
||||
// failure here (a permission error, a full disk, a lock timeout) must not
|
||||
// fail the promotion.
|
||||
const companyHome = resolveManagedCodexHomeDir(env, companyId);
|
||||
if (!(await codexHomeHasUsableAuth(companyHome))) {
|
||||
try {
|
||||
const cacheEntryPath = await ensureCodexAuthCacheEntryDir(env, accountId, companyId);
|
||||
await writeCodexAuthCacheEntry({ sandboxAuthBytes: authBytes, cacheEntryPath, log, env });
|
||||
await mkdir(companyHome, { recursive: true, mode: PRIVATE_DIR_MODE });
|
||||
const companyHomeAuthPath = path.join(companyHome, AUTH_FILE_NAME);
|
||||
await writeCredentialSeedOrNewer({
|
||||
sourceBytes: authBytes,
|
||||
destinationPath: companyHomeAuthPath,
|
||||
seedIfDestAbsent: true,
|
||||
log,
|
||||
writtenLine: "[paperclip] Codex device-login promotion: seeded the company default home.",
|
||||
keptLine: "[paperclip] Codex device-login promotion: kept the company default home.",
|
||||
tempPrefix: "auth.json.promotion-home",
|
||||
errorLabel: "codex device-login promotion",
|
||||
env,
|
||||
});
|
||||
} catch {
|
||||
await log(
|
||||
"[paperclip] Codex device-login promotion: the per-identity cache write failed; the company credential home is durable, so the login stays successful.",
|
||||
"[paperclip] Codex device-login promotion: seeding the company default home failed; this account's own home is durable, so the login stays successful.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (homeOutcome === "written") {
|
||||
return "promoted";
|
||||
}
|
||||
return foreignIdentityKeep ? "kept_foreign_identity" : "kept";
|
||||
return {
|
||||
outcome: accountHomeOutcome === "written" ? "promoted" : "kept",
|
||||
accountId,
|
||||
accountHomeDir,
|
||||
accountHomeCreated,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertAccountHomeCacheDirStillValid,
|
||||
clearCodexAuthCache,
|
||||
clearCodexAuthCacheEntry,
|
||||
ensureCodexAuthCacheEntryDir,
|
||||
|
|
@ -12,6 +13,8 @@ import {
|
|||
resolveCodexAuthCacheEntryPath,
|
||||
selectVendCredential,
|
||||
toCacheKey,
|
||||
withAccountHomeSecretMutationLock,
|
||||
withCodexAccountHomePromotionLock,
|
||||
} from "./codex-auth-cache.js";
|
||||
import { resolveSharedCodexHomeDir } from "./codex-home.js";
|
||||
|
||||
|
|
@ -127,6 +130,17 @@ describe("codex auth cache store", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("resolveCodexAuthCacheEntryPath rejects an identifier that toAccountHandle rejects", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
// A space, a plus sign, and a leading hyphen all fail the stricter
|
||||
// account-handle allowlist, even though the older denylist alone would
|
||||
// accept some of them.
|
||||
expect(() => resolveCodexAuthCacheEntryPath(env, "acct 42", "company-a")).toThrow();
|
||||
expect(() => resolveCodexAuthCacheEntryPath(env, "acct+42", "company-a")).toThrow();
|
||||
expect(() => resolveCodexAuthCacheEntryPath(env, "-rf", "company-a")).toThrow();
|
||||
});
|
||||
|
||||
it("resolveCodexAuthCacheEntryPath verifies the resolved path stays under the cache root", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
|
|
@ -335,4 +349,215 @@ describe("codex auth cache store", () => {
|
|||
expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "no" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 6: whole-promotion serialization for one company", () => {
|
||||
it("withCodexAccountHomePromotionLock never overlaps two concurrent callers for the same company", async () => {
|
||||
// The device-login route holds this lock across its whole promotion
|
||||
// sequence: the account-home directory decision, the credential write,
|
||||
// and the secret bind or cleanup that follows. Two different logins for
|
||||
// the SAME Codex account must run that whole sequence one at a time, so
|
||||
// a login can never delete the shared account-home directory while
|
||||
// another login's own sequence is still writing its credential or
|
||||
// binding its own secret to that same directory. This proves the lock
|
||||
// itself enforces that: two concurrent callers for one company never run
|
||||
// their callbacks at the same time, whichever caller goes first.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const events: string[] = [];
|
||||
let releaseFirstCaller!: () => void;
|
||||
const firstCallerGate = new Promise<void>((resolve) => {
|
||||
releaseFirstCaller = resolve;
|
||||
});
|
||||
|
||||
const firstCall = withCodexAccountHomePromotionLock(env, "company-shared", async () => {
|
||||
events.push("first-enter");
|
||||
await firstCallerGate;
|
||||
events.push("first-exit");
|
||||
return "first";
|
||||
});
|
||||
// Give the first caller a chance to acquire the lock and enter its
|
||||
// callback before the second caller starts racing for the same lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const secondCall = withCodexAccountHomePromotionLock(env, "company-shared", async () => {
|
||||
events.push("second-enter");
|
||||
events.push("second-exit");
|
||||
return "second";
|
||||
});
|
||||
// The second caller must stay blocked on the lock while the first
|
||||
// caller still holds it: it must never log `second-enter` before the
|
||||
// first caller's `first-exit`.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["first-enter"]);
|
||||
|
||||
releaseFirstCaller();
|
||||
const [first, second] = await Promise.all([firstCall, secondCall]);
|
||||
expect(first).toBe("first");
|
||||
expect(second).toBe("second");
|
||||
expect(events).toEqual(["first-enter", "first-exit", "second-enter", "second-exit"]);
|
||||
});
|
||||
|
||||
it("withCodexAccountHomePromotionLock keeps two different companies independent", async () => {
|
||||
// The lock is per company, so two different companies' device logins
|
||||
// never wait on each other.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const events: string[] = [];
|
||||
let releaseCompanyA!: () => void;
|
||||
const companyAGate = new Promise<void>((resolve) => {
|
||||
releaseCompanyA = resolve;
|
||||
});
|
||||
|
||||
const companyACall = withCodexAccountHomePromotionLock(env, "company-a", async () => {
|
||||
events.push("a-enter");
|
||||
await companyAGate;
|
||||
events.push("a-exit");
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const companyBCall = withCodexAccountHomePromotionLock(env, "company-b", async () => {
|
||||
events.push("b-enter");
|
||||
events.push("b-exit");
|
||||
});
|
||||
await companyBCall;
|
||||
// Company B's callback ran and finished while company A's callback was
|
||||
// still waiting on its own gate, so the two locks never contended.
|
||||
expect(events).toEqual(["a-enter", "b-enter", "b-exit"]);
|
||||
|
||||
releaseCompanyA();
|
||||
await companyACall;
|
||||
expect(events).toEqual(["a-enter", "b-enter", "b-exit", "a-exit"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 7: account-home secret-mutation serialization for one company", () => {
|
||||
it("withAccountHomeSecretMutationLock never overlaps two concurrent callers for the same company", async () => {
|
||||
// The secrets service holds this lock for the whole of a `local_encrypted`
|
||||
// secret's create or rotate call, and an account-home cleanup's claimant
|
||||
// scan holds it for the whole of its final check-and-delete step. This
|
||||
// proves the lock itself enforces mutual exclusion between any two
|
||||
// holders for one company, whichever caller goes first.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const events: string[] = [];
|
||||
let releaseFirstCaller!: () => void;
|
||||
const firstCallerGate = new Promise<void>((resolve) => {
|
||||
releaseFirstCaller = resolve;
|
||||
});
|
||||
|
||||
const firstCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
|
||||
events.push("first-enter");
|
||||
await firstCallerGate;
|
||||
events.push("first-exit");
|
||||
return "first";
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const secondCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
|
||||
events.push("second-enter");
|
||||
events.push("second-exit");
|
||||
return "second";
|
||||
});
|
||||
// The second caller must stay blocked on the lock while the first
|
||||
// caller still holds it: it must never log `second-enter` before the
|
||||
// first caller's `first-exit`.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["first-enter"]);
|
||||
|
||||
releaseFirstCaller();
|
||||
const [first, second] = await Promise.all([firstCall, secondCall]);
|
||||
expect(first).toBe("first");
|
||||
expect(second).toBe("second");
|
||||
expect(events).toEqual(["first-enter", "first-exit", "second-enter", "second-exit"]);
|
||||
});
|
||||
|
||||
it("withAccountHomeSecretMutationLock keeps two different companies independent", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const events: string[] = [];
|
||||
let releaseCompanyA!: () => void;
|
||||
const companyAGate = new Promise<void>((resolve) => {
|
||||
releaseCompanyA = resolve;
|
||||
});
|
||||
|
||||
const companyACall = withAccountHomeSecretMutationLock(env, "company-a", async () => {
|
||||
events.push("a-enter");
|
||||
await companyAGate;
|
||||
events.push("a-exit");
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const companyBCall = withAccountHomeSecretMutationLock(env, "company-b", async () => {
|
||||
events.push("b-enter");
|
||||
events.push("b-exit");
|
||||
});
|
||||
await companyBCall;
|
||||
expect(events).toEqual(["a-enter", "b-enter", "b-exit"]);
|
||||
|
||||
releaseCompanyA();
|
||||
await companyACall;
|
||||
expect(events).toEqual(["a-enter", "b-enter", "b-exit", "a-exit"]);
|
||||
});
|
||||
|
||||
it("withAccountHomeSecretMutationLock does not contend with withCodexAccountHomePromotionLock for the same company", async () => {
|
||||
// The two locks use separate lock directories (Security condition: no
|
||||
// shared lock key), so a device-login promotion that holds
|
||||
// `withCodexAccountHomePromotionLock` for its whole sequence can still
|
||||
// call into a secrets-service write that takes this lock without
|
||||
// deadlocking on itself.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const events: string[] = [];
|
||||
|
||||
await withCodexAccountHomePromotionLock(env, "company-shared", async () => {
|
||||
events.push("promotion-enter");
|
||||
await withAccountHomeSecretMutationLock(env, "company-shared", async () => {
|
||||
events.push("mutation-enter");
|
||||
events.push("mutation-exit");
|
||||
});
|
||||
events.push("promotion-exit");
|
||||
});
|
||||
|
||||
expect(events).toEqual(["promotion-enter", "mutation-enter", "mutation-exit", "promotion-exit"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 8: account-home directory validity before a secret write commits", () => {
|
||||
it("resolves for a value naming a directory that still exists under the cache root", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const companyId = "company-still-exists";
|
||||
const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-still-exists", companyId);
|
||||
const accountHomeDir = path.dirname(entryPath);
|
||||
|
||||
await expect(
|
||||
assertAccountHomeCacheDirStillValid(env, companyId, accountHomeDir),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a value naming a directory under the cache root that no longer exists", async () => {
|
||||
// This is the shape an account-home cleanup leaves behind: a value that
|
||||
// once named a real directory, now removed. A create or a rotate that
|
||||
// is about to commit this exact value must fail instead of writing a
|
||||
// secret that points at nothing.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const companyId = "company-removed";
|
||||
const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-removed", companyId);
|
||||
const accountHomeDir = path.dirname(entryPath);
|
||||
await rm(accountHomeDir, { recursive: true, force: true });
|
||||
|
||||
await expect(assertAccountHomeCacheDirStillValid(env, companyId, accountHomeDir)).rejects.toThrow(
|
||||
/no longer exists/,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a value alone when it does not sit under this company's cache root", async () => {
|
||||
// Most `local_encrypted` secret values (an API key, a token, a
|
||||
// hand-typed string) never sit under the cache root, so this check must
|
||||
// never reject a write that only happens to name a missing path.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
|
||||
await expect(
|
||||
assertAccountHomeCacheDirStillValid(env, "company-unrelated", "/some/unrelated/missing/path"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@ import path from "node:path";
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { toAccountHandle } from "@paperclipai/shared";
|
||||
import { USE_SOURCE_EXIT, decideCodexAuthMerge } from "./codex-auth-merge-decision.js";
|
||||
import { writeCredentialSeedOrNewer } from "./codex-auth-seed-write.js";
|
||||
|
||||
// The identity-keyed host credential cache keeps one usable subscription
|
||||
// credential per identity (`account_id`) in a SEPARATE host store, outside the
|
||||
// shared Codex home and outside the symlink allowlist. The cache is additive: it
|
||||
// The identity-keyed host credential store keeps one usable subscription
|
||||
// credential per identity (`account_id`) in a SEPARATE host tree, outside the
|
||||
// shared Codex home and outside the symlink allowlist. Each identity's entry
|
||||
// directory doubles as that account's addressable home: the device-login
|
||||
// promotion writes the durable credential there, and a company secret names the
|
||||
// directory by path. The store also still backs the identity-anchored vend: it
|
||||
// never changes the copy-back path, the fail-closed decision predicate, or the
|
||||
// host default store overwrite. It only refreshes an identity the host already
|
||||
// holds. It never seeds an empty host store and never picks a credential at
|
||||
// host default store overwrite, it only refreshes an identity the host already
|
||||
// holds, it never seeds an empty host store, and it never picks a credential at
|
||||
// random.
|
||||
|
||||
const CACHE_DIR_NAME = "codex-auth-cache";
|
||||
|
|
@ -110,18 +114,25 @@ export function resolveCodexAuthCacheDir(
|
|||
|
||||
/**
|
||||
* Resolves the entry path for one identity: `<cacheRoot>/<safeAccountId>/auth.json`.
|
||||
* The `account_id` is sanitized by {@link toCacheKey}. After the join, this
|
||||
* verifies the resolved entry path stays under the cache root and ends at exactly
|
||||
* `<safeAccountId>/auth.json`. This function does no filesystem work; it is safe
|
||||
* for a read path (the vend and the clear). (Security condition 3.)
|
||||
* The `account_id` is validated first by {@link toAccountHandle} (a strict
|
||||
* allowlist), the entry point of this function, then sanitized again by
|
||||
* {@link toCacheKey} (a denylist) as a second, independent layer. After the
|
||||
* join, this verifies the resolved entry path stays under the cache root and
|
||||
* ends at exactly `<safeAccountId>/auth.json`. This function does no filesystem
|
||||
* work; it is safe for a read path (the vend and the clear). (Security
|
||||
* condition 3.)
|
||||
*/
|
||||
export function resolveCodexAuthCacheEntryPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId: string,
|
||||
companyId: string,
|
||||
): string {
|
||||
const handle = toAccountHandle(accountId);
|
||||
if (!handle) {
|
||||
throw new Error("codex auth cache: account_id is not a valid account handle");
|
||||
}
|
||||
const resolvedRoot = resolveCodexAuthCacheDir(env, companyId);
|
||||
const safeKey = toCacheKey(accountId);
|
||||
const safeKey = toCacheKey(handle);
|
||||
const entryDir = path.resolve(resolvedRoot, safeKey);
|
||||
const entryPath = path.resolve(entryDir, CACHE_ENTRY_FILE);
|
||||
const expectedEntryPath = path.join(resolvedRoot, safeKey, CACHE_ENTRY_FILE);
|
||||
|
|
@ -156,8 +167,9 @@ async function ensurePrivateDir(dir: string): Promise<void> {
|
|||
|
||||
/**
|
||||
* Resolves the entry path and creates the cache root and the entry directory
|
||||
* private (mode 0700), each guarded by `lstat`. Use this on the write path
|
||||
* before the cache slot is written.
|
||||
* private (mode 0700), each guarded by `lstat`. The `account_id` is validated
|
||||
* at the entry point of this function through {@link resolveCodexAuthCacheEntryPath}.
|
||||
* Use this on the write path before the entry is written.
|
||||
*/
|
||||
export async function ensureCodexAuthCacheEntryDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
|
|
@ -170,11 +182,194 @@ export async function ensureCodexAuthCacheEntryDir(
|
|||
return entryPath;
|
||||
}
|
||||
|
||||
export interface EnsureCodexAuthCacheEntryDirExclusiveResult {
|
||||
/** The entry path: `<accountHomeDir>/auth.json`. */
|
||||
entryPath: string;
|
||||
/** True only when this exact call found the account's own home directory
|
||||
* absent and created it. */
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link ensureCodexAuthCacheEntryDir}, but the absence check and the
|
||||
* directory creation run inside one lock keyed to the company-scoped cache
|
||||
* root. Two different logins for the SAME Codex account can promote at the
|
||||
* same time (each login owns its own promotion slot), so a plain
|
||||
* check-then-create sequence lets both concurrent callers see the slot as
|
||||
* absent and both report `created: true`. Under this lock, only the caller
|
||||
* that truly finds the slot absent gets `created: true`; the other caller
|
||||
* correctly reports `created: false` and so never deletes a home the first
|
||||
* caller's login already wrote to and named with a company secret.
|
||||
*/
|
||||
export async function ensureCodexAuthCacheEntryDirExclusive(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId: string,
|
||||
companyId: string,
|
||||
): Promise<EnsureCodexAuthCacheEntryDirExclusiveResult> {
|
||||
const cacheRoot = resolveCodexAuthCacheDir(env, companyId);
|
||||
await ensurePrivateDir(cacheRoot);
|
||||
return withDirectoryMergeLock(
|
||||
cacheRoot,
|
||||
async () => {
|
||||
const entryPath = resolveCodexAuthCacheEntryPath(env, accountId, companyId);
|
||||
const entryDir = path.dirname(entryPath);
|
||||
const created = await lstat(entryDir)
|
||||
.then(() => false)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return true;
|
||||
throw error;
|
||||
});
|
||||
await ensurePrivateDir(entryDir);
|
||||
return { entryPath, created };
|
||||
},
|
||||
env,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a per-company, named lock target directory under the same
|
||||
* instance-scoped `companies/<companyId>/` tree the cache root and the
|
||||
* promotion lock use. Two callers that pass the same `lockName` for the same
|
||||
* `companyId` always resolve the same directory, so they share one lock; two
|
||||
* different `lockName` values never share a lock key, so a caller that holds
|
||||
* one of these named locks can still call {@link ensureCodexAuthCacheEntryDirExclusive}
|
||||
* (which locks the cache root) or another named lock without nesting a lock
|
||||
* inside itself.
|
||||
*/
|
||||
function resolveCodexAuthCacheNamedLockDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
companyId: string,
|
||||
lockName: string,
|
||||
): string {
|
||||
const safeCompanyId = toSafePathSegment(companyId, "companyId");
|
||||
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
|
||||
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
|
||||
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
||||
env,
|
||||
});
|
||||
return path.resolve(instanceRoot, "companies", safeCompanyId, lockName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes one company's whole device-login promotion sequence: the
|
||||
* account-home directory create-or-reuse decision, the credential write, and
|
||||
* the secret bind or cleanup that follows. Two different logins for the SAME
|
||||
* Codex account run this sequence one at a time under this lock, so no login
|
||||
* can decide to delete the shared account-home directory while another
|
||||
* login's flow is still mid-way through writing its credential or binding
|
||||
* its own secret to that same directory.
|
||||
*
|
||||
* `ensureCodexAuthCacheEntryDirExclusive` alone is not enough: it locks only
|
||||
* the short directory-creation step, so its lock is already released by the
|
||||
* time a login reaches the secret bind. A second login can then write its
|
||||
* credential and be about to bind its own secret while the first login's
|
||||
* later, unrelated secret-write failure decides to remove the directory both
|
||||
* logins now share, deleting a credential home the second login still needs.
|
||||
* Holding this lock across the full sequence for every login closes that
|
||||
* window: a login that must clean up its own directory always finishes that
|
||||
* cleanup, including the directory-recreation of any later login that lands
|
||||
* on the now-empty slot, before the next login's sequence starts.
|
||||
*/
|
||||
export async function withCodexAccountHomePromotionLock<T>(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
companyId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const lockDir = resolveCodexAuthCacheNamedLockDir(env, companyId, "codex-auth-cache-promotion-lock");
|
||||
await ensurePrivateDir(lockDir);
|
||||
return withDirectoryMergeLock(lockDir, fn, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes one company's writes to the literal value of any
|
||||
* `local_encrypted` company secret against the account-home cleanup's
|
||||
* claimant scan in {@link withCodexAccountHomePromotionLock}'s caller. Only a
|
||||
* `local_encrypted` secret can hold a literal directory path, so this is the
|
||||
* one provider a cleanup claimant scan must trust. Without this lock, a
|
||||
* secret create or rotation can commit its new value after the scan's last
|
||||
* pass finds no claimant but before the cleanup deletes the directory: the
|
||||
* scan and the write never observe each other, so the write's new value
|
||||
* survives past a directory the delete has already removed.
|
||||
*
|
||||
* The secrets service holds this lock for the full duration of a
|
||||
* `local_encrypted` secret's create or rotate call. A cleanup claimant scan
|
||||
* holds it for the full duration of its final check-and-delete step. The two
|
||||
* critical sections can then never interleave: a write that starts first
|
||||
* finishes (and becomes visible to the scan) before the scan's lock-holding
|
||||
* check runs, and a write that starts after the scan's lock-holding check
|
||||
* finishes only commits once the scan (and any delete it decided on) is
|
||||
* done, so it can never name a directory the delete just removed without the
|
||||
* scan having had a chance to see it first.
|
||||
*
|
||||
* This is a separate lock directory from
|
||||
* {@link withCodexAccountHomePromotionLock}'s, so a device-login promotion
|
||||
* (which holds that lock for its whole sequence, including its own secret
|
||||
* create) can still call into a secrets-service write that takes this lock
|
||||
* without deadlocking on itself.
|
||||
*/
|
||||
export async function withAccountHomeSecretMutationLock<T>(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
companyId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const lockDir = resolveCodexAuthCacheNamedLockDir(env, companyId, "codex-account-home-secret-mutation-lock");
|
||||
await ensurePrivateDir(lockDir);
|
||||
return withDirectoryMergeLock(lockDir, fn, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a `local_encrypted` secret value that names a directory under this
|
||||
* company's Codex account-home cache root still exists on disk. Call this
|
||||
* inside {@link withAccountHomeSecretMutationLock}, before a create or a
|
||||
* rotate commits the value.
|
||||
*
|
||||
* The lock alone stops a write and the account-home cleanup's
|
||||
* check-and-delete from interleaving; it does not stop them from running in
|
||||
* either order. When the cleanup's check-and-delete runs first — it finds no
|
||||
* secret names the directory, because this write had not committed yet, and
|
||||
* removes the directory — a write that was only queued behind the lock still
|
||||
* goes on, once the lock frees, to commit the very directory the cleanup
|
||||
* just removed. That would leave an active secret naming a directory that
|
||||
* does not exist. This check closes that window: a write whose value would
|
||||
* name a directory the cleanup already removed fails instead of committing.
|
||||
*
|
||||
* A value outside this company's cache root is left alone. Only a value
|
||||
* under the cache root can ever be an account-home directory, so this never
|
||||
* rejects an unrelated `local_encrypted` secret write, such as an API key or
|
||||
* a hand-typed token.
|
||||
*/
|
||||
export async function assertAccountHomeCacheDirStillValid(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
companyId: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const cacheRoot = resolveCodexAuthCacheDir(env, companyId);
|
||||
if (!value.startsWith(cacheRoot + path.sep)) return;
|
||||
const exists = await lstat(value)
|
||||
.then(() => true)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
});
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
"codex auth cache: account-home directory no longer exists; refusing to write a secret that names it",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the usable subscription `account_id` from an `auth.json` payload. Returns
|
||||
* `null` for an absent, unusable, or api-key credential (no subscription
|
||||
* identity). This mirrors `parseAuth` in `codex-auth-merge-decision.cjs`; keep
|
||||
* the two in step when the auth format changes.
|
||||
*
|
||||
* The returned value is the exact, untrimmed `account_id` string. A caller
|
||||
* passes it on to {@link toAccountHandle} unchanged: that function is the
|
||||
* one place that decides whether surrounding whitespace is acceptable, and it
|
||||
* must see the raw value to reject an identifier that differs from another
|
||||
* one only by whitespace. Trimming here, before that check runs, would let
|
||||
* two distinct identifiers collapse onto the same account handle.
|
||||
*/
|
||||
export function readSubscriptionAccountId(bytes: Buffer): string | null {
|
||||
let parsed: unknown;
|
||||
|
|
@ -196,7 +391,10 @@ export function readSubscriptionAccountId(bytes: Buffer): string | null {
|
|||
return null;
|
||||
}
|
||||
const tokenRecord = tokens as Record<string, unknown>;
|
||||
const accountId = typeof tokenRecord.account_id === "string" ? tokenRecord.account_id.trim() : "";
|
||||
const rawAccountId = typeof tokenRecord.account_id === "string" ? tokenRecord.account_id : "";
|
||||
// A blank-or-whitespace-only value is absent; a value with real content
|
||||
// keeps its exact, untrimmed form.
|
||||
const accountId = rawAccountId.trim().length > 0 ? rawAccountId : "";
|
||||
const hasTokenMaterial = ["id_token", "access_token", "refresh_token"].some((key) => {
|
||||
const value = tokenRecord[key];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,14 @@ export {
|
|||
type CredentialReadinessResult,
|
||||
type PromoteDeviceLoginCredentialInput,
|
||||
type PromoteDeviceLoginCredentialOutcome,
|
||||
type PromoteDeviceLoginCredentialResult,
|
||||
} from "./adapter-auth-promotion.js";
|
||||
export {
|
||||
withCodexAccountHomePromotionLock,
|
||||
withAccountHomeSecretMutationLock,
|
||||
assertAccountHomeCacheDirStillValid,
|
||||
resolveCodexAuthCacheDir,
|
||||
} from "./codex-auth-cache.js";
|
||||
export { parseCodexJsonl, isCodexHarnessCrash, isCodexProviderQuotaError, isCodexTransientUpstreamError, isCodexUnknownSessionError } from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
|
|
|
|||
|
|
@ -2806,7 +2806,13 @@ describe("Daytona sandbox provider plugin", () => {
|
|||
],
|
||||
});
|
||||
// Let syncIn register on the activity gate and reach the hung upload.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// The real `fs.stat` and `mkdir` round trip run before the upload call,
|
||||
// so a fixed tick count can race ahead of them on a slower or busier
|
||||
// host. Poll for the actual upload call instead of guessing a tick
|
||||
// count, so this assertion never fires before syncIn reaches the hang.
|
||||
await vi.waitFor(() => {
|
||||
expect(sandbox.fs.uploadFiles).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const cancelPromise = plugin.definition.onEnvironmentCancelInteractiveSetup?.({
|
||||
driverKey: "daytona",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ACCOUNT_HANDLE_MAX_LENGTH, toAccountHandle } from "./account-handle.js";
|
||||
import { createUserSecretDefinitionSchema } from "./validators/secret.js";
|
||||
|
||||
describe("toAccountHandle", () => {
|
||||
it("returns the value for a plain identifier", () => {
|
||||
expect(toAccountHandle("acct-42")).toBe("acct-42");
|
||||
});
|
||||
|
||||
it("returns null for a value that holds a space, such as \"a b\"", () => {
|
||||
expect(toAccountHandle("a b")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a value with surrounding whitespace instead of trimming it", () => {
|
||||
// Trimming a value before validation would let " acct-42" and "acct-42"
|
||||
// resolve to the same handle. Two distinct identifiers must never share
|
||||
// one handle, so surrounding whitespace is a rejection, not something to
|
||||
// strip.
|
||||
expect(toAccountHandle(" acct-42")).toBeNull();
|
||||
expect(toAccountHandle("acct-42 ")).toBeNull();
|
||||
expect(toAccountHandle(" acct-42 ")).toBeNull();
|
||||
expect(toAccountHandle("\tacct-42\n")).toBeNull();
|
||||
// The plain identifier with no surrounding whitespace still passes.
|
||||
expect(toAccountHandle("acct-42")).toBe("acct-42");
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(toAccountHandle("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a value that holds a plus sign, such as \"a+b\"", () => {
|
||||
expect(toAccountHandle("a+b")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a value that holds a colon, such as \"a:b\"", () => {
|
||||
expect(toAccountHandle("a:b")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a value longer than 100 characters", () => {
|
||||
const tooLong = "a".repeat(ACCOUNT_HANDLE_MAX_LENGTH + 1);
|
||||
expect(toAccountHandle(tooLong)).toBeNull();
|
||||
const atLimit = "a".repeat(ACCOUNT_HANDLE_MAX_LENGTH);
|
||||
expect(toAccountHandle(atLimit)).toBe(atLimit);
|
||||
});
|
||||
|
||||
it("returns null for \".\" and for \"..\"", () => {
|
||||
expect(toAccountHandle(".")).toBeNull();
|
||||
expect(toAccountHandle("..")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a value that starts with a hyphen, such as \"-rf\"", () => {
|
||||
expect(toAccountHandle("-rf")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a value that holds a shell metacharacter, such as \"$(id)\"", () => {
|
||||
expect(toAccountHandle("$(id)")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a value that holds a path separator or a NUL byte", () => {
|
||||
expect(toAccountHandle("a/b")).toBeNull();
|
||||
expect(toAccountHandle("a\\b")).toBeNull();
|
||||
expect(toAccountHandle("a\0b")).toBeNull();
|
||||
});
|
||||
|
||||
it("every accepted handle also passes the secret key schema", () => {
|
||||
const candidates = ["acct-42", "acct_42", "ACCT.42", "a".repeat(ACCOUNT_HANDLE_MAX_LENGTH)];
|
||||
for (const candidate of candidates) {
|
||||
const handle = toAccountHandle(candidate);
|
||||
expect(handle).not.toBeNull();
|
||||
expect(createUserSecretDefinitionSchema.shape.key.safeParse(handle).success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// A canonical account handle names one login account inside one company. Two
|
||||
// downstream sinks each guard the raw account identifier with a different
|
||||
// validator: a directory-segment denylist, and the secret-key allowlist
|
||||
// (`packages/shared/src/validators/secret.ts`). `toAccountHandle` accepts a
|
||||
// value only when it satisfies both, so one handle can safely name both a
|
||||
// directory and a secret.
|
||||
|
||||
/** The longest accepted account handle. The secret key limit is 120
|
||||
* characters, and the longest name prefix a caller adds is `CODEX_HOME_` at
|
||||
* 11 characters, so 100 leaves headroom. */
|
||||
export const ACCOUNT_HANDLE_MAX_LENGTH = 100;
|
||||
|
||||
// The same character class the secret key schema uses.
|
||||
const ACCOUNT_HANDLE_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* Converts a raw account identifier into a safe account handle. Returns
|
||||
* `null` when the value cannot become one instead of throwing, so a caller
|
||||
* decides how to fail the operation.
|
||||
*
|
||||
* The identifier is the account's identity key: it names one directory and
|
||||
* one secret, so two distinct identifiers must never resolve to the same
|
||||
* handle. A caller must never normalize the value before this check (for
|
||||
* example, by trimming it) — normalizing first can alias a distinct
|
||||
* identifier onto an already-claimed handle and let a login accept a
|
||||
* different account's credential home as its own.
|
||||
*
|
||||
* An input becomes a handle only when every rule below is true:
|
||||
*
|
||||
* - It carries no leading or trailing whitespace.
|
||||
* - It matches `/^[a-zA-Z0-9_.-]+$/`.
|
||||
* - It is {@link ACCOUNT_HANDLE_MAX_LENGTH} characters or shorter.
|
||||
* - It is neither `.` nor `..`.
|
||||
* - It does not start with `-` (a leading `-` reads as a command-line option).
|
||||
*/
|
||||
export function toAccountHandle(rawAccountId: string): string | null {
|
||||
if (typeof rawAccountId !== "string" || rawAccountId.length === 0) return null;
|
||||
if (rawAccountId.trim() !== rawAccountId) return null;
|
||||
if (!ACCOUNT_HANDLE_PATTERN.test(rawAccountId)) return null;
|
||||
if (rawAccountId.length > ACCOUNT_HANDLE_MAX_LENGTH) return null;
|
||||
if (rawAccountId === "." || rawAccountId === "..") return null;
|
||||
if (rawAccountId.startsWith("-")) return null;
|
||||
return rawAccountId;
|
||||
}
|
||||
|
|
@ -2726,3 +2726,4 @@ export {
|
|||
isPaperclipDevRunnerCommand,
|
||||
rewriteUrlHostToLoopback,
|
||||
} from "./runtime-exposure/loopback-bind.js";
|
||||
export { ACCOUNT_HANDLE_MAX_LENGTH, toAccountHandle } from "./account-handle.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
--- a/dist/acp-agent.js
|
||||
+++ b/dist/acp-agent.js
|
||||
@@ -2917,10 +2917,20 @@
|
||||
cost: {
|
||||
amount: message.total_cost_usd,
|
||||
currency: "USD",
|
||||
},
|
||||
- ...(message.origin && {
|
||||
- _meta: { "_claude/origin": message.origin },
|
||||
- }),
|
||||
+ _meta: {
|
||||
+ ...(message.origin && { "_claude/origin": message.origin }),
|
||||
+ // ACP's core Usage shape exposes context occupancy and
|
||||
+ // cost, but not the billable input/output split. Keep the
|
||||
+ // SDK's bounded aggregate in extension metadata so ACPX can
|
||||
+ // normalize it without persisting a raw provider message.
|
||||
+ usage: {
|
||||
+ input_tokens: message.usage.input_tokens,
|
||||
+ output_tokens: message.usage.output_tokens,
|
||||
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
|
||||
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
|
||||
+ },
|
||||
+ },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5307,8 +5317,12 @@
|
||||
const options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
...(thinking !== undefined && { thinking }),
|
||||
...userProvidedOptions,
|
||||
+ // Paperclip Runner owns the complete session context. Its isolated
|
||||
+ // user root contains only assigned skills; project/local settings
|
||||
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
|
||||
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
|
||||
...(settings && { settings }),
|
||||
env,
|
||||
// Override certain fields that must be controlled by ACP
|
||||
@@ -5317,7 +5331,9 @@
|
||||
includePartialMessages: true,
|
||||
forwardSubagentText,
|
||||
mcpServers: {
|
||||
- ...(userProvidedOptions?.mcpServers || {}),
|
||||
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
|
||||
+ ? {}
|
||||
+ : (userProvidedOptions?.mcpServers || {})),
|
||||
...mcpServers,
|
||||
...(fileChangeAuditSupport
|
||||
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
|
||||
|
|
@ -20,4 +20,5 @@ patchedDependencies:
|
|||
acpx@0.12.0: patches/acpx@0.12.0.patch
|
||||
acpx@0.13.1: patches/acpx@0.13.1.patch
|
||||
'@agentclientprotocol/claude-agent-acp@0.70.0': patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch
|
||||
'@agentclientprotocol/claude-agent-acp@0.73.0': patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
|
||||
'@agentclientprotocol/codex-acp@1.6.2': patches/@agentclientprotocol__codex-acp@1.6.2.patch
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ describe("adapter model listing", () => {
|
|||
// Newer flagship models are offered, but Opus 4.8 stays the default (first) option.
|
||||
expect(models[0]?.id).toBe("claude-opus-4-8");
|
||||
expect(models.some((model) => model.id === "claude-sonnet-5")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-mythos-5")).toBe(true);
|
||||
// Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable.
|
||||
|
|
@ -130,6 +131,36 @@ describe("adapter model listing", () => {
|
|||
expect(models).toEqual(claudeFallbackModels);
|
||||
});
|
||||
|
||||
it("does not duplicate claude-fable-5-1 when discovery returns the identical ID", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "claude-fable-5-1", display_name: "Claude Fable 5.1" }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const models = await listAdapterModels("claude_local");
|
||||
|
||||
expect(models.filter((model) => model.id === "claude-fable-5-1")).toHaveLength(1);
|
||||
// Curated fallbacks discovery did not return are still merged in.
|
||||
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes the Bedrock-native Fable 5.1 ID (never the direct ID) in Bedrock mode", async () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const models = await listAdapterModels("claude_local");
|
||||
|
||||
// The Bedrock default (first entry) is unchanged.
|
||||
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8-v1");
|
||||
expect(models.some((model) => model.id === "us.anthropic.claude-fable-5-1")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads codex models dynamically and merges fallback options", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test";
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { lstat, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
|
||||
import { AdapterAuthSessionConflictError } from "../services/device-login-service.js";
|
||||
import type {
|
||||
AdapterAuthSessionRow,
|
||||
|
|
@ -68,6 +72,26 @@ const mockSecretService = vi.hoisted(() => ({
|
|||
secretKeys: new Set<string>(),
|
||||
manifest: [],
|
||||
})),
|
||||
// The account-home secret lookup and creation. Absent by default, so a
|
||||
// successful promotion creates it; a test overrides either to drive the
|
||||
// idempotent-repeat-login and secret-creation-failure paths.
|
||||
getByName: vi.fn(async (_companyId: string, _name: string) => null as Record<string, unknown> | null),
|
||||
create: vi.fn(async (_companyId: string, _input: Record<string, unknown>) => ({ id: "secret-1" })),
|
||||
// The company-wide secret listing the cleanup-safety scan reads. Empty by
|
||||
// default, so a cleanup that finds no other claimant removes the
|
||||
// directory; a test overrides it with a differently named secret to prove
|
||||
// the scan finds a claimant a name-only check would miss.
|
||||
list: vi.fn(
|
||||
async (_companyId: string) => [] as Array<{ id: string; name: string; provider: string }>,
|
||||
),
|
||||
// The pre-existing-secret value check. Defaults to a value that never
|
||||
// matches an account home, so a test that leaves this unset and still
|
||||
// reaches an existing-secret branch fails loud instead of passing by
|
||||
// accident; a test overrides it to the account home under test.
|
||||
resolveSecretValueForDeviceLoginCheck: vi.fn(
|
||||
async (_companyId: string, _secretId: string, _context: { configPath: string }) =>
|
||||
"/unset-mock-value" as string,
|
||||
),
|
||||
}));
|
||||
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
|
|
@ -341,10 +365,20 @@ const loginPath = (companyId: string, type = "codex_local") =>
|
|||
`/api/companies/${companyId}/adapters/${type}/login-sessions`;
|
||||
|
||||
describe("adapter device-login routes", () => {
|
||||
// Real temp directories a test plants as an account home, cleaned up in
|
||||
// `afterEach` whether or not the route removed them itself.
|
||||
const accountHomeTestDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockDeviceLoginPromotion.mockResolvedValue("promoted");
|
||||
mockDeviceLoginPromotion.mockResolvedValue({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-default",
|
||||
accountHomeDir: "/tmp/paperclip-codex-account-home/acct-default",
|
||||
});
|
||||
mockSecretService.getByName.mockResolvedValue(null);
|
||||
mockSecretService.create.mockResolvedValue({ id: "secret-1" });
|
||||
harness.store = createMemoryStore();
|
||||
harness.runtime = createFakeRuntime();
|
||||
harness.acquisitions = [];
|
||||
|
|
@ -376,9 +410,14 @@ describe("adapter device-login routes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
// Release the gate, so every in-flight login run ends and clears its timer.
|
||||
harness.releaseGate();
|
||||
while (accountHomeTestDirs.length > 0) {
|
||||
const dir = accountHomeTestDirs.pop();
|
||||
if (!dir) continue;
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a board actor and rejects an agent-token start", async () => {
|
||||
|
|
@ -726,6 +765,12 @@ describe("adapter device-login routes", () => {
|
|||
});
|
||||
|
||||
it("lets a second owner start an active login in the same company", async () => {
|
||||
// Both logins bind the default account home, and the login service
|
||||
// reconfirms the bound secret's value a second time right before it
|
||||
// commits the terminal `authenticated` state.
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
|
||||
"/tmp/paperclip-codex-account-home/acct-default",
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const first = await request(app)
|
||||
|
|
@ -744,9 +789,31 @@ describe("adapter device-login routes", () => {
|
|||
expect(second.body.sessionId).not.toBe(first.body.sessionId);
|
||||
// Each owner's start acquires its own lease.
|
||||
expect(harness.acquisitions).toHaveLength(2);
|
||||
// Drain both sessions to a terminal status before the test ends. Neither
|
||||
// session's promotion has run yet (both still wait on the gate), so an
|
||||
// undrained session keeps running after this test returns and can consume
|
||||
// a later test's own mocked promotion result. A status read is owner
|
||||
// scoped, so read each session back as the owner that started it.
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
currentActor = boardActor(OWNER_A);
|
||||
const firstStatus = await request(app).get(`${loginPath(COMPANY_1)}/${first.body.sessionId}`);
|
||||
expect(firstStatus.body.status).toBe("authenticated");
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
currentActor = boardActor(OWNER_B);
|
||||
const secondStatus = await request(app).get(`${loginPath(COMPANY_1)}/${second.body.sessionId}`);
|
||||
expect(secondStatus.body.status).toBe("authenticated");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 409 for a second active start in a different environment", async () => {
|
||||
// The login binds the default account home, and the login service
|
||||
// reconfirms the bound secret's value a second time right before it
|
||||
// commits the terminal `authenticated` state.
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
|
||||
"/tmp/paperclip-codex-account-home/acct-default",
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const first = await request(app)
|
||||
|
|
@ -761,12 +828,24 @@ describe("adapter device-login routes", () => {
|
|||
.send({ environmentId: SANDBOX_ENV_2 });
|
||||
expect(second.status, JSON.stringify(second.body)).toBe(409);
|
||||
expect(harness.acquisitions).toHaveLength(1);
|
||||
// Drain the first session to a terminal status before the test ends. An
|
||||
// undrained session keeps running after this test returns and can consume
|
||||
// a later test's own mocked promotion result.
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${first.body.sessionId}`);
|
||||
expect(status.body.status).toBe("authenticated");
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when promotion loses the sole-owner claim", async () => {
|
||||
// This is the expiry/reaper-race result from Decision H. It is a normal
|
||||
// resolved adapter outcome, but it must never be accepted as authentication.
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce("not_sole_owner");
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "not_sole_owner",
|
||||
accountId: null,
|
||||
accountHomeDir: null,
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
|
|
@ -790,12 +869,176 @@ describe("adapter device-login routes", () => {
|
|||
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed when the login is a different account than the company home", async () => {
|
||||
// The promotion keeps the occupied company home and installs nothing durable
|
||||
// for a different-identity login. The identity-anchored vend can never select
|
||||
// this login, so a later run keeps the existing account. The route must fail
|
||||
// the session instead of a report of `authenticated`.
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce("kept_foreign_identity");
|
||||
it("a successful Codex login creates the company secret naming the account home", async () => {
|
||||
// A second, different account no longer fails behind an occupied company
|
||||
// home: each account gets its own home, named by a company secret.
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
|
||||
"/tmp/paperclip-codex-account-home/acct-second",
|
||||
);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-second",
|
||||
accountHomeDir: "/tmp/paperclip-codex-account-home/acct-second",
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("authenticated");
|
||||
});
|
||||
|
||||
expect(mockSecretService.getByName).toHaveBeenCalledWith(COMPANY_1, "CODEX_HOME_acct-second");
|
||||
expect(mockSecretService.create).toHaveBeenCalledWith(
|
||||
COMPANY_1,
|
||||
expect.objectContaining({
|
||||
name: "CODEX_HOME_acct-second",
|
||||
value: "/tmp/paperclip-codex-account-home/acct-second",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("a repeat login for the same account creates no second secret", async () => {
|
||||
// The secret already names this account's home, so the route reads it,
|
||||
// confirms the value still matches, and creates nothing new: a repeat
|
||||
// login is idempotent.
|
||||
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
|
||||
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "kept",
|
||||
accountId: "acct-default",
|
||||
accountHomeDir,
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("authenticated");
|
||||
});
|
||||
|
||||
expect(mockSecretService.create).not.toHaveBeenCalled();
|
||||
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledWith(
|
||||
COMPANY_1,
|
||||
"existing-secret",
|
||||
expect.objectContaining({ configPath: "secrets.CODEX_HOME_acct-default" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds the account-home secret mutation lock while checking a pre-existing secret's value", async () => {
|
||||
// A `local_encrypted` secret rotate takes `withAccountHomeSecretMutationLock`
|
||||
// for the whole of its write. The pre-existing-secret check must run
|
||||
// inside the same lock and return right after: otherwise a rotate could
|
||||
// commit a new value in the gap between the check and the login
|
||||
// reporting success, and the login would report success for a value
|
||||
// that no longer names the account's own home. This proves the check
|
||||
// waits for a lock held elsewhere, and only reads the secret's value
|
||||
// once that lock frees.
|
||||
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
|
||||
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "kept",
|
||||
accountId: "acct-default",
|
||||
accountHomeDir,
|
||||
});
|
||||
|
||||
let releaseHeldLock!: () => void;
|
||||
const heldLockGate = new Promise<void>((resolve) => {
|
||||
releaseHeldLock = resolve;
|
||||
});
|
||||
const lockHolder = withAccountHomeSecretMutationLock(undefined, COMPANY_1, () => heldLockGate);
|
||||
// Give the held lock a chance to actually acquire before the login
|
||||
// starts racing for the same lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const app = await createApp();
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
harness.releaseGate();
|
||||
|
||||
// The login's check must stay blocked behind the held lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).not.toHaveBeenCalled();
|
||||
|
||||
releaseHeldLock();
|
||||
await lockHolder;
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("authenticated");
|
||||
});
|
||||
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconfirms the account-home secret a second time immediately before the terminal authenticated commit", async () => {
|
||||
// The early check inside `promote` releases its lock well before the
|
||||
// login service records the terminal `authenticated` state, so it alone
|
||||
// cannot prove the value still matches at that later point. The service
|
||||
// must run the same check again, right before that commit, under a
|
||||
// fresh lock acquisition: this test proves the second check runs, not
|
||||
// only the first.
|
||||
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
|
||||
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "kept",
|
||||
accountId: "acct-default",
|
||||
accountHomeDir,
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("authenticated");
|
||||
});
|
||||
|
||||
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails closed when the account-home secret is rotated after validation but before the login service commits its terminal state", async () => {
|
||||
// A board user can rotate the matching `CODEX_HOME` secret after
|
||||
// `promote`'s own check passes and before the login service commits its
|
||||
// terminal state, because `promote`'s lock is fully released by then.
|
||||
// The service must catch this with a second check it runs right before
|
||||
// that commit, or it would report `authenticated` for a value that no
|
||||
// longer names the account's own home.
|
||||
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
|
||||
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck
|
||||
// `promote`'s own early check: still matches.
|
||||
.mockResolvedValueOnce(accountHomeDir)
|
||||
// The rotation lands in the gap. The final check, immediately before
|
||||
// the terminal commit, now reads the rotated value.
|
||||
.mockResolvedValueOnce("/rotated/away/from/the/account/home");
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "kept",
|
||||
accountId: "acct-default",
|
||||
accountHomeDir,
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
|
|
@ -811,12 +1054,471 @@ describe("adapter device-login routes", () => {
|
|||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
const row = await (harness.store as ReturnType<typeof createMemoryStore>).getByPublicId(
|
||||
sessionId,
|
||||
COMPANY_1,
|
||||
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails closed when a pre-existing secret names a different account home", async () => {
|
||||
// A stale or hand-entered secret at this name must not let the login
|
||||
// report success while a bound agent reads the wrong credential home.
|
||||
mockSecretService.getByName.mockResolvedValue({ id: "stale-secret" });
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
|
||||
"/some/other/path/left-over-from-before",
|
||||
);
|
||||
expect(row?.status).toBe("failed");
|
||||
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "kept",
|
||||
accountId: "acct-stale",
|
||||
accountHomeDir: "/tmp/paperclip-codex-account-home/acct-stale",
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
expect(mockSecretService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed and removes the account home when secret creation fails", async () => {
|
||||
// This login created the account home, so a secret-creation failure must
|
||||
// fail the login and remove the directory it just created, so the
|
||||
// operation stays atomic.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-broken-secret",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("a secret creation failure keeps an account home that already existed", async () => {
|
||||
// The account home directory pre-dates this login (a user deleted the
|
||||
// secret but kept the directory, or an earlier login already wrote it).
|
||||
// A secret-creation failure must still fail the login, but it must never
|
||||
// remove a directory this login did not create.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-existing-home",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: false,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("a secret name conflict succeeds and keeps the account home", async () => {
|
||||
// Two logins for one account can both read no secret and then race the
|
||||
// create call. The loser gets a 409 conflict, which proves the secret
|
||||
// already exists. A repeat login for the same account must be idempotent,
|
||||
// so the loser must confirm the winning secret's value, report a
|
||||
// successful login, and must never remove the account home the winner
|
||||
// just wrote.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-conflict",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
// `vi.resetModules()` in `beforeEach` gives the route module a fresh copy
|
||||
// of `../errors.js`. Import the conflict helper from that same fresh copy,
|
||||
// so the route's `err instanceof HttpError` check sees a matching class.
|
||||
const { conflict: freshConflict } =
|
||||
await vi.importActual<typeof import("../errors.js")>("../errors.js");
|
||||
mockSecretService.create.mockRejectedValueOnce(
|
||||
freshConflict("a secret with this name already exists"),
|
||||
);
|
||||
// The first read (before `create`) finds no secret; the second read
|
||||
// (after the conflict) finds the winner's secret.
|
||||
mockSecretService.getByName
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: "winning-secret" });
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("authenticated");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("fails closed when the secret that won the create race names a different account home", async () => {
|
||||
// The 409 conflict proves some secret now holds this name, but that
|
||||
// secret could belong to unrelated stale state, not the winner of a
|
||||
// genuine same-account race. The login must not report success without
|
||||
// checking the winning secret's value.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-conflict-mismatch",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
const { conflict: freshConflict } =
|
||||
await vi.importActual<typeof import("../errors.js")>("../errors.js");
|
||||
mockSecretService.create.mockRejectedValueOnce(
|
||||
freshConflict("a secret with this name already exists"),
|
||||
);
|
||||
mockSecretService.getByName
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: "winning-secret" });
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
|
||||
"/some/other/path/left-over-from-before",
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
// The mismatch is a validation failure on the winner's secret, not this
|
||||
// call's own directory, so this call must still never delete it.
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("does not remove an account home a concurrent login already claimed", async () => {
|
||||
// This call created the account home directory (`accountHomeCreated:
|
||||
// true`), but a concurrent login for the same account can still create
|
||||
// the company secret before this call's own, unrelated secret-creation
|
||||
// attempt fails. The directory now belongs to that other login's secret,
|
||||
// so the cleanup scan must find it by value and keep the directory.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-concurrent-claim",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
// The first read (before `create`) finds no secret.
|
||||
mockSecretService.getByName.mockResolvedValueOnce(null);
|
||||
// The cleanup scan lists every company secret and resolves each one's
|
||||
// value. The concurrent login's secret carries this call's own generated
|
||||
// name, so this proves the scan still catches a same-name claimant.
|
||||
mockSecretService.list.mockResolvedValueOnce([
|
||||
{ id: "concurrent-secret", name: "CODEX_HOME_acct-concurrent-claim", provider: "local_encrypted" },
|
||||
]);
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValueOnce(accountHomeDir);
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("does not remove an account home a differently named secret still references", async () => {
|
||||
// A user can bind a hand-named secret to an account home directly, with
|
||||
// no relation to the generated `CODEX_HOME_<handle>` name. A cleanup that
|
||||
// checks only the generated name would miss this secret and delete a
|
||||
// directory it still needs; the scan must catch it by value instead.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-aliased",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
mockSecretService.getByName.mockResolvedValueOnce(null);
|
||||
mockSecretService.list.mockResolvedValueOnce([
|
||||
// An unrelated secret that resolves to a different value. The scan
|
||||
// must not stop at the first entry and must not treat every secret as
|
||||
// a match.
|
||||
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
|
||||
{ id: "hand-named-secret", name: "MY_CODEX_HOME", provider: "local_encrypted" },
|
||||
]);
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockImplementation(
|
||||
async (_companyId: string, secretId: string, _context: { configPath: string }) =>
|
||||
secretId === "hand-named-secret" ? accountHomeDir : "/some/unrelated/path",
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("does not remove an account home an AWS Secrets Manager-backed secret still references", async () => {
|
||||
// A secret's value is a plain string regardless of its provider, so a
|
||||
// hand-named secret bound to this account home through AWS Secrets
|
||||
// Manager is just as real a claimant as a `local_encrypted` one. The
|
||||
// scan must resolve it too, not skip it for its provider.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-aws-claim",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
mockSecretService.getByName.mockResolvedValueOnce(null);
|
||||
mockSecretService.list.mockResolvedValueOnce([
|
||||
{ id: "aws-secret", name: "MY_CODEX_HOME", provider: "aws_secrets_manager" },
|
||||
]);
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValueOnce(accountHomeDir);
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledWith(
|
||||
COMPANY_1,
|
||||
"aws-secret",
|
||||
expect.objectContaining({ configPath: "secrets.MY_CODEX_HOME" }),
|
||||
);
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("removes the account home when the cleanup scan finds no claimant", async () => {
|
||||
// No secret, under any name, resolves to this directory, so the cleanup
|
||||
// must still remove it — the broader scan must not make cleanup any less
|
||||
// eager than the old name-only check when nothing claims the directory.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-unclaimed",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
mockSecretService.getByName.mockResolvedValueOnce(null);
|
||||
mockSecretService.list.mockResolvedValueOnce([
|
||||
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
|
||||
]);
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValueOnce("/some/unrelated/path");
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("preserves the account home when the cleanup scan cannot resolve a secret's value", async () => {
|
||||
// A resolve failure on one secret is not proof that secret names a
|
||||
// different directory: the failure can hit the exact secret that would
|
||||
// have matched. The cleanup must keep the directory rather than treat an
|
||||
// unresolved secret as a non-match, even when every secret that DID
|
||||
// resolve named a different directory.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-unresolvable",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
mockSecretService.getByName.mockResolvedValueOnce(null);
|
||||
mockSecretService.list.mockResolvedValueOnce([
|
||||
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
|
||||
{ id: "unresolvable-secret", name: "MAYBE_CODEX_HOME", provider: "local_encrypted" },
|
||||
]);
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockImplementation(
|
||||
async (_companyId: string, secretId: string, _context: { configPath: string }) => {
|
||||
if (secretId === "unresolvable-secret") throw new Error("decryption key unavailable");
|
||||
return "/some/unrelated/path";
|
||||
},
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves the account home when a secret appears after the scan starts", async () => {
|
||||
// The scan lists secrets once, then resolves each one in turn. A new
|
||||
// secret can appear after that first list call, while the scan is still
|
||||
// resolving an earlier secret. The scan must re-list and check that new
|
||||
// secret too, instead of trusting its first, now-stale list.
|
||||
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
|
||||
accountHomeTestDirs.push(accountHomeDir);
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct-late-claim",
|
||||
accountHomeDir,
|
||||
accountHomeCreated: true,
|
||||
});
|
||||
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
|
||||
mockSecretService.getByName.mockResolvedValueOnce(null);
|
||||
// The first pass sees only the unrelated secret. By the time the scan
|
||||
// re-lists, a second, differently named secret now names this account
|
||||
// home; the scan must catch it on that later pass.
|
||||
mockSecretService.list
|
||||
.mockResolvedValueOnce([{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" }])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
|
||||
{ id: "late-secret", name: "LATE_CODEX_HOME", provider: "local_encrypted" },
|
||||
]);
|
||||
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockImplementation(
|
||||
async (_companyId: string, secretId: string, _context: { configPath: string }) =>
|
||||
secretId === "late-secret" ? accountHomeDir : "/some/unrelated/path",
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("fails closed when the account identifier cannot form a valid account home name", async () => {
|
||||
// The promotion helper itself already rejects an identifier that cannot
|
||||
// become an account handle; a defensive re-check in the route covers a
|
||||
// promotion result the route did not fully trust.
|
||||
mockDeviceLoginPromotion.mockResolvedValueOnce({
|
||||
outcome: "promoted",
|
||||
accountId: "acct with space",
|
||||
accountHomeDir: "/tmp/paperclip-codex-account-home/unused",
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(status.body.status).toBe("failed");
|
||||
expect(status.body.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
expect(mockSecretService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits the URL, code, credential bytes, and lease id from logs and activity", async () => {
|
||||
|
|
|
|||
|
|
@ -382,6 +382,84 @@ describe("device login service", () => {
|
|||
expect(JSON.stringify(activity)).not.toContain(PROMPT_CODE);
|
||||
});
|
||||
|
||||
it("wraps the terminal authenticated commit in a promotion's runTerminalCommit", async () => {
|
||||
// A promotion that defines `runTerminalCommit` gets the chance to wrap the
|
||||
// service's own terminal write, so it can hold a lock across a check it
|
||||
// already ran once earlier in `promote` and the write that publishes
|
||||
// `authenticated`.
|
||||
const store = createMemoryStore();
|
||||
const { runtime } = createFakeRuntime({
|
||||
exec: execSuccess,
|
||||
authBytes: Buffer.from('{"token":"secret"}'),
|
||||
});
|
||||
const companyId = randomUUID();
|
||||
const wrapCalls: unknown[] = [];
|
||||
const service = makeService({
|
||||
store,
|
||||
runtime,
|
||||
promotion: {
|
||||
promote: () => {},
|
||||
async runTerminalCommit(commit, context) {
|
||||
wrapCalls.push(context);
|
||||
return commit();
|
||||
},
|
||||
},
|
||||
});
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
});
|
||||
|
||||
const outcome = await completed;
|
||||
expect(outcome.status).toBe("authenticated");
|
||||
expect(wrapCalls).toHaveLength(1);
|
||||
const row = await store.getByPublicId(session.sessionId, companyId);
|
||||
expect(row?.status).toBe("authenticated");
|
||||
});
|
||||
|
||||
it("records a failed terminal, and never authenticates, when runTerminalCommit rejects", async () => {
|
||||
// A promotion's `runTerminalCommit` rejects when its own re-check, run
|
||||
// immediately before the terminal write, finds the value it validated
|
||||
// earlier no longer holds (for example, a rotation landed in the gap).
|
||||
// The service must fail the login the same way a `promote` rejection
|
||||
// does, never publishing `authenticated` for the stale value.
|
||||
const store = createMemoryStore();
|
||||
const { runtime, deleteCalls } = createFakeRuntime({
|
||||
exec: execSuccess,
|
||||
authBytes: Buffer.from('{"token":"secret"}'),
|
||||
});
|
||||
const companyId = randomUUID();
|
||||
const service = makeService({
|
||||
store,
|
||||
runtime,
|
||||
promotion: {
|
||||
promote: () => {},
|
||||
async runTerminalCommit() {
|
||||
// A real promotion would run its re-check here, find the bound
|
||||
// value stale, and throw before ever calling `commit`.
|
||||
throw new Error("the bound value no longer matches");
|
||||
},
|
||||
},
|
||||
});
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
});
|
||||
|
||||
const outcome = await completed;
|
||||
expect(outcome.status).toBe("failed");
|
||||
const row = await store.getByPublicId(session.sessionId, companyId);
|
||||
expect(row?.status).toBe("failed");
|
||||
expect(row?.failureReason).toBe("promotion_failed");
|
||||
// The service still deletes the sandbox on this failure path, the same
|
||||
// as a `promote` rejection.
|
||||
expect(deleteCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("gives a Grok prompt to a grok_local session, and the session surfaces the Grok code and URL", async () => {
|
||||
// This proves the Grok parser ran: the profile map resolves the parser from
|
||||
// the trusted adapter type, so a `grok_local` session runs the Grok parser,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { resolveCodexAuthCacheDir, withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
|
|
@ -88,6 +90,16 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
return companyId;
|
||||
}
|
||||
|
||||
// Creates a real directory under this company's Codex account-home cache
|
||||
// root, so a test can prove the write-time directory-validity check reads
|
||||
// an actual filesystem entry, the same way the production cleanup and the
|
||||
// production secret write do.
|
||||
async function makeAccountHomeDir(companyId: string, accountHandle: string): Promise<string> {
|
||||
const accountHomeDir = path.join(resolveCodexAuthCacheDir(undefined, companyId), accountHandle);
|
||||
await mkdir(accountHomeDir, { recursive: true });
|
||||
return accountHomeDir;
|
||||
}
|
||||
|
||||
async function seedCompanyMember(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
|
|
@ -360,6 +372,446 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it("serializes two local_encrypted secret creates in the same company so their writes never overlap", async () => {
|
||||
// An account-home cleanup's claimant scan and a `local_encrypted` secret
|
||||
// write share one lock (`withAccountHomeSecretMutationLock`), so a write
|
||||
// can never commit inside the exact window the scan already used to
|
||||
// decide no secret claims a directory it is about to delete. This proves
|
||||
// the lock itself enforces that: two `local_encrypted` creates in the
|
||||
// SAME company never run their provider write at the same time,
|
||||
// whichever caller goes first.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const events: string[] = [];
|
||||
let releaseFirstWrite!: () => void;
|
||||
const firstWriteGate = new Promise<void>((resolve) => {
|
||||
releaseFirstWrite = resolve;
|
||||
});
|
||||
const originalCreateSecret = localEncryptedProvider.createSecret.bind(localEncryptedProvider);
|
||||
vi.spyOn(localEncryptedProvider, "createSecret")
|
||||
.mockImplementationOnce(async (input) => {
|
||||
events.push("first-provider-enter");
|
||||
await firstWriteGate;
|
||||
events.push("first-provider-exit");
|
||||
return originalCreateSecret(input);
|
||||
})
|
||||
.mockImplementationOnce(async (input) => {
|
||||
events.push("second-provider-enter");
|
||||
return originalCreateSecret(input);
|
||||
});
|
||||
|
||||
const firstCreate = svc.create(companyId, {
|
||||
name: `account-home-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "/company/codex-home/acct-a",
|
||||
});
|
||||
// Give the first call a chance to acquire the lock and enter its provider
|
||||
// write before the second call starts racing for the same lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const secondCreate = svc.create(companyId, {
|
||||
name: `hand-named-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "/company/codex-home/acct-a",
|
||||
});
|
||||
// The second call must stay blocked on the lock while the first call
|
||||
// still holds it: it must never enter its own provider write before the
|
||||
// first call's provider write exits.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["first-provider-enter"]);
|
||||
|
||||
releaseFirstWrite();
|
||||
await Promise.all([firstCreate, secondCreate]);
|
||||
expect(events).toEqual(["first-provider-enter", "first-provider-exit", "second-provider-enter"]);
|
||||
});
|
||||
|
||||
it("serializes a local_encrypted secret rotate against a concurrent create of a different secret in the same company", async () => {
|
||||
// Same lock, the other write path: a rotate that writes a new
|
||||
// `local_encrypted` value must serialize against a concurrent create the
|
||||
// same way a create serializes against another create.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const existing = await svc.create(companyId, {
|
||||
name: `existing-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "/company/codex-home/acct-b",
|
||||
});
|
||||
const events: string[] = [];
|
||||
let releaseRotateWrite!: () => void;
|
||||
const rotateWriteGate = new Promise<void>((resolve) => {
|
||||
releaseRotateWrite = resolve;
|
||||
});
|
||||
const originalCreateVersion = localEncryptedProvider.createVersion.bind(localEncryptedProvider);
|
||||
vi.spyOn(localEncryptedProvider, "createVersion").mockImplementationOnce(async (input) => {
|
||||
events.push("rotate-provider-enter");
|
||||
await rotateWriteGate;
|
||||
events.push("rotate-provider-exit");
|
||||
return originalCreateVersion(input);
|
||||
});
|
||||
const originalCreateSecret = localEncryptedProvider.createSecret.bind(localEncryptedProvider);
|
||||
vi.spyOn(localEncryptedProvider, "createSecret").mockImplementationOnce(async (input) => {
|
||||
events.push("create-provider-enter");
|
||||
return originalCreateSecret(input);
|
||||
});
|
||||
|
||||
const rotateCall = svc.rotate(existing.id, { value: "/company/codex-home/acct-b-rotated" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const createCall = svc.create(companyId, {
|
||||
name: `hand-named-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "/company/codex-home/acct-b",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["rotate-provider-enter"]);
|
||||
|
||||
releaseRotateWrite();
|
||||
await Promise.all([rotateCall, createCall]);
|
||||
expect(events).toEqual(["rotate-provider-enter", "rotate-provider-exit", "create-provider-enter"]);
|
||||
});
|
||||
|
||||
it("fails a queued local_encrypted create when an account-home cleanup removes its directory first", async () => {
|
||||
// The mutation lock alone stops a write and an account-home cleanup's
|
||||
// check-and-delete from interleaving; it does not stop them from running
|
||||
// in either order. When the cleanup wins the lock first, deletes the
|
||||
// directory, and releases the lock, a create that was only queued behind
|
||||
// it must not go on to commit that now-deleted directory as a secret
|
||||
// value. It must fail instead.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-create");
|
||||
|
||||
const events: string[] = [];
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
events.push("cleanup-enter");
|
||||
await cleanupGate;
|
||||
await rm(accountHomeDir, { recursive: true, force: true });
|
||||
events.push("cleanup-exit");
|
||||
});
|
||||
// Give the cleanup a chance to acquire the lock before the create starts
|
||||
// racing for the same lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const createCall = svc.create(companyId, {
|
||||
name: `account-home-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: accountHomeDir,
|
||||
});
|
||||
// The create must stay queued behind the held lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
await expect(createCall).rejects.toThrow(/no longer exists/);
|
||||
});
|
||||
|
||||
it("fails a queued local_encrypted rotate when an account-home cleanup removes its directory first", async () => {
|
||||
// Same race, the other write path: a rotate that would commit a
|
||||
// now-deleted account-home directory must fail the same way a create
|
||||
// does.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const existing = await svc.create(companyId, {
|
||||
name: `hand-named-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "/some/unrelated/placeholder/value",
|
||||
});
|
||||
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-rotate");
|
||||
|
||||
const events: string[] = [];
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
events.push("cleanup-enter");
|
||||
await cleanupGate;
|
||||
await rm(accountHomeDir, { recursive: true, force: true });
|
||||
events.push("cleanup-exit");
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const rotateCall = svc.rotate(existing.id, { value: accountHomeDir });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
await expect(rotateCall).rejects.toThrow(/no longer exists/);
|
||||
});
|
||||
|
||||
it("serializes an aws_secrets_manager secret create against a concurrent account-home mutation lock holder", async () => {
|
||||
// A plain string value can equal a Codex account-home path regardless of
|
||||
// which provider stores it. Prove a non-local (`aws_secrets_manager`)
|
||||
// create now takes the SAME `withAccountHomeSecretMutationLock` a
|
||||
// `local_encrypted` create takes, not only when the create's own
|
||||
// provider is `local_encrypted`.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
|
||||
const events: string[] = [];
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
events.push("cleanup-enter");
|
||||
await cleanupGate;
|
||||
events.push("cleanup-exit");
|
||||
});
|
||||
// Give the cleanup a chance to acquire the lock before the AWS create
|
||||
// starts racing for the same lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const createCall = svc.create(companyId, {
|
||||
name: `aws-secret-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
value: "runtime-secret",
|
||||
});
|
||||
// The AWS create must stay queued behind the held lock, the same way a
|
||||
// `local_encrypted` create does.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
const created = await createCall;
|
||||
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
|
||||
expect(created.status).toBe("active");
|
||||
});
|
||||
|
||||
it("serializes an aws_secrets_manager secret rotate against a concurrent account-home mutation lock holder", async () => {
|
||||
// Same reasoning as the create test above, the other write path: a
|
||||
// non-local rotate must also take the lock, not only a
|
||||
// `local_encrypted` rotate.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-2",
|
||||
fingerprintSha256: "fingerprint-sha-2",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
providerVersionRef: "aws-version-2",
|
||||
});
|
||||
const existing = await svc.create(companyId, {
|
||||
name: `aws-secret-rotate-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
value: "runtime-secret",
|
||||
});
|
||||
|
||||
const events: string[] = [];
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
events.push("cleanup-enter");
|
||||
await cleanupGate;
|
||||
events.push("cleanup-exit");
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
const rotated = await rotateCall;
|
||||
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
|
||||
expect(rotated.latestVersion).toBe(2);
|
||||
});
|
||||
|
||||
it("serializes a rename, archive, or disable update against a concurrent account-home mutation lock holder", async () => {
|
||||
// A rename or a status change to `archived` or `disabled` can stop the
|
||||
// generated account-home secret from resolving to the value a
|
||||
// device-login promotion already validated. Prove `update` now holds
|
||||
// the SAME lock a cleanup (or a promotion's terminal-commit re-check)
|
||||
// holds for its whole critical section, so an update can never land
|
||||
// inside that section.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `CODEX_HOME_update-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "/company/codex-home/acct-update",
|
||||
});
|
||||
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
await cleanupGate;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
let updateResolved = false;
|
||||
const updateCall = svc.update(secret.id, { status: "archived" }).then((result) => {
|
||||
updateResolved = true;
|
||||
return result;
|
||||
});
|
||||
// The update must stay queued behind the held lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(updateResolved).toBe(false);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
const updated = await updateCall;
|
||||
expect(updateResolved).toBe(true);
|
||||
expect(updated?.status).toBe("archived");
|
||||
});
|
||||
|
||||
it("serializes a secret delete against a concurrent account-home mutation lock holder", async () => {
|
||||
// Same reasoning as the update test above: a delete must also take the
|
||||
// lock, so it can never land inside a cleanup's, or a promotion's
|
||||
// terminal-commit re-check's, critical section.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `CODEX_HOME_delete-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "/company/codex-home/acct-delete",
|
||||
});
|
||||
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
await cleanupGate;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
let removeResolved = false;
|
||||
const removeCall = svc.remove(secret.id).then((result) => {
|
||||
removeResolved = true;
|
||||
return result;
|
||||
});
|
||||
// The delete must stay queued behind the held lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(removeResolved).toBe(false);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
await removeCall;
|
||||
expect(removeResolved).toBe(true);
|
||||
const remaining = await svc.getById(secret.id);
|
||||
expect(remaining).toBeNull();
|
||||
});
|
||||
|
||||
it("commits a local_encrypted secret naming an account-home directory that still exists", async () => {
|
||||
// A regression guard for the check above: a create whose value names a
|
||||
// directory that is still present must keep succeeding, unblocked.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-still-present");
|
||||
|
||||
const created = await svc.create(companyId, {
|
||||
name: `account-home-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: accountHomeDir,
|
||||
});
|
||||
expect(created.status).toBe("active");
|
||||
});
|
||||
|
||||
it("fails a queued adapter-schema-secret create when an account-home cleanup removes its directory first", async () => {
|
||||
// `normalizeAdapterConfigForPersistence` creates an adapter config secret
|
||||
// through a separate managed-create path (`createManagedLocalSecret`),
|
||||
// not `svc.create`. It must run the same queued-behind-the-lock
|
||||
// directory check as `svc.create` and `svc.rotate`, so an adapter
|
||||
// schema secret can never commit an account-home directory a cleanup
|
||||
// already removed while this call waited for the lock.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-adapter-secret");
|
||||
|
||||
const events: string[] = [];
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
events.push("cleanup-enter");
|
||||
await cleanupGate;
|
||||
await rm(accountHomeDir, { recursive: true, force: true });
|
||||
events.push("cleanup-exit");
|
||||
});
|
||||
// Give the cleanup a chance to acquire the lock before the adapter
|
||||
// config normalization starts racing for the same lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const normalizeCall = svc.normalizeAdapterConfigForPersistence(
|
||||
companyId,
|
||||
{ apiKey: accountHomeDir },
|
||||
{ adapterType: "hermes_gateway" },
|
||||
);
|
||||
// The queued create must stay blocked behind the held lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
await expect(normalizeCall).rejects.toThrow(/no longer exists/);
|
||||
});
|
||||
|
||||
it("keeps an adapter-schema-secret create working for a value outside the account-home cache root", async () => {
|
||||
// A regression guard for the check above: an adapter schema secret whose
|
||||
// value is not an account-home directory (an ordinary API key, for
|
||||
// example) must keep succeeding, unblocked.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
const normalized = await svc.normalizeAdapterConfigForPersistence(
|
||||
companyId,
|
||||
{ apiKey: `plain-api-key-${randomUUID()}` },
|
||||
{ adapterType: "hermes_gateway" },
|
||||
);
|
||||
const apiKeyBinding = (normalized as Record<string, unknown>).apiKey as { type: string; secretId: string };
|
||||
expect(apiKeyBinding.type).toBe("secret_ref");
|
||||
const secret = await svc.getById(apiKeyBinding.secretId);
|
||||
expect(secret?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("validates the access namespace as agent-only with env-style aliases", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db";
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
startAdapterAuthSessionRequestSchema,
|
||||
startClaudeSetupTokenSessionRequestSchema,
|
||||
submitBrowserCodeRequestSchema,
|
||||
toAccountHandle,
|
||||
type AgentAdapterType,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
|
|
@ -174,6 +176,8 @@ import {
|
|||
import {
|
||||
checkStagedCredentialReadiness,
|
||||
promoteDeviceLoginCredential,
|
||||
withAccountHomeSecretMutationLock,
|
||||
withCodexAccountHomePromotionLock,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
import {
|
||||
checkStagedGrokCredentialReadiness,
|
||||
|
|
@ -273,6 +277,119 @@ function readRunIssueId(context: Record<string, unknown> | null) {
|
|||
return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null;
|
||||
}
|
||||
|
||||
// Confirms a pre-existing `CODEX_HOME_<handle>` secret still names this
|
||||
// account's own home before a device login treats the secret's presence as a
|
||||
// successful, idempotent login. The secret name alone is not proof of a
|
||||
// match: a stale value from before the cache root moved, or a value a user
|
||||
// entered by hand, would otherwise let the login report success while a
|
||||
// bound agent reads the wrong (or a missing) credential home. Fails loud on
|
||||
// a mismatch, so the login fails instead of silently pointing agents at the
|
||||
// wrong home.
|
||||
//
|
||||
// Each caller must run this function inside `withAccountHomeSecretMutationLock`,
|
||||
// the same lock a `local_encrypted` secret rotate holds for its whole write.
|
||||
// A caller that only checks once, early in the promotion, and then reports
|
||||
// success later is not enough on its own: the lock this call held is fully
|
||||
// released by the time it returns, so a rotate queued behind it can commit a
|
||||
// new value before the login service records its terminal `authenticated`
|
||||
// state, which happens well after this call returns (see `runTerminalCommit`
|
||||
// below, which runs this same check again, under a fresh lock acquisition,
|
||||
// immediately before that terminal state commits).
|
||||
async function assertAccountHomeSecretMatches(
|
||||
secretsSvc: { resolveSecretValueForDeviceLoginCheck: (companyId: string, secretId: string, context: { configPath: string }) => Promise<string> },
|
||||
companyId: string,
|
||||
secret: { id: string },
|
||||
secretName: string,
|
||||
expectedAccountHomeDir: string,
|
||||
): Promise<void> {
|
||||
const storedValue = await secretsSvc.resolveSecretValueForDeviceLoginCheck(companyId, secret.id, {
|
||||
configPath: `secrets.${secretName}`,
|
||||
});
|
||||
if (storedValue !== expectedAccountHomeDir) {
|
||||
throw new Error(
|
||||
`device-login credential promotion rejected: the existing ${secretName} secret does not name this account's own home`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirms no company secret, under any name or provider, still names this
|
||||
// account home before a failed promotion deletes the directory. The
|
||||
// generated `CODEX_HOME_<handle>` name is not the only secret that can
|
||||
// reference this directory: a user can bind a hand-named secret to the same
|
||||
// account home, so a check that reads only the generated name misses that
|
||||
// secret and deletes a directory it still needs. A bound agent then reads a
|
||||
// `CODEX_HOME` value that points at nothing. A secret's value is a plain
|
||||
// string regardless of its provider, so an AWS Secrets Manager-backed secret
|
||||
// (or any other provider) can equal this directory's path just as a
|
||||
// `local_encrypted` secret can; the scan resolves every secret's value, not
|
||||
// only `local_encrypted` ones.
|
||||
//
|
||||
// A secret whose value fails to resolve is NOT proof that secret names a
|
||||
// different directory: the resolve call can fail for a secret that would
|
||||
// have matched. Treating that failure as a non-match would let the cleanup
|
||||
// delete a directory a secret still needs. So the scan fails closed: any
|
||||
// resolution failure makes the whole scan report a claim, even when every
|
||||
// secret that DID resolve named a different directory.
|
||||
//
|
||||
// The scan lists every secret once, then resolves each secret's value in
|
||||
// turn, and each resolve call is its own round trip. A new secret can enter
|
||||
// the company between the initial list and the last resolve call, so a
|
||||
// single pass can finish, find no claimant among the secrets it read, and
|
||||
// still miss a secret that named this directory moments later. So the scan
|
||||
// re-lists after every pass and resolves only the secrets it has not yet
|
||||
// checked, and it only reports "no claimant" once a pass finds nothing new
|
||||
// to check. A scan that keeps finding new secrets on every pass fails
|
||||
// closed after a bounded number of passes, so a fast stream of concurrent
|
||||
// secret creation cannot force an unsafe delete.
|
||||
//
|
||||
// The scan alone still cannot rule out a secret write that commits after the
|
||||
// scan's own last pass finishes but before the caller's delete runs: the
|
||||
// scan and that write are two separate operations with no shared state, so
|
||||
// neither can see the other. The caller closes that window by running the
|
||||
// scan and the delete inside `withAccountHomeSecretMutationLock`, the same
|
||||
// lock every `local_encrypted` secret create or rotate holds for its whole
|
||||
// write. That lock is the atomic protection; the multi-pass scan above stays
|
||||
// as a defense-in-depth check for a write path that has not taken the lock.
|
||||
const ACCOUNT_HOME_CLAIM_SCAN_MAX_PASSES = 5;
|
||||
|
||||
async function anySecretNamesAccountHome(
|
||||
secretsSvc: {
|
||||
list: (companyId: string) => Promise<Array<{ id: string; name: string; provider: string }>>;
|
||||
resolveSecretValueForDeviceLoginCheck: (
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
context: { configPath: string },
|
||||
) => Promise<string>;
|
||||
},
|
||||
companyId: string,
|
||||
accountHomeDir: string,
|
||||
): Promise<boolean> {
|
||||
const checkedSecretIds = new Set<string>();
|
||||
for (let pass = 0; pass < ACCOUNT_HOME_CLAIM_SCAN_MAX_PASSES; pass += 1) {
|
||||
const secrets = await secretsSvc.list(companyId);
|
||||
const uncheckedSecrets = secrets.filter((secret) => !checkedSecretIds.has(secret.id));
|
||||
if (uncheckedSecrets.length === 0) return false;
|
||||
let resolutionFailed = false;
|
||||
for (const secret of uncheckedSecrets) {
|
||||
checkedSecretIds.add(secret.id);
|
||||
const storedValue = await secretsSvc
|
||||
.resolveSecretValueForDeviceLoginCheck(companyId, secret.id, {
|
||||
configPath: `secrets.${secret.name}`,
|
||||
})
|
||||
.catch(() => {
|
||||
resolutionFailed = true;
|
||||
return null;
|
||||
});
|
||||
if (storedValue === accountHomeDir) return true;
|
||||
}
|
||||
if (resolutionFailed) return true;
|
||||
}
|
||||
// Every pass found a secret it had not yet checked. Fail closed: an
|
||||
// endless stream of new secrets is not proof that none of them claims
|
||||
// this directory.
|
||||
return true;
|
||||
}
|
||||
|
||||
export function agentRoutes(
|
||||
db: Db,
|
||||
options: {
|
||||
|
|
@ -542,6 +659,21 @@ export function agentRoutes(
|
|||
// process owns one instance, so the in-memory prompt and the cancellation
|
||||
// controllers persist across requests.
|
||||
const adapterLoginStore = createDbAdapterAuthSessionStore(db);
|
||||
|
||||
// The account-home secret a Codex login validated and bound, keyed by
|
||||
// session id, queued for `runTerminalCommit` to reconfirm right before the
|
||||
// login service commits its terminal `authenticated` write. `promote`'s own
|
||||
// check runs under a lock that is fully released by the time `promote`
|
||||
// returns, so a rotation can still land after that check and before the
|
||||
// terminal write; `runTerminalCommit` closes that gap by re-running the
|
||||
// same check under a fresh lock acquisition that it holds across the
|
||||
// terminal write itself. `runTerminalCommit` deletes the entry it reads, so
|
||||
// nothing outlives one login attempt.
|
||||
const pendingAccountHomeSecretCommits = new Map<
|
||||
string,
|
||||
{ secretId: string; secretName: string; accountHomeDir: string }
|
||||
>();
|
||||
|
||||
const adapterLoginService = createDeviceLoginService({
|
||||
store: adapterLoginStore,
|
||||
runtime: createProductionLoginSessionRuntime({
|
||||
|
|
@ -577,59 +709,222 @@ export function agentRoutes(
|
|||
// promotion function.
|
||||
promotionByAdapterType: {
|
||||
codex_local: {
|
||||
// Hold one lock across the whole promotion sequence below: the
|
||||
// credential write, the existing-secret check, the secret create, and
|
||||
// the cleanup a create failure can trigger. Two different logins for
|
||||
// the SAME Codex account run this whole sequence one at a time, so a
|
||||
// login can never decide to delete the shared account-home directory
|
||||
// while another login's own sequence is still mid-way through writing
|
||||
// its credential or binding its own secret to that same directory. A
|
||||
// lock around only the directory-creation step is not enough: that
|
||||
// lock is already released by the time a login reaches the secret
|
||||
// bind, so a second login can write its credential and be about to
|
||||
// bind its own secret while the first login's later, unrelated
|
||||
// secret-write failure removes the directory both logins now share.
|
||||
async promote(authBytes, context) {
|
||||
// Hold the promotion critical-section lock across the ownership check and
|
||||
// the credential write. The reaper takes the same lock before it reclaims
|
||||
// a stale `promoting` row. So a reclaim never interleaves with a live
|
||||
// write: the reaper either wins the lock first and the ownership check
|
||||
// then reads a reclaimed row and writes nothing, or the write finishes
|
||||
// first under the lock and the reaper reclaims only after it completes. A
|
||||
// read-only fence is not enough, because the filesystem write can start
|
||||
// after the fence; the lock spans the whole section.
|
||||
const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock(
|
||||
context.companyId,
|
||||
context.startedByUserId,
|
||||
context.adapterType,
|
||||
() =>
|
||||
promoteDeviceLoginCredential({
|
||||
authBytes,
|
||||
companyId: context.companyId,
|
||||
userInitiated: true,
|
||||
checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes),
|
||||
isSoleActiveOwner: async () => {
|
||||
// The partial unique index allows one active row per company and
|
||||
// adapter. So a `promoting` row for this session is the sole
|
||||
// active owner of the company credential slot. The read runs
|
||||
// inside the lock, so it observes a reaper reclaim that committed
|
||||
// before this section acquired the lock.
|
||||
const row = await adapterLoginStore.get(context.sessionId);
|
||||
return row?.status === "promoting" && row.companyId === context.companyId;
|
||||
},
|
||||
log: (line) => {
|
||||
// The promotion lines carry no token bytes and no raw account id,
|
||||
// so it is safe to log them with the session identifier.
|
||||
logger.info({ sessionId: context.sessionId }, line);
|
||||
},
|
||||
}),
|
||||
);
|
||||
// A resolved promotion is not necessarily an accepted promotion. In
|
||||
// particular, a reaper/expiry race can revoke this session's sole
|
||||
// ownership between the service transition and Decision H. Fail closed:
|
||||
// only a credential write or a deliberate safe keep can authenticate.
|
||||
if (outcome === "kept_foreign_identity") {
|
||||
// The login produced a different account than the one the company
|
||||
// credential home already holds. The promotion never clobbers an
|
||||
// occupied home, so this login installed nothing durable, and the
|
||||
// identity-anchored vend can never select it: a later run keeps the
|
||||
// existing account. Fail the session, so the operator never sees a
|
||||
// false `authenticated` for an account the system will not use.
|
||||
throw new Error(
|
||||
"device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept",
|
||||
return withCodexAccountHomePromotionLock(undefined, context.companyId, async () => {
|
||||
// Hold the promotion critical-section lock across the ownership check
|
||||
// and the credential write. The reaper takes the same lock before it
|
||||
// reclaims a stale `promoting` row. So a reclaim never interleaves with
|
||||
// a live write: the reaper either wins the lock first and the
|
||||
// ownership check then reads a reclaimed row and writes nothing, or
|
||||
// the write finishes first under the lock and the reaper reclaims only
|
||||
// after it completes. A read-only fence is not enough, because the
|
||||
// filesystem write can start after the fence; the lock spans the whole
|
||||
// section.
|
||||
const result = await adapterLoginStore.withCompanyAdapterPromotionLock(
|
||||
context.companyId,
|
||||
context.startedByUserId,
|
||||
context.adapterType,
|
||||
() =>
|
||||
promoteDeviceLoginCredential({
|
||||
authBytes,
|
||||
companyId: context.companyId,
|
||||
userInitiated: true,
|
||||
checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes),
|
||||
isSoleActiveOwner: async () => {
|
||||
// The partial unique index allows one active row per company and
|
||||
// adapter. So a `promoting` row for this session is the sole
|
||||
// active owner of the company credential slot. The read runs
|
||||
// inside the lock, so it observes a reaper reclaim that committed
|
||||
// before this section acquired the lock.
|
||||
const row = await adapterLoginStore.get(context.sessionId);
|
||||
return row?.status === "promoting" && row.companyId === context.companyId;
|
||||
},
|
||||
log: (line) => {
|
||||
// The promotion lines carry no token bytes and no raw account id,
|
||||
// so it is safe to log them with the session identifier.
|
||||
logger.info({ sessionId: context.sessionId }, line);
|
||||
},
|
||||
}),
|
||||
);
|
||||
// A resolved promotion is not necessarily an accepted promotion. In
|
||||
// particular, a reaper/expiry race can revoke this session's sole
|
||||
// ownership between the service transition and Decision H. Fail closed:
|
||||
// only a credential write or a deliberate safe keep can authenticate.
|
||||
if (result.outcome !== "promoted" && result.outcome !== "kept") {
|
||||
throw new Error(`device-login credential promotion rejected: ${result.outcome}`);
|
||||
}
|
||||
// The account's own home is durable at this point (the promotion above
|
||||
// wrote it fail-loud). Name it with a company secret, so any agent can
|
||||
// bind to it. Reading the secret by name first keeps a repeat login for
|
||||
// the same account idempotent: `create` throws a conflict when the name
|
||||
// already exists.
|
||||
const handle = result.accountId ? toAccountHandle(result.accountId) : null;
|
||||
if (!handle || !result.accountHomeDir) {
|
||||
throw new Error(
|
||||
"device-login credential promotion rejected: the promotion carried no account home",
|
||||
);
|
||||
}
|
||||
const secretName = `CODEX_HOME_${handle}`;
|
||||
const accountHomeDir = result.accountHomeDir;
|
||||
const existingSecret = await secretsSvc.getByName(context.companyId, secretName);
|
||||
if (existingSecret) {
|
||||
// A same-name secret already exists. Confirm it still names this
|
||||
// account's own home before treating a repeat login as a success:
|
||||
// the name alone is not proof of a match.
|
||||
//
|
||||
// Run the check inside the same lock a `local_encrypted` secret
|
||||
// rotate holds for its whole write, the same lock a rotate
|
||||
// takes. This is an early fail-fast only: the lock is fully
|
||||
// released once this call returns, well before the login
|
||||
// service commits its terminal state, so queue the same check
|
||||
// for `runTerminalCommit` to run again right before that
|
||||
// commit, under a fresh lock acquisition it holds across the
|
||||
// commit itself.
|
||||
await withAccountHomeSecretMutationLock(undefined, context.companyId, () =>
|
||||
assertAccountHomeSecretMatches(secretsSvc, context.companyId, existingSecret, secretName, accountHomeDir),
|
||||
);
|
||||
pendingAccountHomeSecretCommits.set(context.sessionId, {
|
||||
secretId: existingSecret.id,
|
||||
secretName,
|
||||
accountHomeDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const createdSecret = await secretsSvc.create(
|
||||
context.companyId,
|
||||
{
|
||||
name: secretName,
|
||||
provider: "local_encrypted",
|
||||
value: accountHomeDir,
|
||||
description: `CODEX_HOME generated by logging into account ${handle}`,
|
||||
},
|
||||
{ userId: context.startedByUserId, agentId: null },
|
||||
);
|
||||
// The value just committed is correct at this instant, but a
|
||||
// rotate queued behind the create's own lock can still commit a
|
||||
// different value before the login service records its
|
||||
// terminal state. Queue the same reconfirm `runTerminalCommit`
|
||||
// runs for the two branches above.
|
||||
pendingAccountHomeSecretCommits.set(context.sessionId, {
|
||||
secretId: createdSecret.id,
|
||||
secretName,
|
||||
accountHomeDir,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 409) {
|
||||
// A conflict means a concurrent login for the same account won the
|
||||
// create race. Confirm the winning secret still names this
|
||||
// account's own home before treating the race as a successful,
|
||||
// idempotent login.
|
||||
const winningSecret = await secretsSvc.getByName(context.companyId, secretName);
|
||||
if (!winningSecret) {
|
||||
throw new Error(
|
||||
`device-login credential promotion rejected: the ${secretName} secret conflict could not be resolved`,
|
||||
);
|
||||
}
|
||||
// Same lock and the same reasoning as the pre-existing-secret
|
||||
// check above: an early fail-fast only, so also queue the
|
||||
// same check for `runTerminalCommit` to run again, under a
|
||||
// fresh lock acquisition it holds across the terminal commit.
|
||||
await withAccountHomeSecretMutationLock(undefined, context.companyId, () =>
|
||||
assertAccountHomeSecretMatches(secretsSvc, context.companyId, winningSecret, secretName, accountHomeDir),
|
||||
);
|
||||
pendingAccountHomeSecretCommits.set(context.sessionId, {
|
||||
secretId: winningSecret.id,
|
||||
secretName,
|
||||
accountHomeDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The account home write failed for a reason other than a naming
|
||||
// conflict. Remove the directory only when this exact login created
|
||||
// it AND no secret, under any name, still names it. The lock
|
||||
// around this whole method already rules out another
|
||||
// SAME-ACCOUNT LOGIN from being mid-sequence here, but it does
|
||||
// not rule out a secret a different path created at this exact
|
||||
// directory in between the read above and this failure — for
|
||||
// example, a concurrent login for the same account that won a
|
||||
// race on this exact name, or a user who names a secret by hand
|
||||
// under a different name entirely. The re-check is this call's
|
||||
// only signal for that case, so it stays even under the lock:
|
||||
// `accountHomeCreated` alone is not proof no such secret now
|
||||
// claims the directory, and a same-name check alone is not proof
|
||||
// either, because the claiming secret can carry any name.
|
||||
//
|
||||
// The check and the delete run inside `withAccountHomeSecretMutationLock`,
|
||||
// the same lock the secrets service holds for the whole of a
|
||||
// `local_encrypted` secret's create or rotate call. That closes the
|
||||
// window `anySecretNamesAccountHome`'s own multi-pass scan cannot: a
|
||||
// secret write that commits after this check's last pass but before
|
||||
// the delete runs. Under the shared lock, a write either finishes
|
||||
// (and becomes visible to the check) before this section acquires the
|
||||
// lock, or it waits for this section to finish before it can commit.
|
||||
if (result.accountHomeCreated) {
|
||||
await withAccountHomeSecretMutationLock(undefined, context.companyId, async () => {
|
||||
const claimed = await anySecretNamesAccountHome(secretsSvc, context.companyId, accountHomeDir);
|
||||
if (!claimed) {
|
||||
await rm(accountHomeDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
"device-login credential promotion rejected: failed to record the account home secret",
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
// The login service calls this immediately before it commits its
|
||||
// terminal `authenticated` write, wrapping that write in the
|
||||
// callback it hands in as `commit`. `promote` above already
|
||||
// validated the bound account-home secret once, early, but its own
|
||||
// lock is fully released by the time `promote` returns — well before
|
||||
// this runs. Re-run the same check here, and hold the SAME lock
|
||||
// across both the check and `commit`, so a rotate cannot land in the
|
||||
// gap between the validated value and the terminal write that
|
||||
// reports it as authenticated: a rotate either finishes (and this
|
||||
// check reads its new value, and rejects) before this section
|
||||
// acquires the lock, or it waits for this section — including the
|
||||
// terminal commit — to finish first.
|
||||
async runTerminalCommit(commit, context) {
|
||||
const pending = pendingAccountHomeSecretCommits.get(context.sessionId);
|
||||
pendingAccountHomeSecretCommits.delete(context.sessionId);
|
||||
if (!pending) {
|
||||
// `promote` never reached a secret bind for this session (for
|
||||
// example, a rejected promotion already failed the login before
|
||||
// the service ever reaches this call). Nothing to reconfirm.
|
||||
return commit();
|
||||
}
|
||||
if (outcome !== "promoted" && outcome !== "kept") {
|
||||
throw new Error(`device-login credential promotion rejected: ${outcome}`);
|
||||
}
|
||||
return withAccountHomeSecretMutationLock(undefined, context.companyId, async () => {
|
||||
// Resolve by the secret's id, not its name: a rotate changes the
|
||||
// value under the same id, so re-resolving this id picks up a
|
||||
// rotation the same way the very first check would have, with no
|
||||
// need to re-look the secret up by name. A deleted secret makes
|
||||
// this resolve call itself fail (unlike a value mismatch, which
|
||||
// `assertAccountHomeSecretMatches` turns into its own error), and
|
||||
// that failure propagates the same way: the login never
|
||||
// authenticates.
|
||||
await assertAccountHomeSecretMatches(
|
||||
secretsSvc,
|
||||
context.companyId,
|
||||
{ id: pending.secretId },
|
||||
pending.secretName,
|
||||
pending.accountHomeDir,
|
||||
);
|
||||
return commit();
|
||||
});
|
||||
},
|
||||
},
|
||||
grok_local: {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,16 @@ export interface CredentialPromotionContext {
|
|||
*/
|
||||
export interface CredentialPromotion {
|
||||
promote(authBytes: Buffer, context: CredentialPromotionContext): void | Promise<void>;
|
||||
/**
|
||||
* Wraps the service's terminal "authenticated" commit, immediately before
|
||||
* the service runs it. A promotion that already bound and validated a
|
||||
* value in `promote` can hold the SAME lock across this call, so a write
|
||||
* that would invalidate that value cannot land in the gap between the
|
||||
* earlier validation and this terminal commit. When the wrapper throws,
|
||||
* the service records a failed login instead of `authenticated` and never
|
||||
* runs `commit`. A promotion that omits this runs `commit` directly.
|
||||
*/
|
||||
runTerminalCommit?<T>(commit: () => Promise<T>, context: CredentialPromotionContext): Promise<T>;
|
||||
}
|
||||
|
||||
/** The redacted lifecycle phases. Each phase carries no secret data. */
|
||||
|
|
@ -1082,15 +1092,41 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
|
|||
// Publish `authenticated` only when the final conditional write still finds
|
||||
// the held claim. A lost write means the claim expired and the reaper
|
||||
// reclaimed the row, so the service never publishes `authenticated`.
|
||||
return await terminate({
|
||||
const commitAuthenticated = () =>
|
||||
terminate({
|
||||
sessionId,
|
||||
lease,
|
||||
terminal: "authenticated",
|
||||
reason: null,
|
||||
expectedStatuses: ["promoting"],
|
||||
conditionalTransition,
|
||||
activity,
|
||||
});
|
||||
const promotionContext: CredentialPromotionContext = {
|
||||
sessionId,
|
||||
lease,
|
||||
terminal: "authenticated",
|
||||
reason: null,
|
||||
expectedStatuses: ["promoting"],
|
||||
conditionalTransition,
|
||||
activity,
|
||||
});
|
||||
companyId: input.companyId,
|
||||
startedByUserId: input.startedByUserId,
|
||||
adapterType: input.adapterType,
|
||||
};
|
||||
try {
|
||||
return profile.promotion.runTerminalCommit
|
||||
? await profile.promotion.runTerminalCommit(commitAuthenticated, promotionContext)
|
||||
: await commitAuthenticated();
|
||||
} catch {
|
||||
// The wrapped final check rejected the value `promote` bound earlier
|
||||
// (for example, a rotation landed after that validation). The login
|
||||
// did not finish, so fail closed the same way a `promote` rejection
|
||||
// does: never publish `authenticated`, still delete the sandbox.
|
||||
return await terminate({
|
||||
sessionId,
|
||||
lease,
|
||||
terminal: "failed",
|
||||
reason: "promotion_failed",
|
||||
expectedStatuses: ["promoting"],
|
||||
conditionalTransition,
|
||||
activity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const terminal: AdapterAuthSessionStatus =
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -16,6 +16,28 @@ test("the exact published canary installs and reaches Connect a model", async ({
|
|||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
// Browser storage is scoped to the Paperclip origin, not to a data directory.
|
||||
// A customer can therefore start a freshly installed server with an existing
|
||||
// onboarding draft. Creating the organization invalidates the company list;
|
||||
// this release check must prove that a refetch does not remount the wizard
|
||||
// from that old draft and leave the customer on the name screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("paperclip-onboarding-state", JSON.stringify({
|
||||
step: 1,
|
||||
companyName: "",
|
||||
createdCompanyId: null,
|
||||
}));
|
||||
});
|
||||
let delayCompanyListRefetch = false;
|
||||
await page.route("**/api/companies", async (route) => {
|
||||
if (delayCompanyListRefetch && route.request().method() === "GET") {
|
||||
// Make the invalidation window observable. The previous implementation
|
||||
// unmounted the live wizard for this entire request.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const health = await getJson<{
|
||||
status: string;
|
||||
version: string;
|
||||
|
|
@ -30,6 +52,7 @@ test("the exact published canary installs and reaches Connect a model", async ({
|
|||
page.getByRole("heading", { name: "What is the name of your organization?" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("textbox").fill(companyName);
|
||||
delayCompanyListRefetch = true;
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
const agentNameField = page.locator("#onboarding-agent-name");
|
||||
|
|
|
|||
|
|
@ -29,6 +29,26 @@ test.describe("Onboarding wizard", () => {
|
|||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
// `--data-dir` starts a new server, not a new browser profile. Keep a
|
||||
// resumable draft here so this ordinary browser condition is covered when
|
||||
// the company-list invalidation runs after the create request.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("paperclip-onboarding-state", JSON.stringify({
|
||||
step: 1,
|
||||
companyName: "",
|
||||
createdCompanyId: null,
|
||||
}));
|
||||
});
|
||||
let delayCompanyListRefetch = false;
|
||||
await page.route("**/api/companies", async (route) => {
|
||||
if (delayCompanyListRefetch && route.request().method() === "GET") {
|
||||
// Keep the invalidation observable: a background fetch must not reset
|
||||
// the in-progress wizard.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
|
||||
// experimental flag on for this throwaway instance before driving them.
|
||||
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
|
||||
|
|
@ -56,6 +76,7 @@ test.describe("Onboarding wizard", () => {
|
|||
page.getByRole("heading", { name: "What is the name of your organization?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByPlaceholder("e.g. Northwind Labs").fill(COMPANY_NAME);
|
||||
delayCompanyListRefetch = true;
|
||||
await page.getByRole("button", { name: /^Continue/ }).click();
|
||||
|
||||
// Step 1's "Next" now creates the company and goes straight to the agent.
|
||||
|
|
|
|||
|
|
@ -1196,6 +1196,132 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the live wizard mounted while a post-create company-list refetch runs", async () => {
|
||||
// The authorization fetch needs to delay the first mount when a draft
|
||||
// exists. Once the customer has typed, though, invalidating that query is
|
||||
// normal background work. Unmounting for the refetch remounted the wizard
|
||||
// from this page-load draft and made a successful create look like a reset.
|
||||
window.localStorage.setItem(
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
JSON.stringify({ step: 1, companyName: "", createdCompanyId: null }),
|
||||
);
|
||||
let resolveRefetch: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void =
|
||||
() => {};
|
||||
mockCompaniesApi.list
|
||||
.mockResolvedValueOnce([])
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Array<{ id: string; name: string; issuePrefix: string }>>((resolve) => {
|
||||
resolveRefetch = resolve;
|
||||
}),
|
||||
);
|
||||
mockCompaniesApi.create.mockResolvedValue({
|
||||
id: "created",
|
||||
name: "Created Co",
|
||||
issuePrefix: "CRE",
|
||||
});
|
||||
|
||||
const { root, queryClient } = render();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OnboardingWizard />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const companyNameInput = document.querySelector("input") as HTMLInputElement;
|
||||
setControlledValue(companyNameInput, "Created Co");
|
||||
await act(async () => {
|
||||
[...document.body.querySelectorAll("button")]
|
||||
.find((button) => button.textContent?.trim() === "Continue")!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Created Co" });
|
||||
expect(mockCompaniesApi.list).toHaveBeenCalledTimes(2);
|
||||
expect(document.querySelector("#onboarding-agent-name")).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveRefetch([{ id: "created", name: "Created Co", issuePrefix: "CRE" }]);
|
||||
});
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("restores a draft after its initial ownership check fails and a retry succeeds", async () => {
|
||||
// A failed first request cannot authorize the draft, so the wizard opens
|
||||
// with safe defaults. The original gate remounted on a later successful
|
||||
// retry so the now-verified draft could be restored; keep that recovery
|
||||
// while preserving the post-create refetch fix above.
|
||||
window.localStorage.setItem(
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
step: 3,
|
||||
companyName: "Saved Co",
|
||||
agentName: "Ops Lead",
|
||||
createdCompanyId: "c1",
|
||||
}),
|
||||
);
|
||||
const company = { id: "c1", name: "Saved Co", issuePrefix: "SC" };
|
||||
let resolveRetry: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void =
|
||||
() => {};
|
||||
mockCompaniesApi.list
|
||||
.mockRejectedValueOnce(new Error("company list unavailable"))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Array<{ id: string; name: string; issuePrefix: string }>>((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { root, queryClient } = render();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OnboardingWizard />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.querySelector("#onboarding-agent-name")).toBeNull();
|
||||
|
||||
mockCompany.companies = [company];
|
||||
await act(async () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.companies.list(SESSION_USER_ID),
|
||||
});
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// The retry is intentionally still in flight. The wrapper removes the
|
||||
// safe-default wizard so a completed validation can mount the saved draft.
|
||||
expect(document.body.textContent).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
resolveRetry([company]);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockCompaniesApi.list).toHaveBeenCalledTimes(2);
|
||||
expect(queryClient.getQueryData(queryKeys.companies.list(SESSION_USER_ID))).toEqual({
|
||||
companies: [company],
|
||||
unauthorized: false,
|
||||
});
|
||||
const agentNameInput = document.querySelector(
|
||||
"#onboarding-agent-name",
|
||||
) as HTMLInputElement | null;
|
||||
expect(agentNameInput?.value).toBe("Ops Lead");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("discards a saved draft for a company the signed-in account does not own, and wipes the stale blob", async () => {
|
||||
// The actual vulnerability this fix closes: localStorage is per-origin,
|
||||
// not per-account, so a browser that already onboarded "company-old" for
|
||||
|
|
|
|||
|
|
@ -332,6 +332,19 @@ export function OnboardingWizard() {
|
|||
return null; // malformed: treated as stale below
|
||||
}
|
||||
}, []);
|
||||
// The ownership gate is closed after the initial validation succeeds, or
|
||||
// when no validation is needed. A later company-list invalidation is
|
||||
// ordinary background work. If it unmounted the inner wizard then, all of
|
||||
// its live useState values would be reconstructed from `rawBlob` above —
|
||||
// the value from page load, not the draft the customer just typed — and a
|
||||
// successful organization submission would appear to do nothing.
|
||||
//
|
||||
// A failed validation must remain retryable. A later successful fetch needs
|
||||
// to remount the wizard with the now-authorized draft, rather than keeping
|
||||
// the defaults it showed while ownership was unknown.
|
||||
const [initialDraftValidationComplete, setInitialDraftValidationComplete] = useState(
|
||||
rawBlob === undefined || rawBlob === null,
|
||||
);
|
||||
|
||||
// Whether this account owns the company the draft names is an authorization
|
||||
// question, and the answer has to be about the account asking now.
|
||||
|
|
@ -404,10 +417,11 @@ export function OnboardingWizard() {
|
|||
onboardingDraftStorage.clear();
|
||||
}, [staleStateDetected]);
|
||||
|
||||
// A saved blob exists and the verification fetch is still in flight: wait,
|
||||
// rather than mount the inner wizard with a premature and unrecoverable
|
||||
// guess at the draft. Its ~20 `useState(saved?.x ?? default)` initializers
|
||||
// only read `saved` once.
|
||||
// A saved blob exists and its *initial* verification fetch is still in
|
||||
// flight: wait, rather than mount the inner wizard with a premature and
|
||||
// unrecoverable guess at the draft. Its ~20 `useState(saved?.x ?? default)`
|
||||
// initializers only read `saved` once. After that first mount this is a
|
||||
// background refetch, which must not tear down the customer's live state.
|
||||
//
|
||||
// `isFetching`, not `isLoading`. `isLoading` is false whenever the cache
|
||||
// holds retained data, so a refetch over a warm cache would mount the wizard
|
||||
|
|
@ -424,7 +438,20 @@ export function OnboardingWizard() {
|
|||
// it is itself gated on `effectiveOnboardingOpen`, so a mounted-but-closed
|
||||
// wizard writes nothing. If the wizard is open the customer is onboarding
|
||||
// right now, which supersedes the draft anyway.
|
||||
if (rawBlob !== undefined && companiesQuery.isFetching) {
|
||||
const waitForInitialDraftValidation =
|
||||
!initialDraftValidationComplete && rawBlob !== undefined && companiesQuery.isFetching;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!initialDraftValidationComplete &&
|
||||
ownershipDecidable &&
|
||||
!companiesQuery.isFetching
|
||||
) {
|
||||
setInitialDraftValidationComplete(true);
|
||||
}
|
||||
}, [initialDraftValidationComplete, ownershipDecidable, companiesQuery.isFetching]);
|
||||
|
||||
if (waitForInitialDraftValidation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue