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
14 changes: 8 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <runId>`, `exec await <runId> --timeout <s>` and `exec answer <runId> [--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 <B's question>` 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 <id> --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 <id> --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
Expand Down
8 changes: 8 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
104 changes: 104 additions & 0 deletions src/adapters/unattendedPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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['autonomous']> = {}): 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/);
});
});
Loading
Loading