Merge branch 'worktree-sandcastle-subscription-auth-v1': sandcastle subscription auth as primary flow

This commit is contained in:
2026-05-13 09:32:44 +02:00
10 changed files with 321 additions and 63 deletions

View File

@@ -54,7 +54,22 @@ CMS_URL=http://localhost:3001
# --- 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=
# 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=
# Sandbox provider (default: docker; alternatives: podman, vercel, daytona)
# SANDCASTLE_PROVIDER=docker

View File

@@ -1,11 +1,20 @@
# Anthropic Claude API
ANTHROPIC_API_KEY=
# .sandcastle/.env — runtime tokens for sandcastle dispatch.
# 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
OPENAI_API_KEY=
# Anthropic API key (fallback when no host Claude Code session exists)
# ANTHROPIC_API_KEY=
# GitHub (for PR creation by the orchestrator)
GITHUB_TOKEN=
# OpenAI / Codex (alternative)
# 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

View File

@@ -1,14 +1,26 @@
# Minimal sandbox for sandcastle dispatch.
# Will be extended once the CI image is identified (see ADR-future).
# Sandcastle sandbox image — runs the implementer + reviewer agents.
#
# 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
# pnpm via corepack (matches the repo's pnpm version)
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
# 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"]

View File

@@ -50,12 +50,21 @@ Concretely:
orchestrator does NOT mutate state in v1 it prints suggested mutations
for the human to apply.
5. **Two modes:** `pnpm work dispatch` (planning, no agent invoked) and
`pnpm work dispatch --execute` (real sandcastle call, requires
`ANTHROPIC_API_KEY` or `OPENAI_API_KEY`).
`pnpm work dispatch --execute` (real sandcastle call, requires auth see
point 7).
6. **Reviewer agent verifies generator-first.** Hand-rolled output that should
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
dispatch when the operator explicitly provides credentials.
7. **Bring-your-own-auth.** Two paths are supported, in priority order:
- **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
`max-attempts: N` to bound the implementerreviewer retry loop. Default 3.

View File

@@ -87,7 +87,7 @@ pnpm work ready # all ready stories
pnpm work blocked # blocked stories + what they wait on
pnpm work rebuild-state # regenerate docs/work/_state.json
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,12 +139,15 @@ 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`)
| Var | Why |
| --------------------- | ------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Claude API key (sandcastle's default agent) |
| `OPENAI_API_KEY` | OpenAI/Codex alternative |
| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator |
| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` |
Auth is resolved automatically. Subscription (via `~/.claude/`) is the primary path; API key is the fallback.
| Var | Why |
| ----------------------------- | ---------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Claude API key — fallback when no `~/.claude/` present; not needed for subscribers |
| `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 |
| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` |
---
@@ -239,17 +242,14 @@ For the full design see `docs/architecture/agent-first-workflow-and-conformance.
### Prerequisites
1. **Docker running** sandcastle uses Docker for the sandbox by default. `docker info` should succeed.
2. **Agent API key** set ONE of:
- `ANTHROPIC_API_KEY` (recommended; sandcastle's default agent is `claudeCode`)
- `OPENAI_API_KEY` (alternative)
2. **Authentication — pick ONE:**
- **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.
- **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.
4. **`.sandcastle/` config present** already in tree:
- `Dockerfile` node:22 + pnpm sandbox image
- `prd-eliciter.prompt.md` interviews humans to draft PRDs
- `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
- `Dockerfile` node:22 + pnpm + Claude Code CLI; reads creds from `~/.claude/` inside the container
- `prd-eliciter.prompt.md`, `adr-eliciter.prompt.md`, `decomposer.prompt.md`, `implementer.prompt.md`, `reviewer.prompt.md` the five role prompts
### The dispatch flow
@@ -305,6 +305,11 @@ This is safe to run anywhere — it never invokes Sandcastle.
**Step 2 — Execute**
```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
```
@@ -361,8 +366,8 @@ You (the human) then:
### Troubleshooting Sandcastle
**`✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.`**
Set one. The default agent is Claude.
**`✗ --execute requires either: 1. Claude Code logged in on host ... 2. ANTHROPIC_API_KEY ...`**
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'`**
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 50K200K 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
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**
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"**
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.
**`pnpm work dispatch --execute` errors with "requires either: 1. Claude Code logged in..."**
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.
---

View File

@@ -1,5 +1,5 @@
{
"updated_at": "2026-05-13T07:05:55.913Z",
"updated_at": "2026-05-13T07:32:16.097Z",
"epics": {
"agent-workflow-docs-v1": {
"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": {
"status": "done",
"title": "Visual regression v1 — Playwright screenshot infrastructure",

View File

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

View 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)

View File

@@ -4,9 +4,12 @@
* (with --execute) invokes sandcastle to run the implementer then reviewer.
*
* 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 os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
@@ -86,6 +89,54 @@ ${next.bulletLine.trim()}
${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() {
const next = findNextTask();
if (!next) {
@@ -99,11 +150,16 @@ function printPlan() {
console.log(` Bullet: ${next.bulletLine.trim()}`);
console.log(` Prompt: .sandcastle/implementer.prompt.md`);
console.log();
console.log("To execute this dispatch, run:");
console.log(" ANTHROPIC_API_KEY=... pnpm work dispatch --execute");
console.log("To execute this dispatch:");
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(
"(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.");
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(
"✗ --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);
}
console.log(
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
);
console.log(
`Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`,
);
const taskSpec = buildTaskSpec(next);
let sandcastle;
let dockerSandbox;
let sandcastleRoot;
let dockerProvider;
try {
sandcastle = await import("@ai-hero/sandcastle");
({ docker: dockerSandbox } =
await import("@ai-hero/sandcastle/sandboxes/docker"));
sandcastleRoot = await import("@ai-hero/sandcastle");
const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
dockerProvider = dockerModule.docker;
} catch {
console.error(
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
@@ -138,14 +204,30 @@ async function executeDispatch() {
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");
let implResult;
try {
implResult = await sandcastle.run({
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
sandbox: dockerSandbox({
imageName: `sandcastle-dispatch:local`,
}),
implResult = await sandcastleRoot.run({
agent,
sandbox,
promptFile: implementerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec },
cwd: REPO_ROOT,
@@ -153,7 +235,7 @@ async function executeDispatch() {
} catch (e) {
console.error("✗ Implementer dispatch failed:", e.message);
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);
}
@@ -161,7 +243,7 @@ async function executeDispatch() {
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
);
// Reviewer: pass the diff as DIFF prompt variable.
// Reviewer
let diff = "";
try {
diff = execSync(`git diff main..${implResult.branch}`, {
@@ -172,18 +254,16 @@ async function executeDispatch() {
diff = "(diff unavailable)";
}
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
const reviewResult = await sandcastle.run({
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
sandbox: dockerSandbox({
imageName: `sandcastle-dispatch:local`,
}),
const reviewResult = await sandcastleRoot.run({
agent,
sandbox,
promptFile: reviewerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
cwd: REPO_ROOT,
});
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("=== Suggested state mutation ===");
console.log(` Edit ${next.storyPath} — tick the bullet:`);

View File

@@ -6,6 +6,7 @@ import {
findNextTask,
findFirstUncheckedBullet,
buildTaskSpec,
resolveClaudeAuth,
} from "./dispatch.mjs";
function makeWorkTree({ epics }) {
@@ -137,3 +138,63 @@ describe("buildTaskSpec", () => {
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");
});
});