mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
## Thinking Path > - Paperclip manages agents that must write work and report task outcomes through its API. > - Local adapters select an execution engine and its permission settings. > - A higher ACP Node requirement can make an unchanged installation lose access to its default engine. > - The adapter then silently selects CLI, which can change permissions and block API access. > - This pull request keeps the engine choice fixed and reports missing prerequisites before work starts. > - It also gives explicit Codex CLI runs usable defaults and keeps managed services on a supported Node runtime. ## Linked Issues or Issue Description Refs #12215. Related changes: #11792 raised the Node requirement; #13094 addressed separate runner networking behavior. This change fixes the engine-selection and managed-launcher paths. **What happened?** An unchanged agent could switch from ACP to CLI after an upgrade. Codex CLI then used read-only permissions with networking disabled. The run could finish without updating its task. Repeated recovery attempts used the same unavailable setup. Managed updates also skipped the Node check and did not refresh old launchers. **Expected behavior** An unavailable engine must fail with a clear setup error. It must not silently select another engine. Explicit CLI runs must be able to write workspace files and call the API unless the operator configures stricter settings. Managed updates must validate Node and keep child tools on that runtime. **Steps to reproduce** 1. Run an ACP-default agent under Node 22 after the ACP minimum rises to 24.11. 2. Leave the engine unset and disable the approval/sandbox bypass. 3. Observe the old adapter select CLI and fail to write task disposition through the API. 4. Start a managed service with an old launcher and a supervisor PATH that selects a different Node for child tools. ## What Changed - Remove automatic engine fallback for Codex, Claude, Gemini, and Kimi. Check prerequisites for default and explicit ACP selections. - Return a configuration error with proof that provider work did not start. Stop automatic continuation retries for this error. - Enable Codex ACP workspace networking at the actual turn boundary. Upstream mode presets otherwise force it off even when config.toml enables it. Preserve explicit network denial and read-only mode. - Set workspace-write and network access defaults for explicit Codex CLI runs. Preserve explicit sandbox modes, profiles, and network restrictions. - Pin the validated Node directory in managed launcher PATH. Refresh legacy launchers during installs and npm/Git updates. - Reject updates on unsupported Node. Keep update checks, dry runs, and rollback available. - Synchronize the qualified Codex ACP executable identity across server, TypeScript runner, Rust runner, and provider-pack launch paths. - Add regression tests and update engine and installation documentation. ## Verification - [Full CI passed on the final head](https://github.com/paperclipai/paperclip/actions/runs/34387099695): typecheck, build/native runner verification, all general and serialized test shards, all browser shards, release registry, canary dry run, and policy checks. - Greptile: 5/5 on `2c1d6e2815830a5cd39e36c8a082cc0c4441b6c0`, with no unresolved review findings. Security gates are green. - Full workspace typecheck and build also passed locally. The final deployed Linux build passed. - Full Codex, Claude, Gemini, and Kimi source test suites: 804 passed, 2 skipped. Installer, updater, and launcher tests: 47 passed. Installed ACP turn-boundary tests: 3 passed. ACP packaging tests: 14 passed. Focused recovery classification tests also passed. - Real Linux Codex CLI runs, both fresh and resumed, wrote a workspace file and reached the control-plane health API with the new defaults. - Explicit read-only and network-disabled control probes retained those restrictions. - A real ACP run on the final deployed Linux build wrote a file and reached the control-plane API with HTTP 200, without engine fallback. The same probe failed DNS before the turn-policy patch. - Executable-identity and installed-policy contracts: 12 passed. Affected native server tests: 197 passed. Runner factory tests: 21 passed. Rust qualification and native provider integration tests: 11 passed. - Deployed the production changes to a Linux service on Node 24.20 after a verified database backup. Health, bootstrap readiness, static UI, executable/cwd identity, and guarded restart checks passed. The restart lost no runs. - Corrected stale Kimi skill-default and Gemini remote-archive fixtures; both suites pass. ## Risks - Default or legacy auto engine settings now fail when ACP is unavailable. Operators who intend to use CLI must select it explicitly. - Codex CLI now permits workspace writes and networking by default, and ACP workspace-write turns permit networking by default. Explicit operator sandbox settings remain authoritative. - Old managed launchers keep their pinned Node until they are reinstalled under a supported runtime. An old updater cannot repair itself; the documentation gives the current installer command. - Custom service wrappers and global/source installations must configure their runtime PATH. No database migration is required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository inspection, shell execution, and test tools. The exact serving model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { resolvePaperclipHomeDir } from "./config/home.js";
|
|
|
|
export const INSTALL_MANIFEST_VERSION = 1;
|
|
export const MANAGED_SHIM_MARKER = "paperclipai managed install shim v1";
|
|
export const MANAGED_STORE_MARKER = "paperclipai managed install store v1\n";
|
|
export const PATH_BLOCK_START = "# >>> paperclipai managed PATH >>>";
|
|
export const PATH_BLOCK_END = "# <<< paperclipai managed PATH <<<";
|
|
|
|
export type InstallSource = "npm" | "git";
|
|
export type InstallChannel = "latest" | "canary" | "pinned";
|
|
|
|
export type InstallRecord = {
|
|
source: InstallSource;
|
|
version: string;
|
|
channel: InstallChannel;
|
|
payloadPath: string;
|
|
repo?: string;
|
|
ref?: string;
|
|
sha?: string;
|
|
installedAt: string;
|
|
};
|
|
|
|
export type InstallManifest = InstallRecord & {
|
|
schemaVersion: typeof INSTALL_MANIFEST_VERSION;
|
|
previous: InstallRecord[];
|
|
};
|
|
|
|
export type InstallStorePaths = {
|
|
paperclipHome: string;
|
|
cliRoot: string;
|
|
installsRoot: string;
|
|
manifestPath: string;
|
|
markerPath: string;
|
|
lockPath: string;
|
|
currentPath: string;
|
|
shimPath: string;
|
|
};
|
|
|
|
function ensurePrivateDirectory(directoryPath: string): void {
|
|
fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
|
|
const stat = fs.lstatSync(directoryPath);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to use non-directory install-store path ${directoryPath}.`);
|
|
}
|
|
fs.chmodSync(directoryPath, 0o700);
|
|
}
|
|
|
|
function assertOwnedByCurrentUser(stat: fs.Stats, targetPath: string): void {
|
|
const getuid = process.getuid;
|
|
if (typeof getuid === "function" && stat.uid !== getuid()) {
|
|
throw new Error(`Refusing to modify path not owned by the current user: ${targetPath}.`);
|
|
}
|
|
}
|
|
|
|
function writeFileAtomic(filePath: string, contents: string, mode: number): void {
|
|
const temporaryPath = path.join(
|
|
path.dirname(filePath),
|
|
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
);
|
|
try {
|
|
fs.writeFileSync(temporaryPath, contents, { mode, flag: "wx" });
|
|
fs.renameSync(temporaryPath, filePath);
|
|
} finally {
|
|
fs.rmSync(temporaryPath, { force: true });
|
|
}
|
|
}
|
|
|
|
export function resolveInstallStorePaths(options: {
|
|
paperclipHome?: string;
|
|
homeDir?: string;
|
|
} = {}): InstallStorePaths {
|
|
const paperclipHome = path.resolve(options.paperclipHome ?? resolvePaperclipHomeDir());
|
|
const homeDir = path.resolve(options.homeDir ?? process.env.HOME ?? path.dirname(paperclipHome));
|
|
const cliRoot = path.join(paperclipHome, "cli");
|
|
return {
|
|
paperclipHome,
|
|
cliRoot,
|
|
installsRoot: path.join(cliRoot, "installs"),
|
|
manifestPath: path.join(cliRoot, "install.json"),
|
|
markerPath: path.join(cliRoot, ".managed-install"),
|
|
lockPath: path.join(cliRoot, ".install.lock"),
|
|
currentPath: path.join(cliRoot, "current"),
|
|
shimPath: path.join(homeDir, ".local", "bin", "paperclipai"),
|
|
};
|
|
}
|
|
|
|
export function initializeInstallStore(paths = resolveInstallStorePaths()): void {
|
|
ensurePrivateDirectory(paths.cliRoot);
|
|
ensurePrivateDirectory(paths.installsRoot);
|
|
try {
|
|
const markerStat = fs.lstatSync(paths.markerPath);
|
|
if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) {
|
|
throw new Error(`Refusing to use unsafe install-store marker ${paths.markerPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(markerStat, paths.markerPath);
|
|
if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) {
|
|
throw new Error(`Refusing to use unrecognized install store ${paths.cliRoot}.`);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
try {
|
|
fs.writeFileSync(paths.markerPath, MANAGED_STORE_MARKER, { mode: 0o600, flag: "wx" });
|
|
} catch (writeError) {
|
|
if (
|
|
(writeError as NodeJS.ErrnoException).code !== "EEXIST" ||
|
|
fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER
|
|
) {
|
|
throw writeError;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function assertManagedInstallStore(paths = resolveInstallStorePaths()): InstallManifest {
|
|
const cliStat = fs.lstatSync(paths.cliRoot);
|
|
if (!cliStat.isDirectory() || cliStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to remove unsafe install-store path ${paths.cliRoot}.`);
|
|
}
|
|
assertOwnedByCurrentUser(cliStat, paths.cliRoot);
|
|
let markerStat: fs.Stats;
|
|
try {
|
|
markerStat = fs.lstatSync(paths.markerPath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
|
|
}
|
|
throw error;
|
|
}
|
|
if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) {
|
|
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
|
|
}
|
|
assertOwnedByCurrentUser(markerStat, paths.markerPath);
|
|
if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) {
|
|
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
|
|
}
|
|
const manifest = readInstallManifest(paths);
|
|
if (!manifest) throw new Error(`Refusing to remove install store without a manifest at ${paths.cliRoot}.`);
|
|
const relativePayload = path.relative(paths.installsRoot, path.resolve(manifest.payloadPath));
|
|
if (!relativePayload || relativePayload.startsWith("..") || path.isAbsolute(relativePayload)) {
|
|
throw new Error(`Refusing to remove install store with an invalid manifest at ${paths.cliRoot}.`);
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
export async function withInstallStoreLock<T>(
|
|
callback: () => Promise<T>,
|
|
paths = resolveInstallStorePaths(),
|
|
options: { initialize?: boolean } = {},
|
|
): Promise<T> {
|
|
if (options.initialize !== false) initializeInstallStore(paths);
|
|
const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
|
const processIsAlive = (pid: number): boolean => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
}
|
|
};
|
|
const acquire = (): void => {
|
|
const temporaryPath = `${paths.lockPath}.${token}.tmp`;
|
|
try {
|
|
fs.writeFileSync(temporaryPath, `${token}\n`, { mode: 0o600, flag: "wx" });
|
|
try {
|
|
fs.linkSync(temporaryPath, paths.lockPath);
|
|
return;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
}
|
|
const owner = fs.readFileSync(paths.lockPath, "utf8").trim();
|
|
const ownerPid = Number.parseInt(owner.split(":", 1)[0] ?? "", 10);
|
|
if (Number.isInteger(ownerPid) && ownerPid > 0 && !processIsAlive(ownerPid)) {
|
|
fs.rmSync(paths.lockPath);
|
|
fs.rmSync(temporaryPath, { force: true });
|
|
acquire();
|
|
return;
|
|
}
|
|
const ownerLabel = Number.isInteger(ownerPid) && ownerPid > 0 ? ` (pid ${ownerPid})` : "";
|
|
throw new Error(
|
|
`Another managed install is already running${ownerLabel}. ` +
|
|
`If no install process is active, remove the stale lock at ${paths.lockPath} and retry.`,
|
|
);
|
|
} finally {
|
|
fs.rmSync(temporaryPath, { force: true });
|
|
}
|
|
};
|
|
|
|
acquire();
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
try {
|
|
if (fs.readFileSync(paths.lockPath, "utf8").trim() === token) {
|
|
fs.rmSync(paths.lockPath, { force: true });
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function payloadPathFor(
|
|
paths: InstallStorePaths,
|
|
source: InstallSource,
|
|
identifier: string,
|
|
): string {
|
|
if (!/^[A-Za-z0-9._-]+$/.test(identifier)) {
|
|
throw new Error(`Invalid install payload identifier '${identifier}'.`);
|
|
}
|
|
return path.join(paths.installsRoot, source, identifier);
|
|
}
|
|
|
|
export function readInstallManifest(paths = resolveInstallStorePaths()): InstallManifest | null {
|
|
try {
|
|
const value = JSON.parse(fs.readFileSync(paths.manifestPath, "utf8")) as InstallManifest;
|
|
if (
|
|
value.schemaVersion !== INSTALL_MANIFEST_VERSION ||
|
|
(value.source !== "npm" && value.source !== "git") ||
|
|
!Array.isArray(value.previous) ||
|
|
typeof value.payloadPath !== "string"
|
|
) {
|
|
throw new Error("unsupported manifest shape");
|
|
}
|
|
return value;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
throw new Error(`Could not read managed install manifest at ${paths.manifestPath}: ${String(error)}`);
|
|
}
|
|
}
|
|
|
|
export function writeInstallManifestAtomic(
|
|
manifest: InstallManifest,
|
|
paths = resolveInstallStorePaths(),
|
|
): void {
|
|
ensurePrivateDirectory(paths.cliRoot);
|
|
const temporaryPath = `${paths.manifestPath}.tmp-${process.pid}-${Date.now()}`;
|
|
try {
|
|
fs.writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
fs.renameSync(temporaryPath, paths.manifestPath);
|
|
} finally {
|
|
fs.rmSync(temporaryPath, { force: true });
|
|
}
|
|
}
|
|
|
|
function assertPayloadPath(payloadPath: string, paths: InstallStorePaths): void {
|
|
const relative = path.relative(paths.installsRoot, path.resolve(payloadPath));
|
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
throw new Error(`Refusing to activate payload outside ${paths.installsRoot}.`);
|
|
}
|
|
const stat = fs.lstatSync(payloadPath);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to activate non-directory payload ${payloadPath}.`);
|
|
}
|
|
const installsRealPath = fs.realpathSync(paths.installsRoot);
|
|
const payloadRealPath = fs.realpathSync(payloadPath);
|
|
if (!payloadRealPath.startsWith(`${installsRealPath}${path.sep}`)) {
|
|
throw new Error(`Refusing to activate payload that resolves outside ${paths.installsRoot}.`);
|
|
}
|
|
}
|
|
|
|
export function flipCurrentAtomic(
|
|
payloadPath: string,
|
|
paths = resolveInstallStorePaths(),
|
|
hooks: { beforeRename?: () => void } = {},
|
|
): void {
|
|
assertPayloadPath(payloadPath, paths);
|
|
ensurePrivateDirectory(paths.cliRoot);
|
|
try {
|
|
const currentStat = fs.lstatSync(paths.currentPath);
|
|
if (!currentStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to replace non-symlink ${paths.currentPath}.`);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
|
|
const temporaryLink = path.join(
|
|
paths.cliRoot,
|
|
`.current-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
);
|
|
const relativeTarget = path.relative(paths.cliRoot, payloadPath);
|
|
try {
|
|
fs.symlinkSync(relativeTarget, temporaryLink, "dir");
|
|
hooks.beforeRename?.();
|
|
fs.renameSync(temporaryLink, paths.currentPath);
|
|
} finally {
|
|
fs.rmSync(temporaryLink, { force: true });
|
|
}
|
|
}
|
|
|
|
export function buildNextManifest(
|
|
record: InstallRecord,
|
|
current: InstallManifest | null,
|
|
): InstallManifest {
|
|
const candidates: InstallRecord[] = current
|
|
? [
|
|
{
|
|
source: current.source,
|
|
version: current.version,
|
|
channel: current.channel,
|
|
payloadPath: current.payloadPath,
|
|
repo: current.repo,
|
|
ref: current.ref,
|
|
sha: current.sha,
|
|
installedAt: current.installedAt,
|
|
},
|
|
...current.previous,
|
|
]
|
|
: [];
|
|
const previous = candidates
|
|
.filter((candidate) => path.resolve(candidate.payloadPath) !== path.resolve(record.payloadPath))
|
|
.filter(
|
|
(candidate, index, all) =>
|
|
all.findIndex((other) => path.resolve(other.payloadPath) === path.resolve(candidate.payloadPath)) ===
|
|
index,
|
|
)
|
|
.slice(0, 2);
|
|
|
|
return { schemaVersion: INSTALL_MANIFEST_VERSION, ...record, previous };
|
|
}
|
|
|
|
export function pruneInstallPayloads(
|
|
manifest: InstallManifest,
|
|
paths = resolveInstallStorePaths(),
|
|
): string[] {
|
|
const retained = new Set(
|
|
[manifest, ...manifest.previous].map((record) => path.resolve(record.payloadPath)),
|
|
);
|
|
const removed: string[] = [];
|
|
for (const source of ["npm", "git"] as const) {
|
|
const sourceRoot = path.join(paths.installsRoot, source);
|
|
if (!fs.existsSync(sourceRoot)) continue;
|
|
const sourceStat = fs.lstatSync(sourceRoot);
|
|
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to prune unsafe install-store path ${sourceRoot}.`);
|
|
}
|
|
for (const entry of fs.readdirSync(sourceRoot)) {
|
|
if (entry.startsWith(".")) continue;
|
|
const candidate = path.join(sourceRoot, entry);
|
|
if (!retained.has(path.resolve(candidate))) {
|
|
fs.rmSync(candidate, { recursive: true, force: true });
|
|
removed.push(candidate);
|
|
}
|
|
}
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
export function assertManagedShimWritable(paths = resolveInstallStorePaths()): void {
|
|
const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath)));
|
|
for (const directoryPath of [homeDir, path.join(homeDir, ".local"), path.dirname(paths.shimPath)]) {
|
|
if (!fs.existsSync(directoryPath)) continue;
|
|
const directoryStat = fs.lstatSync(directoryPath);
|
|
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to use unsafe shim directory ${directoryPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(directoryStat, directoryPath);
|
|
}
|
|
try {
|
|
const stat = fs.lstatSync(paths.shimPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to replace non-regular shim ${paths.shimPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(stat, paths.shimPath);
|
|
if (stat.nlink > 1) throw new Error(`Refusing to replace multiply linked shim ${paths.shimPath}.`);
|
|
const existing = fs.readFileSync(paths.shimPath, "utf8");
|
|
if (!isManagedShimContents(existing)) {
|
|
throw new Error(`Refusing to replace existing non-managed command ${paths.shimPath}.`);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function isManagedShimContents(contents: string): boolean {
|
|
const lines = contents.split("\n");
|
|
// Accept the original pinned-runtime shim so upgrades can replace it.
|
|
const withRuntimePath = lines.length === 6;
|
|
const execIndex = withRuntimePath ? 4 : 3;
|
|
return (
|
|
(lines.length === 5 || withRuntimePath) &&
|
|
lines[0] === "#!/bin/sh" &&
|
|
lines[1] === `# ${MANAGED_SHIM_MARKER}` &&
|
|
lines[2] === "set -eu" &&
|
|
(!withRuntimePath || /^export PATH='(?:[^']|'"'"')+':"\$\{PATH:-\/usr\/local\/bin:\/usr\/bin:\/bin\}"$/.test(lines[3])) &&
|
|
/^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[execIndex]) &&
|
|
lines[execIndex + 1] === ""
|
|
);
|
|
}
|
|
|
|
export function writeManagedShim(paths = resolveInstallStorePaths()): void {
|
|
assertManagedShimWritable(paths);
|
|
const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath)));
|
|
const localDir = path.dirname(path.dirname(paths.shimPath));
|
|
fs.mkdirSync(homeDir, { recursive: true, mode: 0o700 });
|
|
fs.mkdirSync(localDir, { recursive: true, mode: 0o755 });
|
|
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true, mode: 0o755 });
|
|
assertManagedShimWritable(paths);
|
|
const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js");
|
|
// ACP servers and package-manager shims use /usr/bin/env node. Pin their
|
|
// runtime too, even when systemd/launchd supplies a different PATH.
|
|
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexport PATH=${shellQuote(path.dirname(process.execPath))}:"\${PATH:-/usr/local/bin:/usr/bin:/bin}"\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`;
|
|
writeFileAtomic(paths.shimPath, contents, 0o755);
|
|
}
|
|
|
|
export function removeManagedShim(paths = resolveInstallStorePaths()): boolean {
|
|
try {
|
|
const contents = fs.readFileSync(paths.shimPath, "utf8");
|
|
if (!isManagedShimContents(contents)) return false;
|
|
fs.rmSync(paths.shimPath, { force: true });
|
|
return true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function managedPathBlock(): string {
|
|
return `${PATH_BLOCK_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_BLOCK_END}`;
|
|
}
|
|
|
|
export function addManagedPathBlock(rcPath: string): boolean {
|
|
let existing = "";
|
|
let mode = 0o600;
|
|
try {
|
|
const stat = fs.lstatSync(rcPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to modify non-regular shell rc file ${rcPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(stat, rcPath);
|
|
mode = stat.mode & 0o777;
|
|
existing = fs.readFileSync(rcPath, "utf8");
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
if (existing.includes(PATH_BLOCK_START)) return false;
|
|
fs.mkdirSync(path.dirname(rcPath), { recursive: true });
|
|
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
writeFileAtomic(rcPath, `${existing}${prefix}${managedPathBlock()}\n`, mode);
|
|
return true;
|
|
}
|
|
|
|
export function removeManagedPathBlock(rcPath: string): boolean {
|
|
let existing: string;
|
|
let mode: number;
|
|
try {
|
|
const stat = fs.lstatSync(rcPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to modify non-regular shell rc file ${rcPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(stat, rcPath);
|
|
mode = stat.mode & 0o777;
|
|
existing = fs.readFileSync(rcPath, "utf8");
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
throw error;
|
|
}
|
|
const escapedStart = PATH_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const escapedEnd = PATH_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const next = existing.replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?${escapedEnd}\\n?`), "\n");
|
|
if (next === existing) return false;
|
|
writeFileAtomic(rcPath, next.replace(/^\n/, ""), mode);
|
|
return true;
|
|
}
|
|
|
|
export function isManagedExecutable(
|
|
executablePath: string | undefined,
|
|
manifest: InstallManifest,
|
|
paths = resolveInstallStorePaths(),
|
|
): boolean {
|
|
if (!executablePath) return false;
|
|
try {
|
|
const executableRealPath = fs.realpathSync(executablePath);
|
|
const payloadRealPath = fs.realpathSync(manifest.payloadPath);
|
|
const currentRealPath = fs.realpathSync(paths.currentPath);
|
|
return (
|
|
currentRealPath === payloadRealPath &&
|
|
executableRealPath.startsWith(`${payloadRealPath}${path.sep}`)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|