@valescoagency/runway 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/cli.js +88 -0
- package/dist/commands/doctor.js +464 -0
- package/dist/commands/init.js +421 -0
- package/dist/commands/run.js +61 -0
- package/dist/commands/upgrade-repo.js +325 -0
- package/dist/commands/upgrade.js +177 -0
- package/dist/config.js +45 -0
- package/dist/github.js +34 -0
- package/dist/linear.js +81 -0
- package/dist/orchestrator.js +191 -0
- package/dist/prompts.js +40 -0
- package/package.json +63 -0
- package/templates/.env.schema.target-repo +32 -0
- package/templates/Dockerfile.claude-code.base +55 -0
- package/templates/dockerfile-varlock.snippet +43 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { run, claudeCode } from "@ai-hero/sandcastle";
|
|
4
|
+
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
5
|
+
import { execa } from "execa";
|
|
6
|
+
import { implementVars, loadImplementPrompt, loadReviewPrompt, renderPrompt, reviewVars, } from "./prompts.js";
|
|
7
|
+
const REVIEW_VERDICT_RE = /^REVIEW:\s*(APPROVED|REJECTED)(?:\s+—\s+(.*))?$/m;
|
|
8
|
+
/**
|
|
9
|
+
* Confirms the cwd looks like a sandcastle-initialised repo. If not,
|
|
10
|
+
* we error early with a clear message rather than letting Sandcastle
|
|
11
|
+
* fail deep inside Docker setup.
|
|
12
|
+
*/
|
|
13
|
+
export function assertSandcastleInitialised(cwd) {
|
|
14
|
+
const sandcastleDir = join(cwd, ".sandcastle");
|
|
15
|
+
if (!existsSync(sandcastleDir)) {
|
|
16
|
+
throw new Error(`No .sandcastle/ directory in ${cwd}. Run \`npx sandcastle init\` here first.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Drains the Linear queue until empty (or until --max is hit). One
|
|
21
|
+
* issue at a time in v1; parallel runs are a follow-up.
|
|
22
|
+
*/
|
|
23
|
+
export async function drainQueue(deps, opts = {}) {
|
|
24
|
+
const { config, linear } = deps;
|
|
25
|
+
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
26
|
+
let processed = 0;
|
|
27
|
+
let opened = 0;
|
|
28
|
+
let hitl = 0;
|
|
29
|
+
let errored = 0;
|
|
30
|
+
while (processed < max) {
|
|
31
|
+
const queue = await linear.fetchReady();
|
|
32
|
+
if (queue.length === 0)
|
|
33
|
+
break;
|
|
34
|
+
const issue = queue[0];
|
|
35
|
+
try {
|
|
36
|
+
const verdict = await processIssue(issue, deps);
|
|
37
|
+
processed += 1;
|
|
38
|
+
if (verdict === "opened")
|
|
39
|
+
opened += 1;
|
|
40
|
+
if (verdict === "hitl")
|
|
41
|
+
hitl += 1;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
errored += 1;
|
|
45
|
+
console.error(`[runway] error on ${issue.identifier}:`, err);
|
|
46
|
+
await linear
|
|
47
|
+
.applyLabel(issue.id, config.hitlLabel)
|
|
48
|
+
.catch(() => undefined);
|
|
49
|
+
await linear
|
|
50
|
+
.comment(issue.id, `Runway hit an unrecoverable error and flagged for human review:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
|
|
51
|
+
.catch(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { processed, opened, hitl, errored };
|
|
55
|
+
}
|
|
56
|
+
async function processIssue(issue, deps) {
|
|
57
|
+
const { config, linear, github, cwd } = deps;
|
|
58
|
+
const branch = `agent/${issue.identifier.toLowerCase()}`;
|
|
59
|
+
await linear.transition(issue.id, config.inProgressStatus);
|
|
60
|
+
await linear.comment(issue.id, `Runway picked up this issue. Branch: \`${branch}\`.`);
|
|
61
|
+
// 1. Implementation pass.
|
|
62
|
+
const implementPrompt = renderPrompt(await loadImplementPrompt(), implementVars(issue));
|
|
63
|
+
const implementResult = await run({
|
|
64
|
+
agent: claudeCode("claude-opus-4-6"),
|
|
65
|
+
sandbox: docker({
|
|
66
|
+
env: dockerEnv(config),
|
|
67
|
+
}),
|
|
68
|
+
cwd,
|
|
69
|
+
prompt: implementPrompt,
|
|
70
|
+
branchStrategy: { type: "branch", branch },
|
|
71
|
+
maxIterations: config.maxIterations,
|
|
72
|
+
name: `impl-${issue.identifier}`,
|
|
73
|
+
});
|
|
74
|
+
if (implementResult.commits.length === 0) {
|
|
75
|
+
await flagHitl(issue, deps, "Agent produced no commits — the issue may need clarification or human input.");
|
|
76
|
+
return "hitl";
|
|
77
|
+
}
|
|
78
|
+
// 2. Review pass — read-only-ish, just looking at the diff.
|
|
79
|
+
const diff = await captureDiff(cwd, branch);
|
|
80
|
+
const commitLog = await captureCommitLog(cwd, branch);
|
|
81
|
+
const reviewPrompt = renderPrompt(await loadReviewPrompt(), reviewVars({ issue, diff, commits: commitLog }));
|
|
82
|
+
const reviewResult = await run({
|
|
83
|
+
agent: claudeCode("claude-opus-4-6"),
|
|
84
|
+
sandbox: docker({
|
|
85
|
+
env: dockerEnv(config),
|
|
86
|
+
}),
|
|
87
|
+
cwd,
|
|
88
|
+
prompt: reviewPrompt,
|
|
89
|
+
branchStrategy: { type: "head" },
|
|
90
|
+
maxIterations: 1,
|
|
91
|
+
name: `review-${issue.identifier}`,
|
|
92
|
+
});
|
|
93
|
+
const verdict = parseReviewVerdict(reviewResult);
|
|
94
|
+
if (verdict.kind === "rejected") {
|
|
95
|
+
await flagHitl(issue, deps, `Sub-agent review rejected: ${verdict.reason}`);
|
|
96
|
+
return "hitl";
|
|
97
|
+
}
|
|
98
|
+
// 3. Push + PR.
|
|
99
|
+
await github.pushBranch(cwd, branch);
|
|
100
|
+
const prBody = buildPrBody(issue);
|
|
101
|
+
const prUrl = await github.openPullRequest({
|
|
102
|
+
repoPath: cwd,
|
|
103
|
+
branch,
|
|
104
|
+
issue,
|
|
105
|
+
body: prBody,
|
|
106
|
+
});
|
|
107
|
+
await linear.transition(issue.id, config.inReviewStatus);
|
|
108
|
+
await linear.comment(issue.id, `Runway opened a PR for review: ${prUrl}`);
|
|
109
|
+
return "opened";
|
|
110
|
+
}
|
|
111
|
+
async function flagHitl(issue, deps, reason) {
|
|
112
|
+
const { config, linear } = deps;
|
|
113
|
+
await linear.applyLabel(issue.id, config.hitlLabel);
|
|
114
|
+
await linear.comment(issue.id, `Runway flagged for human review: ${reason}`);
|
|
115
|
+
}
|
|
116
|
+
async function captureDiff(repoPath, branch) {
|
|
117
|
+
const { stdout } = await execa("git", ["diff", `main...${branch}`], {
|
|
118
|
+
cwd: repoPath,
|
|
119
|
+
});
|
|
120
|
+
// Truncate to keep the review prompt under the model's context budget.
|
|
121
|
+
return stdout.length > 60_000 ? `${stdout.slice(0, 60_000)}\n…(truncated)` : stdout;
|
|
122
|
+
}
|
|
123
|
+
async function captureCommitLog(repoPath, branch) {
|
|
124
|
+
const { stdout } = await execa("git", ["log", "--oneline", `main..${branch}`], { cwd: repoPath });
|
|
125
|
+
return stdout;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Sandcastle's `RunResult` shape varies by version; defensively dig out
|
|
129
|
+
* the last assistant message text. We only need to match the
|
|
130
|
+
* `REVIEW: APPROVED` / `REVIEW: REJECTED — …` line at the tail.
|
|
131
|
+
*/
|
|
132
|
+
function parseReviewVerdict(result) {
|
|
133
|
+
const text = stringifyResult(result);
|
|
134
|
+
const match = text.match(REVIEW_VERDICT_RE);
|
|
135
|
+
if (!match) {
|
|
136
|
+
return {
|
|
137
|
+
kind: "rejected",
|
|
138
|
+
reason: "review output did not contain a REVIEW: verdict line",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (match[1] === "APPROVED")
|
|
142
|
+
return { kind: "approved", reason: "" };
|
|
143
|
+
return {
|
|
144
|
+
kind: "rejected",
|
|
145
|
+
reason: match[2]?.trim() || "no reason given",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function stringifyResult(result) {
|
|
149
|
+
if (typeof result === "string")
|
|
150
|
+
return result;
|
|
151
|
+
if (result && typeof result === "object") {
|
|
152
|
+
const r = result;
|
|
153
|
+
if (r.iterations?.length) {
|
|
154
|
+
return r.iterations
|
|
155
|
+
.map((i) => i.output ?? i.text ?? "")
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join("\n");
|
|
158
|
+
}
|
|
159
|
+
if (typeof r.output === "string")
|
|
160
|
+
return r.output;
|
|
161
|
+
return JSON.stringify(result);
|
|
162
|
+
}
|
|
163
|
+
return String(result);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Env vars to inject into every sandcastle container. Today this is
|
|
167
|
+
* just OP_SERVICE_ACCOUNT_TOKEN (when present) so the in-container
|
|
168
|
+
* varlock shim can authenticate with 1Password and resolve
|
|
169
|
+
* ANTHROPIC_API_KEY / GH_TOKEN at agent run time. See
|
|
170
|
+
* docs/secrets-with-varlock.md for the full flow.
|
|
171
|
+
*/
|
|
172
|
+
function dockerEnv(config) {
|
|
173
|
+
const env = {};
|
|
174
|
+
if (config.opServiceAccountToken) {
|
|
175
|
+
env.OP_SERVICE_ACCOUNT_TOKEN = config.opServiceAccountToken;
|
|
176
|
+
}
|
|
177
|
+
return env;
|
|
178
|
+
}
|
|
179
|
+
function buildPrBody(issue) {
|
|
180
|
+
return [
|
|
181
|
+
`Runway-generated PR for **${issue.identifier} — ${issue.title}**.`,
|
|
182
|
+
"",
|
|
183
|
+
"Sub-agent review pass: APPROVED.",
|
|
184
|
+
"",
|
|
185
|
+
"## Linear issue",
|
|
186
|
+
"",
|
|
187
|
+
issue.description || "(no description)",
|
|
188
|
+
"",
|
|
189
|
+
`Refs ${issue.identifier}`,
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
// Prompts ship with the runway package, NOT in the target repo's
|
|
6
|
+
// .sandcastle/. Runway substitutes {{KEY}} placeholders before passing
|
|
7
|
+
// the rendered string inline to sandcastle.run({ prompt }).
|
|
8
|
+
// runway/src/prompts.ts → runway/prompts/
|
|
9
|
+
const PROMPT_DIR = join(__dirname, "..", "prompts");
|
|
10
|
+
export async function loadImplementPrompt() {
|
|
11
|
+
return readFile(join(PROMPT_DIR, "implement.md"), "utf8");
|
|
12
|
+
}
|
|
13
|
+
export async function loadReviewPrompt() {
|
|
14
|
+
return readFile(join(PROMPT_DIR, "review.md"), "utf8");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Render a prompt by replacing all `{{KEY}}` placeholders with values
|
|
18
|
+
* from `vars`. We do the substitution here (instead of relying on
|
|
19
|
+
* sandcastle's promptArgs) because we pass the prompt inline and want
|
|
20
|
+
* one canonical place to template.
|
|
21
|
+
*/
|
|
22
|
+
export function renderPrompt(template, vars) {
|
|
23
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
24
|
+
}
|
|
25
|
+
export function implementVars(issue) {
|
|
26
|
+
return {
|
|
27
|
+
ISSUE_IDENTIFIER: issue.identifier,
|
|
28
|
+
ISSUE_TITLE: issue.title,
|
|
29
|
+
ISSUE_DESCRIPTION: issue.description || "(no description)",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function reviewVars(args) {
|
|
33
|
+
return {
|
|
34
|
+
ISSUE_IDENTIFIER: args.issue.identifier,
|
|
35
|
+
ISSUE_TITLE: args.issue.title,
|
|
36
|
+
ISSUE_DESCRIPTION: args.issue.description || "(no description)",
|
|
37
|
+
DIFF: args.diff || "(empty diff)",
|
|
38
|
+
COMMITS: args.commits || "(no commits)",
|
|
39
|
+
};
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@valescoagency/runway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Linear-driven orchestrator + scaffolder for coding agents on Sandcastle. `runway init` scaffolds a target repo (sandcastle + varlock + 1Password); `runway run` drains a Linear queue against it; `runway doctor`, `runway upgrade`, `runway upgrade-repo` round out the lifecycle.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Valesco Agency",
|
|
8
|
+
"email": "jason@valescoagency.com",
|
|
9
|
+
"url": "https://valescoagency.com"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ValescoAgency/runway#readme",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/ValescoAgency/runway.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/ValescoAgency/runway/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude-code",
|
|
21
|
+
"sandcastle",
|
|
22
|
+
"linear",
|
|
23
|
+
"orchestrator",
|
|
24
|
+
"agent",
|
|
25
|
+
"varlock",
|
|
26
|
+
"1password",
|
|
27
|
+
"valesco",
|
|
28
|
+
"cli"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"bin": {
|
|
32
|
+
"runway": "./dist/cli.js"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"templates",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@ai-hero/sandcastle": "^0.5.10",
|
|
42
|
+
"@linear/sdk": "^41.0.0",
|
|
43
|
+
"execa": "^9.5.2",
|
|
44
|
+
"zod": "^3.23.8"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"tsx": "^4.19.2",
|
|
49
|
+
"typescript": "^5.7.2"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsc && chmod +x dist/cli.js",
|
|
59
|
+
"typecheck": "tsc --noEmit",
|
|
60
|
+
"dev": "tsx src/cli.ts",
|
|
61
|
+
"lint": "echo 'lint not configured yet'"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Per-target-repo secrets manifest. Lives at the target repo root
|
|
2
|
+
# (NOT in .sandcastle/, since that directory is sandcastle's territory).
|
|
3
|
+
#
|
|
4
|
+
# This file is committed. Values resolve at runtime via varlock + the
|
|
5
|
+
# 1Password CLI inside the sandcastle container — see
|
|
6
|
+
# .sandcastle/Dockerfile for the wiring.
|
|
7
|
+
#
|
|
8
|
+
# When the agent runs, the `claude` binary inside the container is a
|
|
9
|
+
# wrapper that invokes `varlock run -- claude.real`. varlock reads
|
|
10
|
+
# THIS file, fetches the op:// references using the OP_SERVICE_ACCOUNT_TOKEN
|
|
11
|
+
# that runway passed in at container start, and exposes the resolved
|
|
12
|
+
# values to the real claude process for the duration of that one
|
|
13
|
+
# invocation. After it exits, secrets are gone from container memory.
|
|
14
|
+
#
|
|
15
|
+
# Note on the op:// shape: with service-account auth (the only mode
|
|
16
|
+
# runway uses), the token already encodes the 1Password tenant, so the
|
|
17
|
+
# URI omits the account segment — `op://<vault>/<item>`, not
|
|
18
|
+
# `op://<account>/<vault>/<item>`.
|
|
19
|
+
|
|
20
|
+
# @sensitive @required
|
|
21
|
+
ANTHROPIC_API_KEY=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}"')
|
|
22
|
+
|
|
23
|
+
# @sensitive @required
|
|
24
|
+
GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}"')
|
|
25
|
+
|
|
26
|
+
# Add other secrets the agent needs at runtime here. Examples:
|
|
27
|
+
#
|
|
28
|
+
# @sensitive @required
|
|
29
|
+
# OPENAI_API_KEY=exec('op read "op://{{OP_VAULT}}/openai-api-key"')
|
|
30
|
+
#
|
|
31
|
+
# @sensitive @required
|
|
32
|
+
# DATABASE_URL=exec('op read "op://{{OP_VAULT}}/database-url"')
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Canonical claude-code Dockerfile — vendored from
|
|
2
|
+
# @ai-hero/sandcastle's InitService.ts (CLAUDE_CODE_DOCKERFILE constant).
|
|
3
|
+
# Kept here so `runway init` can write it directly, without invoking
|
|
4
|
+
# `sandcastle init` (which has interactive prompts that hang in
|
|
5
|
+
# non-TTY environments like CI / Mac Mini cron).
|
|
6
|
+
#
|
|
7
|
+
# Drift policy: when sandcastle bumps its claude-code Dockerfile,
|
|
8
|
+
# refresh this file. The diff should be tiny — runway's tier 2 layer
|
|
9
|
+
# patches AFTER this base, so adopters re-run `runway init --force`
|
|
10
|
+
# to roll forward.
|
|
11
|
+
|
|
12
|
+
FROM node:22-bookworm
|
|
13
|
+
|
|
14
|
+
# Install system dependencies
|
|
15
|
+
RUN apt-get update && apt-get install -y \
|
|
16
|
+
git \
|
|
17
|
+
curl \
|
|
18
|
+
jq \
|
|
19
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
20
|
+
|
|
21
|
+
# Build-args for UID/GID alignment: defaults match the host user's
|
|
22
|
+
# UID/GID at build time so image-built files and bind-mounted files
|
|
23
|
+
# share an owner without runtime chown.
|
|
24
|
+
ARG AGENT_UID=1000
|
|
25
|
+
ARG AGENT_GID=1000
|
|
26
|
+
|
|
27
|
+
# Rename the base image's "node" user to "agent" and align UID/GID.
|
|
28
|
+
#
|
|
29
|
+
# Divergence from sandcastle's stock Dockerfile: stock runs
|
|
30
|
+
# `groupmod -g $AGENT_GID node` unconditionally, which fails on macOS
|
|
31
|
+
# hosts where the host GID is 20 (`staff`) — Debian's `dialout` group
|
|
32
|
+
# already has GID 20, and `groupmod` refuses to assign a duplicate
|
|
33
|
+
# GID. We guard with `getent group` so groupmod only runs if the
|
|
34
|
+
# target GID is unused; if it's already taken, we point the agent
|
|
35
|
+
# user at the pre-existing group via `usermod -g <gid>` and the
|
|
36
|
+
# image still works (the in-image group name is irrelevant — only the
|
|
37
|
+
# numeric GID matters for bind-mount permissions).
|
|
38
|
+
RUN if ! getent group $AGENT_GID >/dev/null; then \
|
|
39
|
+
groupmod -g $AGENT_GID node; \
|
|
40
|
+
fi \
|
|
41
|
+
&& usermod -u $AGENT_UID -g $AGENT_GID -d /home/agent -m -l agent node
|
|
42
|
+
USER ${AGENT_UID}:${AGENT_GID}
|
|
43
|
+
|
|
44
|
+
# Install Claude Code CLI
|
|
45
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash
|
|
46
|
+
|
|
47
|
+
# Add Claude to PATH
|
|
48
|
+
ENV PATH="/home/agent/.local/bin:$PATH"
|
|
49
|
+
|
|
50
|
+
WORKDIR /home/agent
|
|
51
|
+
|
|
52
|
+
# In worktree sandbox mode, Sandcastle bind-mounts the git worktree at
|
|
53
|
+
# the sandbox repo dir and overrides the working directory to that dir
|
|
54
|
+
# at container start.
|
|
55
|
+
ENTRYPOINT ["sleep", "infinity"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# --- runway tier-2 layer (varlock + 1Password) ---
|
|
2
|
+
# Spliced in by `runway init --tier=2` immediately before the
|
|
3
|
+
# final `ENTRYPOINT ["sleep", "infinity"]` line.
|
|
4
|
+
|
|
5
|
+
# Install varlock via npm. The official `ghcr.io/dmno-dev/varlock`
|
|
6
|
+
# image is musl/Alpine — copying its binary into a glibc base
|
|
7
|
+
# (node:22-bookworm) produces an ELF that the loader can't resolve
|
|
8
|
+
# ("not found" on exec). npm install gets the right binary for the
|
|
9
|
+
# image's libc.
|
|
10
|
+
USER root
|
|
11
|
+
RUN npm install -g varlock
|
|
12
|
+
|
|
13
|
+
# Install the 1Password CLI so varlock can resolve `op://` references.
|
|
14
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
15
|
+
ca-certificates curl gnupg \
|
|
16
|
+
&& curl -sS https://downloads.1password.com/linux/keys/1password.asc \
|
|
17
|
+
| gpg --dearmor -o /usr/share/keyrings/1password-archive-keyring.gpg \
|
|
18
|
+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/$(dpkg --print-architecture) stable main" \
|
|
19
|
+
> /etc/apt/sources.list.d/1password.list \
|
|
20
|
+
&& apt-get update && apt-get install -y 1password-cli \
|
|
21
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
22
|
+
|
|
23
|
+
# Shim the `claude` binary so every invocation runs through varlock.
|
|
24
|
+
# The real binary moves to claude.real; the shim resolves secrets from
|
|
25
|
+
# 1Password (using OP_SERVICE_ACCOUNT_TOKEN passed in by runway) and
|
|
26
|
+
# execs the real claude with secrets in its process env. Secrets exist
|
|
27
|
+
# only inside the lifetime of one claude invocation — never in any
|
|
28
|
+
# image layer, never on the container filesystem.
|
|
29
|
+
RUN mv /home/agent/.local/bin/claude /home/agent/.local/bin/claude.real \
|
|
30
|
+
&& printf '%s\n' \
|
|
31
|
+
'#!/usr/bin/env bash' \
|
|
32
|
+
'set -euo pipefail' \
|
|
33
|
+
'exec varlock run --env-file /home/agent/workspace/.env.schema -- /home/agent/.local/bin/claude.real "$@"' \
|
|
34
|
+
> /home/agent/.local/bin/claude \
|
|
35
|
+
&& chmod +x /home/agent/.local/bin/claude \
|
|
36
|
+
&& chown ${AGENT_UID}:${AGENT_GID} /home/agent/.local/bin/claude
|
|
37
|
+
|
|
38
|
+
USER ${AGENT_UID}:${AGENT_GID}
|
|
39
|
+
|
|
40
|
+
# Final ENTRYPOINT remains:
|
|
41
|
+
# ENTRYPOINT ["sleep", "infinity"]
|
|
42
|
+
# Sandcastle will `docker exec <container> claude ...` and our shim
|
|
43
|
+
# transparently wraps each invocation.
|