mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 12:29:37 +01:00
Restore disposable Codex authentication and safe provider configuration before cold source recovery. Preserve source receipts and conversation state until reconciliation, and rebuild assigned tools from the admitted launch binding. Validate remote launch destinations, clear stale authentication when no selected credential exists, and atomically publish SSH file transfers without following retained links. Red/green recovery regressions, a real two-turn Codex/MCP probe, and all current PR checks pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
205 lines
9.7 KiB
TypeScript
205 lines
9.7 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { lstat, readdir } from "node:fs/promises";
|
|
import { posix, resolve } from "node:path";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
|
|
import type { NativeRunTrace } from "./native-run-trace.js";
|
|
|
|
type StageAsset = {
|
|
sourcePath: string;
|
|
targetPath: string;
|
|
mode: number;
|
|
};
|
|
|
|
type LaunchAssetsInput = {
|
|
scope?: "launch" | "recovery";
|
|
root: string;
|
|
transport: "ssh" | "sandbox";
|
|
runner: CommandManagedRuntimeRunner;
|
|
persistenceDirectories: readonly {
|
|
name: string;
|
|
location: "runner" | "filesystem";
|
|
targetPath: string | null;
|
|
}[];
|
|
runtimeContext: {
|
|
source: NativeRuntimeContextSnapshot;
|
|
target: NativeRuntimeContextSnapshot;
|
|
filesystemRoot: string;
|
|
} | null;
|
|
caBundleMapping: { sourcePath: string; targetPath: string } | null;
|
|
stageFile: (input: StageAsset) => Promise<void>;
|
|
stageDirectory: (input: StageAsset) => Promise<void>;
|
|
isolatedCodexSkills?: { workspaceRoot: string };
|
|
trace?: NativeRunTrace;
|
|
};
|
|
|
|
async function assertSelectedSkillsTree(root: string, names: string[]): Promise<void> {
|
|
const found = new Set<string>();
|
|
const reject = () => { throw new Error("runner_remote_codex_skills_invalid"); };
|
|
async function visit(relativePath: string) {
|
|
const path = relativePath ? resolve(root, relativePath) : root;
|
|
const stat = await lstat(path);
|
|
if (stat.isSymbolicLink()) reject();
|
|
if (stat.isDirectory()) {
|
|
if (relativePath && !names.some(name => relativePath === name || relativePath.startsWith(`${name}/`) || name.startsWith(`${relativePath}/`))) reject();
|
|
for (const entry of await readdir(path)) await visit(relativePath ? `${relativePath}/${entry}` : entry);
|
|
} else {
|
|
if (!stat.isFile() || (stat.mode & 0o222) !== 0 || !names.some(name => relativePath.startsWith(`${name}/`))) reject();
|
|
for (const name of names) if (relativePath === `${name}/SKILL.md`) found.add(name);
|
|
}
|
|
}
|
|
await visit("");
|
|
if (found.size !== names.length) reject();
|
|
}
|
|
|
|
// Launch preparation owns this isolated directory and runs only after the old
|
|
// provider has stopped. Never follow a retained symlink while removing stale
|
|
// assignments; auth, config and provider conversation history are not touched.
|
|
const validateRemoteCodexHome = `
|
|
const fs = require("node:fs"), path = require("node:path");
|
|
const home = path.resolve(process.argv[1]), workspace = path.resolve(process.argv[2]);
|
|
const relative = path.relative(workspace, home);
|
|
if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) throw Error("unsafe_codex_home");
|
|
// The execution target authorizes the workspace anchor. Resolve only that
|
|
// anchor (which can have an OS alias), then refuse links below it.
|
|
let current = fs.realpathSync(workspace);
|
|
if (!fs.lstatSync(current).isDirectory()) throw Error("unsafe_workspace_root");
|
|
for (const segment of relative.split(path.sep)) {
|
|
current = path.join(current, segment);
|
|
if (!fs.lstatSync(current, { throwIfNoEntry: false })) fs.mkdirSync(current, { mode: 0o700 });
|
|
const stat = fs.lstatSync(current);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("unsafe_codex_home");
|
|
}
|
|
for (const name of ["auth.json", "config.toml"]) {
|
|
const file = fs.lstatSync(path.join(current, name), { throwIfNoEntry: false });
|
|
if (file && (!file.isFile() || file.isSymbolicLink() || file.nlink > 1)) throw Error("unsafe_codex_launch_file");
|
|
}
|
|
`;
|
|
|
|
const clearRemoteCodexSkills = `${validateRemoteCodexHome}
|
|
const skills = path.join(current, "skills");
|
|
function unseal(directory) {
|
|
const stat = fs.lstatSync(directory);
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error("unsafe_skills_root");
|
|
fs.chmodSync(directory, 0o700);
|
|
for (const name of fs.readdirSync(directory)) {
|
|
const child = path.join(directory, name), stat = fs.lstatSync(child);
|
|
if (stat.isDirectory() && !stat.isSymbolicLink()) unseal(child);
|
|
}
|
|
}
|
|
if (fs.lstatSync(skills, { throwIfNoEntry: false })) {
|
|
unseal(skills);
|
|
fs.rmSync(skills, { recursive: true });
|
|
}
|
|
// Native directory transfers omit the source root and may ignore its mapping
|
|
// mode. Create the private discovery root before their mkdir/extract step.
|
|
fs.mkdirSync(skills, { mode: 0o700 });
|
|
fs.chmodSync(skills, 0o700);
|
|
`;
|
|
|
|
const prepareRemoteCodexRecoveryHome = `${validateRemoteCodexHome}
|
|
if (process.argv[3] === "remove-auth") fs.rmSync(path.join(current, "auth.json"), { force: true });
|
|
`;
|
|
|
|
export async function stageRemoteLaunchAssets(input: LaunchAssetsInput): Promise<void> {
|
|
const measure = async (name: string, operation: () => Promise<void>) =>
|
|
input.trace ? input.trace.measure(name, operation) : operation();
|
|
const materializeLaunchState = () => measure("harness_state.launch_material", async () => {
|
|
for (const directory of input.persistenceDirectories) {
|
|
if (directory.location !== "filesystem") continue;
|
|
const targetPath = directory.targetPath;
|
|
if (!targetPath) throw new Error("runner_harness_state_mismatch: bootstrap_target_unavailable");
|
|
const escapedTarget = targetPath.replaceAll("'", "'\\''");
|
|
const created = await input.runner.execute({
|
|
command: "sh",
|
|
args: ["-c", `umask 077; install -d -m 0700 '${escapedTarget}'`],
|
|
bypassSession: true,
|
|
timeoutMs: 10_000,
|
|
});
|
|
if (created.exitCode !== 0 || created.timedOut) {
|
|
throw new Error("runner_remote_directory_staging_failed");
|
|
}
|
|
if (directory.name !== "codex-home") continue;
|
|
const localDirectory = resolve(input.root, directory.name);
|
|
for (const name of input.scope === "recovery" ? ["config.toml", "auth.json"] : ["auth.json", "config.toml"]) {
|
|
const sourcePath = resolve(localDirectory, name);
|
|
if (input.scope === "recovery" && name === "config.toml" && !existsSync(sourcePath)) throw new Error("runner_remote_codex_recovery_config_missing");
|
|
if (!existsSync(sourcePath)) continue;
|
|
await input.stageFile({ sourcePath, targetPath: posix.join(targetPath, name), mode: 0o600 });
|
|
}
|
|
}
|
|
});
|
|
if (input.scope === "recovery") {
|
|
const home = input.persistenceDirectories.find(directory => directory.name === "codex-home" && directory.location === "filesystem");
|
|
if (!home?.targetPath || !input.isolatedCodexSkills) throw new Error("runner_remote_codex_credentials_path_invalid");
|
|
const validated = await input.runner.execute({
|
|
command: "node",
|
|
args: ["-e", prepareRemoteCodexRecoveryHome, home.targetPath, input.isolatedCodexSkills.workspaceRoot,
|
|
existsSync(resolve(input.root, "codex-home", "auth.json")) ? "keep-auth" : "remove-auth"],
|
|
bypassSession: true,
|
|
timeoutMs: 10_000,
|
|
});
|
|
if (validated.exitCode !== 0 || validated.timedOut) throw new Error("runner_remote_codex_credentials_path_invalid");
|
|
await materializeLaunchState();
|
|
return;
|
|
}
|
|
const stageRuntimeContext = async () => {
|
|
const context = input.runtimeContext;
|
|
if (!context) return;
|
|
await measure("stage.asset.runtime_context", async () => {
|
|
await input.stageDirectory({
|
|
sourcePath: context.source.instructions.bundle.rootPath,
|
|
targetPath: context.target.instructions.bundle.rootPath,
|
|
mode: 0o555,
|
|
});
|
|
for (let index = 0; index < context.source.skills.length; index += 1) {
|
|
await input.stageDirectory({
|
|
sourcePath: context.source.skills[index]!.bundle.rootPath,
|
|
targetPath: context.target.skills[index]!.bundle.rootPath,
|
|
mode: 0o555,
|
|
});
|
|
}
|
|
await input.stageFile({
|
|
sourcePath: resolve(input.root, "runtime-context.json"),
|
|
targetPath: posix.join(context.filesystemRoot, "runtime-context.json"),
|
|
mode: 0o600,
|
|
});
|
|
});
|
|
};
|
|
const stageCodexSkills = async () => {
|
|
if (!input.isolatedCodexSkills) return;
|
|
const directory = input.persistenceDirectories.find(entry => entry.name === "codex-home" && entry.location === "filesystem");
|
|
if (!directory?.targetPath) throw new Error("runner_harness_state_mismatch: bootstrap_target_unavailable");
|
|
const sourcePath = resolve(input.root, "codex-home", "skills");
|
|
await assertSelectedSkillsTree(sourcePath, input.runtimeContext?.source.skills.map(skill => skill.runtimeName) ?? []);
|
|
await measure("stage.asset.codex_skills", async () => {
|
|
const cleared = await input.runner.execute({
|
|
command: "node",
|
|
args: ["-e", clearRemoteCodexSkills, directory.targetPath!, input.isolatedCodexSkills!.workspaceRoot],
|
|
bypassSession: true,
|
|
timeoutMs: 10_000,
|
|
});
|
|
if (cleared.exitCode !== 0 || cleared.timedOut) throw new Error("runner_remote_codex_skills_reset_failed");
|
|
// Match prepareIsolatedCodexHome: the discovery root is private/writable
|
|
// for Codex's own built-ins; each selected package remains sealed.
|
|
await input.stageDirectory({ sourcePath, targetPath: posix.join(directory.targetPath!, "skills"), mode: 0o700 });
|
|
});
|
|
};
|
|
// Resolve the owned home and remove obsolete assignments before any launch
|
|
// file can be written through a retained, redirected path.
|
|
await stageCodexSkills();
|
|
if (input.transport === "sandbox" && input.runner.allowConcurrentSyncOperations === true) {
|
|
const outcomes = await Promise.allSettled([materializeLaunchState(), stageRuntimeContext()]);
|
|
for (const outcome of outcomes) {
|
|
if (outcome.status === "rejected") throw outcome.reason;
|
|
}
|
|
} else {
|
|
await materializeLaunchState();
|
|
await stageRuntimeContext();
|
|
}
|
|
if (input.caBundleMapping) {
|
|
const mapping = input.caBundleMapping;
|
|
await measure("stage.asset.ca_bundle", () => input.stageFile({ ...mapping, mode: 0o600 }));
|
|
}
|
|
}
|