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 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.0",
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
- /** Clone the repo into `repoDir` unless a clone already exists there. */
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"))) return;
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
  }
@@ -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: its executable is
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
- RUN curl -fsSL https://cursor.com/install | bash
54
- ENV PATH="/root/.local/bin:${PATH}"
55
- # Claude Code: RUN npm install -g @anthropic-ai/claude-code
56
- # Codex: RUN npm install -g @openai/codex
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: