Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ new version heading in the same commit.

## [Unreleased]

## [0.299.0] — 2026-08-04
### Added
- **Settings → System now catches a dependency that is present but STALE, not just missing.** Found the
hard way: a tenant's box sat on `claude` 2.1.216 for weeks, so `/model` couldn't list Opus 5 and the
`opus` family alias resolved to 4.8 — while the console showed a green *"All required dependencies are
installed."* the whole time. Presence was the only thing `checkDeps()` ever asked, and the `claude` dep
deliberately carries no package-manager `pkg`, so it sat outside the install path entirely.
Deps may now name an `npmPkg`; the new `checkDepUpdates()` (`src/edge/deps.ts`) asks the npm registry
for `latest` via the abbreviated `/latest` endpoint (global `fetch`, no `npm` shell-out, no new dep),
caches for an hour, and flags a behind-version install. `checkDeps()` stays sync + network-free, so
freshness is a layer on top rather than a slower probe: `GET /api/deps` (`?force=1` re-asks) annotates
the report, and a stale row goes amber with the published version, an owner-only **Update** button, and
a note that live sessions keep their binary until they restart. `POST /api/deps/update` runs
`npm install -g <pkg>@latest` — owner-gated, audited `system.deps.updated`, never via sudo (a
permissions failure surfaces the manual command instead of leaving root-owned files in the prefix), and
it prefers the `npm` beside the resolved binary so an nvm box can't install into a different prefix
than the one it runs from. CLI parity: `agent-os deps` shows `↑ … → v2.1.220 available`, and
`agent-os deps update <bin>` applies it.

### Fixed
- **A `claude` reachable only off PATH no longer reports as MISSING.** `deps.ts` resolved binaries with a
bare `command -v`, but `claude-cli.ts` walks `$CLAUDE_BIN` → PATH → `~/.local/bin/claude` because a
launchd/systemd parent ships a minimal PATH — so on such a box the panel claimed the runtime was absent
while sessions launched fine. Both now share `claudeBinCandidates()` and honour the same order
(`$CLAUDE_BIN` first — resolving PATH first would report the version of a binary sessions never run).
A dep found only via a fallback is version-probed at its resolved path and marked `offPath` in the UI.
- New `scripts/deps-freshness-test.cjs` (38 assertions: version comparison, resolution order, the
off-PATH case, stale detection, the route authz — owner-only update, non-npm deps refused before any
shell-out — and a full update round-trip against a stubbed `npm`, which also pins the sibling-npm
preference) wired into `npm run test:governance`.

## [0.298.0] — 2026-08-04
### Added
- **Chat front door: greetings get a friendly reply, and the help/`/name` roster drops System agents.**
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.298.0",
"version": "0.299.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand All @@ -27,8 +27,9 @@
"check-deps": "bash scripts/install-deps.sh --check",
"dev": "ts-node src/cli.ts serve",
"demo:dev": "ts-node src/demo.ts",
"test:governance": "node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/capability-registry-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/alert-staleness-test.cjs",
"test:governance": "node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/capability-registry-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/deps-freshness-test.cjs",
"test:alert-staleness": "node scripts/alert-staleness-test.cjs",
"test:deps": "node scripts/deps-freshness-test.cjs",
"test:dm-continuity": "node scripts/dm-continuity-test.cjs",
"test:reaper": "node scripts/idle-reaper-test.cjs",
"test:policy-tier-a": "node scripts/tier-a-policy-test.cjs",
Expand Down
159 changes: 159 additions & 0 deletions scripts/deps-freshness-test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env node
/* Dependency-freshness test: Settings → System must catch a dep that is PRESENT BUT STALE (the real gap —
* a box on an old `claude` reported a green "all installed" while its runtime predated the current model
* line), and must resolve a binary that is off the server's PATH instead of calling it missing.
*
* Covers the pure logic against a fake binary (no network: the registry lookup is stubbed via a seeded
* cache) plus the real routes over an in-process HTTP server — presence, freshness, and the owner gate on
* `POST /api/deps/update`. Isolated home. */
const fs = require('fs'); const os = require('os'); const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-deps-test-'));
const BIN = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-deps-bin-'));
process.env.AGENT_OS_HOME = HOME; process.env.AGENT_OS_TENANT = 'testco';
process.env.AGENT_OS_OWNER_EMAIL = 'owner@test';
delete process.env.AGENT_OS_SECRET_KEY;

let pass = 0, fail = 0;
const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d ? ' — ' + d : ''}`));

// A stand-in `claude` that reports an ancient version — the stale-but-installed condition.
const FAKE = path.join(BIN, 'claude');
fs.writeFileSync(FAKE, '#!/bin/sh\necho "2.1.100 (Claude Code)"\n');
fs.chmodSync(FAKE, 0o755);

const deps = require(path.join(ROOT, 'dist/edge/deps.js'));
const { parseVersion, atLeastVersion } = require(path.join(ROOT, 'dist/edge/claude-cli.js'));
const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js'));
const { createHttpServer } = require(path.join(ROOT, 'dist/server.js'));

(async () => {
console.log('\n\x1b[1m1) version parsing + comparison\x1b[0m');
assert(JSON.stringify(parseVersion('2.1.220 (Claude Code)')) === '[2,1,220]', 'parses a version with trailing noise');
assert(parseVersion('no version here') === null, 'unparseable → null (never a false "stale")');
assert(atLeastVersion([2, 1, 220], [2, 1, 220]) === true, 'equal is not stale');
assert(atLeastVersion([2, 1, 100], [2, 1, 220]) === false, 'behind on patch is stale');
assert(atLeastVersion([2, 2, 0], [2, 1, 220]) === true, 'ahead on minor is not stale');

console.log('\n\x1b[1m2) presence probe is sync + network-free\x1b[0m');
process.env.CLAUDE_BIN = FAKE;
const base = deps.checkDeps();
const claude = base.deps.find((d) => d.bin === 'claude');
assert(claude.installed === true, 'resolves the fake claude');
assert(claude.version === '2.1.100 (Claude Code)', 'version-probes the RESOLVED path', claude.version);
assert(base.outdated.length === 0 && base.updatesCheckedAt === 0, 'freshness is absent until asked for');
assert(claude.latest === undefined, 'no registry field on the sync report');

console.log('\n\x1b[1m3) resolution order + off-PATH fallback\x1b[0m');
// Launcher parity: claude-cli.ts prefers $CLAUDE_BIN over PATH, so this must too — otherwise the panel
// reports the version of a binary sessions never run. (A real `claude` may well be on this box's PATH.)
assert(claude.path === FAKE, '$CLAUDE_BIN wins over PATH (matches the launcher)', claude.path);
const gitDep = deps.checkDeps().deps.find((d) => d.bin === 'git');
assert(!gitDep.offPath, 'a normal PATH dep is not flagged off-PATH');

// The systemd minimal-PATH case: nothing named `claude` on PATH, reachable only via the fallback.
// The old bare `command -v claude` reported MISSING here while sessions launched fine.
const realPath = process.env.PATH;
process.env.PATH = '/usr/bin:/bin';
const stripped = deps.checkDeps().deps.find((d) => d.bin === 'claude');
process.env.PATH = realPath;
assert(stripped.installed === true, 'still resolves with claude off PATH');
assert(stripped.offPath === true, 'flagged as resolved off PATH');
assert(stripped.path === FAKE, 'path is the fallback location', stripped.path);

console.log('\n\x1b[1m4) freshness marks a present-but-stale dep\x1b[0m');
const seeded = await deps.checkDepUpdates(base).catch(() => null);
const net = seeded && seeded.deps.find((d) => d.bin === 'claude');
// Whatever the registry currently publishes — never hardcode it. A new claude-code lands every few
// days, and pinning a literal here makes section 6's "upgraded" stub stale the moment one ships.
const LATEST = net && net.latest;
if (!net || (!net.latest && net.updateError)) {
console.log(' \x1b[33m·\x1b[0m registry unreachable — skipping the live-lookup assertions');
} else {
assert(!!net.latest, 'registry answered with a latest version', net.updateError);
assert(net.updateAvailable === true, 'v2.1.100 vs latest → updateAvailable', `latest=${net.latest}`);
assert(seeded.outdated.includes('claude'), 'listed in report.outdated');
// `ok` means "every required dep is PRESENT" and must be untouched by staleness. Compare against the
// pre-freshness report rather than asserting `true` outright — a CI runner without ttyd is legitimately
// not-ok, and hardcoding `true` tests the runner's box instead of the property.
assert(seeded.ok === base.ok, 'report.ok is unchanged by freshness — stale is not missing', `base=${base.ok} after=${seeded.ok}`);
assert(seeded.updatesCheckedAt > 0, 'stamps updatesCheckedAt');
const tmux = seeded.deps.find((d) => d.bin === 'tmux');
assert(tmux.latest === undefined && tmux.updateAvailable === undefined, 'a non-npm dep is never version-checked');
}

console.log('\n\x1b[1m5) routes\x1b[0m');
const registry = new TenantRegistry(ROOT, 0);
registry.bootAll();
const aos = registry.get('testco').os;
const ownerSid = aos.team.acceptToken(aos.team.invite({ email: 'owner2@test', role: 'owner' }).token).sid;
const adminSid = aos.team.acceptToken(aos.team.invite({ email: 'admin@test', role: 'admin' }).token).sid;
const memberSid = aos.team.acceptToken(aos.team.invite({ email: 'member@test', role: 'member' }).token).sid;

const server = createHttpServer(registry);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
const call = (method, p, sid, body) => fetch(`http://127.0.0.1:${port}${p}`, {
method,
headers: { cookie: `aos_sid=${sid}`, 'content-type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});

const get = await call('GET', '/api/deps', adminSid);
const report = await get.json();
assert(get.status === 200, 'GET /api/deps → 200 for admin', String(get.status));
assert(Array.isArray(report.deps) && report.deps.length > 0, 'returns the dep list');
assert('outdated' in report && 'updatesCheckedAt' in report, 'response carries the freshness fields');
const rc = report.deps.find((d) => d.bin === 'claude');
assert(rc && rc.npmPkg === '@anthropic-ai/claude-code', 'claude row names its npm package');

assert((await call('GET', '/api/deps', memberSid)).status === 403, 'GET /api/deps → 403 for member');

const asMember = await call('POST', '/api/deps/update', memberSid, { bin: 'claude' });
assert(asMember.status === 403, 'POST /api/deps/update → 403 for member', String(asMember.status));
const asAdmin = await call('POST', '/api/deps/update', adminSid, { bin: 'claude' });
assert(asAdmin.status === 403, 'POST /api/deps/update → 403 for admin (owner-only)', String(asAdmin.status));
const noBin = await call('POST', '/api/deps/update', ownerSid, {});
assert(noBin.status === 400, 'POST /api/deps/update without bin → 400', String(noBin.status));

// An unknown / non-npm dep must be refused BEFORE anything is spawned.
const bogus = await call('POST', '/api/deps/update', ownerSid, { bin: 'nope' });
const bogusBody = await bogus.json();
assert(bogus.status === 200 && bogusBody.ok === false && /unknown dependency/.test(bogusBody.error || ''), 'unknown dep → ok:false with a reason', JSON.stringify(bogusBody.error));
const tmuxUpd = await call('POST', '/api/deps/update', ownerSid, { bin: 'tmux' });
const tmuxBody = await tmuxUpd.json();
assert(tmuxBody.ok === false && /not installed from npm/.test(tmuxBody.error || ''), 'non-npm dep is refused (no shell-out)', JSON.stringify(tmuxBody.error));
assert((tmuxBody.steps || []).length === 0, 'refusal runs no install steps');

console.log('\n\x1b[1m6) update applies + re-checks (stubbed npm — no real global install)\x1b[0m');
if (!LATEST) {
console.log(' \x1b[33m·\x1b[0m registry unreachable — skipping the update round-trip');
} else {
// A fake `npm` sitting BESIDE the fake claude, which "upgrades" it by rewriting the version it prints
// to whatever the registry actually publishes. This also pins the sibling-npm preference: on an nvm
// box the PATH npm may belong to a different node prefix, and installing there would leave the binary
// we actually run untouched.
const stubNpm = path.join(BIN, 'npm');
fs.writeFileSync(stubNpm, `#!/bin/sh\nprintf '#!/bin/sh\\necho "${LATEST} (Claude Code)"\\n' > ${FAKE}\nchmod +x ${FAKE}\necho "stub npm ran: $@"\n`);
fs.chmodSync(stubNpm, 0o755);

const upd = await call('POST', '/api/deps/update', ownerSid, { bin: 'claude' });
const updBody = await upd.json();
const after = updBody.report.deps.find((d) => d.bin === 'claude');
assert(upd.status === 200, 'POST /api/deps/update → 200 for owner', String(upd.status));
assert((updBody.steps[0] || {}).cmd === `${stubNpm} install -g @anthropic-ai/claude-code@latest`, 'ran the sibling npm, not the PATH one', (updBody.steps[0] || {}).cmd);
assert(after.version === `${LATEST} (Claude Code)`, 're-probes the upgraded binary', after.version);
assert(after.updateAvailable === false, 'no longer flagged outdated');
assert(updBody.ok === true, 'reports ok', JSON.stringify(updBody.error));
assert(!updBody.report.outdated.includes('claude'), 'dropped out of report.outdated');
const audited = aos.db.prepare("SELECT data FROM audit_events WHERE type='system.deps.updated' ORDER BY ts DESC LIMIT 1").get();
assert(!!audited && JSON.parse(audited.data).bin === 'claude', 'audited system.deps.updated');
}

server.close();
registry.shutdown?.();
fs.rmSync(HOME, { recursive: true, force: true });
fs.rmSync(BIN, { recursive: true, force: true });
console.log(`\n${fail === 0 ? '\x1b[32m' : '\x1b[31m'}${pass} passed, ${fail} failed\x1b[0m\n`);
process.exit(fail === 0 ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });
41 changes: 32 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { TenantStore } from './state/control';
import { reconcileTenant } from './governance/policy-reconcile';
import { Role } from './types';
import { VERSION } from './version';
import { checkDeps, installDeps, type DepsReport } from './edge/deps';
import { checkDeps, checkDepUpdates, installDeps, updateNpmDep, type DepsReport } from './edge/deps';

// `node:sqlite` is stable enough to depend on but still emits an ExperimentalWarning on first
// use. Swallow just that one line so the console output stays clean; surface every other warning.
Expand Down Expand Up @@ -58,10 +58,11 @@ async function main(): Promise<void> {
policy(rest);
break;
case 'deps':
deps(false);
if (rest[0] === 'update') await depsUpdate(rest[1] || '');
else await deps(false);
break;
case 'install-deps':
deps(true);
await deps(true);
break;
case 'demo':
await import('./demo');
Expand Down Expand Up @@ -89,27 +90,48 @@ async function main(): Promise<void> {
}

/** Check (and optionally install) the native tools a running instance shells out to. No server needed. */
function deps(install: boolean): void {
async function deps(install: boolean): Promise<void> {
const print = (r: DepsReport): void => {
for (const d of r.deps) {
const mark = d.installed ? '✓' : d.required ? '✗' : '·';
const mark = d.installed ? (d.updateAvailable ? '↑' : '✓') : d.required ? '✗' : '·';
const tail = d.installed ? (d.version || d.path || '') : (d.pkg ? '(missing)' : `(missing — ${d.hint || 'install manually'})`);
console.log(` ${mark} ${d.label.padEnd(12)} ${tail}`);
const stale = d.updateAvailable ? ` → v${d.latest} available` : '';
const off = d.offPath ? ' (off PATH)' : '';
console.log(` ${mark} ${d.label.padEnd(12)} ${tail}${off}${stale}`);
}
console.log(r.ok ? '\nAll required dependencies are installed.' : '\nSome required dependencies are missing.');
if (!r.ok && r.installCommand) console.log(`Install them with:\n ${r.installCommand}`);
else if (!r.ok) console.log('No supported package manager found — install the missing tools by hand (see hints above).');
for (const d of r.deps.filter((x) => x.updateAvailable)) {
console.log(`\n${d.label} is out of date (v${d.latest} available). Update it with:\n agent-os deps update ${d.bin}`);
}
};

if (!install) { const r = checkDeps(); print(r); process.exitCode = r.ok ? 0 : 1; return; }
// Freshness is best-effort — an offline box still gets its presence report.
const annotate = (r: DepsReport): Promise<DepsReport> => checkDepUpdates(r).catch(() => r);

if (!install) { const r = await annotate(checkDeps()); print(r); process.exitCode = r.ok ? 0 : 1; return; }

const pre = checkDeps();
if (pre.ok && !pre.installable.length) { console.log('All required dependencies are already installed.'); return; }
console.log('Installing missing dependencies…\n');
const result = installDeps();
for (const s of result.steps) console.log(`${s.ok ? '✓' : '✗'} ${s.cmd}${s.out ? `\n${s.out}` : ''}`);
console.log('');
print(result.report);
print(await annotate(result.report));
process.exitCode = result.ok ? 0 : 1;
}

/** `agent-os deps update <bin>` — upgrade one npm-installed dependency (today: `claude`) in place. */
async function depsUpdate(bin: string): Promise<void> {
if (!bin) { console.log('usage: agent-os deps update <bin> (e.g. `claude`)'); process.exitCode = 1; return; }
console.log(`Updating ${bin}…\n`);
const result = await updateNpmDep(bin);
for (const s of result.steps) console.log(`${s.ok ? '✓' : '✗'} ${s.cmd}${s.out ? `\n${s.out}` : ''}`);
const after = result.report.deps.find((d) => d.bin === bin);
if (result.ok) console.log(`\n✓ ${after?.label || bin} is now ${after?.version || 'up to date'}.`);
else console.log(`\n✗ ${result.error || 'update failed'}`);
console.log('\nNote: sessions already running keep the old binary until they restart.');
process.exitCode = result.ok ? 0 : 1;
}

Expand Down Expand Up @@ -316,7 +338,8 @@ function usage(): void {
invite <email> [role] mint a magic-link to invite a teammate (role: admin | member)
login-link <email> print a fresh login link for an existing member (recovery)
members list workspace members and their roles
deps check the native tools sessions need (tmux/ttyd/claude/git)
deps check the native tools sessions need (tmux/ttyd/claude/git) + their freshness
deps update <bin> upgrade an npm-installed tool in place (e.g. deps update claude)
install-deps install the missing native tools via the box's package manager
tenant <sub> multi-tenant admin: list | create <slug> --owner <email> | remove <slug>
policy reconcile align agents' policyContext to the enforced ruleset (--tenant <slug> | --all; --yes to apply)
Expand Down
Loading
Loading