diff --git a/CHANGELOG.md b/CHANGELOG.md index 783350a8..4d95a768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ new version heading in the same commit. ## [Unreleased] +## [0.470.1] - 2026-11-12 +### Fixed +- **New agents are born on the tenant's own ruleset, and a hand edit to `agent.json` no longer gets + reverted** (#903, #904). Every creation path (console, `agent_create`, manifest import, catalog install, + the seeded `agent-author`, and the System agents) hardcoded `policyContext: 'default@v3'`, so on a tenant + whose enforced ruleset has another id each new agent logged a false `policyContextMismatch` drift warning. + They now stamp `os.policy.id`. The obvious workaround, editing `agent.json` by hand, didn't stick either: + every writer (self-edit, cross-agent apply, console config, revert, duplicate) built the next manifest + from the in-memory copy and wrote it over disk, silently dropping the hand edit. They now merge onto the + manifest on disk (`onDiskManifest` in `src/state/agent-edit.ts`), which is what a rescan or restart + would load anyway. Pinned by `scripts/agent-manifest-disk-test.cjs`. +- **`agent_create` can declare `shellSecrets`.** The create route already accepted them but the tool + schema didn't, so an agent building a teammate that needs a shell credential had no way to say so. It + declares intent only: injection is still governed by the vault grant. + ## [0.470.0] - 2026-11-10 ### Added - **An agent's CLAUDE.md has a size budget.** Self-improvement only ever appends — an agent that learns a diff --git a/docs/agent-mcp-tools.md b/docs/agent-mcp-tools.md index 936e2df3..880ee2db 100644 --- a/docs/agent-mcp-tools.md +++ b/docs/agent-mcp-tools.md @@ -292,6 +292,10 @@ available to any agent. Creating an agent **definition escalates nothing**: the every side effect through the gate, and only a **human** can run or assign it (spawn is role-gated). So it follows the same **auto-apply + audited** posture as `kb_write` / `task_create` — no approval card — emitting `agent.created` with `principal: agent:`. Guard: strict id validation + collision check. +The new agent's `policyContext` is the tenant's enforced ruleset (`os.policy.id`), never a hardcoded +label. An optional `shellSecrets` (vault KEY names) declares the env vars the agent needs in its shell; +it declares intent only — the value is injected only when the vault grants that key to the agent, the +same as a console-declared `shellSecrets`, so it widens nothing. `agent_update` / `agent_history` / `agent_revert` are the **self-improvement** loop — an agent refining its OWN listing (description, starter prompts, category, icon, tuning) and CLAUDE.md system prompt when it diff --git a/package-lock.json b/package-lock.json index d2e680f8..7f88bc6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.470.0", + "version": "0.470.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.470.0", + "version": "0.470.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 6b2f6e12..e2e8dbe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.470.0", + "version": "0.470.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -28,7 +28,7 @@ "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", "test:governance": "node scripts/governance-isolated.cjs", - "test:governance:run": "node scripts/version-sync-test.cjs && node scripts/router-dispatch-test.cjs && node scripts/prompt-credential-guard-test.cjs && node scripts/standing-instructions-test.cjs && node scripts/session-trust-test.cjs && node scripts/video-gen-mapping-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/connector-auth-stub-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/resume-transcript-stage-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/session-search-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/launch-window-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/protected-read-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/ask-block-link-test.cjs && node scripts/answer-pinning-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", + "test:governance:run": "node scripts/version-sync-test.cjs && node scripts/agent-manifest-disk-test.cjs && node scripts/router-dispatch-test.cjs && node scripts/prompt-credential-guard-test.cjs && node scripts/standing-instructions-test.cjs && node scripts/session-trust-test.cjs && node scripts/video-gen-mapping-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/connector-auth-stub-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/resume-transcript-stage-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/session-search-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/launch-window-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/protected-read-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/ask-block-link-test.cjs && node scripts/answer-pinning-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/agent-manifest-disk-test.cjs b/scripts/agent-manifest-disk-test.cjs new file mode 100644 index 00000000..ba0db795 --- /dev/null +++ b/scripts/agent-manifest-disk-test.cjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/* Agent manifests — born on the tenant's enforced ruleset, and never reverted by the next edit. + * + * Two defects that compounded (issues #903, #904): + * + * 1. Every agent-creation path hardcoded `policyContext: 'default@v3'`. On a tenant whose enforced + * ruleset has another id, each new agent was born with a `policyContextMismatch` drift warning — a + * false alarm per agent that trains operators to ignore the real one. + * 2. The natural fix-by-hand (edit agent.json) didn't stick: every writer built the next manifest from + * the IN-MEMORY copy, which never saw the out-of-band edit, and wrote it back over disk. The same + * revert silently dropped a hand-added `shellSecrets` — and `agent_create` offered no way to declare + * one, so hand-editing was the only agent-side route. + * + * Pins: creation stamps `os.policy.id`; `agent_create` carries `shellSecrets`; the self-edit lane, the + * console config route and a revert all build on the manifest ON DISK. + */ +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-agent-manifest-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +delete process.env.AGENT_OS_SECRET_KEY; + +// A tenant whose enforced ruleset is NOT the bundled label. +const RULESET = 'acme@v2'; +const bundled = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/policy/default.policy.json'), 'utf8')); +fs.mkdirSync(path.join(HOME, 'policy'), { recursive: true }); +fs.writeFileSync(path.join(HOME, 'policy', 'default.policy.json'), JSON.stringify({ ...bundled, id: RULESET }, null, 2)); + +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 !== undefined ? ' — ' + JSON.stringify(d).slice(0, 400) : ''}`)); + +console.log('\n\x1b[1m1) agent_create exposes shellSecrets\x1b[0m'); +{ + const src = fs.readFileSync(path.join(ROOT, 'src/memory/memory-mcp.ts'), 'utf8'); + const start = src.indexOf("name: 'agent_create'"); + const block = src.slice(start, src.indexOf("name: 'agent_get'", start)); + assert(/shellSecrets:\s*\{/.test(block), 'the tool schema declares shellSecrets'); + const handler = src.slice(src.indexOf('async function agentCreate('), src.indexOf('async function agentCreate(') + 2000); + assert(/shellSecrets:/.test(handler), 'and the handler forwards it to the create route'); +} + +(async () => { + const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); + const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js')); + const registry = new TenantRegistry(ROOT, 0, path.join(ROOT, 'config/agent-os.config.json')); + registry.bootAll(); + const { os: aos, tm } = registry.default(); + const server = createHttpServer(registry); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + assert(aos.policy.id === RULESET, `the tenant enforces ${RULESET}`, aos.policy.id); + + // The authoring agent. + const authorDir = path.join(aos.paths.userAgents, 'agent-author-bot'); + fs.mkdirSync(authorDir, { recursive: true }); + const authorManifest = { id: 'agent-author-bot', version: '1.0.0', description: 'author', principal: 'svc-agent-author-bot', policyContext: RULESET, runtime: 'claude-code' }; + fs.writeFileSync(path.join(authorDir, 'agent.json'), JSON.stringify(authorManifest, null, 2) + '\n'); + fs.writeFileSync(path.join(authorDir, 'CLAUDE.md'), '# Author\n'); + aos.registerAgent({ ...authorManifest, dir: authorDir }); + + const asSession = (agentId) => { + const s = tm.createSession(agentId, 'manifest test', 'task'); + const secret = aos.db.prepare('SELECT secret FROM term_sessions WHERE id = ?').get(s.id).secret; + return async (p, body) => { + const res = await fetch(base + p, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-aos-secret': secret }, + body: JSON.stringify({ session: s.id, ...body }), + }); + return res.json(); + }; + }; + const NEW = 'db-reporter'; + const file = path.join(aos.paths.userAgents, NEW, 'agent.json'); + const disk = () => JSON.parse(fs.readFileSync(file, 'utf8')); + + console.log('\n\x1b[1m2) creation stamps the enforced ruleset and carries shellSecrets\x1b[0m'); + const warnings = []; + const origWarn = console.warn; + console.warn = (...a) => { warnings.push(a.join(' ')); }; + const created = await asSession('agent-author-bot')('/api/agents/create', { + id: NEW, description: 'Reports from the read replica', claudeMd: '# DB reporter\n\n## Method\n\nQuery, then report.\n', + shellSecrets: ['DB_RO_PASS'], + }); + console.warn = origWarn; + assert(created.ok, 'agent_create succeeds', created); + assert(disk().policyContext === RULESET, `agent.json policyContext = ${RULESET}, not the bundled label`, disk().policyContext); + assert(aos.agents.get(NEW).policyContext === RULESET, 'and so does the live registry'); + assert(!warnings.some((w) => w.includes(`"${NEW}"`) && w.includes('policyContext')), 'no drift warning at registration', warnings); + assert(JSON.stringify(disk().shellSecrets) === '["DB_RO_PASS"]', 'shellSecrets declared at creation land in agent.json', disk().shellSecrets); + + console.log('\n\x1b[1m3) a self-edit builds on the manifest ON DISK\x1b[0m'); + // Out-of-band: a human adds a second secret and an extra key the in-memory copy never sees. + fs.writeFileSync(file, JSON.stringify({ ...disk(), shellSecrets: ['DB_RO_PASS', 'REPORT_TOKEN'], handNote: 'kept' }, null, 2) + '\n'); + assert(!aos.agents.get(NEW).handNote, '(precondition) memory has not seen the hand edit'); + const self = asSession(NEW); + const got = await self('/api/agents/get', {}); + const upd = await self('/api/agents/update', { baseHash: got.baseHash, claudeMdAppend: '## Output\n\nA short table.' }); + assert(upd.ok, 'agent_update succeeds', upd); + assert(JSON.stringify(disk().shellSecrets) === '["DB_RO_PASS","REPORT_TOKEN"]', 'the hand-added secret survives the self-edit', disk().shellSecrets); + assert(disk().handNote === 'kept' && disk().policyContext === RULESET, 'as does every other on-disk key', disk()); + assert(JSON.stringify(aos.agents.get(NEW).shellSecrets) === '["DB_RO_PASS","REPORT_TOKEN"]', 'and the registry now matches disk'); + assert(fs.readFileSync(path.join(aos.paths.userAgents, NEW, 'CLAUDE.md'), 'utf8').includes('## Output'), 'while the edit itself applied'); + + console.log('\n\x1b[1m4) the console config route and a revert do too\x1b[0m'); + const owner = aos.team.listMembers().find((m) => m.role === 'owner'); + const cookie = `aos_sid=${aos.team.createSession(owner.id)}`; + fs.writeFileSync(file, JSON.stringify({ ...disk(), handNote: 'kept-2' }, null, 2) + '\n'); + const cfg = await fetch(base + `/api/agents/${NEW}/config`, { method: 'PUT', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ effort: 'high' }) }).then((r) => r.json()); + assert(cfg.ok && disk().effort === 'high', 'the config PUT applied', cfg); + assert(disk().handNote === 'kept-2', 'without reverting the out-of-band edit', disk()); + + fs.writeFileSync(file, JSON.stringify({ ...disk(), handNote: 'kept-3' }, null, 2) + '\n'); + const hist = await self('/api/agents/history', {}); + const first = hist.revisions && hist.revisions[hist.revisions.length - 1]; + const rev = await self('/api/agents/revert', { rev: first && first.rev }); + assert(rev.ok, 'agent_revert runs', { hist, rev }); + assert(disk().handNote === 'kept-3', 'a revert keeps on-disk keys a snapshot does not carry', disk()); + + console.log('\n\x1b[1m5) built-in and system agents are stamped too\x1b[0m'); + const builtin = JSON.parse(fs.readFileSync(path.join(aos.paths.userAgents, 'agent-author', 'agent.json'), 'utf8')); + assert(builtin.policyContext === RULESET, 'the seeded agent-author names the enforced ruleset', builtin.policyContext); + for (const id of ['concierge', 'consolidator']) { + const a = aos.agents.get(id); + if (a) assert(a.policyContext === RULESET, `system agent "${id}" registers on the enforced ruleset`, a.policyContext); + } + + server.close(); + console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m`); + fs.rmSync(HOME, { recursive: true, force: true }); + process.exit(fail ? 1 : 0); +})().catch((e) => { console.error(e); fs.rmSync(HOME, { recursive: true, force: true }); process.exit(1); }); diff --git a/src/edge/agent-catalog.ts b/src/edge/agent-catalog.ts index a30e60a5..946de803 100644 --- a/src/edge/agent-catalog.ts +++ b/src/edge/agent-catalog.ts @@ -95,13 +95,20 @@ export function readAgentCatalog(catalogDir: string | undefined, userAgentsDir: /** Deep-copy a catalog agent's folder into the data home and return its manifest tagged with the new * (home) folder. Pure filesystem — the caller registers it live + audits. Throws if the id isn't in the * catalog or a folder already exists in the home (installed already). */ -export function installAgentFromCatalog(catalogDir: string | undefined, userAgentsDir: string, id: string): AgentManifest { +export function installAgentFromCatalog(catalogDir: string | undefined, userAgentsDir: string, id: string, policyContext?: string): AgentManifest { if (!catalogDir) throw new Error('no agent catalog is configured'); const manifest = readCatalogManifest(catalogDir, id); if (!manifest) throw new Error(`"${id}" is not in the agent library`); const dest = path.join(userAgentsDir, manifest.id); if (fs.existsSync(dest)) throw new Error(`an agent named "${manifest.id}" already exists`); fs.cpSync(path.join(catalogDir, id), dest, { recursive: true }); + // The catalog ships the bundled ruleset label; an installed copy should name the ruleset this tenant + // actually enforces, or it is born with a policyContextMismatch drift warning. + if (policyContext && manifest.policyContext !== policyContext) { + const { dir: _dir, ...onDisk } = { ...manifest, policyContext }; + fs.writeFileSync(path.join(dest, 'agent.json'), JSON.stringify(onDisk, null, 2) + '\n'); + return { ...manifest, policyContext, dir: dest }; + } return { ...manifest, dir: dest }; } @@ -125,7 +132,7 @@ export function seedBuiltinAgents(os: AgentOS): void { continue; } try { - const manifest = installAgentFromCatalog(catalogDir, os.paths.userAgents, id); + const manifest = installAgentFromCatalog(catalogDir, os.paths.userAgents, id, os.policy.id); os.registerAgent(manifest); } catch (e) { console.error(`[agents] could not seed built-in "${id}": ${e instanceof Error ? e.message : e}`); diff --git a/src/edge/concierge.ts b/src/edge/concierge.ts index b91f1844..16b32145 100644 --- a/src/edge/concierge.ts +++ b/src/edge/concierge.ts @@ -22,8 +22,10 @@ export const CONCIERGE_ID = 'concierge'; export const OPERATOR_ID = 'operator'; /** Provision + register a System agent from its manifest + persona. Idempotent; safe to call on demand. */ -function ensureSystemAgent(os: AgentOS, manifest: AgentManifest, claudeMd: string): void { - if (os.agents.get(manifest.id)?.dir) return; +function ensureSystemAgent(os: AgentOS, declared: AgentManifest, claudeMd: string): void { + if (os.agents.get(declared.id)?.dir) return; + // Stamp the ruleset this tenant enforces, not the bundled label (else: a drift warning every boot). + const manifest: AgentManifest = { ...declared, policyContext: os.policy.id }; const base = os.paths?.userAgents ?? path.join(process.cwd(), 'data', 'agents'); const dir = path.join(base, manifest.id); fs.mkdirSync(dir, { recursive: true }); diff --git a/src/edge/consolidation.ts b/src/edge/consolidation.ts index 57dab2e7..135db874 100644 --- a/src/edge/consolidation.ts +++ b/src/edge/consolidation.ts @@ -104,9 +104,10 @@ export class Consolidation { const dir = path.join(base, AGENT_ID); fs.mkdirSync(dir, { recursive: true }); const manifestPath = path.join(dir, 'agent.json'); - if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2)); + const manifest = { ...MANIFEST, policyContext: this.os.policy.id }; // the enforced ruleset, not the bundled label + if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD); - this.os.registerAgent({ ...MANIFEST, dir }); + this.os.registerAgent({ ...manifest, dir }); } } diff --git a/src/edge/diagnosis.ts b/src/edge/diagnosis.ts index 714c7f9e..6c8dcda4 100644 --- a/src/edge/diagnosis.ts +++ b/src/edge/diagnosis.ts @@ -85,9 +85,10 @@ export class Diagnosis { const dir = path.join(base, ANALYST_ID); fs.mkdirSync(dir, { recursive: true }); const manifestPath = path.join(dir, 'agent.json'); - if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2)); + const manifest = { ...MANIFEST, policyContext: this.os.policy.id }; // the enforced ruleset, not the bundled label + if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD); - this.os.registerAgent({ ...MANIFEST, dir }); + this.os.registerAgent({ ...manifest, dir }); } } diff --git a/src/edge/improver.ts b/src/edge/improver.ts index 705a4ee8..b52777f2 100644 --- a/src/edge/improver.ts +++ b/src/edge/improver.ts @@ -108,9 +108,10 @@ export class Improver { const dir = path.join(base, IMPROVER_ID); fs.mkdirSync(dir, { recursive: true }); const manifestPath = path.join(dir, 'agent.json'); - if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2)); + const manifest = { ...MANIFEST, policyContext: this.os.policy.id }; // the enforced ruleset, not the bundled label + if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD); - this.os.registerAgent({ ...MANIFEST, dir }); + this.os.registerAgent({ ...manifest, dir }); } } diff --git a/src/edge/skill-scout.ts b/src/edge/skill-scout.ts index d8eb1cbe..34e7a15e 100644 --- a/src/edge/skill-scout.ts +++ b/src/edge/skill-scout.ts @@ -88,9 +88,10 @@ export class SkillScout { const dir = path.join(base, SCOUT_ID); fs.mkdirSync(dir, { recursive: true }); const manifestPath = path.join(dir, 'agent.json'); - if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2)); + const manifest = { ...MANIFEST, policyContext: this.os.policy.id }; // the enforced ruleset, not the bundled label + if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD); - this.os.registerAgent({ ...MANIFEST, dir }); + this.os.registerAgent({ ...manifest, dir }); } } diff --git a/src/edge/strategist.ts b/src/edge/strategist.ts index 6fb96976..8833971b 100644 --- a/src/edge/strategist.ts +++ b/src/edge/strategist.ts @@ -219,9 +219,10 @@ export class Strategist { const dir = path.join(base, AGENT_ID); fs.mkdirSync(dir, { recursive: true }); const manifestPath = path.join(dir, 'agent.json'); - if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2)); + const manifest = { ...MANIFEST, policyContext: this.os.policy.id }; // the enforced ruleset, not the bundled label + if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD); - this.os.registerAgent({ ...MANIFEST, dir }); + this.os.registerAgent({ ...manifest, dir }); } } diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index 32c351e0..d8e5695b 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -1585,7 +1585,7 @@ const TOOLS = [ description: 'Create a brand-new agent in this workspace and register it live (no restart) — the way to turn a ' + 'role into a real, governed teammate. You supply its id, description, and CLAUDE.md (its system ' + - 'prompt), plus optional category/model/effort/icon/example prompts. The new agent appears in the ' + + 'prompt), plus optional category/model/effort/icon/example prompts/shellSecrets. The new agent appears in the ' + 'console under its category and can then be run or assigned by a human. Use this when someone asks ' + 'you to build or spin up an agent for a job.', inputSchema: { @@ -1600,6 +1600,10 @@ const TOOLS = [ effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max'], description: 'Reasoning effort override. Omit → inherit the workspace default.' }, examplePrompts: { type: 'array', items: { type: 'string' }, description: '2–3 clickable starter tasks shown on the agent\'s spawn card.' }, icon: { type: 'string', description: 'A lucide icon name from the built-in library (e.g. "Bot", "Wrench", "Megaphone"). Omit → default glyph.' }, + shellSecrets: { + type: 'array', items: { type: 'string' }, + description: 'Vault secret KEYS the new agent needs as shell env vars (e.g. ["GH_TOKEN", "DB_RO_PASS"]), exported into its shell at launch so a plain CLI authenticates. Declares intent only — the value is injected only if the vault grants that key to the agent; nothing is widened. Omit → none.', + }, }, required: ['id', 'description', 'claudeMd'], }, @@ -3298,6 +3302,7 @@ async function agentCreate(args: Record): Promise { effort: args.effort !== undefined ? String(args.effort) : undefined, examplePrompts: Array.isArray(args.examplePrompts) ? args.examplePrompts.map(String) : undefined, icon: args.icon !== undefined ? String(args.icon) : undefined, + shellSecrets: Array.isArray(args.shellSecrets) ? args.shellSecrets.map(String) : undefined, }), }); const d = (await res.json()) as { ok?: boolean; id?: string; error?: string }; diff --git a/src/server.ts b/src/server.ts index 9497b897..3e924639 100644 --- a/src/server.ts +++ b/src/server.ts @@ -100,7 +100,7 @@ import { MAX_LIVE_BETS, MAX_LIVE_BETS_CEILING, resolveLiveBetCap } from './state import { AgentConfigSnapshot } from './state/agent-revisions'; import { FeedFilter } from './state/feed'; import { computeAgentStats, computeAgentStat } from './state/agent-stats'; -import { agentEditable, applyAgentEdit, assessClaudeMdEdit, assessClaudeMdSize, contentHash, diffStat, manifestToSnapshot, readAgentSnapshot, resolveClaudeMd } from './state/agent-edit'; +import { agentEditable, applyAgentEdit, assessClaudeMdEdit, assessClaudeMdSize, contentHash, diffStat, manifestToSnapshot, onDiskManifest, readAgentSnapshot, resolveClaudeMd } from './state/agent-edit'; /** Sum busy + idle CPU tick counters across all cores (for a sampled utilization %). */ function cpuTicks(): { idle: number; total: number } { @@ -281,7 +281,8 @@ function troubledAutomations(os: AgentOS, now = Date.now()): Array<{ id: string; /** Re-apply a full snapshot to disk (agent.json + CLAUDE.md) and re-register — the shared revert primitive. */ -function applyAgentSnapshot(os: AgentOS, ag: AgentManifest, snap: AgentConfigSnapshot): AgentManifest { +function applyAgentSnapshot(os: AgentOS, live: AgentManifest, snap: AgentConfigSnapshot): AgentManifest { + const ag = onDiskManifest(live); // keep out-of-band edits to fields a snapshot doesn't carry const next: AgentManifest = { ...ag, description: snap.description, category: snap.category, icon: snap.icon, @@ -2668,7 +2669,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const manifest: AgentManifest = { id, version: '1.0.0', description, ...(category ? { category } : {}), - principal: `svc-${id}`, policyContext: 'default@v3', runtime: 'claude-code', + principal: `svc-${id}`, policyContext: os.policy.id, runtime: 'claude-code', ...tuning, ...(examplePrompts ? { examplePrompts } : {}), ...(shellSecrets ? { shellSecrets } : {}), @@ -5385,7 +5386,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: description, ...(category ? { category } : {}), principal: `svc-${id}`, - policyContext: 'default@v3', + // The ruleset this tenant actually ENFORCES — a hardcoded bundled label births every agent with a + // policyContextMismatch drift warning on any tenant whose ruleset is named differently. + policyContext: os.policy.id, runtime, ...tuning, ...(examplePrompts ? { examplePrompts } : {}), @@ -5456,7 +5459,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: description: typeof m.description === 'string' ? m.description.trim() : '', ...(category ? { category } : {}), principal: `svc-${id}`, - policyContext: 'default@v3', + // The ruleset this tenant actually ENFORCES — a hardcoded bundled label births every agent with a + // policyContextMismatch drift warning on any tenant whose ruleset is named differently. + policyContext: os.policy.id, runtime: 'claude-code', ...(tErr ? {} : tuning), ...(examplePrompts ? { examplePrompts } : {}), @@ -5519,7 +5524,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' }); if (!os.paths) return sendJson(res, 400, { error: 'installing agents requires a data home' }); try { - const manifest = installAgentFromCatalog(os.paths.bundledAgents, os.paths.userAgents, agentInstall[1]); + const manifest = installAgentFromCatalog(os.paths.bundledAgents, os.paths.userAgents, agentInstall[1], os.policy.id); os.registerAgent(manifest); // Installing a built-in clears any deletion tombstone, so it seeds normally again on future boots. os.settings.unsuppressBuiltin(manifest.id, me.email); @@ -5553,7 +5558,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const folder = path.join(os.paths.userAgents, newId); if (fs.existsSync(folder)) return sendJson(res, 409, { error: `folder "${newId}" already exists in the agents home` }); fs.cpSync(src.dir, folder, { recursive: true }); - const next: AgentManifest = { ...src, id: newId, principal: `svc-${newId}` }; + const next: AgentManifest = { ...onDiskManifest(src), id: newId, principal: `svc-${newId}` }; const { dir: _dir, ...onDisk } = next; // `dir` is set at load, not persisted fs.writeFileSync(path.join(folder, 'agent.json'), JSON.stringify(onDisk, null, 2) + '\n'); os.registerAgent({ ...next, dir: folder }); @@ -5625,8 +5630,10 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const agentConfig = p.match(/^\/api\/agents\/([\w.-]+)\/config$/); if (agentConfig && (method === 'GET' || method === 'PUT')) { if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' }); - const ag = os.agents.get(agentConfig[1]); - if (!ag?.dir) return sendJson(res, 404, { error: 'agent not found or has no folder' }); + const live = os.agents.get(agentConfig[1]); + if (!live?.dir) return sendJson(res, 404, { error: 'agent not found or has no folder' }); + // Build on the on-disk manifest so a PUT doesn't revert an out-of-band agent.json edit. + const ag = onDiskManifest(live); if (!isCodingRuntime(ag.runtime)) return sendJson(res, 400, { error: 'runtime tuning applies to CLI-backed agents only' }); if (method === 'GET') { // `runtimes` lets the console render the picker (and its per-runtime model suggestions + @@ -5691,7 +5698,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const description = 'description' in b ? String(b.description ?? '').trim() : ag.description; const next: AgentManifest = { ...ag, runtime, description, model: tuning.model, effort: tuning.effort, permissionMode: tuning.permissionMode, outputStyle: tuning.outputStyle, examplePrompts: prompts, shellSecrets, skills, tools, usableSubagents, spawnableAsSubagent, subagentOnly, chatReachable, netMode: netMode === 'open' ? undefined : netMode, category, icon }; const { dir: _dir, ...onDisk } = next; // `dir` is set at load, not persisted - fs.writeFileSync(path.join(ag.dir, 'agent.json'), JSON.stringify(onDisk, null, 2) + '\n'); + fs.writeFileSync(path.join(live.dir, 'agent.json'), JSON.stringify(onDisk, null, 2) + '\n'); os.registerAgent(next); const rev = os.agentRevisions.commit(os.tenant, ag.id, before, manifestToSnapshot(next, before.claudeMd), 'edited config', me.email); os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'agent.config.updated', data: { agent: ag.id, runtime, model: tuning.model, effort: tuning.effort, permissionMode: tuning.permissionMode, outputStyle: tuning.outputStyle, category, shellSecrets: shellSecrets ?? [], skills: skills ?? [], tools: tools ?? [], netMode: netMode ?? 'open', rev } }); diff --git a/src/state/agent-edit.ts b/src/state/agent-edit.ts index 2374e1bb..44b36162 100644 --- a/src/state/agent-edit.ts +++ b/src/state/agent-edit.ts @@ -35,6 +35,25 @@ export function manifestToSnapshot(ag: AgentManifest, claudeMd: string): AgentCo }; } +/** + * The agent's manifest as it stands ON DISK — exactly what a rescan (`AgentOS.rescanAgents`) or a restart + * would register, since disk is the source of truth for a folder-backed agent. Every writer of + * `agent.json` builds its next manifest on THIS rather than on the in-memory copy: an out-of-band edit + * (a hand-added `shellSecrets`, a corrected `policyContext`) never reached memory, so writing + * `{ ...inMemory }` back silently reverted it on the next self-edit. Falls back to the in-memory manifest + * when there's no folder, no file, or it doesn't parse as this agent — the edit then behaves as before. + */ +export function onDiskManifest(ag: AgentManifest): AgentManifest { + if (!ag.dir) return ag; + try { + const disk = JSON.parse(fs.readFileSync(path.join(ag.dir, 'agent.json'), 'utf8')) as AgentManifest; + if (!disk || typeof disk !== 'object' || disk.id !== ag.id) return ag; + return { ...disk, dir: ag.dir }; + } catch { + return ag; + } +} + /** Read the agent's current on-disk snapshot (manifest fields + CLAUDE.md), to record as the "before". */ export function readAgentSnapshot(ag: AgentManifest): AgentConfigSnapshot { const file = ag.dir ? path.join(ag.dir, 'CLAUDE.md') : ''; @@ -236,10 +255,12 @@ export function diffStat(risk: ClaudeMdRisk): string { */ export function applyAgentEdit( os: AgentOS, - ag: AgentManifest, + live: AgentManifest, fields: Record, opts: { summary: string; author: string }, ): { ok: true; rev: number | null; target: string } | { ok: false; error: string } { + // Merge onto the manifest on DISK, not the in-memory copy — see onDiskManifest. + const ag = onDiskManifest(live); // NB: no `runtime` arg — same as the self-edit and approve routes this shares. An agent-proposed model // is validated by the CLI at launch, not here, so the three lanes stay behaviourally identical. The // output-style ALLOWLIST is passed though, because nothing downstream validates it: a name the CLI