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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.0] - 2026-04-08

Env-leak gate hardening, SSE reliability fixes, isolation cleanup smarter merge detection, build/version improvements, and deploy hardening.

### Added

- **Env-leak gate (target repo `.env` keys)**: scan auto-loaded `.env` filenames for 7 sensitive keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) and refuse to register or spawn into a codebase whose `.env` would silently re-inject keys into Claude/Codex subprocesses. Default is fail-closed (`allow_env_keys = false`). Includes a per-codebase consent column, registration gate, pre-spawn check in both Claude and Codex clients, and a 422 API error with web UI checkbox (#1036).
- **CLI `--allow-env-keys` flag** for `archon workflow run` — grant env-leak-gate consent during auto-registration without needing the Web UI. Audit-logged as `env_leak_consent_granted` with `actor: 'user-cli'` (#973, #983).
- **Global `allow_target_repo_keys` flag** in `~/.archon/config.yaml` — bypass the env-leak gate for all codebases on this machine. Per-repo `.archon/config.yaml` `allow_target_repo_keys: false` re-enables the gate for that repo. The server emits `env_leak_gate_disabled` once per process per source the first time `loadConfig` resolves the bypass as active (#973, #983).
- **`PATCH /api/codebases/:id`** endpoint to flip `allow_env_keys` on existing codebases without delete/re-add. Audit-logged at `warn` level on every grant and revoke, including a `scanStatus` field that distinguishes "scanned" from "scan failed" so audit reviewers can tell empty key lists apart (#973, #983).
- **Settings → Projects per-row toggle** to grant or revoke env-key consent retroactively, with an "env keys allowed" badge and inline error feedback if the PATCH fails (#973, #983).
- **Startup env-leak scan**: when `allow_target_repo_keys` is not set, the server emits one `startup_env_leak_gate_will_block` warn per registered codebase whose `.env` would block the next spawn. Skipped entirely when the global bypass is active (#973, #983).
- **Squash-merge and PR-merge detection** for `isolation cleanup --merged`. Unions three signals (ancestry via `git branch --merged`, patch equivalence via `git cherry`, and PR state via `gh`) to safely clean up worktrees whose branches were squash-merged. Adds `--include-closed` flag to also remove worktrees whose PRs were closed without merging (#1027).
- **Git commit hash in `archon version`** output. Read at runtime via `git rev-parse` in dev or from a build-time constant in compiled binaries; falls back to `unknown` (#1035).

### Changed

- **Env-leak gate error messages** are now context-aware: separate remediation copy for Web Add-Project, CLI auto-register, and pre-spawn-of-existing-codebase paths. Previously every error pointed at the Web UI checkbox even from the CLI (#973, #983).
- **SSE event buffer TTL** raised from 3s to 60s and capacity from 50 to 500 events, fixing dropped `tool_result` events during the 5s reconnect grace window that left tool cards perpetually spinning. Cleanup timer now resets on each new event so the buffer is held for TTL past the most recent event, not the first one. Buffer overflow and TTL expiration now log at `warn` level for observability (#1037).
- **Binary build detection** moved from runtime env sniffing (`import.meta.dir` / `process.execPath`) to a build-time `BUNDLED_IS_BINARY` constant in `@archon/paths`. Logger uses `pino-pretty` as a destination stream on the main thread instead of a worker-thread transport, eliminating the `require.resolve('pino-pretty')` lookup that crashed inside Bun's `$bunfs` virtual filesystem in compiled binaries. Same code path runs in dev and binaries — no environment detection (#982).
- **Cloud-init deployment script** hardened: dedicated `archon` user (docker group, no sudo) with SSH keys copied from the default cloud user, 2GB swapfile to prevent OOM during docker build on small VPSes, `ufw allow 443/tcp` and `443/udp` for HTTP/3 QUIC, fail-fast on network errors, and clearer setup-complete messaging (#981).

### Fixed

- **Env-leak gate worktree path lookup**: pre-spawn consent check now falls back to `findCodebaseByPathPrefix()` when the exact path lookup misses, so workflow runs in `.../worktrees/feature-branch` correctly inherit consent from the source codebase (#1036).
- **`EnvLeakError` FATAL classification** in the workflow executor now checks `error.name === 'EnvLeakError'` directly instead of pattern-matching the message, immune to message rewording (#1036).
- **Scanner unreadable-file handling**: distinguishes `ENOENT` (skip) from `EACCES` and other errors so unreadable `.env` files surface as findings instead of silently bypassing the gate (#1036).

### Security

- The default `allow_env_keys` per codebase is `false` (fail-closed). Codebases with sensitive keys in their auto-loaded `.env` files (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) are blocked at the next workflow run. **Remediation paths** (any one): (1) remove the key from `.env`, (2) rename to `.env.secrets`, (3) toggle "Allow env keys" in Settings → Projects, (4) `archon workflow run --allow-env-keys ...`, (5) set `allow_target_repo_keys: true` in `~/.archon/config.yaml`. See `docs/reference/security.md` for full details (#1036, #973, #983).


## [0.2.12] - 2026-03-20

Chat-first navigation redesign, DAG graph viewer, per-node MCP and skills, and extensive bug fixes across the web UI and workflow engine.
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ bun run cli workflow run implement --branch feature-auth "Add auth"
# Opt out of isolation (run in live checkout)
bun run cli workflow run quick-fix --no-worktree "Fix typo"

# Grant env-leak-gate consent during auto-registration (for repos whose .env
# contains sensitive keys). Audit-logged with actor: 'user-cli'.
bun run cli workflow run plan --cwd /path/to/leaky/repo --allow-env-keys "..."

# Show running workflows
bun run cli workflow status

Expand All @@ -224,6 +228,9 @@ bun run cli isolation cleanup 14 # Custom days
# Clean up environments with branches merged into main (also deletes remote branches)
bun run cli isolation cleanup --merged

# Also remove environments with closed (abandoned) PRs
bun run cli isolation cleanup --merged --include-closed

# Validate workflow definitions and their referenced resources
bun run cli validate workflows # All workflows
bun run cli validate workflows my-workflow # Single workflow
Expand Down Expand Up @@ -740,6 +747,12 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er
- `POST /api/workflows/runs/{runId}/abandon` - Abandon a non-terminal run (marks as cancelled)
- `DELETE /api/workflows/runs/{runId}` - Delete a terminal workflow run and its events

**Codebases:**
- `GET /api/codebases` / `GET /api/codebases/:id` - List / fetch codebases
- `POST /api/codebases` - Register a codebase (clone or local path); body accepts `allowEnvKeys` for the env-leak gate
- `PATCH /api/codebases/:id` - Flip the `allow_env_keys` consent bit; body: `{ allowEnvKeys: boolean }`. Audit-logged at `warn` level on every grant/revoke (`env_leak_consent_granted` / `env_leak_consent_revoked`) with `codebaseId`, `path`, `files`, `keys`, `scanStatus`, `actor`
- `DELETE /api/codebases/:id` - Delete a codebase and clean up resources

**Artifact Files:**
- `GET /api/artifacts/:runId/*` - Serve a workflow artifact file by run ID and relative path; returns `text/markdown` for `.md` files, `text/plain` otherwise; 400 on path traversal (`..`), 404 if run or file not found

Expand Down
4 changes: 1 addition & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 72 additions & 18 deletions deploy/cloud-init.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,30 @@
#
# Paste this into your VPS provider's "User Data" field when creating a server.
# Tested on: Ubuntu 22.04+, Debian 12+
# Works with any cloud-init compatible provider (DigitalOcean, Hetzner, Linode,
# Vultr, AWS EC2, Hostinger, etc.)
#
# What this does:
# 1. Installs Docker + Docker Compose plugin
# 2. Opens firewall ports (SSH, HTTP, HTTPS)
# 3. Clones the repo to /opt/archon
# 4. Prepares .env and Caddyfile from examples
# 5. Builds the Docker image (~5 min)
# 3. Creates a 2GB swapfile (helps small VPS builds avoid OOM)
# 4. Clones the repo to /opt/archon
# 5. Prepares .env and Caddyfile from examples
# 6. Creates a dedicated 'archon' user (docker group only, no sudo)
# 7. Builds the Docker image (~5 min) as the archon user
#
# After the server boots (~5-8 min), SSH in and:
# Note: On VPS with <2GB RAM, the docker build step can OOM without swap.
# Note: The 'archon' user has docker access but NOT sudo. For administrative
# tasks (updates, reboots), use the default cloud user or root.
#
# After the server boots (~5-8 min), SSH in as the archon user:
# ssh archon@your-server-ip
# 1. Edit /opt/archon/.env — set your AI credentials, DOMAIN, DATABASE_URL
# 2. cd /opt/archon && docker compose --profile with-db --profile cloud up -d
# 3. Open https://your-domain.com
#
# IMPORTANT: Before starting, point your domain's DNS A record to this server's IP.
# SSH keys from the default cloud user are copied to 'archon'.
#

package_update: true
Expand All @@ -30,29 +40,66 @@ packages:
- git
- ufw

users:
- default
- name: archon
gecos: Archon Service User
shell: /bin/bash
lock_passwd: true

runcmd:
# --- Swap (helps small VPS avoid OOM during docker build) ---
- |
if [ ! -f /swapfile ]; then
fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi

# --- Docker ---
- curl -fsSL https://get.docker.com | sh
- systemctl enable docker
- systemctl start docker
- usermod -aG docker archon

# --- Firewall ---
# --- Copy SSH keys from default user to archon (so login works immediately) ---
- |
DEFAULT_USER=$(getent passwd 1000 | cut -d: -f1)
if [ -n "$DEFAULT_USER" ] && [ -f /home/$DEFAULT_USER/.ssh/authorized_keys ]; then
mkdir -p /home/archon/.ssh
cp /home/$DEFAULT_USER/.ssh/authorized_keys /home/archon/.ssh/authorized_keys
chmod 700 /home/archon/.ssh
chmod 600 /home/archon/.ssh/authorized_keys
chown -R archon:archon /home/archon/.ssh
elif [ -f /root/.ssh/authorized_keys ]; then
mkdir -p /home/archon/.ssh
cp /root/.ssh/authorized_keys /home/archon/.ssh/authorized_keys
chmod 700 /home/archon/.ssh
chmod 600 /home/archon/.ssh/authorized_keys
chown -R archon:archon /home/archon/.ssh
fi

# --- Firewall (443/udp needed for HTTP/3 QUIC via Caddy) ---
- ufw allow 22/tcp
- ufw allow 80/tcp
- ufw allow 443
- ufw allow 443/tcp
- ufw allow 443/udp
- ufw --force enable

# --- Clone and configure ---
- git clone https://github.com/coleam00/Archon.git /opt/archon
- cp /opt/archon/.env.example /opt/archon/.env
- cp /opt/archon/Caddyfile.example /opt/archon/Caddyfile
# --- Clone and configure (fail fast — single shell so set -e applies) ---
- |
set -e
git clone https://github.com/coleam00/Archon.git /opt/archon
cp /opt/archon/.env.example /opt/archon/.env
cp /opt/archon/Caddyfile.example /opt/archon/Caddyfile
chown -R archon:archon /opt/archon

# --- Pre-pull external images ---
- docker pull postgres:17-alpine
- docker pull caddy:2-alpine
# --- Pre-pull external images (as archon, via docker group) ---
- sudo -u archon docker pull postgres:17-alpine
- sudo -u archon docker pull caddy:2-alpine

# --- Build the app image ---
- cd /opt/archon && docker compose build
# --- Build the app image as archon ---
- sudo -u archon -H bash -c 'cd /opt/archon && docker compose build'

# --- Signal completion ---
- |
Expand All @@ -61,6 +108,13 @@ runcmd:
Archon server setup complete!
============================================

Log in as the 'archon' user (not root):
ssh archon@<server-ip>

Note: the 'archon' user has docker access but no sudo. For system
maintenance (apt upgrade, reboots), log in as the default cloud user
or root.

Next steps:

1. Edit credentials and domain:
Expand All @@ -85,7 +139,7 @@ runcmd:

Logs: docker compose logs -f
Health: curl https://your-domain.com/api/health
Docs: https://github.com/coleam00/Archon/blob/main/docs/docker.md
Docs: https://archon.diy/deployment/docker/
============================================
DONE
- echo "[archon] Setup complete. Edit /opt/archon/.env and run docker compose up."
5 changes: 5 additions & 0 deletions migrations/000_combined.sql
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS remote_agent_codebases (
repository_url VARCHAR(500),
default_cwd VARCHAR(500) NOT NULL,
ai_assistant_type VARCHAR(20) DEFAULT 'claude',
allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE,
commands JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
Expand Down Expand Up @@ -307,3 +308,7 @@ ALTER TABLE remote_agent_conversations
-- From migration 016: ended_reason on sessions
ALTER TABLE remote_agent_sessions
ADD COLUMN IF NOT EXISTS ended_reason TEXT;

-- From migration 021: allow_env_keys on codebases
ALTER TABLE remote_agent_codebases
ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE;
4 changes: 4 additions & 0 deletions migrations/021_add_allow_env_keys_to_codebases.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Add per-codebase consent bit for subprocess .env key leakage
-- DEFAULT FALSE = safe by default; user must explicitly opt in
ALTER TABLE remote_agent_codebases
ADD COLUMN allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "archon",
"version": "0.2.13",
"version": "0.3.0",
"private": true,
"workspaces": [
"packages/*"
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ Options:
--json Output machine-readable JSON (for workflow list)
--workflow <name> Workflow to run for 'continue' (default: archon-assist)
--no-context Skip context injection for 'continue'
--allow-env-keys Grant env-key consent during auto-registration
(bypasses the env-leak gate for this codebase;
logs an audit entry)

Examples:
archon chat "What does the orchestrator do?"
Expand Down Expand Up @@ -190,6 +193,7 @@ async function main(): Promise<number> {
reason: { type: 'string' },
workflow: { type: 'string' },
'no-context': { type: 'boolean' },
'allow-env-keys': { type: 'boolean' },
},
allowPositionals: true,
strict: false, // Allow unknown flags to pass through
Expand All @@ -211,6 +215,7 @@ async function main(): Promise<number> {
const resumeFlag = values.resume as boolean | undefined;
const spawnFlag = values.spawn as boolean | undefined;
const jsonFlag = values.json as boolean | undefined;
const allowEnvKeysFlag = values['allow-env-keys'] as boolean | undefined;

// Handle help flag
if (values.help) {
Expand Down Expand Up @@ -323,6 +328,7 @@ async function main(): Promise<number> {
fromBranch,
noWorktree,
resume: resumeFlag,
allowEnvKeys: allowEnvKeysFlag,
quiet: values.quiet as boolean | undefined,
verbose: values.verbose as boolean | undefined,
};
Expand Down Expand Up @@ -459,7 +465,8 @@ async function main(): Promise<number> {
// Check for --merged flag in remaining args
const mergedFlag = args.includes('--merged') || positionals.includes('--merged');
if (mergedFlag) {
await isolationCleanupMergedCommand();
const includeClosed = args.includes('--include-closed');
await isolationCleanupMergedCommand({ includeClosed });
} else {
const days = parseInt(positionals[2] ?? '7', 10);
await isolationCleanupCommand(days);
Expand Down
10 changes: 0 additions & 10 deletions packages/cli/src/commands/bundled-version.ts

This file was deleted.

52 changes: 51 additions & 1 deletion packages/cli/src/commands/isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tests for isolation complete command
*/
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import { isolationCompleteCommand } from './isolation';
import { isolationCompleteCommand, isolationCleanupMergedCommand } from './isolation';

const mockLogger = {
fatal: mock(() => undefined),
Expand Down Expand Up @@ -44,6 +44,27 @@ mock.module('@archon/core/services/cleanup-service', () => ({
cleanupMergedWorktrees: mockCleanupMergedWorktrees,
}));

const mockListEnvironments = mock(() =>
Promise.resolve({
codebases: [
{
codebaseId: 'cb-1',
defaultCwd: '/test/repo',
repositoryUrl: 'https://github.com/owner/repo',
environments: [],
},
],
totalEnvironments: 0,
ghostsReconciled: 0,
})
);
const mockCleanupMergedEnvironments = mock(() => Promise.resolve({ removed: [], skipped: [] }));

mock.module('@archon/core/operations/isolation-operations', () => ({
listEnvironments: mockListEnvironments,
cleanupMergedEnvironments: mockCleanupMergedEnvironments,
}));

const mockHasUncommittedChanges = mock(() => Promise.resolve(false));
// Default: gh returns empty PR array, git log returns empty string (no commits to report)
const mockExecFileAsync = mock((cmd: string) =>
Expand Down Expand Up @@ -358,3 +379,32 @@ describe('isolationCompleteCommand', () => {
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 1 completed, 1 failed, 1 not found');
});
});

describe('isolationCleanupMergedCommand', () => {
let consoleLogSpy: ReturnType<typeof spyOn>;
let consoleErrorSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
mockCleanupMergedEnvironments.mockReset();
mockCleanupMergedEnvironments.mockResolvedValue({ removed: [], skipped: [] });
});

afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});

it('passes includeClosed=true when --include-closed flag is set', async () => {
await isolationCleanupMergedCommand({ includeClosed: true });
expect(mockCleanupMergedEnvironments).toHaveBeenCalledWith('cb-1', '/test/repo', {
includeClosed: true,
});
});

it('defaults to includeClosed=false', async () => {
await isolationCleanupMergedCommand();
expect(mockCleanupMergedEnvironments).toHaveBeenCalledWith('cb-1', '/test/repo', {});
});
});
Loading
Loading