diff --git a/CHANGELOG.md b/CHANGELOG.md index eaceacc7..c75cf351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,24 +4,26 @@ ### Changed -- **Worker shell commands get loopback-only network by default (AGT-4798).** AGT-4783 removed the credentials a worker's shell picked up on its own; a command could still read a token file by absolute path and use it while the sandbox allowed the network. The macOS sandbox profile now allows only `localhost` (bind and inbound on the local end, outbound to the remote end — a single `network* (local ip "localhost:*")` rule was measured to also admit remote connections), and bwrap keeps `--unshare-net`. Tests that start a local server still work; anything that reaches another host fails with `EPERM`. Measured before the change: cgf-portal's `uv run --frozen pytest` and OpenSwarm's vitest run offline, because worktrees share installed dependencies. New `autonomous.workerNetwork: loopback | open` (default `loopback`) — set `open` on a host whose sandbox cannot isolate the network. The worker prompt now says what is true (no credentials, no remote network) and tells it not to probe release/deploy scripts with `--help`. - -### Added +- **The daemon refuses to start when unattended work is routed to a delegated CLI adapter (AGT-4444).** `claude`, `codex` and `cursor` hand the whole tool loop to another program, so OpenSwarm's publication fence, protected files, role tool limits and (since AGT-4783/4798) the credential-free, loopback-only shell never reach them; `base.ts` could only log that they were dropped, and `claude` runs with `--permission-mode bypassPermissions`. `src/adapters/unattendedPolicy.ts` now checks every place the daemon can pick an adapter — the top-level `adapter`, `autonomous.defaultRoles`, per-project `roles`, `adapterRouting` primary **and fallbacks** (whose schema default is `['cc-router', 'cursor']`, so a bare `adapterRouting:` block already routed to cursor), `orchestrator` and `loopAdvisor` — and startup throws naming each offending setting. A role switched off is not checked. Opt out with `autonomous.allowDelegatedAdapters: true`. Interactive CLI use is unaffected: the check runs only when an `autonomous` section exists. Periodic read-only reviews (`periodicReviews[].adapter`) are not covered. -- **`projectAgents[].setup` — dependency install commands that run in the worktree before the worker (AGT-4798).** They run with the network open but inside the sandbox and without credentials, in order, stopping at the first failure; a failed setup ends the run as `infra_error` before any model call. Most projects need none. - -### Changed +- **Worker shell commands get loopback-only network by default (AGT-4798).** AGT-4783 removed the credentials a worker's shell picked up on its own; a command could still read a token file by absolute path and use it while the sandbox allowed the network. The macOS sandbox profile now allows only `localhost` (bind and inbound on the local end, outbound to the remote end — a single `network* (local ip "localhost:*")` rule was measured to also admit remote connections), and bwrap keeps `--unshare-net`. Tests that start a local server still work; anything that reaches another host fails with `EPERM`. Measured before the change: cgf-portal's `uv run --frozen pytest` and OpenSwarm's vitest run offline, because worktrees share installed dependencies. New `autonomous.workerNetwork: loopback | open` (default `loopback`) — set `open` on a host whose sandbox cannot isolate the network. The worker prompt now says what is true (no credentials, no remote network) and tells it not to probe release/deploy scripts with `--help`. - **Worker shell commands no longer carry the operator's credentials (AGT-4783).** On 2026-10-20 a cgf-portal worker ran `bash scripts/dev/release.sh --help`; the script called `gh workflow run` with the daemon's GitHub login and shipped an unapproved production deploy. A command-name fence cannot see inside a script, so the worker's `bash` tool now runs with `src/adapters/workerEnv.ts`: an allowlisted environment (toolchain, locale, terminal, test-resource budget — no tokens, keys, DSNs, `SSH_AUTH_SOCK` or `OPENSWARM_*` secrets), a private `HOME` with caches and toolchain homes (`CARGO_HOME`, `UV_CACHE_DIR`, `npm_config_cache`, …) pointed back at the real directories, an empty `GH_CONFIG_DIR`, git credential helpers reset to none, and git-over-ssh without an identity. Measured inside the real sandbox before and after: `gh auth token`, `git credential fill` and `wrangler whoami` all authenticated before and none do after; `uv run --frozen` in cgf-portal and `cargo`/`npm` still work. This reverses #493's choice to keep DevOps and DB credentials in the worker shell. A command that reads a token file by absolute path is still possible while the sandbox allows the network; turning the network off is the next step (S6 W1, with the dependency setup step AGT-4798). + - **`autonomous.workerSandbox: on` on a host with neither `sandbox-exec` nor `bwrap` now refuses worker bash commands instead of warning once and running them unfenced.** Set `autonomous.workerSandbox: off` to run them unfenced on purpose (for example on Windows). The boot log line says which applies. ### Added +- **`projectAgents[].setup` — dependency install commands that run in the worktree before the worker (AGT-4798).** They run with the network open but inside the sandbox and without credentials, in order, stopping at the first failure; a failed setup ends the run as `infra_error` before any model call. Most projects need none. + - **`openswarm exec --issue INT-123` deploys the worker pipeline for one Linear issue asynchronously and hands the run to an external orchestrator.** It resolves the issue, checks the repo's Linear project mapping and the deployable state, records an **exec hold**, and posts to the daemon's `/api/work` (falling back to a detached `work` child only when the daemon is unreachable — never on an HTTP refusal, so a 403 from `autonomous.allowedProjects` cannot be routed around), then prints `{runId, identifier, transport, deployed}` and returns. `exec status `, `exec await --timeout ` and `exec answer [--correlation hq-…] [--text …]` are the observation half: `await` blocks until the run elevates or finishes and prints the structured elevation JSON (state, park reason, branch/worktree/PR, pending questions with their class, and the exact next command), and `answer` writes the reply to the coordination board as `actorRole: 'orchestrator'` — the same delegated authority OpenSwarm's own supervisor answers with — resumes the ledger row through the exact-correlation park contract first, re-marks the hold, and redeploys. All four take `--json`. Reads use a read-only handle (`{ readonly: true }`, the `ledgerOverviewTool` pattern) so `status`/`await` never create a database, migrate a schema, or take a write lock on a live daemon's file. + - **`src/automation/execHold.ts` — a durable hold that stops `idle_fill` from re-running a run its external orchestrator is still deciding on.** The park AGT-4257's idle_fill is designed to lift is exactly the elevation `exec await` is waiting for, so a held issue skips every idle_fill branch in `filterAlreadyProcessed` (the DONE/Backlog reopen, the ephemeral-guard resume, the NEEDS_HUMAN resume, and the RETRY_AT lift) and the daemon logs `held by exec elevation — awaiting the external orchestrator`. Its own table, not `automation_runs.metadata_json`, which `registerRun`'s conflict branch replaces whole and `observeTask` rewrites every pass (`draftCache.ts` keeps its state in its own table for the same reason). Writes fail **closed** (a run deployed without its hold is one idle_fill will steal, so the deploy aborts), reads fail **open** (the heartbeat hot path degrades to the previous behaviour rather than dying), and `EXEC_HOLD_TTL_MS` (24h, refreshed by every `exec answer`) is the safety valve that keeps an orchestrator who walked away from blocking the issue forever. + - **The hold covers the built-in `OrchestratorSupervisor` too, so the escalation belongs to the external agent alone.** The plan's requirement was that a held run elevates to the external orchestrator *only*; gating `idle_fill` is not sufficient, because the supervisor independently selects a held task's `human-question` from the board and can answer it with its `coordination_answer_question` tool — resuming the run behind the agent that is waiting on `exec await`. `selectOrchestratorItems` now drops any event whose `taskId` is held (holding set injectable, resolved lazily so a sweep with no candidate events never opens the database), and `executeOrchestratorTrackerTool` refuses the answer for a held task as a second, fail-**closed** line of defence — an unreadable hold store reads as held there, the opposite of the heartbeat's fail-open, because a wrongly refused answer costs one more sweep while a wrongly accepted one is the silent re-run this feature exists to prevent. (The supervisor is off in a default config — `AutonomousConfig.orchestrator` is optional and absent from `config.yaml` — but `config.example.yaml` enables it, so the gate matters for any deployment that turned it on.) - **Review-hardening pass on the same four commands, after re-reading them against the plan.** Five fixes, each a real defect rather than a polish item. (1) `exec await` could never wait for a run it had just deployed: the detached child resolves config, Linear, and its project mapping before it registers the ledger row, and `await` returned `2` on the first missing-row read, so `exec --issue … && exec await …` always lost. It now polls through the gap until the deadline and only then reports `UNKNOWN`. (2) `exec answer` without `--correlation` reused `classifyRunForAwait`, which admits `NEEDS_RECONCILE` — an elevation the orchestrator *should* see, but not an input park: `markReady` would have admitted the run back into the pool with artifact reconciliation skipped. Replaced with an explicit `ANSWERABLE_STATES` whitelist (`NEEDS_HUMAN`, `RETRY_AT`, `NEEDS_SPEC`, `NEEDS_ENV`); a correlation-carrying answer is still allowed in any state. (3) `exec answer A --correlation ` answered B — settling B's sibling asks through `answerHumanQuestion` — and then resumed and redeployed A. The correlation is now bound to the run before any write and a mismatch exits 2. (4) The daemon health probe folded every non-2xx into "unreachable" via `res.ok`, so a daemon answering 401/403/503 on its own `/api/stats` triggered the local-child fallback — precisely the allow-list bypass the deploy path is written to prevent. The probe now separates `down` (fetch/connect failure → fallback) from `refused` (reachable but declining → exit 2, no fallback). (5) Exit codes: a refused dispatch returns 2, not 1 (a `202` with `queued: 0` is a refusal before deploying, not a deploy that failed), and bare `openswarm exec` exits 2 rather than 0 — `.help()` calls `process.exit(0)` before `process.exitCode = 2` could matter, so it is `outputHelp()` now. + - **`exec status --json` is a clean document even after `exec` gained subcommands.** Commander 12 attributes a parent-declared option name to the parent while parsing a subcommand, so `openswarm exec status --json` left `--json` on `exec` and the subcommand's action saw an empty opts object — the flags are read with `optsWithGlobals()` instead. Three supporting fixes came with it: the `preAction` hook reads `optsWithGlobals()` too; that hook's **second** `loadConfig()` (the adapter alignment, after the quiet telemetry read) is now silenced under `--json` as well, because it announces the config path on stdout *before* any command action installs its redirect, so `openswarm exec status --json | jq` got a three-line banner ahead of the document on a machine with no `provider-override.json` (a configured override skips that load, which is how the bug hid); and `execCommand`'s stdout writer is bound at module load, because the `--json` reroute of `console.log` to stderr was swallowing the one document that must reach stdout. ### Fixed diff --git a/config.example.yaml b/config.example.yaml index 8d5ee98d..c9398acd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -272,6 +272,14 @@ autonomous: # that need the network go in projectAgents[].setup below. # workerNetwork: loopback + # Delegated CLI adapters (claude, codex, cursor) own their tool loop, so + # OpenSwarm's publication fence, protected files, role tool limits and the + # credential-free sandboxed shell do not reach them (claude even runs with + # bypassPermissions). The daemon refuses to start when any unattended setting + # names one: adapter, defaultRoles, projectAgents roles, adapterRouting + # primary/fallbacks, orchestrator, loopAdvisor. true accepts that on purpose. + # allowDelegatedAdapters: false + # Deterministic baseline-diff verification (enabled by default). # Define repo-specific commands in .openswarm/verify.yaml; see templates/verify.example.yaml. verify: diff --git a/src/adapters/unattendedPolicy.test.ts b/src/adapters/unattendedPolicy.test.ts new file mode 100644 index 00000000..7ff16e4f --- /dev/null +++ b/src/adapters/unattendedPolicy.test.ts @@ -0,0 +1,104 @@ +// ============================================ +// OpenSwarm - Which adapters may run unattended (AGT-4444) +// ============================================ +// +// The refusal is only worth having if it covers every place the daemon can pick +// an adapter. Each surface below is checked on its own: a policy that looked at +// `defaultRoles` but not `adapterRouting.fallbacks` would pass the first test +// and still route unattended work to a CLI that has no fences. + +import { describe, expect, it } from 'vitest'; +import { + DELEGATED_ADAPTERS, + assertUnattendedAdapters, + collectUnattendedAdapterViolations, + unattendedAdapterViolation, + type UnattendedAdapterConfig, +} from './unattendedPolicy.js'; +import { ADAPTER_NAMES } from '../core/adapterNames.js'; + +const safe = (autonomous: NonNullable = {}): UnattendedAdapterConfig => ({ + adapter: 'codex-responses', + autonomous, +}); + +describe('unattendedAdapterViolation', () => { + it('refuses exactly the adapters whose CLI owns the tool loop', () => { + for (const name of DELEGATED_ADAPTERS) expect(unattendedAdapterViolation(name)).toContain(`'${name}'`); + const inProcess = ADAPTER_NAMES.filter((name) => !(DELEGATED_ADAPTERS as readonly string[]).includes(name)); + expect(inProcess.length).toBeGreaterThan(0); + for (const name of inProcess) expect(unattendedAdapterViolation(name), name).toBeNull(); + }); + + it('only names adapters that exist, so a rename cannot silently empty the list', () => { + for (const name of DELEGATED_ADAPTERS) expect(ADAPTER_NAMES).toContain(name); + }); +}); + +describe('collectUnattendedAdapterViolations', () => { + it('is quiet for a config that stays on OpenSwarm\'s own loop', () => { + expect(collectUnattendedAdapterViolations(safe({ + defaultRoles: { worker: { adapter: 'openrouter', enabled: true }, reviewer: { adapter: 'codex-responses' } }, + adapterRouting: { primary: 'codex-responses', fallbacks: ['cc-router', 'codex-responses'] }, + }))).toEqual([]); + }); + + it('is quiet when there is no autonomous section — an interactive CLI is attended', () => { + expect(collectUnattendedAdapterViolations({ adapter: 'claude' })).toEqual([]); + }); + + it.each([ + ['the top-level adapter', { adapter: 'claude', autonomous: {} }, 'adapter:'], + ['a default role', safe({ defaultRoles: { worker: { adapter: 'cursor', enabled: true } } }), 'autonomous.defaultRoles.worker.adapter'], + ['a project role override', safe({ projectAgents: [{ projectPath: '/p', roles: { reviewer: { adapter: 'codex' } } }] }), 'autonomous.projectAgents[/p].roles.reviewer.adapter'], + ['the routing primary', safe({ adapterRouting: { primary: 'claude' } }), 'autonomous.adapterRouting.primary'], + ['a routing fallback', safe({ adapterRouting: { fallbacks: ['cc-router', 'cursor'] } }), 'autonomous.adapterRouting.fallbacks[1]'], + ['the orchestrator', safe({ orchestrator: { adapter: 'codex' } }), 'autonomous.orchestrator.adapter'], + ['the loop advisor', safe({ loopAdvisor: { adapter: 'claude' } }), 'autonomous.loopAdvisor.adapter'], + ] as const)('catches a delegated adapter in %s', (_label, config, where) => { + const violations = collectUnattendedAdapterViolations(config as UnattendedAdapterConfig); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain(where); + }); + + it('reports every offending setting, not just the first', () => { + const violations = collectUnattendedAdapterViolations({ + adapter: 'claude', + autonomous: { + defaultRoles: { worker: { adapter: 'cursor' } }, + adapterRouting: { fallbacks: ['codex'] }, + }, + }); + expect(violations).toHaveLength(3); + }); + + it('skips a role the operator switched off, which never runs', () => { + expect(collectUnattendedAdapterViolations(safe({ + defaultRoles: { advisor: { adapter: 'claude', enabled: false } }, + }))).toEqual([]); + }); + + it('accepts everything once the operator opts in', () => { + expect(collectUnattendedAdapterViolations({ + adapter: 'claude', + autonomous: { allowDelegatedAdapters: true, defaultRoles: { worker: { adapter: 'cursor' } } }, + })).toEqual([]); + }); + + it('treats only an explicit true as an opt-in', () => { + for (const value of [false, undefined]) { + expect(collectUnattendedAdapterViolations({ adapter: 'claude', autonomous: { allowDelegatedAdapters: value } })).toHaveLength(1); + } + }); +}); + +describe('assertUnattendedAdapters', () => { + it('does not throw for a safe config', () => { + expect(() => assertUnattendedAdapters(safe())).not.toThrow(); + }); + + it('throws naming each setting, the safe alternatives and the opt-out', () => { + expect(() => assertUnattendedAdapters({ adapter: 'claude', autonomous: { adapterRouting: { fallbacks: ['cursor'] } } })) + .toThrowError(/adapter: 'claude'[\s\S]*fallbacks\[0\]: 'cursor'[\s\S]*codex-responses[\s\S]*allowDelegatedAdapters: true/); + }); +}); diff --git a/src/adapters/unattendedPolicy.ts b/src/adapters/unattendedPolicy.ts new file mode 100644 index 00000000..1fe6e802 --- /dev/null +++ b/src/adapters/unattendedPolicy.ts @@ -0,0 +1,91 @@ +// ============================================ +// OpenSwarm - Which adapters may run unattended (AGT-4444) +// ============================================ +// +// Four of the worker's safeguards live in OpenSwarm's own tool loop: the +// publication fence, protected files, the role tool allow/deny list and (since +// AGT-4783/4798) the credential-free, loopback-only shell. The delegated CLI +// adapters hand the whole turn loop to another program (`claude`, `codex`, +// `cursor`), so none of those reach it — `base.ts` can only log that they were +// dropped. A daemon that routes unattended work there has no fence at all; +// `claude` even runs with `--permission-mode bypassPermissions`. +// +// Unattended means the daemon: any configured `autonomous` section, heartbeat +// on or off, because an explicit dispatch runs the same workers. The refusal is +// at startup, where it is loud, rather than at the first run, where it is a +// warning in a log nobody reads. `autonomous.allowDelegatedAdapters: true` is +// the operator's explicit acceptance of the missing fences. + +/** Adapters whose CLI owns the tool loop; OpenSwarm's fences do not apply to them. */ +export const DELEGATED_ADAPTERS = ['claude', 'codex', 'cursor'] as const; + +/** Why `name` may not run unattended, or null when it runs OpenSwarm's own loop. */ +export function unattendedAdapterViolation(name: string): string | null { + if (!(DELEGATED_ADAPTERS as readonly string[]).includes(name)) return null; + return `'${name}' delegates the tool loop to its own CLI, so the publication fence, protected files, ` + + 'role tool limits and the credential-free sandboxed shell do not apply to it'; +} + +interface RoleLike { adapter?: string; enabled?: boolean } +type RolesLike = Partial>; + +/** The part of `SwarmConfig` that decides which adapters unattended runs can reach. */ +export interface UnattendedAdapterConfig { + adapter?: string; + autonomous?: { + allowDelegatedAdapters?: boolean; + defaultRoles?: RolesLike; + projectAgents?: Array<{ projectPath: string; roles?: RolesLike }>; + adapterRouting?: { primary?: string; fallbacks?: readonly string[] }; + orchestrator?: { adapter?: string }; + loopAdvisor?: { adapter?: string }; + }; +} + +function* roleAdapters(roles: RolesLike | undefined, where: string): Generator<[string, string]> { + for (const [role, value] of Object.entries(roles ?? {})) { + // A role the operator switched off never runs, so its adapter is not reachable. + if (!value?.adapter || value.enabled === false) continue; + yield [`${where}.${role}.adapter`, value.adapter]; + } +} + +/** + * Every place a delegated adapter is named for unattended work, as + * `: ` lines. Empty when the operator opted in. A role that + * names no adapter inherits the top-level one, which is checked on its own. + */ +export function collectUnattendedAdapterViolations(config: UnattendedAdapterConfig): string[] { + const autonomous = config.autonomous; + if (!autonomous || autonomous.allowDelegatedAdapters === true) return []; + + const named: Array<[string, string | undefined]> = [ + ['adapter', config.adapter], + ['autonomous.adapterRouting.primary', autonomous.adapterRouting?.primary], + ['autonomous.orchestrator.adapter', autonomous.orchestrator?.adapter], + ['autonomous.loopAdvisor.adapter', autonomous.loopAdvisor?.adapter], + ...(autonomous.adapterRouting?.fallbacks ?? []).map((name, i): [string, string] => [`autonomous.adapterRouting.fallbacks[${i}]`, name]), + ...roleAdapters(autonomous.defaultRoles, 'autonomous.defaultRoles'), + ...(autonomous.projectAgents ?? []).flatMap((agent) => + [...roleAdapters(agent.roles, `autonomous.projectAgents[${agent.projectPath}].roles`)]), + ]; + + const violations: string[] = []; + for (const [where, name] of named) { + const reason = name ? unattendedAdapterViolation(name) : null; + if (reason) violations.push(`${where}: ${reason}`); + } + return violations; +} + +/** Throw, naming every offending setting and the opt-out, when the daemon would route unattended work to a delegated CLI. */ +export function assertUnattendedAdapters(config: UnattendedAdapterConfig): void { + const violations = collectUnattendedAdapterViolations(config); + if (violations.length === 0) return; + throw new Error( + 'Refusing to start the autonomous runner on adapters that cannot be fenced:\n - ' + + `${violations.join('\n - ')}\n` + + 'Use an adapter that runs OpenSwarm\'s own loop (codex-responses, openrouter, gpt, atlascloud, lmstudio, local, ' + + 'cc-router, ollama-cloud), or set autonomous.allowDelegatedAdapters: true to run unattended without those fences.', + ); +} diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 3989fd40..42098f54 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -380,11 +380,13 @@ agents: autonomous: { enabled: true, workerNetwork: 'open', + allowDelegatedAdapters: true, projectAgents: [{ projectPath: '~/dev/cgf-portal', setup: [' uv sync --frozen '] }], }, })); const autonomous = loadConfig('/tmp/config.json').autonomous; expect(autonomous?.workerNetwork).toBe('open'); + expect(autonomous?.allowDelegatedAdapters).toBe(true); expect(autonomous?.projectAgents?.[0].setup).toEqual(['uv sync --frozen']); }); @@ -470,6 +472,7 @@ agents: expect(config.autonomous?.periodicReviews?.[0]).toMatchObject({ profile: 'hygiene' }); expect(config.autonomous?.workerSandbox).toBe('on'); // default: the fence is on (AGT-4387) expect(config.autonomous?.workerNetwork).toBe('loopback'); // default: no remote network (AGT-4798) + expect(config.autonomous?.allowDelegatedAdapters).toBe(false); // default: delegated CLIs refused (AGT-4444) expect(config.autonomous?.draftModel).toBe('deepseek/deepseek-v4-flash'); // single-model fleet knob expect(config.autonomous?.orchestrator).toEqual({ enabled: true, diff --git a/src/core/config.ts b/src/core/config.ts index 345baaf5..8a30ecb0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -479,6 +479,8 @@ const AutonomousConfigSchema = z.object({ * (bash as the daemon user, regex denylist only). */ workerSandbox: z.enum(['on', 'off']).default('on'), + /** Accept delegated CLI adapters (claude/codex/cursor) for unattended work despite the missing fences (AGT-4444) */ + allowDelegatedAdapters: z.boolean().default(false), /** Network for the worker's shell: loopback only (default) or open (AGT-4798) */ workerNetwork: z.enum(['loopback', 'open']).default('loopback'), periodicReviews: z.array(z.object({ @@ -888,6 +890,7 @@ function transformConfig(raw: RawConfig): SwarmConfig { adapterRouting: raw.autonomous.adapterRouting, workerSandbox: raw.autonomous.workerSandbox, workerNetwork: raw.autonomous.workerNetwork, + allowDelegatedAdapters: raw.autonomous.allowDelegatedAdapters, periodicReviews: raw.autonomous.periodicReviews, orchestrator: raw.autonomous.orchestrator, orchestratorSchedule: raw.autonomous.orchestratorSchedule, diff --git a/src/core/service.test.ts b/src/core/service.test.ts index 29a2906c..68ec232e 100644 --- a/src/core/service.test.ts +++ b/src/core/service.test.ts @@ -202,6 +202,9 @@ describe('service', () => { const mockAutonomousConfig: SwarmConfig = { ...mockConfig, autonomous: { + // These tests exercise boot behaviour on adapter 'claude'; the adapter + // policy itself is covered in unattendedPolicy.test.ts. (AGT-4444) + allowDelegatedAdapters: true, enabled: true, pairMode: true, schedule: '*/30 * * * *', @@ -248,6 +251,19 @@ describe('service', () => { }); + it('refuses to start the autonomous runner on a delegated CLI adapter unless the operator opted in (AGT-4444)', async () => { + const { startAutonomous } = await import('../automation/autonomousRunner.js'); + vi.mocked(startAutonomous).mockClear(); + const strict = { + ...mockAutonomousConfig, + adapter: 'claude', + autonomous: { ...mockAutonomousConfig.autonomous!, allowDelegatedAdapters: false }, + } as SwarmConfig; + + await expect(startService(strict)).rejects.toThrow(/Refusing to start the autonomous runner[\s\S]*adapter: 'claude'[\s\S]*allowDelegatedAdapters/); + expect(startAutonomous).not.toHaveBeenCalled(); + }); + it('forwards the whole decomposition config, not a hand-picked subset', async () => { const { startAutonomous } = await import('../automation/autonomousRunner.js'); const decomposition = { @@ -733,6 +749,7 @@ describe('service', () => { const configWithAutonomous: SwarmConfig = { ...mockConfig, autonomous: { + allowDelegatedAdapters: true, enabled: true, pairMode: true, schedule: '*/30 * * * *', diff --git a/src/core/service.ts b/src/core/service.ts index af3f3b4d..defe4940 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -278,6 +278,10 @@ async function startServiceLocked(config: SwarmConfig): Promise { // and user-initiated dispatch (POST /api/work, dashboard buttons) all keep // working. `enabled: true` additionally turns the heartbeat on. (INT-3388) if (config.autonomous) { + // Refuse before anything starts: an unattended worker on a CLI-owned tool + // loop has none of OpenSwarm's fences. (AGT-4444) + const { assertUnattendedAdapters } = await import('../adapters/unattendedPolicy.js'); + assertUnattendedAdapters(config); const heartbeatEnabled = config.autonomous.enabled === true; console.log(heartbeatEnabled ? '[Service] Autonomous mode auto-start enabled' diff --git a/src/core/types.ts b/src/core/types.ts index 213bc1e8..49284e2a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -717,6 +717,13 @@ export type AutonomousStartupConfig = { * Dependency installs that need the network go in projectAgents[].setup. */ workerNetwork?: 'loopback' | 'open'; + /** + * Run unattended work on delegated CLI adapters (claude/codex/cursor) even + * though OpenSwarm's publication fence, protected files and credential-free + * shell do not reach them (AGT-4444). Default false: the daemon refuses to + * start when such an adapter is configured. + */ + allowDelegatedAdapters?: boolean; periodicReviews?: Array<{ profile: 'permissions' | 'hygiene' | 'security' | 'review'; schedule: string; adapter?: 'codex' | 'cc-router' | 'cursor' }>; /** Explicit high-capability project supervisor. */ orchestrator?: OrchestratorConfig;