mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(codex): address WHAM 401 classification, test ordering, and regex fixes (PAP-2066)
- Tighten classifyCodexAuthRefreshFailure: bare 401s without credential context no longer match the contextual-auth regex (WHAM 401 quota probe stays null) - Expand invalid_grant and missing-bearer classification paths - Fix execute.remote.test.ts: move post-run telemetry test above SSH-ordering tests and wire runSshCommand correctly in vi.mock for the ssh module - Fix quota-spawn-error.test.ts: bare WHAM 401 errorFamily must be undefined, not refresh_token_invalidated - Minor code-organisation moves in execute.ts and credential-telemetry.ts Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d8bc224137
commit
67220638f0
5 files changed
+142
-118
No files matched your search
@@ -17,12 +17,32 @@ describe("Codex credential telemetry", () => {
|
||||
expect(classifyCodexAuthRefreshFailure({ stderr: "OAuth failed: refresh token expired" })).toBe(
|
||||
"refresh_token_expired",
|
||||
);
|
||||
expect(classifyCodexAuthRefreshFailure({ errorMessage: "chatgpt wham api returned 401" })).toBe(
|
||||
expect(classifyCodexAuthRefreshFailure({ errorMessage: "OAuth failed: invalid_grant" })).toBe(
|
||||
"refresh_token_invalidated",
|
||||
);
|
||||
expect(classifyCodexAuthRefreshFailure({ errorMessage: "refresh token has been invalidated" })).toBe(
|
||||
"refresh_token_invalidated",
|
||||
);
|
||||
expect(classifyCodexAuthRefreshFailure({ errorMessage: "Codex OAuth refresh failed: unauthorized" })).toBe(
|
||||
"refresh_token_invalidated",
|
||||
);
|
||||
expect(classifyCodexAuthRefreshFailure({ errorMessage: "model is at capacity" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not classify generic downstream or transient unauthorized output as refresh-token invalidation", () => {
|
||||
expect(classifyCodexAuthRefreshFailure({ errorMessage: "chatgpt wham api returned 401" })).toBeNull();
|
||||
expect(
|
||||
classifyCodexAuthRefreshFailure({
|
||||
stdout: "downstream webhook failed with 401 while reporting the result",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyCodexAuthRefreshFailure({
|
||||
stderr: "transient upstream unavailable: provider returned unauthorized",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("buckets last_refresh into the approved low-cardinality buckets", () => {
|
||||
const now = new Date("2026-07-09T12:00:00.000Z");
|
||||
|
||||
|
||||
@@ -32,9 +32,6 @@ export interface CodexCredentialTelemetrySnapshot {
|
||||
lastRefreshAgeBucket: CodexLastRefreshAgeBucket;
|
||||
}
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const EIGHT_DAYS_MS = 8 * 24 * ONE_HOUR_MS;
|
||||
|
||||
function missingCodexCredentialTelemetrySnapshot(): CodexCredentialTelemetrySnapshot {
|
||||
return {
|
||||
refreshTokenFingerprint: null,
|
||||
@@ -42,6 +39,9 @@ function missingCodexCredentialTelemetrySnapshot(): CodexCredentialTelemetrySnap
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const EIGHT_DAYS_MS = 8 * 24 * ONE_HOUR_MS;
|
||||
|
||||
const CODEX_REFRESH_TOKEN_REUSED_RE =
|
||||
/(?:refresh[_\s-]?token[_\s-]?reused|refresh token (?:has )?already been used|token reuse detected)/i;
|
||||
const CODEX_REFRESH_TOKEN_EXPIRED_RE =
|
||||
@@ -97,6 +97,7 @@ export async function readCodexCredentialTelemetrySnapshot(
|
||||
} catch {
|
||||
return missingCodexCredentialTelemetrySnapshot();
|
||||
}
|
||||
|
||||
return parseCodexCredentialTelemetrySnapshot(raw, now);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ const {
|
||||
resolveCommandForLogs,
|
||||
prepareWorkspaceForSshExecution,
|
||||
restoreWorkspaceFromSshExecution,
|
||||
runSshCommand,
|
||||
syncDirectoryToSsh,
|
||||
runSshCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async () => ({
|
||||
@@ -26,8 +26,8 @@ const {
|
||||
resolveCommandForLogs: vi.fn(async () => "/usr/bin/codex"),
|
||||
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
|
||||
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
|
||||
runSshCommand: vi.fn(async (_spec: unknown, _command: string) => ({ stdout: "", stderr: "" })),
|
||||
syncDirectoryToSsh: vi.fn(async () => undefined),
|
||||
runSshCommand: vi.fn(async (_spec: unknown, _command: string) => ({ stdout: "", stderr: "" })),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
||||
env: {
|
||||
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
||||
@@ -59,6 +59,7 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||
prepareWorkspaceForSshExecution,
|
||||
restoreWorkspaceFromSshExecution,
|
||||
syncDirectoryToSsh,
|
||||
runSshCommand,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -68,7 +69,6 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
runAdapterExecutionTargetShellCommand: runSshCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
};
|
||||
});
|
||||
@@ -206,6 +206,88 @@ describe("codex remote execution", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("reads post-run credential telemetry from the remote CODEX_HOME", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-telemetry-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const codexHomeDir = path.join(rootDir, "codex-home");
|
||||
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-telemetry/workspace";
|
||||
const remoteAuthPath = `${managedRemoteWorkspace}/.paperclip-runtime/codex/home/auth.json`;
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(codexHomeDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(codexHomeDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: { refresh_token: "seed-refresh-token-secret" },
|
||||
last_refresh: "2999-01-01T00:00:00.000Z",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
runSshCommand.mockImplementationOnce(async (_spec, command) => {
|
||||
expect(command).toContain(remoteAuthPath);
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
tokens: { refresh_token: "remote-rotated-refresh-token-secret" },
|
||||
last_refresh: "2999-01-01T00:05:00.000Z",
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-telemetry",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "codex",
|
||||
env: {
|
||||
CODEX_HOME: codexHomeDir,
|
||||
},
|
||||
},
|
||||
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 telemetry = (result.resultJson as Record<string, unknown>).codexCredentialTelemetry;
|
||||
expect(telemetry).toEqual({
|
||||
seedSource: "snapshot_file",
|
||||
lastRefreshAgeBucket: "lt_1h",
|
||||
rotationsDetected: true,
|
||||
});
|
||||
const serialized = JSON.stringify(telemetry);
|
||||
expect(serialized).not.toContain("seed-refresh-token-secret");
|
||||
expect(serialized).not.toContain("remote-rotated-refresh-token-secret");
|
||||
expect(runSshCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not resume saved Codex sessions for remote SSH execution without a matching remote identity", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-resume-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
@@ -422,86 +504,4 @@ describe("codex remote execution", () => {
|
||||
expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`);
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
|
||||
});
|
||||
|
||||
it("reads post-run credential telemetry from the remote CODEX_HOME", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-telemetry-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const codexHomeDir = path.join(rootDir, "codex-home");
|
||||
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-telemetry/workspace";
|
||||
const remoteAuthPath = `${managedRemoteWorkspace}/.paperclip-runtime/codex/home/auth.json`;
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(codexHomeDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(codexHomeDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: { refresh_token: "seed-refresh-token-secret" },
|
||||
last_refresh: "2999-01-01T00:00:00.000Z",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
runSshCommand.mockImplementationOnce(async (_spec: unknown, command: string) => {
|
||||
expect(command).toContain(remoteAuthPath);
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
tokens: { refresh_token: "remote-rotated-refresh-token-secret" },
|
||||
last_refresh: "2999-01-01T00:05:00.000Z",
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-telemetry",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "codex",
|
||||
env: {
|
||||
CODEX_HOME: codexHomeDir,
|
||||
},
|
||||
},
|
||||
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 telemetry = (result.resultJson as Record<string, unknown>).codexCredentialTelemetry;
|
||||
expect(telemetry).toEqual({
|
||||
seedSource: "snapshot_file",
|
||||
lastRefreshAgeBucket: "lt_1h",
|
||||
rotationsDetected: true,
|
||||
});
|
||||
const serialized = JSON.stringify(telemetry);
|
||||
expect(serialized).not.toContain("seed-refresh-token-secret");
|
||||
expect(serialized).not.toContain("remote-rotated-refresh-token-secret");
|
||||
expect(runSshCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -63,8 +63,8 @@ import {
|
||||
parseCodexCredentialTelemetrySnapshot,
|
||||
readCodexCredentialTelemetrySnapshot,
|
||||
type CodexAuthRefreshFailureClass,
|
||||
type CodexCredentialSeedSource,
|
||||
type CodexCredentialTelemetrySnapshot,
|
||||
type CodexCredentialSeedSource,
|
||||
} from "./credential-telemetry.js";
|
||||
import { prepareCodexRuntimeConfig } from "./runtime-config.js";
|
||||
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||
@@ -87,12 +87,39 @@ const executeCodexAcp = createCodexAcpExecutor();
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
||||
|
||||
function stripCodexRolloutNoise(text: string): string {
|
||||
const parts = text.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
kept.push(part);
|
||||
continue;
|
||||
}
|
||||
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function missingCodexCredentialTelemetrySnapshot(): CodexCredentialTelemetrySnapshot {
|
||||
return { refreshTokenFingerprint: null, lastRefreshAgeBucket: "missing" };
|
||||
return {
|
||||
refreshTokenFingerprint: null,
|
||||
lastRefreshAgeBucket: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
async function readPostRunCodexCredentialTelemetrySnapshot(input: {
|
||||
@@ -128,30 +155,6 @@ async function readPostRunCodexCredentialTelemetrySnapshot(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function stripCodexRolloutNoise(text: string): string {
|
||||
const parts = text.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
kept.push(part);
|
||||
continue;
|
||||
}
|
||||
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function signalCodexChild(
|
||||
target: { pid: number | null; processGroupId: number | null },
|
||||
signal: NodeJS.Signals,
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("CodexRpcClient spawn failures", () => {
|
||||
expect(result.error).toContain("spawn codex ENOENT");
|
||||
});
|
||||
|
||||
it("classifies WHAM 401 quota probe failures without exposing token material", async () => {
|
||||
it("does not classify bare WHAM 401 quota probe failures or expose token material", async () => {
|
||||
const token = "access-token-fixture-secret";
|
||||
fs.writeFileSync(
|
||||
path.join(isolatedCodexHome!, "auth.json"),
|
||||
@@ -105,7 +105,7 @@ describe("CodexRpcClient spawn failures", () => {
|
||||
const result = await getQuotaWindows();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errorFamily).toBe("refresh_token_invalidated");
|
||||
expect(result.errorFamily).toBeUndefined();
|
||||
expect(result.error).toContain("chatgpt wham api returned 401");
|
||||
expect(JSON.stringify(result)).not.toContain(token);
|
||||
expect(JSON.stringify(result)).not.toContain("refresh-token-fixture-secret");
|
||||
|
||||
Reference in new issue
Block a user