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

View File

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

View File

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

View File

@@ -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 implementerreviewer retry loop. Default 3. `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 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,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`) ### Optional — sandcastle dispatch (only when running `pnpm work dispatch --execute`)
| Var | Why | Auth is resolved automatically. Subscription (via `~/.claude/`) is the primary path; API key is the fallback.
| --------------------- | ------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Claude API key (sandcastle's default agent) | | Var | Why |
| `OPENAI_API_KEY` | OpenAI/Codex alternative | | ----------------------------- | ---------------------------------------------------------------------------------- |
| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator | | `ANTHROPIC_API_KEY` | Claude API key — fallback when no `~/.claude/` present; not needed for subscribers |
| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` | | `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 ### 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 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). **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 ### 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.
--- ---

View File

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

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. * (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:`);

View File

@@ -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");
});
});