phoebe-agent 0.1.0 → 0.1.1
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/bootstrap/boot.ts +48 -0
- package/package.json +1 -1
- package/src/git-model.ts +34 -2
- package/templates/.env.example +6 -0
- package/templates/container/Dockerfile +94 -7
- package/templates/container/compose.yml +9 -0
package/bootstrap/boot.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// the fallback policy in crash-loop.ts, and everything impure is passed in from
|
|
23
23
|
// here.
|
|
24
24
|
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
25
26
|
import { existsSync } from "node:fs";
|
|
26
27
|
import { tmpdir } from "node:os";
|
|
27
28
|
import { join } from "node:path";
|
|
@@ -56,6 +57,48 @@ import { propagateExit, spawnEngine } from "./spawn-engine.mjs";
|
|
|
56
57
|
/** Where the local-engine compose overlay mounts the engine for `source: "local"`. */
|
|
57
58
|
export const LOCAL_ENGINE_DIR = "/opt/phoebe-engine";
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Runs `gh` with the given argv. Injectable so boot's credential-helper setup is
|
|
62
|
+
* unit-tested without a real `gh` binary or a writable `~/.gitconfig`.
|
|
63
|
+
*/
|
|
64
|
+
export type GhRunner = (args: readonly string[]) => void;
|
|
65
|
+
|
|
66
|
+
export const defaultGh: GhRunner = (args) => {
|
|
67
|
+
execFileSync("gh", args, { stdio: "inherit" });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Configure a global git credential helper from `GH_TOKEN` so every later git
|
|
72
|
+
* call against github.com authenticates — the engine's `ensureClone` /
|
|
73
|
+
* `fetchOrigin` / `pushBranch`, and the agent child's own `git push`/`fetch`.
|
|
74
|
+
*
|
|
75
|
+
* Uses `gh auth setup-git --hostname github.com`, which writes a
|
|
76
|
+
* `!gh auth git-credential` helper into `~/.gitconfig`. That helper reads
|
|
77
|
+
* `GH_TOKEN` live per call, so no secret is written to disk and token rotation
|
|
78
|
+
* keeps working. Only `github.com` is configured (Phoebe is github-only).
|
|
79
|
+
*
|
|
80
|
+
* Skipped when no token is present (public/anonymous path unchanged). A failed
|
|
81
|
+
* setup warns and continues — a missing helper is better diagnosed at the first
|
|
82
|
+
* private-repo clone than by aborting the container here.
|
|
83
|
+
*/
|
|
84
|
+
export function setupGitCredentials(deps: {
|
|
85
|
+
token: string | undefined;
|
|
86
|
+
gh?: GhRunner;
|
|
87
|
+
warn?: (message: string) => void;
|
|
88
|
+
}): void {
|
|
89
|
+
if (!deps.token) return;
|
|
90
|
+
const gh = deps.gh ?? defaultGh;
|
|
91
|
+
const warn = deps.warn ?? ((message) => console.warn(message));
|
|
92
|
+
try {
|
|
93
|
+
gh(["auth", "setup-git", "--hostname", "github.com"]);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
warn(
|
|
96
|
+
`[phoebe] boot: could not configure git credentials — ${describe(error)}. ` +
|
|
97
|
+
`Continuing without a credential helper.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
59
102
|
/**
|
|
60
103
|
* Resolve a `local` engine source to the mounted engine's `src/cli.ts`, failing
|
|
61
104
|
* loudly if it is absent — a missing/empty mount means a misconfigured
|
|
@@ -322,6 +365,11 @@ function runOutcome(run: EngineRun): RunOutcome | null {
|
|
|
322
365
|
* persistent loop).
|
|
323
366
|
*/
|
|
324
367
|
export async function runBoot(argv: readonly string[]): Promise<void> {
|
|
368
|
+
// Before any engine git call (ensureClone, fetch/push, agent child): one
|
|
369
|
+
// global github.com credential helper from GH_TOKEN. Survives reconcile
|
|
370
|
+
// relaunches via ~/.gitconfig + the agent-env HOME/GH_TOKEN allowlist.
|
|
371
|
+
setupGitCredentials({ token: process.env["GH_TOKEN"] });
|
|
372
|
+
|
|
325
373
|
const configPath = resolveConfigPath(undefined, process.cwd());
|
|
326
374
|
const guard = await createBootCrashGuard(configPath);
|
|
327
375
|
const intervalMs = reconcileIntervalMs();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phoebe-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Phoebe — an AFK coding agent: a configurable engine that works a GitHub issue tracker one ticket at a time, distributed as a pinned CLI.",
|
|
6
6
|
"homepage": "https://github.com/JesusFilm/phoebe#readme",
|
package/src/git-model.ts
CHANGED
|
@@ -23,12 +23,44 @@ export const defaultGit: GitRunner = (args, opts) =>
|
|
|
23
23
|
...(opts?.timeout ? { timeout: opts.timeout } : {}),
|
|
24
24
|
}) as unknown as string;
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Clone the repo into `repoDir` unless a clone already exists there.
|
|
28
|
+
*
|
|
29
|
+
* An existing clone is only adopted if its `origin` actually points at
|
|
30
|
+
* `repoUrl`. A `/data/repo` volume is supposed to be this instance's private
|
|
31
|
+
* clone, but two Phoebe instances on one host can end up sharing it (a compose
|
|
32
|
+
* project-name collision namespaces the "private" volumes identically). Adopting
|
|
33
|
+
* a foreign clone by mere presence of `.git` would silently run every worktree,
|
|
34
|
+
* branch, and push against the wrong repo while `gh` calls still used this
|
|
35
|
+
* instance's `repoSlug` — reading one repo's issues and doing the work on
|
|
36
|
+
* another's tree. So a mismatch fails loudly instead: isolate the instances (see
|
|
37
|
+
* `COMPOSE_PROJECT_NAME`) or wipe the shared volume.
|
|
38
|
+
*/
|
|
27
39
|
export function ensureClone(
|
|
28
40
|
opts: { repoUrl: string; repoDir: string },
|
|
29
41
|
git: GitRunner = defaultGit,
|
|
30
42
|
): void {
|
|
31
|
-
if (existsSync(join(opts.repoDir, ".git")))
|
|
43
|
+
if (existsSync(join(opts.repoDir, ".git"))) {
|
|
44
|
+
// `git config --get` exits non-zero when the key is unset, which `defaultGit`
|
|
45
|
+
// surfaces as a throw. An unreadable origin is not the configured `repoUrl`,
|
|
46
|
+
// so treat it as an absent origin and fall through to the refusal below —
|
|
47
|
+
// reaching the explicit `<none>` message rather than a raw `Command failed`.
|
|
48
|
+
let origin = "";
|
|
49
|
+
try {
|
|
50
|
+
origin = git(["config", "--get", "remote.origin.url"], { cwd: opts.repoDir }).trim();
|
|
51
|
+
} catch {
|
|
52
|
+
origin = "";
|
|
53
|
+
}
|
|
54
|
+
if (origin !== opts.repoUrl) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Existing clone at ${opts.repoDir} has origin \`${origin || "<none>"}\`, but this ` +
|
|
57
|
+
`Phoebe is configured for \`${opts.repoUrl}\`. Refusing to work a foreign clone — the ` +
|
|
58
|
+
`state volume is shared with another instance. Give each instance a distinct compose ` +
|
|
59
|
+
`project (COMPOSE_PROJECT_NAME), or wipe this volume, then retry.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
32
64
|
mkdirSync(opts.repoDir, { recursive: true });
|
|
33
65
|
git(["clone", opts.repoUrl, opts.repoDir], { stdio: "inherit" });
|
|
34
66
|
}
|
package/templates/.env.example
CHANGED
|
@@ -16,6 +16,12 @@ ANTHROPIC_API_KEY=
|
|
|
16
16
|
CURSOR_API_KEY=
|
|
17
17
|
OPENAI_KEY=
|
|
18
18
|
|
|
19
|
+
# --- One host, multiple repos (set this if so) ---
|
|
20
|
+
# Namespaces this instance's containers and named volumes. REQUIRED to be unique
|
|
21
|
+
# per repo when you run Phoebe for more than one repo on the same host —
|
|
22
|
+
# otherwise every instance shares one /data/repo clone and works the wrong repo.
|
|
23
|
+
# COMPOSE_PROJECT_NAME=phoebe-your-org-your-repo
|
|
24
|
+
|
|
19
25
|
# --- Runtime toggles (optional) ---
|
|
20
26
|
# PHOEBE_AGENT=claude
|
|
21
27
|
# PHOEBE_MODEL=claude-sonnet-4-6
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
# * /data/worktrees — Per-work-unit git worktrees.
|
|
23
23
|
# * /data/state — Lock, watermarks, logs, crash-loop record.
|
|
24
24
|
# * /data/engine — Engine checkouts, so a restart fetches instead of cloning.
|
|
25
|
+
#
|
|
26
|
+
# The workload runs as the unprivileged `phoebe` user, not root — see the
|
|
27
|
+
# privilege-drop block below for the two things that constrains.
|
|
25
28
|
|
|
26
29
|
FROM node:24-bookworm-slim
|
|
27
30
|
|
|
@@ -39,21 +42,89 @@ RUN apt-get update \
|
|
|
39
42
|
&& apt-get install -y --no-install-recommends gh \
|
|
40
43
|
&& rm -rf /var/lib/apt/lists/*
|
|
41
44
|
|
|
45
|
+
# The unprivileged user everything below the privilege drop runs as, and the
|
|
46
|
+
# four volume mount points, created *now* while we are still root.
|
|
47
|
+
#
|
|
48
|
+
# The ordering matters and is not cosmetic: Docker seeds a **fresh named volume
|
|
49
|
+
# from the image's contents at that path, ownership included**. If /data/repo
|
|
50
|
+
# does not exist in the image, Docker creates the mount point as root:root and
|
|
51
|
+
# the first `git clone` fails with EACCES. Creating and chowning them here is
|
|
52
|
+
# what makes the volumes writable after the drop.
|
|
53
|
+
#
|
|
54
|
+
# (`node:24` already ships a uid-1000 `node` user. We add our own so the name
|
|
55
|
+
# in `ps`, in log output, and on the volumes says what it is, and so the uid is
|
|
56
|
+
# stable if the base image ever renumbers `node`. The uid is a literal rather
|
|
57
|
+
# than a build ARG because nothing needs to vary it — everything Phoebe writes
|
|
58
|
+
# lives on named volumes, which take their ownership from this image, not from
|
|
59
|
+
# any host uid. Change it here if you have a reason to.)
|
|
60
|
+
RUN useradd --create-home --shell /bin/bash --uid 10001 --user-group phoebe \
|
|
61
|
+
&& mkdir -p /data/repo /data/worktrees /data/state /data/engine \
|
|
62
|
+
&& chown -R phoebe:phoebe /data
|
|
63
|
+
|
|
42
64
|
# The bootstrapper. Unpinned on purpose: which *engine* you run is `engine.ref`
|
|
43
65
|
# in phoebe.config.ts, and this package is only the thin launcher around it.
|
|
44
66
|
# Pin it (`{{CLI_BIN}}@<version>`) if you want the launcher itself frozen too.
|
|
67
|
+
#
|
|
68
|
+
# Installed as root into /usr/local, which `phoebe` only ever reads: at boot it
|
|
69
|
+
# copies itself out to PHOEBE_ENGINE_DIR (/data/engine, owned by us) before
|
|
70
|
+
# running anything, because Node 24 refuses to type-strip TypeScript under a
|
|
71
|
+
# `node_modules/` path segment.
|
|
45
72
|
RUN npm install -g {{CLI_BIN}}
|
|
46
73
|
|
|
47
74
|
# Provider agent CLI. The engine spawns the selected provider's CLI as a child
|
|
48
75
|
# (`agent` for cursor, `claude`, or `codex`), so the one matching your
|
|
49
76
|
# `defaultProvider` / `PHOEBE_AGENT` must be on PATH — otherwise the run dies
|
|
50
|
-
# with `spawn <cli> ENOENT`. Default below installs Cursor
|
|
51
|
-
# symlinked as `agent` into ~/.local/bin, which we add to PATH. Swap or add the
|
|
77
|
+
# with `spawn <cli> ENOENT`. Default below installs Cursor. Swap or add the
|
|
52
78
|
# commented lines for the provider you actually run.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
#
|
|
56
|
-
#
|
|
79
|
+
#
|
|
80
|
+
# This is deliberately NOT the `curl https://cursor.com/install | bash` one-liner
|
|
81
|
+
# Cursor documents. That pipes whatever the vendor serves at build time straight
|
|
82
|
+
# into a shell, so two builds of the same Dockerfile can produce different
|
|
83
|
+
# images and neither records which agent it got. Cursor publishes no checksums
|
|
84
|
+
# and no `latest` indirection, but its installer does resolve to a plain
|
|
85
|
+
# versioned artifact, which is what we pin to. The digests below are ours,
|
|
86
|
+
# computed on 2026-07-28 by fetching both architectures' tarballs from the URL
|
|
87
|
+
# below — they pin the build, they are not a vendor attestation.
|
|
88
|
+
#
|
|
89
|
+
# To bump: run the vendor installer once and read the version out of the
|
|
90
|
+
# DOWNLOAD_URL it echoes, then `sha256sum` the linux/x64 *and* linux/arm64
|
|
91
|
+
# tarballs. Both, even if you only build one — an unchecked digest for the other
|
|
92
|
+
# arch turns into a hard build failure the day someone builds on it.
|
|
93
|
+
ARG CURSOR_AGENT_VERSION=2026.07.23-e383d2b
|
|
94
|
+
ARG CURSOR_AGENT_SHA256_X64=702ad595213bee5df0268be9f80a19f29fcceaa2a42fc55e39f2b5199051f0c4
|
|
95
|
+
ARG CURSOR_AGENT_SHA256_ARM64=f40b99647cb24e0da885e97620a2048034f1fe8961910d573d827d77c4d26dcb
|
|
96
|
+
# Set by BuildKit; the fallback keeps the classic builder working.
|
|
97
|
+
ARG TARGETARCH
|
|
98
|
+
RUN set -eux; \
|
|
99
|
+
case "${TARGETARCH:-$(dpkg --print-architecture)}" in \
|
|
100
|
+
amd64) cursor_arch=x64; cursor_sha="${CURSOR_AGENT_SHA256_X64}" ;; \
|
|
101
|
+
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_AGENT_SHA256_ARM64}" ;; \
|
|
102
|
+
*) echo "cursor-agent publishes no build for ${TARGETARCH}" >&2; exit 1 ;; \
|
|
103
|
+
esac; \
|
|
104
|
+
curl -fsSL -o /tmp/cursor-agent.tar.gz \
|
|
105
|
+
"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
|
|
106
|
+
echo "${cursor_sha} /tmp/cursor-agent.tar.gz" | sha256sum -c -; \
|
|
107
|
+
mkdir -p /opt/cursor-agent; \
|
|
108
|
+
tar --strip-components=1 -xzf /tmp/cursor-agent.tar.gz -C /opt/cursor-agent; \
|
|
109
|
+
rm /tmp/cursor-agent.tar.gz; \
|
|
110
|
+
ln -s /opt/cursor-agent/cursor-agent /usr/local/bin/agent; \
|
|
111
|
+
ln -s /opt/cursor-agent/cursor-agent /usr/local/bin/cursor-agent
|
|
112
|
+
# Both symlink names on purpose: the engine spawns `agent`, but the vendor
|
|
113
|
+
# installer we replaced also created `cursor-agent`, and Cursor's own docs use
|
|
114
|
+
# that name. Dropping it would make this a silent behaviour regression for
|
|
115
|
+
# anyone whose scripts follow those docs.
|
|
116
|
+
#
|
|
117
|
+
# The installer would have put these in ~/.local/bin and needed a PATH entry
|
|
118
|
+
# pointing into a home directory. /usr/local/bin is already on PATH for every
|
|
119
|
+
# user, so the agent child resolves `agent` after the privilege drop with no
|
|
120
|
+
# PATH edit — and no root-owned home directory leaks into its env
|
|
121
|
+
# (src/agent-env.ts forwards PATH and HOME to the agent verbatim).
|
|
122
|
+
#
|
|
123
|
+
# Swapping providers? Pin these too — an unpinned `npm install -g` has the same
|
|
124
|
+
# problem as the installer pipe above, just with a lockfile-shaped hole instead
|
|
125
|
+
# of a shell-shaped one. Check the registry for the current version:
|
|
126
|
+
# Claude Code: RUN npm install -g @anthropic-ai/claude-code@<version>
|
|
127
|
+
# Codex: RUN npm install -g @openai/codex@<version>
|
|
57
128
|
|
|
58
129
|
# Consumer install command, kept as an ARG so it lands in the image metadata
|
|
59
130
|
# and can be inspected with `docker image inspect`. The engine re-runs the
|
|
@@ -63,9 +134,25 @@ LABEL phoebe.install-command="${PHOEBE_INSTALL_COMMAND}"
|
|
|
63
134
|
|
|
64
135
|
# Execution-gate marker: the engine refuses to mutate a clone, launch an agent,
|
|
65
136
|
# or push unless `/.phoebe-container` exists (src/execution-gate.ts). Without it
|
|
66
|
-
# the container refuses every non-dry-run unit.
|
|
137
|
+
# the container refuses every non-dry-run unit. Root-owned and read-only to us
|
|
138
|
+
# on purpose — the gate is only ever tested for existence, never written.
|
|
67
139
|
RUN touch /.phoebe-container
|
|
68
140
|
|
|
141
|
+
# The privilege drop. Everything past this line — `boot`, the engine, the agent
|
|
142
|
+
# child, and your repo's install/check/test commands — runs unprivileged.
|
|
143
|
+
#
|
|
144
|
+
# Two things this constrains:
|
|
145
|
+
# * Anything the workload writes must be under /data (the volumes above) or
|
|
146
|
+
# $HOME. That covers the package manager and provider CLI caches, which is
|
|
147
|
+
# why HOME is set explicitly rather than left to the runtime: the engine
|
|
148
|
+
# forwards HOME to the agent child, and an unwritable one breaks the
|
|
149
|
+
# provider CLI in ways that surface as unrelated errors.
|
|
150
|
+
# * Files you bind-mount in (phoebe.config.ts, prompts/) must be readable by
|
|
151
|
+
# *other* — the default 0644 from a git checkout is, but a 0600 file is not
|
|
152
|
+
# and boot will fail to read its config.
|
|
153
|
+
ENV HOME=/home/phoebe
|
|
154
|
+
USER phoebe
|
|
155
|
+
|
|
69
156
|
# Compose's `command:` fully replaces `CMD` (it does not append to
|
|
70
157
|
# `ENTRYPOINT`), so the whole `phoebe boot` invocation must live in
|
|
71
158
|
# `ENTRYPOINT` — otherwise a compose `command: ["--run-once"]` would strip the
|
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
# within a poll interval and relaunches at the next work-unit boundary. No
|
|
21
21
|
# rebuild and no restart; rebuild only when this image's toolchain changes.
|
|
22
22
|
|
|
23
|
+
# Project name — namespaces this instance's containers and named volumes. It
|
|
24
|
+
# defaults to `phoebe`, but if you run Phoebe for more than one repo on the same
|
|
25
|
+
# host you MUST make it unique per repo (set COMPOSE_PROJECT_NAME in `.env`).
|
|
26
|
+
# Compose otherwise derives the project from this file's parent directory —
|
|
27
|
+
# `container` — which is identical for every consumer, so the "private"
|
|
28
|
+
# /data/repo, /data/state, … volumes would be shared across repos. Set it and
|
|
29
|
+
# each repo gets its own `phoebe-<repo>_phoebe-repo` volume set.
|
|
30
|
+
name: ${COMPOSE_PROJECT_NAME:-phoebe}
|
|
31
|
+
|
|
23
32
|
services:
|
|
24
33
|
phoebe:
|
|
25
34
|
build:
|