Merge branch 'worktree-sandcastle-subscription-auth-v1': sandcastle subscription auth as primary flow
This commit is contained in:
15
.env.example
15
.env.example
@@ -54,7 +54,22 @@ CMS_URL=http://localhost:3001
|
|||||||
|
|
||||||
# --- Optional: sandcastle dispatch (only when running `pnpm work dispatch --execute`) ---
|
# --- Optional: sandcastle dispatch (only when running `pnpm work dispatch --execute`) ---
|
||||||
|
|
||||||
|
# Auth (pick one — subscription is preferred):
|
||||||
|
#
|
||||||
|
# 1. Subscription mode (recommended for Pro/Max subscribers):
|
||||||
|
# Run `claude login` on the host once. Sandcastle bind-mounts ~/.claude/
|
||||||
|
# into the sandbox so the container's Claude Code CLI uses your session.
|
||||||
|
# Zero per-task token spend. No env var needed.
|
||||||
|
#
|
||||||
|
# 2. API-key mode (fallback when no host creds available):
|
||||||
# ANTHROPIC_API_KEY=
|
# ANTHROPIC_API_KEY=
|
||||||
# OPENAI_API_KEY=
|
# OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Override the path to host Claude Code creds (default: ~/.claude/)
|
||||||
|
# SANDCASTLE_CLAUDE_CREDS_DIR=
|
||||||
|
|
||||||
|
# GitHub access (optional — for orchestrator-created PRs)
|
||||||
# GITHUB_TOKEN=
|
# GITHUB_TOKEN=
|
||||||
|
|
||||||
|
# Sandbox provider (default: docker; alternatives: podman, vercel, daytona)
|
||||||
# SANDCASTLE_PROVIDER=docker
|
# SANDCASTLE_PROVIDER=docker
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
# Anthropic Claude API
|
# .sandcastle/.env — runtime tokens for sandcastle dispatch.
|
||||||
ANTHROPIC_API_KEY=
|
# Copy to .sandcastle/.env (gitignored) and fill what you need.
|
||||||
|
#
|
||||||
|
# Most developers don't need ANY of these if they've run `claude login` on
|
||||||
|
# the host — sandcastle mounts ~/.claude/ into the sandbox by default.
|
||||||
|
|
||||||
# OpenAI / Codex
|
# Anthropic API key (fallback when no host Claude Code session exists)
|
||||||
OPENAI_API_KEY=
|
# ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
# GitHub (for PR creation by the orchestrator)
|
# OpenAI / Codex (alternative)
|
||||||
GITHUB_TOKEN=
|
# OPENAI_API_KEY=
|
||||||
|
|
||||||
# Sandcastle's own configuration — sandbox provider (docker | podman | vercel | custom)
|
# GitHub access for orchestrator-created PRs
|
||||||
|
# GITHUB_TOKEN=
|
||||||
|
|
||||||
|
# Override Claude creds path (default: ~/.claude/)
|
||||||
|
# SANDCASTLE_CLAUDE_CREDS_DIR=
|
||||||
|
|
||||||
|
# Sandbox provider (docker / podman / vercel / daytona)
|
||||||
SANDCASTLE_PROVIDER=docker
|
SANDCASTLE_PROVIDER=docker
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
# Minimal sandbox for sandcastle dispatch.
|
# Sandcastle sandbox image — runs the implementer + reviewer agents.
|
||||||
# Will be extended once the CI image is identified (see ADR-future).
|
#
|
||||||
|
# Includes Claude Code CLI so the sandbox can authenticate via the host's
|
||||||
|
# mounted ~/.claude/ session (sandcastle issue #191 workaround — subscription
|
||||||
|
# auth, not API-key auth, is our primary flow). Falls back to ANTHROPIC_API_KEY
|
||||||
|
# when no host credentials are available.
|
||||||
|
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
# pnpm via corepack (matches the repo's pnpm version)
|
# pnpm via corepack (matches the repo's pnpm version)
|
||||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
|
||||||
|
# Claude Code CLI — used by sandcastle's claudeCode() agent provider.
|
||||||
|
# The CLI reads credentials from ~/.claude/ inside the container; the host
|
||||||
|
# mounts its ~/.claude/ over that path at sandbox start.
|
||||||
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
# Minimal system deps for git operations + healthchecks.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
# Repo files are mounted by sandcastle at /workspace at runtime.
|
|
||||||
# The agent runs `pnpm install --frozen-lockfile` as its first step
|
|
||||||
# per the implementer prompt template.
|
|
||||||
|
|
||||||
CMD ["bash"]
|
CMD ["bash"]
|
||||||
|
|||||||
@@ -50,12 +50,21 @@ Concretely:
|
|||||||
orchestrator does NOT mutate state in v1 — it prints suggested mutations
|
orchestrator does NOT mutate state in v1 — it prints suggested mutations
|
||||||
for the human to apply.
|
for the human to apply.
|
||||||
5. **Two modes:** `pnpm work dispatch` (planning, no agent invoked) and
|
5. **Two modes:** `pnpm work dispatch` (planning, no agent invoked) and
|
||||||
`pnpm work dispatch --execute` (real sandcastle call, requires
|
`pnpm work dispatch --execute` (real sandcastle call, requires auth — see
|
||||||
`ANTHROPIC_API_KEY` or `OPENAI_API_KEY`).
|
point 7).
|
||||||
6. **Reviewer agent verifies generator-first.** Hand-rolled output that should
|
6. **Reviewer agent verifies generator-first.** Hand-rolled output that should
|
||||||
have been a `pnpm turbo gen <kind>` invocation is grounds for rejection.
|
have been a `pnpm turbo gen <kind>` invocation is grounds for rejection.
|
||||||
7. **Bring-your-own-key for cost control.** No bundled API key. Agents only
|
7. **Bring-your-own-auth.** Two paths are supported, in priority order:
|
||||||
dispatch when the operator explicitly provides credentials.
|
- **Subscription (primary)** — bind-mount the host's `~/.claude/` into the
|
||||||
|
sandbox. Claude Code CLI inside the sandbox uses the host's logged-in
|
||||||
|
subscription session. Zero per-task token spend for Pro/Max subscribers.
|
||||||
|
Path overridable via `SANDCASTLE_CLAUDE_CREDS_DIR` env var.
|
||||||
|
- **API key (fallback)** — `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` passed
|
||||||
|
through to the sandbox env. Used when no host creds directory exists.
|
||||||
|
- The resolver (`resolveClaudeAuth` in `scripts/work/dispatch.mjs`) picks
|
||||||
|
automatically with subscription always preferred. Sandcastle's own issue
|
||||||
|
#191 documents that subscription support won't be added natively;
|
||||||
|
this mount-based pattern is our workaround promoted to first-class.
|
||||||
8. **Per-task max-attempts honoured (v2).** Each task's frontmatter may carry
|
8. **Per-task max-attempts honoured (v2).** Each task's frontmatter may carry
|
||||||
`max-attempts: N` to bound the implementer↔reviewer retry loop. Default 3.
|
`max-attempts: N` to bound the implementer↔reviewer retry loop. Default 3.
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ pnpm work ready # all ready stories
|
|||||||
pnpm work blocked # blocked stories + what they wait on
|
pnpm work blocked # blocked stories + what they wait on
|
||||||
pnpm work rebuild-state # regenerate docs/work/_state.json
|
pnpm work rebuild-state # regenerate docs/work/_state.json
|
||||||
pnpm work dispatch # print next dispatch plan
|
pnpm work dispatch # print next dispatch plan
|
||||||
pnpm work dispatch --execute # invoke sandcastle (requires ANTHROPIC_API_KEY)
|
pnpm work dispatch --execute # invoke sandcastle (subscription or API key — see runbook)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -139,10 +139,13 @@ Copy `.env.example` to `.env` and fill what you need. NOT every variable is requ
|
|||||||
|
|
||||||
### Optional — sandcastle dispatch (only when running `pnpm work dispatch --execute`)
|
### Optional — sandcastle dispatch (only when running `pnpm work dispatch --execute`)
|
||||||
|
|
||||||
|
Auth is resolved automatically. Subscription (via `~/.claude/`) is the primary path; API key is the fallback.
|
||||||
|
|
||||||
| Var | Why |
|
| Var | Why |
|
||||||
| --------------------- | ------------------------------------------------- |
|
| ----------------------------- | ---------------------------------------------------------------------------------- |
|
||||||
| `ANTHROPIC_API_KEY` | Claude API key (sandcastle's default agent) |
|
| `ANTHROPIC_API_KEY` | Claude API key — fallback when no `~/.claude/` present; not needed for subscribers |
|
||||||
| `OPENAI_API_KEY` | OpenAI/Codex alternative |
|
| `OPENAI_API_KEY` | OpenAI/Codex alternative (fallback) |
|
||||||
|
| `SANDCASTLE_CLAUDE_CREDS_DIR` | Override host Claude creds path (default: `~/.claude/`) |
|
||||||
| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator |
|
| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator |
|
||||||
| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` |
|
| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` |
|
||||||
|
|
||||||
@@ -239,17 +242,14 @@ For the full design see `docs/architecture/agent-first-workflow-and-conformance.
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
1. **Docker running** — sandcastle uses Docker for the sandbox by default. `docker info` should succeed.
|
1. **Docker running** — sandcastle uses Docker for the sandbox by default. `docker info` should succeed.
|
||||||
2. **Agent API key** — set ONE of:
|
2. **Authentication — pick ONE:**
|
||||||
- `ANTHROPIC_API_KEY` (recommended; sandcastle's default agent is `claudeCode`)
|
- **Recommended: Claude Pro / Max subscription.** Run `claude login` once on the host. Sandcastle's sandbox bind-mounts your `~/.claude/` into the container so the Claude Code CLI inside the sandbox uses your subscription session. Zero per-task token spend for subscribers.
|
||||||
- `OPENAI_API_KEY` (alternative)
|
- **Alternative: API key.** Set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in your environment. Falls back automatically when `~/.claude/` is absent.
|
||||||
|
- **Override the creds path** via `SANDCASTLE_CLAUDE_CREDS_DIR` if your Claude Code config lives somewhere non-standard.
|
||||||
3. **GitHub token** (optional) — `GITHUB_TOKEN` if you want the orchestrator to create PRs.
|
3. **GitHub token** (optional) — `GITHUB_TOKEN` if you want the orchestrator to create PRs.
|
||||||
4. **`.sandcastle/` config present** — already in tree:
|
4. **`.sandcastle/` config present** — already in tree:
|
||||||
- `Dockerfile` — node:22 + pnpm sandbox image
|
- `Dockerfile` — node:22 + pnpm + Claude Code CLI; reads creds from `~/.claude/` inside the container
|
||||||
- `prd-eliciter.prompt.md` — interviews humans to draft PRDs
|
- `prd-eliciter.prompt.md`, `adr-eliciter.prompt.md`, `decomposer.prompt.md`, `implementer.prompt.md`, `reviewer.prompt.md` — the five role prompts
|
||||||
- `adr-eliciter.prompt.md` — same shape, for infrastructure decisions
|
|
||||||
- `decomposer.prompt.md` — PRD → epic + stories with generator-first task lists
|
|
||||||
- `implementer.prompt.md` — executes one task; runs all 5 gates before committing
|
|
||||||
- `reviewer.prompt.md` — reviews implementer's diff against AC + scope
|
|
||||||
|
|
||||||
### The dispatch flow
|
### The dispatch flow
|
||||||
|
|
||||||
@@ -305,6 +305,11 @@ This is safe to run anywhere — it never invokes Sandcastle.
|
|||||||
**Step 2 — Execute**
|
**Step 2 — Execute**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Subscription mode (recommended):
|
||||||
|
claude login # one-time, host
|
||||||
|
pnpm work dispatch --execute # uses ~/.claude/
|
||||||
|
|
||||||
|
# API-key mode (fallback):
|
||||||
ANTHROPIC_API_KEY=sk-ant-... pnpm work dispatch --execute
|
ANTHROPIC_API_KEY=sk-ant-... pnpm work dispatch --execute
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -361,8 +366,8 @@ You (the human) then:
|
|||||||
|
|
||||||
### Troubleshooting Sandcastle
|
### Troubleshooting Sandcastle
|
||||||
|
|
||||||
**`✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.`**
|
**`✗ --execute requires either: 1. Claude Code logged in on host ... 2. ANTHROPIC_API_KEY ...`**
|
||||||
— Set one. The default agent is Claude.
|
— No auth resolved. Run `claude login` to enable subscription mode (recommended), OR set `ANTHROPIC_API_KEY` (fallback). Override the host creds path via `SANDCASTLE_CLAUDE_CREDS_DIR`.
|
||||||
|
|
||||||
**`Error: Cannot find module '@ai-hero/sandcastle'`**
|
**`Error: Cannot find module '@ai-hero/sandcastle'`**
|
||||||
— Run `pnpm install`. Sandcastle is a dev dependency at the workspace root.
|
— Run `pnpm install`. Sandcastle is a dev dependency at the workspace root.
|
||||||
@@ -381,6 +386,9 @@ You (the human) then:
|
|||||||
|
|
||||||
**Cost control** — each dispatch typically uses 50K–200K agent tokens depending on task complexity. The orchestrator does NOT cap retries; if you want to limit, set `max-attempts: 1` in the task's frontmatter (the orchestrator respects this in v2 — for now, just don't re-run dispatch after a reject).
|
**Cost control** — each dispatch typically uses 50K–200K agent tokens depending on task complexity. The orchestrator does NOT cap retries; if you want to limit, set `max-attempts: 1` in the task's frontmatter (the orchestrator respects this in v2 — for now, just don't re-run dispatch after a reject).
|
||||||
|
|
||||||
|
**Sandbox boots but Claude Code inside it says "Not authenticated" / "API key required"**
|
||||||
|
— The host `~/.claude/` mount didn't make it into the sandbox, OR your local Claude Code session expired. On the host, run `claude` once to confirm your session is live, then re-dispatch. If you're on Linux + SELinux, the mount may have been blocked — check the sandcastle output for SELinux warnings; set `selinuxLabel: "z"` or `false` in dispatch.mjs's docker opts if needed.
|
||||||
|
|
||||||
### Cost-aware variant: planning-only loop
|
### Cost-aware variant: planning-only loop
|
||||||
|
|
||||||
If you want sandcastle's structure without the agent spend, use planning mode + manual execution:
|
If you want sandcastle's structure without the agent spend, use planning mode + manual execution:
|
||||||
@@ -417,8 +425,8 @@ This gives you the same DAG-aware "what's next?" without invoking any agent. Use
|
|||||||
**Tests fail in `@repo/turbo-generators` with Vitest worker timeouts**
|
**Tests fail in `@repo/turbo-generators` with Vitest worker timeouts**
|
||||||
— Known flaky on slow machines. Re-run; if persistent, increase the `turbo-generators` package's vitest `testTimeout`.
|
— Known flaky on slow machines. Re-run; if persistent, increase the `turbo-generators` package's vitest `testTimeout`.
|
||||||
|
|
||||||
**`pnpm work dispatch --execute` errors with "ANTHROPIC_API_KEY required"**
|
**`pnpm work dispatch --execute` errors with "requires either: 1. Claude Code logged in..."**
|
||||||
— You're trying to run the sandcastle orchestrator without an agent API key. Set the env var, or run `pnpm work dispatch` (no flag) to just print the plan.
|
— No auth source found. Run `claude login` (subscription mode, recommended), or set `ANTHROPIC_API_KEY` (fallback). Run `pnpm work dispatch` (no flag) to just print the plan without auth.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"updated_at": "2026-05-13T07:05:55.913Z",
|
"updated_at": "2026-05-13T07:32:16.097Z",
|
||||||
"epics": {
|
"epics": {
|
||||||
"agent-workflow-docs-v1": {
|
"agent-workflow-docs-v1": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -382,6 +382,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sandcastle-subscription-auth-v1": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Sandcastle subscription auth as primary flow",
|
||||||
|
"stories": {
|
||||||
|
"01-subscription-first": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Subscription-first dispatch — mount ~/.claude, install claude CLI, docs",
|
||||||
|
"ac_total": 8,
|
||||||
|
"ac_completed": 8,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"visual-regression-v1": {
|
"visual-regression-v1": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Visual regression v1 — Playwright screenshot infrastructure",
|
"title": "Visual regression v1 — Playwright screenshot infrastructure",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
id: 01-subscription-first
|
||||||
|
epic: sandcastle-subscription-auth-v1
|
||||||
|
title: Subscription-first dispatch — mount ~/.claude, install claude CLI, docs
|
||||||
|
type: technical-story
|
||||||
|
status: done
|
||||||
|
feature: tooling
|
||||||
|
depends-on: []
|
||||||
|
blocks: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Story scaffold
|
||||||
|
- [x] Update .sandcastle/Dockerfile to install @anthropic-ai/claude-code CLI
|
||||||
|
- [x] Update scripts/work/dispatch.mjs: resolveClaudeAuth helper + sandbox mount + fallback to API key
|
||||||
|
- [x] Tests for resolveClaudeAuth
|
||||||
|
- [x] Update runbook "Using Sandcastle" section: subscription is primary flow
|
||||||
|
- [x] Update ADR-019: subscription auth as primary, API key as fallback
|
||||||
|
- [x] Update .env.example + .sandcastle/.env.example: reframe ANTHROPIC_API_KEY as fallback
|
||||||
|
- [x] Final verification + closeout
|
||||||
29
docs/work/sandcastle-subscription-auth-v1/_epic.md
Normal file
29
docs/work/sandcastle-subscription-auth-v1/_epic.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
id: sandcastle-subscription-auth-v1
|
||||||
|
prd: null
|
||||||
|
title: Sandcastle subscription auth as primary flow
|
||||||
|
type: epic
|
||||||
|
status: done
|
||||||
|
features: [tooling, ci]
|
||||||
|
created: 2026-05-13
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Subscription-based Claude Code auth (via host `~/.claude` mount) becomes
|
||||||
|
the default for `pnpm work dispatch --execute`. API-key auth remains as a
|
||||||
|
fallback. Works for any developer who has run `claude login` on their
|
||||||
|
host — zero per-user configuration in the repo.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The sandcastle community has explicitly rejected native subscription
|
||||||
|
support (issue #191, wontfix). But the host's `~/.claude/` directory
|
||||||
|
contains the logged-in session; mounting it into the sandbox lets the
|
||||||
|
container's Claude Code CLI use that session. This pattern is
|
||||||
|
environment-agnostic (each developer has their own `~/.claude`) and
|
||||||
|
spends zero per-task agent tokens for Pro/Max subscribers.
|
||||||
|
|
||||||
|
## Stories
|
||||||
|
|
||||||
|
- [x] [01 — Subscription-first dispatch + Dockerfile + docs](01-subscription-first/_story.md)
|
||||||
@@ -4,9 +4,12 @@
|
|||||||
* (with --execute) invokes sandcastle to run the implementer then reviewer.
|
* (with --execute) invokes sandcastle to run the implementer then reviewer.
|
||||||
*
|
*
|
||||||
* Default mode prints the dispatch plan without invoking sandcastle —
|
* Default mode prints the dispatch plan without invoking sandcastle —
|
||||||
* safe to run anywhere. --execute requires ANTHROPIC_API_KEY in the env.
|
* safe to run anywhere. --execute requires EITHER:
|
||||||
|
* 1. Claude Code logged in on host (~/.claude/ — recommended for subscribers)
|
||||||
|
* 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
@@ -86,6 +89,54 @@ ${next.bulletLine.trim()}
|
|||||||
${next.storyContent}`;
|
${next.storyContent}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the auth method for sandcastle dispatch.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. Subscription (primary) — mount host's ~/.claude/ into the sandbox.
|
||||||
|
* Active when the host's Claude creds directory exists. The path
|
||||||
|
* defaults to ~/.claude/ and can be overridden via the
|
||||||
|
* SANDCASTLE_CLAUDE_CREDS_DIR env var.
|
||||||
|
* 2. API key (fallback) — pass ANTHROPIC_API_KEY (or OPENAI_API_KEY)
|
||||||
|
* through to the sandbox env.
|
||||||
|
* 3. Neither available → returns { mode: "missing" } and the dispatcher
|
||||||
|
* prints a clear error before exiting.
|
||||||
|
*
|
||||||
|
* Returns: { mode: "subscription", hostPath, sandboxPath }
|
||||||
|
* | { mode: "api-key", env }
|
||||||
|
* | { mode: "missing" }
|
||||||
|
*/
|
||||||
|
export function resolveClaudeAuth({
|
||||||
|
env = process.env,
|
||||||
|
home = os.homedir(),
|
||||||
|
} = {}) {
|
||||||
|
// 1. Subscription path
|
||||||
|
const credsHostPath =
|
||||||
|
env.SANDCASTLE_CLAUDE_CREDS_DIR ?? path.join(home, ".claude");
|
||||||
|
if (fs.existsSync(credsHostPath)) {
|
||||||
|
return {
|
||||||
|
mode: "subscription",
|
||||||
|
hostPath: credsHostPath,
|
||||||
|
// Inside the sandbox, claude looks at the agent user's home — tilde
|
||||||
|
// expansion in MountConfig handles the actual /home/agent/.claude
|
||||||
|
// resolution.
|
||||||
|
sandboxPath: "~/.claude",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 2. API key fallback
|
||||||
|
if (env.ANTHROPIC_API_KEY) {
|
||||||
|
return {
|
||||||
|
mode: "api-key",
|
||||||
|
env: { ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (env.OPENAI_API_KEY) {
|
||||||
|
return { mode: "api-key", env: { OPENAI_API_KEY: env.OPENAI_API_KEY } };
|
||||||
|
}
|
||||||
|
// 3. Neither available
|
||||||
|
return { mode: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
function printPlan() {
|
function printPlan() {
|
||||||
const next = findNextTask();
|
const next = findNextTask();
|
||||||
if (!next) {
|
if (!next) {
|
||||||
@@ -99,11 +150,16 @@ function printPlan() {
|
|||||||
console.log(` Bullet: ${next.bulletLine.trim()}`);
|
console.log(` Bullet: ${next.bulletLine.trim()}`);
|
||||||
console.log(` Prompt: .sandcastle/implementer.prompt.md`);
|
console.log(` Prompt: .sandcastle/implementer.prompt.md`);
|
||||||
console.log();
|
console.log();
|
||||||
console.log("To execute this dispatch, run:");
|
console.log("To execute this dispatch:");
|
||||||
console.log(" ANTHROPIC_API_KEY=... pnpm work dispatch --execute");
|
console.log(
|
||||||
|
" - With Claude subscription: `claude login` (one-time) then `pnpm work dispatch --execute`",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" - With API key: `ANTHROPIC_API_KEY=... pnpm work dispatch --execute`",
|
||||||
|
);
|
||||||
console.log();
|
console.log();
|
||||||
console.log(
|
console.log(
|
||||||
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and an agent API key.)",
|
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see above.)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,24 +169,34 @@ async function executeDispatch() {
|
|||||||
console.log("No ready task to dispatch.");
|
console.log("No ready task to dispatch.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) {
|
|
||||||
|
const auth = resolveClaudeAuth();
|
||||||
|
if (auth.mode === "missing") {
|
||||||
|
console.error("✗ --execute requires either:");
|
||||||
console.error(
|
console.error(
|
||||||
"✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.",
|
" 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)",
|
||||||
|
);
|
||||||
|
console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)");
|
||||||
|
console.error("");
|
||||||
|
console.error(
|
||||||
|
" Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
console.log(
|
||||||
|
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`,
|
`Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`,
|
||||||
);
|
);
|
||||||
const taskSpec = buildTaskSpec(next);
|
const taskSpec = buildTaskSpec(next);
|
||||||
|
|
||||||
let sandcastle;
|
let sandcastleRoot;
|
||||||
let dockerSandbox;
|
let dockerProvider;
|
||||||
try {
|
try {
|
||||||
sandcastle = await import("@ai-hero/sandcastle");
|
sandcastleRoot = await import("@ai-hero/sandcastle");
|
||||||
({ docker: dockerSandbox } =
|
const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
|
||||||
await import("@ai-hero/sandcastle/sandboxes/docker"));
|
dockerProvider = dockerModule.docker;
|
||||||
} catch {
|
} catch {
|
||||||
console.error(
|
console.error(
|
||||||
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
|
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
|
||||||
@@ -138,14 +204,30 @@ async function executeDispatch() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build sandbox + agent providers based on auth mode
|
||||||
|
const dockerOpts = {};
|
||||||
|
const agentOpts = {};
|
||||||
|
if (auth.mode === "subscription") {
|
||||||
|
dockerOpts.mounts = [
|
||||||
|
{
|
||||||
|
hostPath: auth.hostPath,
|
||||||
|
sandboxPath: auth.sandboxPath,
|
||||||
|
readonly: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (auth.mode === "api-key") {
|
||||||
|
agentOpts.env = auth.env;
|
||||||
|
}
|
||||||
|
const sandbox = dockerProvider(dockerOpts);
|
||||||
|
const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts);
|
||||||
|
|
||||||
|
// Implementer
|
||||||
const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md");
|
const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md");
|
||||||
let implResult;
|
let implResult;
|
||||||
try {
|
try {
|
||||||
implResult = await sandcastle.run({
|
implResult = await sandcastleRoot.run({
|
||||||
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
|
agent,
|
||||||
sandbox: dockerSandbox({
|
sandbox,
|
||||||
imageName: `sandcastle-dispatch:local`,
|
|
||||||
}),
|
|
||||||
promptFile: implementerPrompt,
|
promptFile: implementerPrompt,
|
||||||
promptArgs: { TASK_FILE_CONTENT: taskSpec },
|
promptArgs: { TASK_FILE_CONTENT: taskSpec },
|
||||||
cwd: REPO_ROOT,
|
cwd: REPO_ROOT,
|
||||||
@@ -153,7 +235,7 @@ async function executeDispatch() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("✗ Implementer dispatch failed:", e.message);
|
console.error("✗ Implementer dispatch failed:", e.message);
|
||||||
console.error(
|
console.error(
|
||||||
" See .sandcastle/README.md for setup. Provider name(s) may need updating.",
|
" See docs/guides/runbook.md → 'Using Sandcastle' for setup.",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -161,7 +243,7 @@ async function executeDispatch() {
|
|||||||
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
|
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reviewer: pass the diff as DIFF prompt variable.
|
// Reviewer
|
||||||
let diff = "";
|
let diff = "";
|
||||||
try {
|
try {
|
||||||
diff = execSync(`git diff main..${implResult.branch}`, {
|
diff = execSync(`git diff main..${implResult.branch}`, {
|
||||||
@@ -172,18 +254,16 @@ async function executeDispatch() {
|
|||||||
diff = "(diff unavailable)";
|
diff = "(diff unavailable)";
|
||||||
}
|
}
|
||||||
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
|
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
|
||||||
const reviewResult = await sandcastle.run({
|
const reviewResult = await sandcastleRoot.run({
|
||||||
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
|
agent,
|
||||||
sandbox: dockerSandbox({
|
sandbox,
|
||||||
imageName: `sandcastle-dispatch:local`,
|
|
||||||
}),
|
|
||||||
promptFile: reviewerPrompt,
|
promptFile: reviewerPrompt,
|
||||||
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
|
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
|
||||||
cwd: REPO_ROOT,
|
cwd: REPO_ROOT,
|
||||||
});
|
});
|
||||||
console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`);
|
console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`);
|
||||||
|
|
||||||
// V1: the orchestrator does NOT auto-mutate state. Print what should happen.
|
// V1: orchestrator does NOT auto-mutate state. Print what should happen.
|
||||||
console.log();
|
console.log();
|
||||||
console.log("=== Suggested state mutation ===");
|
console.log("=== Suggested state mutation ===");
|
||||||
console.log(` Edit ${next.storyPath} — tick the bullet:`);
|
console.log(` Edit ${next.storyPath} — tick the bullet:`);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
findNextTask,
|
findNextTask,
|
||||||
findFirstUncheckedBullet,
|
findFirstUncheckedBullet,
|
||||||
buildTaskSpec,
|
buildTaskSpec,
|
||||||
|
resolveClaudeAuth,
|
||||||
} from "./dispatch.mjs";
|
} from "./dispatch.mjs";
|
||||||
|
|
||||||
function makeWorkTree({ epics }) {
|
function makeWorkTree({ epics }) {
|
||||||
@@ -137,3 +138,63 @@ describe("buildTaskSpec", () => {
|
|||||||
expect(spec).toContain("## Goal");
|
expect(spec).toContain("## Goal");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveClaudeAuth", () => {
|
||||||
|
it("returns subscription mode when ~/.claude exists on host", () => {
|
||||||
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-sub-"));
|
||||||
|
fs.mkdirSync(path.join(tmpHome, ".claude"));
|
||||||
|
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
|
||||||
|
expect(result.mode).toBe("subscription");
|
||||||
|
expect(result.hostPath).toBe(path.join(tmpHome, ".claude"));
|
||||||
|
expect(result.sandboxPath).toBe("~/.claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours SANDCASTLE_CLAUDE_CREDS_DIR override", () => {
|
||||||
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "auth-override-"));
|
||||||
|
const overrideDir = path.join(tmpRoot, "custom-claude");
|
||||||
|
fs.mkdirSync(overrideDir);
|
||||||
|
const result = resolveClaudeAuth({
|
||||||
|
env: { SANDCASTLE_CLAUDE_CREDS_DIR: overrideDir },
|
||||||
|
home: "/nonexistent",
|
||||||
|
});
|
||||||
|
expect(result.mode).toBe("subscription");
|
||||||
|
expect(result.hostPath).toBe(overrideDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to ANTHROPIC_API_KEY when ~/.claude does not exist", () => {
|
||||||
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-key-"));
|
||||||
|
// No .claude directory created
|
||||||
|
const result = resolveClaudeAuth({
|
||||||
|
env: { ANTHROPIC_API_KEY: "sk-test" },
|
||||||
|
home: tmpHome,
|
||||||
|
});
|
||||||
|
expect(result.mode).toBe("api-key");
|
||||||
|
expect(result.env).toEqual({ ANTHROPIC_API_KEY: "sk-test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to OPENAI_API_KEY when only that is set", () => {
|
||||||
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-openai-"));
|
||||||
|
const result = resolveClaudeAuth({
|
||||||
|
env: { OPENAI_API_KEY: "sk-openai" },
|
||||||
|
home: tmpHome,
|
||||||
|
});
|
||||||
|
expect(result.mode).toBe("api-key");
|
||||||
|
expect(result.env).toEqual({ OPENAI_API_KEY: "sk-openai" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns missing when neither subscription nor API key available", () => {
|
||||||
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-missing-"));
|
||||||
|
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
|
||||||
|
expect(result.mode).toBe("missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers subscription over API key when both available", () => {
|
||||||
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-both-"));
|
||||||
|
fs.mkdirSync(path.join(tmpHome, ".claude"));
|
||||||
|
const result = resolveClaudeAuth({
|
||||||
|
env: { ANTHROPIC_API_KEY: "sk-test" },
|
||||||
|
home: tmpHome,
|
||||||
|
});
|
||||||
|
expect(result.mode).toBe("subscription");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user