mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
fix: sync codex sandbox auth refreshes
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
4 files changed
+555
-5
No files matched your search
@@ -1,8 +1,10 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// Co-change notice: parseAuth below mirrors hasUsableAuthPayload in
|
||||
// packages/adapters/codex-local/src/server/codex-home.ts. If the auth format
|
||||
// changes (new shape, renamed field), update both sites together.
|
||||
// packages/adapters/codex-local/src/server/codex-home.ts and
|
||||
// parseCodexAuthPayload in packages/adapter-utils/src/sandbox-managed-runtime.ts.
|
||||
// If the auth format changes (new shape, renamed field), update all sites
|
||||
// together.
|
||||
function parseAuth(filePath) {
|
||||
let parsed;
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
@@ -28,6 +28,52 @@ async function listTarMembers(rootDir: string, name: string, bytes: Buffer): Pro
|
||||
return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function createLocalSandboxClient(): SandboxManagedRuntimeClient {
|
||||
return {
|
||||
makeDir: async (remotePath) => {
|
||||
await mkdir(remotePath, { recursive: true });
|
||||
},
|
||||
writeFile: async (remotePath, bytes) => {
|
||||
await mkdir(path.dirname(remotePath), { recursive: true });
|
||||
await writeFile(remotePath, Buffer.from(bytes));
|
||||
},
|
||||
readFile: async (remotePath) => await readFile(remotePath),
|
||||
listFiles: async (remotePath) => {
|
||||
const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []);
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
},
|
||||
remove: async (remotePath) => {
|
||||
await rm(remotePath, { recursive: true, force: true });
|
||||
},
|
||||
run: async (command) => {
|
||||
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function codexSubscriptionAuth(input: {
|
||||
accountId?: string;
|
||||
lastRefresh: string;
|
||||
refreshToken: string;
|
||||
accessToken?: string;
|
||||
idToken?: string;
|
||||
padding?: string;
|
||||
}): string {
|
||||
return `${JSON.stringify({
|
||||
tokens: {
|
||||
account_id: input.accountId ?? "acct-1",
|
||||
id_token: input.idToken ?? "id-token",
|
||||
access_token: input.accessToken ?? "access-token",
|
||||
refresh_token: input.refreshToken,
|
||||
},
|
||||
last_refresh: input.lastRefresh,
|
||||
...(input.padding ? { padding: input.padding } : {}),
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
describe("sandbox managed runtime", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
@@ -162,6 +208,261 @@ describe("sandbox managed runtime", () => {
|
||||
expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing sandbox workspace");
|
||||
});
|
||||
|
||||
it("syncs newer sandbox Codex auth.json back to the host symlink source and seeds the next run", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-codex-auth-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const nextRemoteWorkspaceDir = path.join(rootDir, "remote-workspace-next");
|
||||
const localCodexHome = path.join(rootDir, "local-codex-home");
|
||||
const hostCodexHome = path.join(rootDir, "host-codex-home");
|
||||
const hostAuthSource = path.join(hostCodexHome, "auth.json");
|
||||
await mkdir(localWorkspaceDir, { recursive: true });
|
||||
await mkdir(localCodexHome, { recursive: true });
|
||||
await mkdir(hostCodexHome, { recursive: true });
|
||||
|
||||
const oldAuth = codexSubscriptionAuth({
|
||||
lastRefresh: "2026-01-01T00:00:00.000Z",
|
||||
refreshToken: "refresh-old",
|
||||
accessToken: "access-old",
|
||||
idToken: "id-old",
|
||||
});
|
||||
const newAuth = codexSubscriptionAuth({
|
||||
lastRefresh: "2026-01-01T00:01:00.000Z",
|
||||
refreshToken: "refresh-new-secret",
|
||||
accessToken: "access-new-secret",
|
||||
idToken: "id-new-secret",
|
||||
padding: "x".repeat(1024 * 1024),
|
||||
});
|
||||
await writeFile(hostAuthSource, oldAuth, { mode: 0o600 });
|
||||
await symlink(hostAuthSource, path.join(localCodexHome, "auth.json"));
|
||||
|
||||
const client = createLocalSandboxClient();
|
||||
const lines: string[] = [];
|
||||
const runtimeStatuses: string[] = [];
|
||||
const prepared = await prepareSandboxManagedRuntime({
|
||||
spec: {
|
||||
transport: "sandbox",
|
||||
provider: "test",
|
||||
sandboxId: "sandbox-1",
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
apiKey: null,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
client,
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
assets: [{ key: "home", localDir: localCodexHome, followSymlinks: true }],
|
||||
onProgress: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
onRuntimeProgress: (status) => {
|
||||
runtimeStatuses.push(`${status.phase}:${status.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
await writeFile(path.join(prepared.assetDirs.home, "auth.json"), newAuth, "utf8");
|
||||
|
||||
const invalidReads: string[] = [];
|
||||
let polling = true;
|
||||
const pollPromise = (async () => {
|
||||
while (polling) {
|
||||
const raw = await readFile(hostAuthSource, "utf8");
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch {
|
||||
invalidReads.push(raw.slice(0, 64));
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await prepared.restoreWorkspace();
|
||||
} finally {
|
||||
polling = false;
|
||||
await pollPromise;
|
||||
}
|
||||
|
||||
expect(invalidReads).toEqual([]);
|
||||
await expect(readFile(hostAuthSource, "utf8")).resolves.toBe(newAuth);
|
||||
expect((await lstat(path.join(localCodexHome, "auth.json"))).isSymbolicLink()).toBe(true);
|
||||
expect(await realpath(path.join(localCodexHome, "auth.json"))).toBe(await realpath(hostAuthSource));
|
||||
expect((await lstat(hostAuthSource)).mode & 0o777).toBe(0o600);
|
||||
expect((await readdir(hostCodexHome)).filter((name) => name.includes(".auth.json.paperclip"))).toEqual([]);
|
||||
|
||||
const combinedTelemetry = `${lines.join("")}\n${runtimeStatuses.join("\n")}`;
|
||||
expect(combinedTelemetry).toContain("updated host auth source");
|
||||
expect(combinedTelemetry).not.toContain("refresh-new-secret");
|
||||
expect(combinedTelemetry).not.toContain("access-new-secret");
|
||||
expect(combinedTelemetry).not.toContain("id-new-secret");
|
||||
|
||||
const nextPrepared = await prepareSandboxManagedRuntime({
|
||||
spec: {
|
||||
transport: "sandbox",
|
||||
provider: "test",
|
||||
sandboxId: "sandbox-2",
|
||||
remoteCwd: nextRemoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
apiKey: null,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
client,
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
assets: [{ key: "home", localDir: localCodexHome, followSymlinks: true }],
|
||||
});
|
||||
await expect(readFile(path.join(nextPrepared.assetDirs.home, "auth.json"), "utf8")).resolves.toBe(newAuth);
|
||||
});
|
||||
|
||||
it("does not replace newer host Codex auth.json with older sandbox auth", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-codex-auth-older-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const localCodexHome = path.join(rootDir, "local-codex-home");
|
||||
const hostCodexHome = path.join(rootDir, "host-codex-home");
|
||||
const hostAuthSource = path.join(hostCodexHome, "auth.json");
|
||||
await mkdir(localWorkspaceDir, { recursive: true });
|
||||
await mkdir(localCodexHome, { recursive: true });
|
||||
await mkdir(hostCodexHome, { recursive: true });
|
||||
|
||||
const hostAuth = codexSubscriptionAuth({
|
||||
lastRefresh: "2026-01-01T00:10:00.000Z",
|
||||
refreshToken: "refresh-host-newer",
|
||||
});
|
||||
const sandboxAuth = codexSubscriptionAuth({
|
||||
lastRefresh: "2026-01-01T00:05:00.000Z",
|
||||
refreshToken: "refresh-sandbox-older",
|
||||
});
|
||||
await writeFile(hostAuthSource, hostAuth, { mode: 0o600 });
|
||||
await symlink(hostAuthSource, path.join(localCodexHome, "auth.json"));
|
||||
|
||||
const lines: string[] = [];
|
||||
const prepared = await prepareSandboxManagedRuntime({
|
||||
spec: {
|
||||
transport: "sandbox",
|
||||
provider: "test",
|
||||
sandboxId: "sandbox-1",
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
apiKey: null,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
client: createLocalSandboxClient(),
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
assets: [{ key: "home", localDir: localCodexHome, followSymlinks: true }],
|
||||
onProgress: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
});
|
||||
await writeFile(path.join(prepared.assetDirs.home, "auth.json"), sandboxAuth, "utf8");
|
||||
|
||||
await prepared.restoreWorkspace();
|
||||
|
||||
await expect(readFile(hostAuthSource, "utf8")).resolves.toBe(hostAuth);
|
||||
expect(lines.join("")).toContain("sandbox auth is not newer");
|
||||
expect(lines.join("")).not.toContain("refresh-sandbox-older");
|
||||
});
|
||||
|
||||
it("does not sync sandbox Codex auth.json when account ids differ", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-codex-auth-mismatch-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const localCodexHome = path.join(rootDir, "local-codex-home");
|
||||
const hostCodexHome = path.join(rootDir, "host-codex-home");
|
||||
const hostAuthSource = path.join(hostCodexHome, "auth.json");
|
||||
await mkdir(localWorkspaceDir, { recursive: true });
|
||||
await mkdir(localCodexHome, { recursive: true });
|
||||
await mkdir(hostCodexHome, { recursive: true });
|
||||
|
||||
const hostAuth = codexSubscriptionAuth({
|
||||
accountId: "acct-host",
|
||||
lastRefresh: "2026-01-01T00:00:00.000Z",
|
||||
refreshToken: "refresh-host",
|
||||
});
|
||||
const sandboxAuth = codexSubscriptionAuth({
|
||||
accountId: "acct-sandbox",
|
||||
lastRefresh: "2026-01-01T00:10:00.000Z",
|
||||
refreshToken: "refresh-sandbox-mismatch",
|
||||
});
|
||||
await writeFile(hostAuthSource, hostAuth, { mode: 0o600 });
|
||||
await symlink(hostAuthSource, path.join(localCodexHome, "auth.json"));
|
||||
|
||||
const lines: string[] = [];
|
||||
const prepared = await prepareSandboxManagedRuntime({
|
||||
spec: {
|
||||
transport: "sandbox",
|
||||
provider: "test",
|
||||
sandboxId: "sandbox-1",
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
apiKey: null,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
client: createLocalSandboxClient(),
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
assets: [{ key: "home", localDir: localCodexHome, followSymlinks: true }],
|
||||
onProgress: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
});
|
||||
await writeFile(path.join(prepared.assetDirs.home, "auth.json"), sandboxAuth, "utf8");
|
||||
|
||||
await prepared.restoreWorkspace();
|
||||
|
||||
await expect(readFile(hostAuthSource, "utf8")).resolves.toBe(hostAuth);
|
||||
expect(lines.join("")).toContain("account ids do not match");
|
||||
expect(lines.join("")).not.toContain("refresh-sandbox-mismatch");
|
||||
expect(lines.join("")).not.toContain("acct-sandbox");
|
||||
});
|
||||
|
||||
it("does not sync Codex auth.json back through a regular host auth file", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-codex-auth-regular-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const localCodexHome = path.join(rootDir, "local-codex-home");
|
||||
await mkdir(localWorkspaceDir, { recursive: true });
|
||||
await mkdir(localCodexHome, { recursive: true });
|
||||
|
||||
const hostAuth = codexSubscriptionAuth({
|
||||
lastRefresh: "2026-01-01T00:00:00.000Z",
|
||||
refreshToken: "refresh-regular-host",
|
||||
});
|
||||
const sandboxAuth = codexSubscriptionAuth({
|
||||
lastRefresh: "2026-01-01T00:10:00.000Z",
|
||||
refreshToken: "refresh-regular-sandbox",
|
||||
});
|
||||
await writeFile(path.join(localCodexHome, "auth.json"), hostAuth, { mode: 0o600 });
|
||||
|
||||
const lines: string[] = [];
|
||||
const prepared = await prepareSandboxManagedRuntime({
|
||||
spec: {
|
||||
transport: "sandbox",
|
||||
provider: "test",
|
||||
sandboxId: "sandbox-1",
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
apiKey: null,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
client: createLocalSandboxClient(),
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
assets: [{ key: "home", localDir: localCodexHome, followSymlinks: true }],
|
||||
onProgress: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
});
|
||||
await writeFile(path.join(prepared.assetDirs.home, "auth.json"), sandboxAuth, "utf8");
|
||||
|
||||
await prepared.restoreWorkspace();
|
||||
|
||||
await expect(readFile(path.join(localCodexHome, "auth.json"), "utf8")).resolves.toBe(hostAuth);
|
||||
expect(lines.join("")).toContain("host auth.json is not a symlink");
|
||||
expect(lines.join("")).not.toContain("refresh-regular-sandbox");
|
||||
});
|
||||
|
||||
it("syncs git-backed workspaces through a shallow standalone clone and keeps .git out of archives", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-git-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
@@ -52,6 +52,7 @@ const SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES = SANDBOX_WORKSPACE_HEAVY_DIR_NAMES.f
|
||||
`*/${entry}`,
|
||||
`*/${entry}/*`,
|
||||
]);
|
||||
const CODEX_AUTH_JSON_NAME = "auth.json";
|
||||
|
||||
export interface SandboxRemoteExecutionSpec {
|
||||
transport: "sandbox";
|
||||
@@ -100,6 +101,20 @@ export interface PreparedSandboxManagedRuntime {
|
||||
restoreWorkspace(onProgress?: RuntimeProgressSink): Promise<void>;
|
||||
}
|
||||
|
||||
type ParsedCodexAuth =
|
||||
| { kind: "unusable" }
|
||||
| { kind: "apikey" }
|
||||
| { kind: "subscription"; accountId: string; lastRefresh: number | null };
|
||||
|
||||
type CodexAuthSyncBackOutcome =
|
||||
| "updated"
|
||||
| "skipped_no_host_symlink"
|
||||
| "skipped_sandbox_auth_missing"
|
||||
| "skipped_unusable_auth"
|
||||
| "skipped_api_key_auth"
|
||||
| "skipped_account_mismatch"
|
||||
| "skipped_not_newer";
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -349,6 +364,209 @@ function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
// Co-change notice: this parser mirrors hasUsableAuthPayload in
|
||||
// packages/adapters/codex-local/src/server/codex-home.ts and parseAuth in
|
||||
// packages/adapter-utils/src/codex-auth-merge-decision.cjs. If the auth format
|
||||
// changes (new shape, renamed field), update all sites together.
|
||||
function parseCodexAuthPayload(authPayload: unknown): ParsedCodexAuth {
|
||||
if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) {
|
||||
return { kind: "unusable" };
|
||||
}
|
||||
|
||||
const parsedPayload = authPayload as Record<string, unknown>;
|
||||
const apiKey = parsedPayload.OPENAI_API_KEY;
|
||||
if (typeof apiKey === "string" && apiKey.trim().length > 0) {
|
||||
return { kind: "apikey" };
|
||||
}
|
||||
|
||||
const tokens = parsedPayload.tokens;
|
||||
if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) {
|
||||
return { kind: "unusable" };
|
||||
}
|
||||
|
||||
const parsedTokens = tokens as Record<string, unknown>;
|
||||
const accountId = typeof parsedTokens.account_id === "string" ? parsedTokens.account_id.trim() : "";
|
||||
const hasTokenMaterial = ["id_token", "access_token", "refresh_token"].some((key) => {
|
||||
const value = parsedTokens[key];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
});
|
||||
if (!accountId || !hasTokenMaterial) {
|
||||
return { kind: "unusable" };
|
||||
}
|
||||
|
||||
const lastRefresh = typeof parsedPayload.last_refresh === "string"
|
||||
? Date.parse(parsedPayload.last_refresh)
|
||||
: NaN;
|
||||
return {
|
||||
kind: "subscription",
|
||||
accountId,
|
||||
lastRefresh: Number.isFinite(lastRefresh) ? lastRefresh : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCodexAuthBytes(bytes: Buffer): ParsedCodexAuth {
|
||||
try {
|
||||
return parseCodexAuthPayload(JSON.parse(bytes.toString("utf8")));
|
||||
} catch {
|
||||
return { kind: "unusable" };
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const code = typeof error === "object" && error !== null
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
return code === "ENOENT" || code === "ENOTDIR" || code === "NotFound";
|
||||
}
|
||||
|
||||
function errorCodeSuffix(error: unknown): string {
|
||||
const code = typeof error === "object" && error !== null
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
return typeof code === "string" && code.trim().length > 0 ? ` (${code.trim()})` : "";
|
||||
}
|
||||
|
||||
function codexAuthSyncBackMessage(outcome: CodexAuthSyncBackOutcome): string {
|
||||
switch (outcome) {
|
||||
case "updated":
|
||||
return "updated host auth source with newer sandbox refresh";
|
||||
case "skipped_no_host_symlink":
|
||||
return "skipped because host auth.json is not a symlink";
|
||||
case "skipped_sandbox_auth_missing":
|
||||
return "skipped because sandbox auth.json is missing";
|
||||
case "skipped_unusable_auth":
|
||||
return "skipped because sandbox or host auth.json is not usable subscription auth";
|
||||
case "skipped_api_key_auth":
|
||||
return "skipped because API-key auth is not synced back";
|
||||
case "skipped_account_mismatch":
|
||||
return "skipped because account ids do not match";
|
||||
case "skipped_not_newer":
|
||||
return "skipped because sandbox auth is not newer";
|
||||
}
|
||||
}
|
||||
|
||||
async function emitCodexAuthSyncBackOutcome(input: {
|
||||
onProgress?: RuntimeProgressSink;
|
||||
onRuntimeProgress?: RuntimeStatusSink;
|
||||
outcome: CodexAuthSyncBackOutcome;
|
||||
}): Promise<void> {
|
||||
const message = `Codex auth.json sync-back: ${codexAuthSyncBackMessage(input.outcome)}`;
|
||||
await input.onProgress?.(`[paperclip] ${message}\n`);
|
||||
await emitRuntimeStatus(input.onRuntimeProgress, "restore", message);
|
||||
}
|
||||
|
||||
async function resolveHostCodexAuthSymlinkSource(localHomeDir: string): Promise<string | null> {
|
||||
const localAuthPath = path.join(localHomeDir, CODEX_AUTH_JSON_NAME);
|
||||
const stats = await fs.lstat(localAuthPath).catch(() => null);
|
||||
if (!stats?.isSymbolicLink()) return null;
|
||||
|
||||
const linkTarget = await fs.readlink(localAuthPath);
|
||||
const resolved = path.resolve(path.dirname(localAuthPath), linkTarget);
|
||||
return await fs.realpath(resolved).catch(() => resolved);
|
||||
}
|
||||
|
||||
function decideCodexAuthSyncBack(input: {
|
||||
sandboxAuth: ParsedCodexAuth;
|
||||
hostAuth: ParsedCodexAuth;
|
||||
}): CodexAuthSyncBackOutcome {
|
||||
if (input.sandboxAuth.kind === "unusable" || input.hostAuth.kind === "unusable") {
|
||||
return "skipped_unusable_auth";
|
||||
}
|
||||
|
||||
if (input.sandboxAuth.kind === "apikey" || input.hostAuth.kind === "apikey") {
|
||||
return "skipped_api_key_auth";
|
||||
}
|
||||
|
||||
if (input.sandboxAuth.accountId !== input.hostAuth.accountId) {
|
||||
return "skipped_account_mismatch";
|
||||
}
|
||||
|
||||
if (
|
||||
input.sandboxAuth.lastRefresh === null ||
|
||||
input.hostAuth.lastRefresh === null ||
|
||||
input.sandboxAuth.lastRefresh <= input.hostAuth.lastRefresh
|
||||
) {
|
||||
return "skipped_not_newer";
|
||||
}
|
||||
|
||||
return "updated";
|
||||
}
|
||||
|
||||
async function writeFileAtomic0600(targetPath: string, bytes: Buffer): Promise<void> {
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const tempPath = path.join(
|
||||
path.dirname(targetPath),
|
||||
`.${path.basename(targetPath)}.paperclip-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPath, bytes, { mode: 0o600 });
|
||||
await fs.chmod(tempPath, 0o600);
|
||||
await fs.rename(tempPath, targetPath);
|
||||
await fs.chmod(targetPath, 0o600);
|
||||
} catch (error) {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCodexHomeAuthJsonBackToHost(input: {
|
||||
adapterKey: string;
|
||||
assets?: SandboxManagedRuntimeAsset[];
|
||||
assetDirs: Record<string, string>;
|
||||
client: SandboxManagedRuntimeClient;
|
||||
onProgress?: RuntimeProgressSink;
|
||||
onRuntimeProgress?: RuntimeStatusSink;
|
||||
}): Promise<void> {
|
||||
const homeAsset = input.adapterKey === "codex"
|
||||
? input.assets?.find((asset) => asset.key === "home")
|
||||
: undefined;
|
||||
if (!homeAsset) return;
|
||||
|
||||
let outcome: CodexAuthSyncBackOutcome;
|
||||
try {
|
||||
const hostAuthSource = await resolveHostCodexAuthSymlinkSource(homeAsset.localDir);
|
||||
if (!hostAuthSource) {
|
||||
outcome = "skipped_no_host_symlink";
|
||||
} else {
|
||||
const remoteHomeDir = input.assetDirs[homeAsset.key];
|
||||
const sandboxAuthBytes = remoteHomeDir
|
||||
? await input.client.readFile(path.posix.join(remoteHomeDir, CODEX_AUTH_JSON_NAME))
|
||||
.then((bytes) => toBuffer(bytes))
|
||||
.catch((error: unknown) => {
|
||||
if (isMissingFileError(error)) return null;
|
||||
throw error;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!sandboxAuthBytes) {
|
||||
outcome = "skipped_sandbox_auth_missing";
|
||||
} else {
|
||||
const hostAuthBytes = await fs.readFile(hostAuthSource)
|
||||
.catch((error: unknown) => {
|
||||
if (isMissingFileError(error)) return null;
|
||||
throw error;
|
||||
});
|
||||
outcome = decideCodexAuthSyncBack({
|
||||
sandboxAuth: parseCodexAuthBytes(sandboxAuthBytes),
|
||||
hostAuth: hostAuthBytes ? parseCodexAuthBytes(hostAuthBytes) : { kind: "unusable" },
|
||||
});
|
||||
if (outcome === "updated") {
|
||||
await writeFileAtomic0600(hostAuthSource, sandboxAuthBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to sync Codex auth.json from sandbox to host${errorCodeSuffix(error)}.`);
|
||||
}
|
||||
|
||||
await emitCodexAuthSyncBackOutcome({
|
||||
onProgress: input.onProgress,
|
||||
onRuntimeProgress: input.onRuntimeProgress,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
|
||||
function tarExcludeFlags(exclude: string[] | undefined): string {
|
||||
return ["._*", ...(exclude ?? [])].map((entry) => `--exclude ${shellQuote(entry)}`).join(" ");
|
||||
}
|
||||
@@ -647,6 +865,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
let importedRef: string | null = null;
|
||||
let importedHead: string | null = null;
|
||||
let remoteWorkspaceStatus = "dirty";
|
||||
let restoreError: unknown = null;
|
||||
try {
|
||||
if (gitSnapshot) {
|
||||
await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from sandbox");
|
||||
@@ -741,11 +960,38 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
restoreError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
let syncBackError: unknown = null;
|
||||
try {
|
||||
await syncCodexHomeAuthJsonBackToHost({
|
||||
adapterKey: input.adapterKey,
|
||||
assets: input.assets,
|
||||
assetDirs,
|
||||
client: input.client,
|
||||
onProgress: restoreSink,
|
||||
onRuntimeProgress: input.onRuntimeProgress,
|
||||
});
|
||||
} catch (error) {
|
||||
syncBackError = error;
|
||||
if (restoreError) {
|
||||
await emitRuntimeStatus(
|
||||
input.onRuntimeProgress,
|
||||
"restore",
|
||||
"Codex auth.json sync-back skipped after workspace restore failure",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing sandbox workspace");
|
||||
if (importedRef) {
|
||||
await deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef });
|
||||
}
|
||||
if (!restoreError && syncBackError) {
|
||||
throw syncBackError;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -39,8 +39,9 @@ export async function pathExists(candidate: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
// Co-change notice: this function's logic is mirrored by parseAuth in
|
||||
// packages/adapter-utils/src/sandbox-managed-runtime.ts (buildCodexAuthMergeDecisionScript).
|
||||
// If the auth format changes (new shape, renamed field), update both sites together.
|
||||
// packages/adapter-utils/src/codex-auth-merge-decision.cjs and
|
||||
// parseCodexAuthPayload in packages/adapter-utils/src/sandbox-managed-runtime.ts.
|
||||
// If the auth format changes (new shape, renamed field), update all sites together.
|
||||
function hasUsableAuthPayload(authPayload: unknown): boolean {
|
||||
if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) {
|
||||
return false;
|
||||
|
||||
Reference in new issue
Block a user