From 46e71ea40c6f47819642108c4e6814889959adfd Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Fri, 10 Jul 2026 07:54:53 +0000 Subject: [PATCH] fix(launcher): repoint shared-bundle symlinks to absolute in per-member agent copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncAgentDir copies an agent dir into the member home with `cp -a` (symlinks kept as relative links). An agent whose tools are relative symlinks to a shared bundle (`iwp -> ../../tools/iwp`, `tools -> ../../tools/tools`, `eng-repo`, …) then broke at the new location — `//agents//../../tools` resolves under the member home where nothing exists — so every `./iwp` / `bash tools/…` / `./eng-repo` would dangle the moment AOS_UID_ISOLATION is enabled. After the full copy, resolveExternalSymlinks() repoints each symlink that escapes the agent dir to an ABSOLUTE target resolved against the original source (in-tree links left relative). Pure helper externalSymlinkAbsoluteTarget() decides per link. Real-fs sanity confirms: the copied relative link dangles pre-fix and resolves post-fix. Test: npm run test:symlink-isolation (7/7). Regressions green (governance 57/57, enrich 14/14). tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HiUDC14XfeTQJZHf2wswma --- CHANGELOG.md | 14 +++++++++ package.json | 5 +-- scripts/test-symlink-isolation.cjs | 31 +++++++++++++++++++ src/edge/launcher.ts | 49 ++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 scripts/test-symlink-isolation.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 328c970..8ecb2e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ new version heading in the same commit. ## [Unreleased] +## [0.78.1] — 2026-07-10 +### Fixed +- **Per-member agent copies no longer dangle their shared-bundle symlinks under uid isolation.** + `syncAgentDir` copies an agent's dir into the member home with `cp -a` (symlinks preserved as relative + links). An agent whose tools are relative symlinks to a shared bundle (`iwp -> ../../tools/iwp`, + `tools -> ../../tools/tools`, `eng-repo`, …) then broke at the new location, because + `//agents//../../tools` resolves under the member home where nothing exists — + so every `./iwp` / `bash tools/…` / `./eng-repo` would fail the moment `AOS_UID_ISOLATION` is enabled. + After the full copy, `resolveExternalSymlinks` now repoints each symlink that escapes the agent dir to + an ABSOLUTE target resolved against the original source (in-tree links are left relative). Pure helper + `externalSymlinkAbsoluteTarget` decides per link. (The member uid still needs read access to the shared + bundle — this fixes path resolution, not bundle permissions.) Test: `npm run test:symlink-isolation`. + + ## [0.78.0] — 2026-07-10 ### Added - **Host credential injection (Phase 2c) — a granted SSH host's key is now delivered to the agent's diff --git a/package.json b/package.json index c53c091..4dd2ff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.78.0", + "version": "0.78.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -24,7 +24,8 @@ "demo:dev": "ts-node src/demo.ts", "test:governance": "node scripts/governance-conformance.cjs", "test:enrich": "node scripts/test-enrich-patterns.cjs", - "test:context": "node scripts/context-injection-test.cjs" + "test:context": "node scripts/context-injection-test.cjs", + "test:symlink-isolation": "node scripts/test-symlink-isolation.cjs" }, "keywords": [ "agents", diff --git a/scripts/test-symlink-isolation.cjs b/scripts/test-symlink-isolation.cjs new file mode 100644 index 0000000..320ec2d --- /dev/null +++ b/scripts/test-symlink-isolation.cjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Unit test for `externalSymlinkAbsoluteTarget` — decides whether a copied agent symlink escapes the + * agent dir (the shared tool bundle) and, if so, the absolute target to repoint it at so it survives the + * per-member copy under uid isolation. Run: node scripts/test-symlink-isolation.cjs + */ +const path = require('path'); +const assert = require('assert'); +const { externalSymlinkAbsoluteTarget } = require(path.join(__dirname, '..', 'dist/edge/launcher')); + +const SRC = '/srv/aos/data/agents/billing-ops'; +let pass = 0; +const eq = (got, want, label) => { assert.strictEqual(got, want, `FAIL ${label}: got ${got}`); pass++; console.log(' ok ' + label); }; + +// Shared-bundle symlinks (the real case) → rewrite to absolute, resolved against the source. +eq(externalSymlinkAbsoluteTarget(SRC, 'iwp', '../../tools/iwp'), '/srv/aos/data/tools/iwp', 'iwp → absolute bundle path'); +eq(externalSymlinkAbsoluteTarget(SRC, 'tools', '../../tools/tools'), '/srv/aos/data/tools/tools', 'tools/ dir symlink → absolute'); +eq(externalSymlinkAbsoluteTarget(SRC, 'eng-repo', '../../tools/eng-repo'), '/srv/aos/data/tools/eng-repo', 'eng-repo → absolute'); + +// Absolute link → leave alone. +eq(externalSymlinkAbsoluteTarget(SRC, 'iwp', '/opt/tools/iwp'), null, 'already-absolute → null'); + +// In-tree relative link (stays valid after the copy) → leave alone. +eq(externalSymlinkAbsoluteTarget(SRC, '.claude/skills/x', '../../CLAUDE.md'), null, 'in-tree link (resolves inside agent dir) → null'); +eq(externalSymlinkAbsoluteTarget(SRC, 'a/b', '../c'), null, 'in-tree sibling link → null'); + +// Degenerate: link to the agent dir itself → treated as in-tree (null). +eq(externalSymlinkAbsoluteTarget(SRC, 'self', '.'), null, 'link to agent dir itself → null'); + +console.log(`\nsymlink-isolation: ${pass}/7 checks passed`); +process.exit(0); diff --git a/src/edge/launcher.ts b/src/edge/launcher.ts index fdacc84..356cf58 100644 --- a/src/edge/launcher.ts +++ b/src/edge/launcher.ts @@ -63,6 +63,28 @@ const MEMBER_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/; // also a safe path segmen const SESSION_RE = /^[a-z0-9]{1,32}$/i; const TMUX_RE = /^aos-[a-z0-9]{1,32}$/i; const AGENT_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; // agent id = a folder name; safe path segment + +/** + * A per-member agent working copy is made with `cp -a` (symlinks preserved as-is). A RELATIVE symlink + * that points OUTSIDE the agent's own dir — e.g. the shared tool bundle `iwp -> ../../tools/iwp` — then + * dangles at the new location, because `//agents//../../tools` resolves under + * the member home, where nothing exists. Given the ORIGINAL agent source dir, a symlink's path within it, + * and its (relative) target, return the ABSOLUTE target to repoint it at (resolved against the source), + * or `null` to leave it alone — an absolute link, or an in-tree link that still resolves after the copy. + */ +export function externalSymlinkAbsoluteTarget( + agentSrcDir: string, + symlinkRelPath: string, + linkTarget: string, +): string | null { + if (path.isAbsolute(linkTarget)) return null; + const resolved = path.resolve(path.dirname(path.join(agentSrcDir, symlinkRelPath)), linkTarget); + const rel = path.relative(agentSrcDir, resolved); + // rel === '' → the agent dir itself; a sub-path (not starting with '..') → in-tree. Either way the link + // does not escape, so it still resolves after the copy — leave it. Only a '..'-escaping link is rewritten. + const insideAgentDir = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); + return insideAgentDir ? null : resolved; +} /** env keys the app may set on a session (everything else is dropped). */ export const ENV_ALLOWLIST = new Set([ 'AOS_URL', 'SESSION', 'AGENT', 'TASK_B64', 'AOS_SECRET', 'CLAUDE_SESSION_ID', @@ -351,6 +373,7 @@ export class LauncherDaemon { this.exec('mkdir', ['-p', work]); if (firstTime) { this.exec('cp', ['-a', `${src}/.`, `${work}/`]); + this.resolveExternalSymlinks(src, work); } else { if (fs.existsSync(path.join(src, 'CLAUDE.md'))) this.exec('cp', ['-a', path.join(src, 'CLAUDE.md'), `${work}/`]); const srcSkills = path.join(src, '.claude', 'skills'); @@ -365,6 +388,32 @@ export class LauncherDaemon { return work; } + /** + * After a full copy, repoint every symlink that escapes the agent dir (e.g. the shared tool bundle, + * `iwp -> ../../tools/iwp`) to an ABSOLUTE target resolved against the ORIGINAL source — so `./iwp`, + * `./eng-repo`, `bash tools/…` keep resolving from the per-member working copy under `/…` + * where the relative `../../tools` would otherwise dangle. In-tree relative symlinks are left as-is. + * (The member uid still needs read access to the shared bundle; this only fixes the path resolution.) + */ + private resolveExternalSymlinks(src: string, work: string): void { + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const wpath = path.join(dir, e.name); + if (e.isSymbolicLink()) { + let target: string; + try { target = fs.readlinkSync(wpath); } catch { continue; } + const abs = externalSymlinkAbsoluteTarget(src, path.relative(work, wpath), target); + if (abs) this.exec('ln', ['-sfn', abs, wpath]); + } else if (e.isDirectory()) { + walk(wpath); + } + } + }; + walk(work); + } + /** The member holder's live (dynamic) uid, via the injected resolver or systemd MainPID → /proc. */ private resolveUid(member: string): number | null { if (this.opts.resolveUid) return this.opts.resolveUid(member);