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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

### 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

- **`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 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.

Expand Down
13 changes: 13 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,14 @@ autonomous:
# database credentials.
# workerSandbox: on

# Network for the worker's shell (AGT-4798). loopback (default): only
# 127.0.0.1/::1 — a script the worker runs cannot reach GitHub, Cloudflare or
# a package registry, but a test can still start a local server. open: the
# pre-AGT-4798 behaviour, for a host whose sandbox cannot isolate the network
# (e.g. a container where bwrap cannot create a network namespace). Installs
# that need the network go in projectAgents[].setup below.
# workerNetwork: loopback

# Deterministic baseline-diff verification (enabled by default).
# Define repo-specific commands in .openswarm/verify.yaml; see templates/verify.example.yaml.
verify:
Expand Down Expand Up @@ -320,6 +328,11 @@ autonomous:
# generatedOutputRules:
# - command: npm run generate:catalog
# outputs: [docs/DATA-CATALOG.md]
# # Run in the worktree before the worker, network open, no credentials
# # (AGT-4798). Worktrees already share installed deps via sharedPaths, so
# # most projects need none; a failure ends the run before any model call.
# setup:
# - uv sync --frozen

# Job profiles override the default Worker/Reviewer pair by task estimate.
jobProfiles:
Expand Down
26 changes: 26 additions & 0 deletions src/adapters/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,32 @@ describe.runIf(hasMacSandbox)('bash tool with sandbox: on (live sandbox-exec)',
}
});

it('worker bash reaches loopback but not a remote host by default; workerNetwork open restores it (AGT-4798)', async () => {
const { setWorkerNetworkPolicy } = await import('../support/osSandbox.js');
const wt = await fs.mkdtemp(path.join(os.tmpdir(), 'osw-tool-net-'));
const probe = [
'import socket, errno',
's = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen()',
'c = socket.socket(); c.connect(s.getsockname()); print("loopback=ok")',
'try:',
' socket.create_connection(("1.1.1.1", 443), timeout=3); print("remote=OPEN")',
'except OSError as e:',
' print("remote=" + ("EPERM" if e.errno == errno.EPERM else "other"))',
].join('\n');
await fs.writeFile(path.join(wt, 'probe.py'), probe);
try {
const fenced = await executeTool(makeCall('bash', { command: 'python3 probe.py' }), wt, createReadCache(), { sandbox: 'on' });
expect(fenced.content).toContain('loopback=ok');
expect(fenced.content).toContain('remote=EPERM');
setWorkerNetworkPolicy('open');
const open = await executeTool(makeCall('bash', { command: 'python3 probe.py' }), wt, createReadCache(), { sandbox: 'on' });
expect(open.content).not.toContain('remote=EPERM');
} finally {
setWorkerNetworkPolicy('loopback');
await fs.rm(wt, { recursive: true, force: true });
}
});

it('sandbox: off (or unset) keeps the old unfenced behaviour', async () => {
const wt = await fs.mkdtemp(path.join(os.tmpdir(), 'osw-tool-nofence-'));
const homeTarget = path.join(homedir(), `.osw-nofence-probe-${process.pid}`);
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
isHumanSurfaceReadOnlyEnabled,
} from '../mcp/humanSurfacePolicy.js';
import { SandboxOutcomeUnknownError, type SandboxExecutorSession } from '../sandboxExecutor/protocol.js';
import { looksLikeSandboxDenial, workerWritableRoots, wrapForSandbox } from '../support/osSandbox.js';
import { getWorkerNetworkPolicy, looksLikeSandboxDenial, workerWritableRoots, wrapForSandbox } from '../support/osSandbox.js';
import { listNotes, readNote, writeNote } from '../support/scratchpad.js';
import { stageMemory, type RememberKind } from '../agents/stagedMemory.js';
import { linkedMainCheckoutOf } from '../security/gitWorktreeIdentity.js';
Expand Down Expand Up @@ -1087,7 +1087,7 @@ export async function executeTool(
const fenced = execOptions?.sandbox === 'on'
? wrapForSandbox(['bash', '-c', boundedCommand], {
writableRoots: workerWritableRoots(cwd, execOptions.scratchpadRunId),
allowNetwork: true,
allowNetwork: getWorkerNetworkPolicy() === 'open',
})
: null;
if (execOptions?.sandbox === 'on' && !fenced) {
Expand Down
94 changes: 94 additions & 0 deletions src/agents/workerSetup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// ============================================
// OpenSwarm - Worker dependency setup step (AGT-4798)
// ============================================

import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

// Only the "sandbox on, no backend" case reaches the wrapper; every other test
// runs with the sandbox off so it does not depend on the host.
vi.mock('../support/osSandbox.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../support/osSandbox.js')>()),
wrapForSandbox: () => null,
}));

const { runWorkerSetup, setupFailureBeforeWorker } = await import('./workerSetup.js');

const dirs: string[] = [];
const workdir = () => {
const dir = mkdtempSync(path.join(tmpdir(), 'osw-setup-'));
dirs.push(dir);
return dir;
};
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

describe('runWorkerSetup', () => {
it('is a no-op when the project declares no setup', async () => {
expect(await runWorkerSetup(undefined, workdir(), { sandbox: 'off' })).toEqual({ ok: true, ran: 0 });
expect(await runWorkerSetup([' '], workdir(), { sandbox: 'off' })).toEqual({ ok: true, ran: 0 });
});

it('runs the commands in order inside the worktree', async () => {
const cwd = workdir();
const result = await runWorkerSetup(['echo one > log', 'echo two >> log'], cwd, { sandbox: 'off' });
expect(result).toEqual({ ok: true, ran: 2 });
expect(readFileSync(path.join(cwd, 'log'), 'utf8')).toBe('one\ntwo\n');
});

it('stops at the first failing command and reports its output', async () => {
const cwd = workdir();
const result = await runWorkerSetup(
['echo resolving; echo "error: lockfile out of date" >&2; exit 3', 'touch second'],
cwd,
{ sandbox: 'off' },
);
expect(result).toMatchObject({ ok: false, command: expect.stringContaining('exit 3') });
expect(!result.ok && result.detail).toContain('lockfile out of date');
expect(existsSync(path.join(cwd, 'second'))).toBe(false);
});

it('reports a timeout as a timeout', async () => {
const result = await runWorkerSetup(['sleep 5'], workdir(), { sandbox: 'off', timeoutMs: 200 });
expect(result).toMatchObject({ ok: false, detail: 'timed out' });
});

it('runs without the daemon\'s credentials', async () => {
const cwd = workdir();
const saved = process.env.GH_TOKEN;
process.env.GH_TOKEN = 'ghp_daemon_secret';
try {
await runWorkerSetup(['printf "%s" "${GH_TOKEN:-none}" > seen'], cwd, { sandbox: 'off' });
expect(readFileSync(path.join(cwd, 'seen'), 'utf8')).toBe('none');
} finally {
if (saved === undefined) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = saved;
}
});

it('refuses when the sandbox is on but the host has none, like the bash tool', async () => {
const cwd = workdir();
const result = await runWorkerSetup(['touch ran'], cwd, { sandbox: 'on' });
expect(result).toMatchObject({ ok: false, detail: expect.stringContaining('autonomous.workerSandbox: off') });
expect(existsSync(path.join(cwd, 'ran'))).toBe(false);
});
});

describe('setupFailureBeforeWorker', () => {
it('lets the worker start when there is no setup or it succeeds', async () => {
expect(await setupFailureBeforeWorker(undefined, workdir(), 'off')).toBeUndefined();
expect(await setupFailureBeforeWorker(['true'], workdir(), 'off')).toBeUndefined();
});

it('ends the run as infra_error, naming the failed command, before any stage runs', async () => {
const result = await setupFailureBeforeWorker(['echo "no lockfile" >&2; exit 1'], workdir(), 'off');
expect(result).toMatchObject({
success: false, iterations: 0, finalStatus: 'infra_error', stages: [],
failureDetail: expect.stringContaining('no lockfile'),
});
expect(result?.failureDetail).toContain('worker setup `echo');
});
});
100 changes: 100 additions & 0 deletions src/agents/workerSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// ============================================
// OpenSwarm - Worker dependency setup step (AGT-4798)
// ============================================
//
// The worker's shell runs with loopback-only network (AGT-4798) and no
// credentials (AGT-4783). Most runs need nothing more: worktrees share the
// project's installed dependencies through sharedPaths, and measured on
// cgf-portal and OpenSwarm the test suites run offline. A project that does
// need an install step declares it as `projectAgents[].setup`; those commands
// run here once, before the worker's loop, with the network open but still
// inside the sandbox and still without credentials. A failed setup ends the
// run before any model call: a worker started on a broken environment spends
// its turns discovering that.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { buildWorkerShellEnv } from '../adapters/workerEnv.js';
import { workerWritableRoots, wrapForSandbox } from '../support/osSandbox.js';
import type { PipelineResult } from './pairPipeline.js';

const execFileAsync = promisify(execFile);

/** Upper bound for one setup command; a cold `uv sync` or `npm ci` fits. */
export const WORKER_SETUP_TIMEOUT_MS = 10 * 60_000;

export interface WorkerSetupOptions {
/** `autonomous.workerSandbox`; `on` without a sandbox backend refuses, like the bash tool. */
sandbox?: 'on' | 'off';
timeoutMs?: number;
}

export type WorkerSetupResult =
| { ok: true; ran: number }
| { ok: false; command: string; detail: string };

function tail(text: string, lines = 12): string {
return text.trim().split('\n').slice(-lines).join('\n');
}

/** Run the project's declared setup commands in the worktree, in order, stopping at the first failure. */
export async function runWorkerSetup(
commands: readonly string[] | undefined,
cwd: string,
options: WorkerSetupOptions = {},
): Promise<WorkerSetupResult> {
const steps = (commands ?? []).map((command) => command.trim()).filter(Boolean);
const sandbox = options.sandbox ?? 'on';
for (const command of steps) {
const fenced = sandbox === 'on'
? wrapForSandbox(['bash', '-c', command], { writableRoots: workerWritableRoots(cwd), allowNetwork: true })
: null;
if (sandbox === 'on' && !fenced) {
return {
ok: false,
command,
detail: 'autonomous.workerSandbox is "on" but this host has neither sandbox-exec nor bwrap; '
+ 'set autonomous.workerSandbox: off to run setup unfenced on purpose',
};
}
try {
await execFileAsync(fenced?.file ?? 'bash', fenced?.args ?? ['-c', command], {
cwd,
env: buildWorkerShellEnv(),
timeout: options.timeoutMs ?? WORKER_SETUP_TIMEOUT_MS,
maxBuffer: 1024 * 1024 * 8,
});
} catch (error) {
const err = error as { stdout?: string; stderr?: string; killed?: boolean; message?: string };
const output = `${err.stdout ?? ''}\n${err.stderr ?? ''}`;
const reason = err.killed ? 'timed out' : tail(output) || err.message || 'failed';
return { ok: false, command, detail: reason };
}
}
return { ok: true, ran: steps.length };
}

/**
* The pipeline result for a run whose setup failed, or undefined when setup
* succeeded or there was none. Called before the worker's loop: installs need
* the network the worker's shell no longer has. The caller's cleanup removes
* the (clean) worktree.
*/
export async function setupFailureBeforeWorker(
commands: readonly string[] | undefined,
cwd: string,
sandbox?: 'on' | 'off',
): Promise<PipelineResult | undefined> {
if (!commands?.length) return undefined;
const setup = await runWorkerSetup(commands, cwd, { sandbox });
if (setup.ok) return undefined;
return {
success: false,
sessionId: `worker-setup-fail-${Date.now()}`,
iterations: 0,
totalDuration: 0,
finalStatus: 'infra_error',
failureDetail: `worker setup \`${setup.command}\` failed: ${setup.detail}`,
stages: [],
};
}
3 changes: 2 additions & 1 deletion src/automation/autonomousRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
import { checkWorkAllowed } from '../support/timeWindow.js';
import { shouldEarlyStuckForInfeasibility } from '../support/feasibilityDetector.js';
import { citedPathsAreEphemeral } from '../support/worktreeEphemeral.js';
import { resolveProjectGoal } from '../support/projectGoal.js';
import { resolveOwningProject, resolveProjectGoal } from '../support/projectGoal.js';
import { recordTaskOutcome } from '../memory/repoKnowledge.js';
import { updateProjectAfterTask } from '../linear/projectUpdater.js';
import { TaskScheduler, initScheduler, normalizeProjectPath } from '../orchestration/taskScheduler.js';
Expand Down Expand Up @@ -3600,6 +3600,7 @@ export class AutonomousRunner {
jobProfiles: this.config.jobProfiles,
getRolesForProject: (p) => this.getRolesForProject(p),
getProjectGoal: (p) => resolveProjectGoal(this.config.projectAgents, p),
getProjectSetup: (p) => resolveOwningProject(this.config.projectAgents, p)?.setup,
reportToDiscord,
worktreeMode: this.config.worktreeMode ?? false,
scheduleNextHeartbeat: () => this.scheduleNextHeartbeat(),
Expand Down
3 changes: 3 additions & 0 deletions src/automation/runnerExecution.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// ============================================
// OpenSwarm - Runner Execution Helpers
// Execution/reporting/integration logic extracted from AutonomousRunner
Expand Down Expand Up @@ -177,6 +177,7 @@
getRolesForProject: (projectPath: string) => DefaultRolesConfig | undefined;
/** Standing goal configured for the project at this path; every stage reads it (AGT-4662). */
getProjectGoal?: (projectPath: string) => string | undefined;
getProjectSetup?: (projectPath: string) => string[] | undefined; // projectAgents[].setup (AGT-4798)
reportToDiscord: (message: string | EmbedBuilder) => Promise<void>;
/** Git worktree mode: work in an isolated worktree per issue, auto-create PR */
worktreeMode?: boolean;
Expand Down Expand Up @@ -969,6 +970,8 @@
}

try {
const setupFailure = await (await import('../agents/workerSetup.js')).setupFailureBeforeWorker(ctx.getProjectSetup?.(projectPath), actualPath, ctx.workerSandbox); // AGT-4798
if (setupFailure) return setupFailure;
const roles = ctx.getRolesForProject(projectPath); // look up config using original path
const { prepareRunCoordination, normalizeAdapterRouting } = await import('../coordination/runCoordination.js');
const runMetadata = pipelineMetadata(task, projectPath, worktreeInfo);
Expand Down
18 changes: 18 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,23 @@ agents:
expect(agents?.[1].goal).toBeUndefined();
});

it('carries workerNetwork and a project setup list to the runtime config (AGT-4798)', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
language: 'en',
linear: { apiKey: 'k', teamId: 't' },
agents: [{ name: 'main', projectPath: '/p', enabled: true, paused: false }],
autonomous: {
enabled: true,
workerNetwork: 'open',
projectAgents: [{ projectPath: '~/dev/cgf-portal', setup: [' uv sync --frozen '] }],
},
}));
const autonomous = loadConfig('/tmp/config.json').autonomous;
expect(autonomous?.workerNetwork).toBe('open');
expect(autonomous?.projectAgents?.[0].setup).toEqual(['uv sync --frozen']);
});

it('rejects a goal that is empty or longer than a goal should be (AGT-4662)', () => {
const withGoal = (goal: string) => JSON.stringify({
language: 'en',
Expand Down Expand Up @@ -452,6 +469,7 @@ agents:
expect(config.autonomous?.mcpPolicies?.orchestrator.servers).toEqual(['github', 'linear']);
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?.draftModel).toBe('deepseek/deepseek-v4-flash'); // single-model fleet knob
expect(config.autonomous?.orchestrator).toEqual({
enabled: true,
Expand Down
Loading
Loading