From 71ca546c178b2e053ae8ab86c4e441fb2aacd8a4 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Sun, 2 Aug 2026 21:07:56 -0700 Subject: [PATCH] feat(cli): support Linux listener services Add a platform-neutral listener lifecycle backed by launchd on macOS and a systemd user unit on Linux. Document and validate an isolated non-root container listener workflow, and extend packed-consumer CI to Ubuntu. Co-Authored-By: OpenAI Codex --- .dockerignore | 8 ++ .github/workflows/ci.yml | 3 +- CHANGELOG.md | 16 ++- Dockerfile.listener | 44 ++++++ README.md | 107 ++++++++++---- compose.listener.yaml | 24 ++++ packages/cli/package.json | 3 +- packages/cli/src/commands/line.ts | 32 +++-- packages/cli/src/commands/rotate.ts | 10 +- packages/cli/src/doctor.ts | 52 ++++--- packages/cli/src/guard.ts | 7 +- packages/cli/src/index.ts | 20 +-- packages/cli/src/listener-service.ts | 130 ++++++++++++++++++ .../src/{launchPath.ts => listenerPath.ts} | 26 ++-- packages/cli/src/setup.ts | 38 ++--- packages/cli/src/systemd.ts | 109 +++++++++++++++ packages/cli/src/verify.ts | 6 +- packages/cli/test/bin.test.ts | 4 +- packages/cli/test/cli-actions.test.ts | 21 ++- packages/cli/test/container-listener.test.ts | 31 +++++ packages/cli/test/doctor.test.ts | 35 ++++- packages/cli/test/guard.test.ts | 8 ++ packages/cli/test/launchd.test.ts | 3 +- packages/cli/test/line-cmd.test.ts | 62 ++++----- packages/cli/test/listener-service.test.ts | 52 +++++++ ...aunchPath.test.ts => listenerPath.test.ts} | 18 +-- packages/cli/test/release-workflow.test.ts | 10 ++ packages/cli/test/rotate.test.ts | 12 ++ packages/cli/test/setup.test.ts | 114 +++++++++------ 29 files changed, 789 insertions(+), 216 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.listener create mode 100644 compose.listener.yaml create mode 100644 packages/cli/src/listener-service.ts rename packages/cli/src/{launchPath.ts => listenerPath.ts} (59%) create mode 100644 packages/cli/src/systemd.ts create mode 100644 packages/cli/test/container-listener.test.ts create mode 100644 packages/cli/test/listener-service.test.ts rename packages/cli/test/{launchPath.test.ts => listenerPath.test.ts} (85%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5f4a9fd7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.codex +.claude +node_modules +**/node_modules +**/dist +*.log +.dev.vars diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9cc8830..7dfad119 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,10 +53,11 @@ jobs: packed-cli-consumer: needs: verify - runs-on: macos-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [macos-latest, ubuntu-latest] node: [20, 22, 24] steps: # No checkout or pnpm: this job exercises only what npm users receive. diff --git a/CHANGELOG.md b/CHANGELOG.md index 809e6266..05cf6651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ which are released together. ## Unreleased +### Linux and isolated container listeners + +- Publish the CLI for Linux and install one restartable systemd user service + there, while keeping launchd as the macOS adapter behind the same listener + service interface. +- Teach setup, line management, uninstall, and doctor to use the active + platform adapter; replace the launchd-specific `--skip-launchd` option with + `--skip-service`. +- Add a source-built, non-root container listener that requires an exact agent + package version, keeps enrollment/authentication in an isolated named volume, + and mounts the selected project read-only by default. +- Test packed CLI installation on both macOS and Linux, and prevent Claude's + enforcing guard from rewriting Linux systemd user units. + ### Documentation — GTM sequencing and privacy positioning - Keep the first design-partner segment focused on non-EU, non-unionized @@ -73,7 +87,7 @@ which are released together. ### Added — Multiple lines: several agentcall addresses on one machine -A single Mac can now hold more than one agentcall address ("line"), each with its +A single machine can now hold more than one agentcall address ("line"), each with its own handle, relay token, agent kind (or none, for caller-only), policy, tasks, and working directory under `~/.agentcall/lines//`. One supervised process still runs — `agentcall listen` now opens one socket per callable line instead of diff --git a/Dockerfile.listener b/Dockerfile.listener new file mode 100644 index 00000000..22476d55 --- /dev/null +++ b/Dockerfile.listener @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 + +FROM node:24-bookworm-slim AS build +RUN corepack enable +WORKDIR /src + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY packages/shared/package.json packages/shared/package.json +COPY packages/cli/package.json packages/cli/package.json +RUN pnpm install --frozen-lockfile + +COPY packages/shared packages/shared +COPY packages/cli packages/cli +RUN pnpm -r build && \ + mkdir -p /packs && \ + pnpm --filter @benree/agentcall-shared pack --pack-destination /packs && \ + pnpm --filter @benree/agentcall pack --pack-destination /packs + +FROM node:24-bookworm-slim AS runtime + +# Require an exact, operator-reviewed agent package version. Leaving the +# answering agent at "latest" would make a listener restart an unreviewed +# security-boundary upgrade. +ARG AGENT_PACKAGE +RUN printf '%s\n' "$AGENT_PACKAGE" | \ + grep -Eq '^(@anthropic-ai/claude-code|@openai/codex)@[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' || \ + { echo >&2 "AGENT_PACKAGE must be an exact @anthropic-ai/claude-code@VERSION or @openai/codex@VERSION"; exit 2; } + +COPY --from=build /packs/*.tgz /tmp/agentcall-packages/ +RUN npm install --global \ + /tmp/agentcall-packages/benree-agentcall-shared-*.tgz \ + /tmp/agentcall-packages/benree-agentcall-[0-9]*.tgz \ + "$AGENT_PACKAGE" && \ + rm -rf /tmp/agentcall-packages /root/.npm + +RUN groupadd --gid 10001 agentcall && \ + useradd --uid 10001 --gid 10001 --create-home --home-dir /home/agentcall agentcall && \ + chown -R agentcall:agentcall /home/agentcall + +ENV HOME=/home/agentcall \ + AGENTCALL_HOME=/home/agentcall +WORKDIR /home/agentcall +USER agentcall +ENTRYPOINT ["agentcall", "listen"] diff --git a/README.md b/README.md index d927e96f..f239b8c0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # agentcall -Call another person's coding agent (Claude Code or Codex) on their Mac, across the +Call another person's coding agent (Claude Code or Codex) on their machine, across the public internet, like a phone call. Install with one command, get an address -(`ken@acme.agentcall.benree.tech`), share it. When someone calls your address, your Mac +(`ken@acme.agentcall.benree.tech`), share it. When someone calls your address, your machine spawns an agent that answers, even while you're away. ## How a call works @@ -12,7 +12,7 @@ sequenceDiagram participant A as A's Claude Code participant CLI as agentcall call (A's Mac) participant Relay as Cloudflare Worker + DO - participant L as agentcall listen (B's Mac, LaunchAgent) + participant L as agentcall listen (B's machine, supervised) participant Agent as claude -p / codex exec A->>CLI: agentcall call ken@acme.agentcall.benree.tech "msg" @@ -34,8 +34,8 @@ understands `call_answer`, so it never emits `call_status answered` today; the caller-facing `answered` status is dark until the relay picks up the new frames. -Non-goals for v1: store-and-forward, non-macOS platforms, anonymous callers, -payment/reputation. +Non-goals for v1: store-and-forward, Windows listener installation, anonymous +callers, payment/reputation. ## Install @@ -65,7 +65,9 @@ is not configured. handle, token, agent kind, and relay URL — `` defaults to the agent kind (e.g. `claude`); see "Several agents, several addresses" below for adding more - create `~/AgentCall//public/`, the callee agent's working directory -- install and load the `tech.benree.agentcall.listener` LaunchAgent +- install and start one background listener: the + `tech.benree.agentcall.listener` LaunchAgent on macOS or + `agentcall-listener.service` systemd user unit on Linux - offer to append a short usage snippet to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` so *your own* agent knows how to call other people - print your address, e.g. `ken@acme.agentcall.benree.tech` @@ -73,6 +75,61 @@ is not configured. Setup verifies by default that your agent — claude or codex — can actually answer a call, including that it's authenticated. Pass `--no-verify` to skip the post-setup test call (e.g. when provisioning before logging in). +Pass `--skip-service` only when another supervisor, such as a container +runtime, will own the foreground `agentcall listen` process. + +### Linux listener + +The npm package supports Linux as well as macOS. On Linux, callable setup writes +`~/.config/systemd/user/agentcall-listener.service`, enables it, and restarts it +through `systemctl --user`. The unit has the same one-process/many-lines model as +the macOS LaunchAgent, restarts on failure, and appends stdout/stderr to +`~/.agentcall/listener.log`. + +For a headless account, make sure its systemd user manager survives logout. The +usual host-level configuration is `loginctl enable-linger `; whether users +may enable lingering themselves is an administrator policy. Diagnose either +platform with `agentcall doctor`. `agentcall uninstall` stops and removes the +active platform's listener definition. + +### Container listener + +[`Dockerfile.listener`](./Dockerfile.listener) builds AgentCall from this checkout +and installs one exact, operator-reviewed Claude Code or Codex package version. +[`compose.listener.yaml`](./compose.listener.yaml) runs `agentcall listen` directly +as a non-root process; it does not run systemd inside the container. + +The container deliberately gets a new named home volume. It does **not** mount +the host's existing `.agentcall`, `.claude`, or `.codex` credentials. Enroll and +authenticate inside that isolated volume, then start the listener: + +```bash +export AGENTCALL_AGENT_PACKAGE='@openai/codex@' # or @anthropic-ai/claude-code@ +export AGENTCALL_WORKDIR=/absolute/path/to/project + +docker compose -f compose.listener.yaml build +docker compose -f compose.listener.yaml run --rm --entrypoint codex listener login +docker compose -f compose.listener.yaml run --rm --entrypoint agentcall listener \ + setup --skip-service --invite --agent codex --handle +docker compose -f compose.listener.yaml up -d +``` + +For Claude, select an exact `@anthropic-ai/claude-code` package version, then +authenticate interactively and complete `/login` inside the Claude session: + +```bash +docker compose -f compose.listener.yaml run --rm --entrypoint claude listener +# At the Claude prompt: /login +docker compose -f compose.listener.yaml run --rm --entrypoint agentcall listener \ + setup --skip-service --invite --agent claude --handle +docker compose -f compose.listener.yaml up -d +``` + +The selected project is mounted read-only by default. That supports answering and +review tasks without widening the host write boundary; remove `:ro` from the +workdir mount only after deliberately granting write-capable tasks. Keep the +named home volume backed up according to the same credential policy as a native +installation. Handles are unique within an organization, not globally: Acme and Beta can both register `ken`. Hosted addresses carry the tenant in the hostname @@ -314,11 +371,11 @@ listener from starting. Hot edits are also rechecked before every call. ## Several agents, several addresses -One Mac can hold more than one address — one per "line". A line is a full +One machine can hold more than one address — one per "line". A line is a full identity: its own handle, relay token, agent kind (or none, if it's caller-only), policy, tasks, and working directory, stored under `~/.agentcall/lines//`. -One process (`agentcall listen`, run for you by the LaunchAgent) opens one socket -per callable line, so a single Mac can answer as `ken@...` on one address and +One supervised process (`agentcall listen`) opens one socket per callable line, +so a single machine can answer as `ken@...` on one address and `ken-codex@...` on another at the same time. ```bash @@ -345,14 +402,14 @@ skips the post-registration test call, same as `setup --no-verify`. `--invite` i required: every line enrols in its own organization, so a second line needs its own invite even on a machine that already has one — it may be joining a different tenant entirely, and only the relay can say which. Like `line -remove` below, a callable `line add` reinstalls the LaunchAgent afterward -(`--skip-launchd` to skip it) — since one process serves every line, adding one +remove` below, a callable `line add` reinstalls the platform listener service +afterward (`--skip-service` to skip it) — since one process serves every line, adding one briefly drops every other line's socket and any calls in flight on them too. `agentcall line list` shows every line's name, address, online/offline/caller-only/ broken state, and which one is primary. `agentcall line remove --yes` archives that line's `calls.log` under `~/.agentcall/removed/` (or deletes it -outright with `--purge`) and reinstalls the LaunchAgent to stop serving it — the +outright with `--purge`) and reinstalls the listener service to stop serving it — the `--yes` isn't a formality: **handle release isn't implemented (see Limitations), so a removed handle is gone for good, not freed for reuse.** You can't remove your only line or the current primary; promote another first with @@ -492,9 +549,10 @@ and never leave your machine. ## How the callee side works -- `agentcall listen` runs continuously as a LaunchAgent (`KeepAlive`, - `RunAtLoad`, logs to `~/.agentcall/listener.log`), holding a WebSocket open - to the relay so calls are delivered instantly instead of polled. +- `agentcall listen` runs continuously under launchd on macOS or a systemd user + unit on Linux (or directly under the container runtime), logs to + `~/.agentcall/listener.log` for native services, and holds a WebSocket open to + the relay so calls are delivered instantly instead of polled. - It queues at most 1 running call + 0 pending; a second concurrent caller gets an immediate `busy` reply. With a 5-minute agent timeout running against a 6-minute relay deadline, a queued call would not have enough @@ -695,7 +753,7 @@ organization administrator, and the relay operator can see—read the write-only call cannot rewrite an already-offered task's capability envelope, which is read verbatim from frontmatter — not just the answering line's own tasks, since a caller could otherwise widen a *different* - line's grants), to `~/Library/LaunchAgents`, and to shell startup files + line's grants), to `~/Library/LaunchAgents`, to `~/.config/systemd/user`, and to shell startup files (`.zshrc` and friends). This risk remains live via `exec` and on a Codex answering agent, which has no read guard. - `~/.codex` is refused for a Claude answering agent, but a **Codex** @@ -706,7 +764,7 @@ organization administrator, and the relay operator can see—read the they run. File reads, writes, searches, and listings that reach credential paths (`~/.ssh`, `~/.aws`, `.env`, Keychains, `~/.agentcall`, `~/.claude`, `~/.codex`), the guard's own installed code, `~/AgentCall//tasks` for every line, `~/Library/LaunchAgents`, -and shell startup files are refused. For Claude, file-shaped tools outside the +`~/.config/systemd/user`, and shell startup files are refused. For Claude, file-shaped tools outside the resolved task workdir are also refused. Every tool call reaching the guard is recorded to that line's `~/.agentcall/lines//tools.log`; on verified codex-cli 0.146.0, Codex runs the same hook in observe-only mode so long as @@ -881,16 +939,17 @@ telemetry health a call-health requirement. See the [observability boundary](./docs/superpowers/specs/2026-08-02-observability-boundary.md) for the exact span, metric, privacy, and Cloudflare separation contracts. -Platform installers are deliberately deferred. AgentCall will keep its current -Commander CLI until the non-macOS service/container work (#14) defines the -artifacts each platform needs and managed policy (#104) defines who controls -versions and updates. A future self-update mechanism must be disabled whenever -managed policy is present so IT can pin the deployed version. +Signed platform installers remain deliberately deferred. Linux systemd and the +container runtime now define the service artifacts, but managed policy (#104) +still needs to define who controls versions and updates before AgentCall adopts +an installer framework or self-update mechanism. Any future self-update must be +disabled whenever managed policy is present so IT can pin the deployed version. ## Limitations -- **macOS only.** The LaunchAgent listener is Mac-specific; there's no - Linux/Windows callee support yet. +- **No Windows listener installer.** Native background listeners are supported + on macOS (launchd) and Linux (systemd user service); Windows remains + unsupported. Containers run the Linux listener in the foreground. - **The relay operator sees message plaintext.** Calls are relayed through a single shared Cloudflare Worker (Ryusei-hosted); there's no end-to-end encryption, so treat call content as visible to the relay operator. diff --git a/compose.listener.yaml b/compose.listener.yaml new file mode 100644 index 00000000..e4370b84 --- /dev/null +++ b/compose.listener.yaml @@ -0,0 +1,24 @@ +services: + listener: + build: + context: . + dockerfile: Dockerfile.listener + args: + AGENT_PACKAGE: ${AGENTCALL_AGENT_PACKAGE:?Set AGENTCALL_AGENT_PACKAGE to an exact supported package version} + init: true + restart: unless-stopped + environment: + HOME: /home/agentcall + AGENTCALL_HOME: /home/agentcall + working_dir: ${AGENTCALL_WORKDIR:?Set AGENTCALL_WORKDIR to the absolute host project path} + volumes: + # Enrollment and agent login happen inside the container. This named + # volume deliberately does not import the host's AgentCall/Claude/Codex + # credentials into a new execution boundary. + - agentcall-listener-home:/home/agentcall + # Read-only is the safe default. An owner who deliberately grants write + # tasks can remove :ro after reviewing that larger host boundary. + - ${AGENTCALL_WORKDIR}:${AGENTCALL_WORKDIR}:ro + +volumes: + agentcall-listener-home: diff --git a/packages/cli/package.json b/packages/cli/package.json index 2e7b89a6..00cbb0b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,7 +15,8 @@ "node": ">=20" }, "os": [ - "darwin" + "darwin", + "linux" ], "publishConfig": { "access": "public" diff --git a/packages/cli/src/commands/line.ts b/packages/cli/src/commands/line.ts index ba1b6b6b..3e87fbf4 100644 --- a/packages/cli/src/commands/line.ts +++ b/packages/cli/src/commands/line.ts @@ -5,12 +5,12 @@ import { registerHandle } from "../api.js"; import { publishCard } from "../card.js"; import { resolveLineWorkdir, type LineConfig } from "../config.js"; import { assertValidLineName, listLines, readyLines, saveLineConfig } from "../lines.js"; -import { launchPathDirs } from "../launchPath.js"; +import { listenerPathDirs } from "../listenerPath.js"; import { host } from "../outbound.js"; import { getLinePaths, type LinePaths, type MachinePaths } from "../paths.js"; import { loadPerson, resolvePrimary, savePerson } from "../person.js"; import { DEFAULT_POLICY } from "../policy.js"; -import { uninstallLaunchAgent, installLaunchAgent } from "../launchd.js"; +import { installListenerService, uninstallListenerService } from "../listener-service.js"; import { formatCheck, verifyAgent, type VerifyFns } from "../verify.js"; export interface AddLineOpts { @@ -40,16 +40,16 @@ export interface AddLineOpts { // undefined without constructing a full card upload. register?: typeof registerHandle; publishCardFn?: (cfg: LineConfig, p: LinePaths) => Promise; - installLaunchAgentFn?: typeof installLaunchAgent; + installListenerServiceFn?: typeof installListenerService; // Dirs (an agent/npx binary resolved outside launchd's fixed base PATH) to - // prepend to the LaunchAgent's plist PATH. Defaults to launchPathDirs(m, + // prepend to the listener service's PATH. Defaults to listenerPathDirs(m, // resolveBin) — derived from every ready line on the machine, including // the one this call just wrote to disk — rather than requiring the caller // to compute and pass it. Explicit values here are a test seam only; a // real caller has no reason to override the derived answer. extraPathDirs?: string[]; // Only consulted when extraPathDirs is absent, and only as an input to - // launchPathDirs's own derivation — see there for the default. + // listenerPathDirs's own derivation — see there for the default. resolveBin?: (name: string) => string | null; } @@ -112,9 +112,11 @@ export async function addLine(m: MachinePaths, opts: AddLineOpts): Promise<{ add ); } // saveLineConfig above already put this line's config on disk, so - // launchPathDirs (which reads readyLines(m)) sees it — the derived PATH + // listenerPathDirs (which reads readyLines(m)) sees it — the derived PATH // covers this line's agent kind alongside every other ready line's. - (opts.installLaunchAgentFn ?? installLaunchAgent)(m, undefined, opts.extraPathDirs ?? launchPathDirs(m, opts.resolveBin)); + (opts.installListenerServiceFn ?? installListenerService)(m, { + extraPathDirs: opts.extraPathDirs ?? listenerPathDirs(m, opts.resolveBin), + }); // Verification is best-effort feedback, not a gate: the handle is already // spent (see above), so a failed verify warns rather than throwing — @@ -144,15 +146,15 @@ export async function addLine(m: MachinePaths, opts: AddLineOpts): Promise<{ add export interface RemoveLineOpts { confirm?: boolean; purge?: boolean; - uninstallFn?: typeof uninstallLaunchAgent; + uninstallFn?: typeof uninstallListenerService; // Separate from uninstallFn: the reinstall branch below (readyLines still // has a callable line) calls install, not uninstall, and a test that only - // stubs uninstallFn must not fall through to the real installLaunchAgent — + // stubs uninstallFn must not fall through to the real listener installer — // that shells out to the actual `launchctl bootstrap` on the real user's // launchd session regardless of how sandboxed MachinePaths.userHome is. - installFn?: typeof installLaunchAgent; + installFn?: typeof installListenerService; // Same seam as AddLineOpts.resolveBin — feeds the reinstall branch's - // launchPathDirs derivation. + // listenerPathDirs derivation. resolveBin?: (name: string) => string | null; } @@ -213,13 +215,13 @@ export function removeLine(m: MachinePaths, name: string, opts: RemoveLineOpts = // unloading a per-line service. Reinstalling the single agent is how that // happens; skip it when nothing callable is left. The target's directory // is already gone/archived above, so readyLines(m) here reflects the - // surviving lines only — launchPathDirs derives their PATH dirs, not the + // surviving lines only — listenerPathDirs derives their PATH dirs, not the // removed line's, and not an empty list that would clobber them. if (readyLines(m).some((l) => l.config.agent_kind)) { - (opts.uninstallFn ?? uninstallLaunchAgent)(m); - (opts.installFn ?? installLaunchAgent)(m, undefined, launchPathDirs(m, opts.resolveBin)); + (opts.uninstallFn ?? uninstallListenerService)(m); + (opts.installFn ?? installListenerService)(m, { extraPathDirs: listenerPathDirs(m, opts.resolveBin) }); } else { - (opts.uninstallFn ?? uninstallLaunchAgent)(m); + (opts.uninstallFn ?? uninstallListenerService)(m); } } diff --git a/packages/cli/src/commands/rotate.ts b/packages/cli/src/commands/rotate.ts index b10a8d72..06727732 100644 --- a/packages/cli/src/commands/rotate.ts +++ b/packages/cli/src/commands/rotate.ts @@ -1,12 +1,13 @@ import { rotateToken } from "../api.js"; import { relayUrl } from "../config.js"; -import { LAUNCH_LABEL } from "../launchd.js"; import type { LineContext } from "../lineContext.js"; +import { listenerServiceRestartCommand } from "../listener-service.js"; import { saveLineConfig } from "../lines.js"; export interface RotateDeps { rotate?: typeof rotateToken; log?: (line: string) => void; + platform?: NodeJS.Platform; } // One line's token, rewritten in place. The multi-line listener (Task 8) @@ -27,12 +28,15 @@ export async function rotateLine(ctx: LineContext, deps: RotateDeps = {}): Promi // about a listener this line doesn't have. Pre-lines code guarded this with // `else if (cfg.agent_kind)`; only print it for a line that can actually be // listening. + const restartCommand = listenerServiceRestartCommand(deps.platform); + const backgroundGuidance = restartCommand + ? `, or for the background one, \`${restartCommand}\`` + : ""; const listenerGuidance = ctx.config.agent_kind ? `, but this line's listener won't use the new one until its next reconnect — other lines are ` + `unaffected either way.\n` + `If the old token may have leaked, restart the listener now to force it off the relay immediately ` + - `instead of waiting for that reconnect: \`agentcall listen\` in the foreground, or for the background ` + - `one, \`launchctl kickstart -k gui/$UID/${LAUNCH_LABEL}\`.` + `instead of waiting for that reconnect: \`agentcall listen\` in the foreground${backgroundGuidance}.` : "."; log(`Token rotated for line "${ctx.name}" (${ctx.config.handle}). The old token is invalid for new connections immediately${listenerGuidance}`); } diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 6b9f7e01..bc06f6a7 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -1,8 +1,10 @@ -import { execFileSync } from "node:child_process"; import { getStatus } from "./api.js"; import { callAgent } from "./callClient.js"; import { relayUrl, resolveLineWorkdir, type LineConfig, type Workdir } from "./config.js"; -import { isLaunchAgentInstalled, LAUNCH_LABEL } from "./launchd.js"; +import { + inspectListenerService, + type ListenerServiceStatus, +} from "./listener-service.js"; import { listLines } from "./lines.js"; import type { MachinePaths } from "./paths.js"; import type { AgentKind } from "./runner.js"; @@ -19,17 +21,14 @@ export interface DoctorDeps { verifyFns?: VerifyFns; getStatusFn?: typeof getStatus; callFn?: typeof callAgent; - launchctlList?: () => string; - isDarwin?: boolean; + platform?: NodeJS.Platform; + inspectListenerServiceFn?: (machine: MachinePaths) => ListenerServiceStatus; log?: (line: string) => void; guardFn?: GuardProbeFn; guardBinaryFn?: GuardBinaryProbeFn; codexGuardFn?: CodexGuardProbeFn; } -const defaultLaunchctlList = () => - execFileSync("launchctl", ["list"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - // Verifies every line on this install can answer calls, printing one line // per check under a `line ` header for each. Ladder semantics (see the // design spec): static checks are informational and never block the agent @@ -47,36 +46,35 @@ export async function runDoctor(deps: DoctorDeps): Promise { log(formatCheck(c)); }; - // Machine-level, once: there is one plist and one supervised process - // serving every line, so a per-line launchd check would be meaningless + // Machine-level, once: there is one supervisor artifact and one process + // serving every line, so a per-line service check would be meaningless // (and would misreport N-1 lines as broken whenever the listener is down). - if (deps.isDarwin ?? process.platform === "darwin") { - let loaded = false; - try { - loaded = (deps.launchctlList ?? defaultLaunchctlList)().includes(LAUNCH_LABEL); - } catch { - loaded = false; - } + const platform = deps.platform ?? process.platform; + if (platform === "darwin" || platform === "linux") { + const status = (deps.inspectListenerServiceFn ?? ((machine) => + inspectListenerService(machine, { platform })))(deps.machine); report({ - name: "background listener (launchd)", - ok: loaded, - hint: loaded ? undefined : "re-run `agentcall setup` to install it, or run `agentcall listen` in a terminal", + name: `background listener (${status.kind})`, + ok: status.running, + hint: status.running ? undefined : "re-run `agentcall setup` to install it, or run `agentcall listen` in a terminal", }); // Diagnostic only, never fatal on its own: this distinguishes "setup // never installed the plist" from "it's installed but not currently // loaded" (e.g. someone ran `launchctl bootout` by hand). Both explain // the same failed check above, so this must not double-count it. - if (!loaded) { - const installed = isLaunchAgentInstalled(deps.machine); + if (!status.running) { + const artifact = status.kind === "launchd" ? "launch agent plist" : "systemd user unit"; report({ - name: "launch agent plist", + name: artifact, ok: true, warn: true, - detail: installed - ? "plist file exists but is not currently loaded" - : "plist file was never installed", - hint: installed - ? "try `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/tech.benree.agentcall.listener.plist`, or re-run `agentcall setup`" + detail: status.installed + ? `${status.kind === "launchd" ? "plist" : "unit"} file exists but the listener is not running` + : `${status.kind === "launchd" ? "plist" : "unit"} file was never installed`, + hint: status.installed + ? status.kind === "launchd" + ? "run `launchctl kickstart gui/$(id -u)/tech.benree.agentcall.listener`, or re-run `agentcall setup`" + : "run `systemctl --user restart agentcall-listener.service`, or re-run `agentcall setup`" : "run `agentcall setup`", }); } diff --git a/packages/cli/src/guard.ts b/packages/cli/src/guard.ts index 2786421f..5fb9195a 100644 --- a/packages/cli/src/guard.ts +++ b/packages/cli/src/guard.ts @@ -46,6 +46,7 @@ const DENIED_DIRS = [ ".claude", // executable configuration; cf. CVE-2025-59536 ".codex", // auth.json, plus a config.toml that routinely holds API keys "Library/LaunchAgents", // how the listener itself gets launched + ".config/systemd/user", // Linux user units can replace the listener command // Legacy flat layout. As of Task 12, nothing in this codebase reads or // writes this path anymore — card.ts, index.ts, and lint.ts all moved to // the per-line AgentCall//tasks layout, and setup.ts no longer @@ -111,9 +112,9 @@ const NO_PATH_SURFACE = new Set(["WebSearch"]); // can climb out of that root entirely. LS has no selector. const SELECTOR_KEY: Record = { Grep: "glob", Glob: "pattern" }; -// package.json pins os: ["darwin"], and the default macOS filesystem is -// case-INsensitive — ~/.SSH opens ~/.ssh. Folding can over-deny on a -// case-sensitive volume, which is the safe direction for a floor. +// The default macOS filesystem is case-INsensitive — ~/.SSH opens ~/.ssh. +// Linux is commonly case-sensitive; folding can over-deny there, which is the +// safe direction for a security floor shared by both supported platforms. const fold = (p: string) => p.toLowerCase(); // Denied roots are canonicalized alongside the targets they get compared with. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 56f9f2b9..91652c54 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -11,7 +11,7 @@ import { getStatus, fetchCard, createInvite, listInvites, revokeInvite, createRo import { startAllListeners } from "./listenAll.js"; import { startListener } from "./listener.js"; import { runSetup } from "./setup.js"; -import { uninstallLaunchAgent } from "./launchd.js"; +import { uninstallListenerService } from "./listener-service.js"; import { publishCard } from "./card.js"; import { loadPolicy, loadUserPolicy, savePolicy, validatePolicy } from "./policy.js"; import { assertValidLineName, loadLineConfig, readyLines } from "./lines.js"; @@ -48,7 +48,7 @@ program .option("--agent ", "agent kind: claude or codex (auto-detected if omitted)") .option("--relay ", "relay URL to register against") .option("--no-snippet", "skip appending the agentcall usage snippet to CLAUDE.md/AGENTS.md") - .option("--skip-launchd", "skip installing the launchd background listener") + .option("--skip-service", "skip installing the background listener service") .option("--caller-only", "register a handle to call others without making your own agent callable") .option("--no-verify", "skip verifying the agent can answer a test call") .action( @@ -58,7 +58,7 @@ program agent?: string; relay?: string; snippet?: boolean; - skipLaunchd?: boolean; + skipService?: boolean; callerOnly?: boolean; verify?: boolean; }) => { @@ -68,7 +68,7 @@ program agent: o.agent as AgentKind | undefined, relay: o.relay, snippet: o.snippet, - skipLaunchd: o.skipLaunchd, + skipService: o.skipService, callerOnly: o.callerOnly, verify: o.verify, }); @@ -982,12 +982,12 @@ line .option("--agent ", "agent kind: claude or codex (omit with --caller-only)") .option("--relay ", "relay URL to register against") .option("--caller-only", "register a handle to call others without making this line's agent callable") - .option("--skip-launchd", "skip reinstalling the background listener") + .option("--skip-service", "skip reinstalling the background listener service") .option("--no-verify", "skip verifying the agent can answer a test call") .action( async ( name: string, - o: { handle?: string; invite?: string; agent?: string; relay?: string; callerOnly?: boolean; skipLaunchd?: boolean; verify?: boolean }, + o: { handle?: string; invite?: string; agent?: string; relay?: string; callerOnly?: boolean; skipService?: boolean; verify?: boolean }, ) => { const machine = getMachinePaths(); if (!o.callerOnly && o.agent !== "claude" && o.agent !== "codex") { @@ -1032,7 +1032,7 @@ line agent: o.callerOnly ? undefined : (o.agent as AgentKind), callerOnly: o.callerOnly, verify: o.verify, - installLaunchAgentFn: o.skipLaunchd ? () => {} : undefined, + installListenerServiceFn: o.skipService ? () => {} : undefined, }); console.log(`Added line "${name}": ${address}`); } catch (e) { @@ -1114,7 +1114,7 @@ line program .command("listen") - .description("run the foreground listener (launchd runs this in the background after setup)") + .description("run the foreground listener (the platform service runs this after setup)") .option("--line ", "run only this line instead of every callable line") .action((o: { line?: string }) => { const machine = getMachinePaths(); @@ -1179,7 +1179,7 @@ program // The multi-line listener (Task 8) re-reads each line's config.json on // every reconnect, so a running listener — foreground or under launchd — // picks up the new token on its own; no restart needed here. This is - // what replaced main's explicit installLaunchAgent() restart: the + // what replaced main's explicit installListenerService() restart: the // restart existed only because the old single listener read its token // once at startup. await rotateLine(ctx); @@ -1195,7 +1195,7 @@ program .option("--purge", "also delete ~/.agentcall (config, token, logs)") .action((o: { purge?: boolean }) => { const machine = getMachinePaths(); - uninstallLaunchAgent(machine); + uninstallListenerService(machine); if (o.purge) rmSync(machine.dir, { recursive: true, force: true }); console.log("agentcall listener removed." + (o.purge ? " Config purged." : "")); }); diff --git a/packages/cli/src/listener-service.ts b/packages/cli/src/listener-service.ts new file mode 100644 index 00000000..99a48249 --- /dev/null +++ b/packages/cli/src/listener-service.ts @@ -0,0 +1,130 @@ +import { execFileSync } from "node:child_process"; +import { + installLaunchAgent, + isLaunchAgentInstalled, + LAUNCH_LABEL, + launchAgentFile, + uninstallLaunchAgent, +} from "./launchd.js"; +import type { MachinePaths } from "./paths.js"; +import { + installSystemdService, + isSystemdServiceInstalled, + type ServiceExec, + SYSTEMD_UNIT, + systemdServiceFile, + uninstallSystemdService, +} from "./systemd.js"; + +export interface ListenerServiceOptions { + platform?: NodeJS.Platform; + execCmd?: ServiceExec; + extraPathDirs?: string[]; +} + +export interface ListenerServiceStatus { + kind: "launchd" | "systemd"; + installed: boolean; + running: boolean; +} + +type QueryService = (command: string[]) => string; + +interface ListenerServiceAdapter { + kind: ListenerServiceStatus["kind"]; + file: (machine: MachinePaths) => string; + isInstalled: (machine: MachinePaths) => boolean; + install: (machine: MachinePaths, exec?: ServiceExec, extraPathDirs?: string[]) => void; + uninstall: (machine: MachinePaths, exec?: ServiceExec) => void; + restartCommand: string; + isRunning: (query: QueryService) => boolean; +} + +const ADAPTERS: Partial> = { + darwin: { + kind: "launchd", + file: launchAgentFile, + isInstalled: isLaunchAgentInstalled, + install: installLaunchAgent, + uninstall: uninstallLaunchAgent, + restartCommand: `launchctl kickstart -k gui/$UID/${LAUNCH_LABEL}`, + isRunning: (query) => query(["launchctl", "list"]).includes(LAUNCH_LABEL), + }, + linux: { + kind: "systemd", + file: systemdServiceFile, + isInstalled: isSystemdServiceInstalled, + install: installSystemdService, + uninstall: uninstallSystemdService, + restartCommand: `systemctl --user restart ${SYSTEMD_UNIT}`, + isRunning: (query) => query(["systemctl", "--user", "is-active", SYSTEMD_UNIT]).trim() === "active", + }, +}; + +function platformFrom(options?: ListenerServiceOptions): NodeJS.Platform { + return options?.platform ?? process.platform; +} + +function unsupported(platform: NodeJS.Platform): never { + throw new Error( + `Background listener installation is not supported on ${platform}. ` + + "Run `agentcall listen` under your platform's process supervisor instead.", + ); +} + +function adapterFor(platform: NodeJS.Platform): ListenerServiceAdapter { + return ADAPTERS[platform] ?? unsupported(platform); +} + +export function listenerServiceRestartCommand(platform: NodeJS.Platform = process.platform): string | null { + return ADAPTERS[platform]?.restartCommand ?? null; +} + +export function listenerServiceFile( + machine: MachinePaths, + platform: NodeJS.Platform = process.platform, +): string { + return adapterFor(platform).file(machine); +} + +export function isListenerServiceInstalled( + machine: MachinePaths, + platform: NodeJS.Platform = process.platform, +): boolean { + return ADAPTERS[platform]?.isInstalled(machine) ?? false; +} + +export function installListenerService( + machine: MachinePaths, + options: ListenerServiceOptions = {}, +): void { + adapterFor(platformFrom(options)).install(machine, options.execCmd, options.extraPathDirs); +} + +export function uninstallListenerService( + machine: MachinePaths, + options: Omit = {}, +): void { + adapterFor(platformFrom(options)).uninstall(machine, options.execCmd); +} + +export function inspectListenerService( + machine: MachinePaths, + options: { platform?: NodeJS.Platform; query?: QueryService } = {}, +): ListenerServiceStatus { + const platform = options.platform ?? process.platform; + const query = options.query ?? ((command: string[]) => { + return execFileSync(command[0]!, command.slice(1), { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + }); + const adapter = adapterFor(platform); + let running = false; + try { + running = adapter.isRunning(query); + } catch { + running = false; + } + return { kind: adapter.kind, installed: adapter.isInstalled(machine), running }; +} diff --git a/packages/cli/src/launchPath.ts b/packages/cli/src/listenerPath.ts similarity index 59% rename from packages/cli/src/launchPath.ts rename to packages/cli/src/listenerPath.ts index 6522269d..71f2221c 100644 --- a/packages/cli/src/launchPath.ts +++ b/packages/cli/src/listenerPath.ts @@ -6,10 +6,10 @@ import { readyLines } from "./lines.js"; import type { MachinePaths } from "./paths.js"; // Dirnames of the resolved bins, deduped and skipping any that failed to -// resolve. Used to widen the LaunchAgent's PATH (see launchd.ts) so the -// listener can find an agent/npx install that lives outside its base dirs. -// Ephemeral dirs (see EPHEMERAL_ROOTS) are dropped even if that's where the -// bin resolved — a PATH entry into temp is wrong in a persistent LaunchAgent. +// resolve. Used to widen the listener service's PATH so it can find an +// agent/npx install that lives outside its base dirs. Ephemeral dirs (see +// EPHEMERAL_ROOTS) are dropped even if that's where the bin resolved — a PATH +// entry into temp is wrong in a persistent listener service. export function resolveExtraPathDirs(names: string[], resolveBin: (name: string) => string | null): string[] { const dirs = names .map((name) => resolveBin(name)) @@ -29,17 +29,13 @@ export function defaultResolveBin(name: string): string | null { } // One process serves every line (see listenAll.ts), so there is exactly one -// LaunchAgent plist and its PATH has to cover every callable line's agent -// binary, not just whichever line happened to be added first or most -// recently. Computing this per-caller (as setup used to, for its own -// agentKind only) silently drops coverage the moment a second line runs a -// different agent — this derives it fresh from machine state instead, so -// every installLaunchAgent call site (setup -> addLine, addLine directly, -// removeLine's reinstall) gets the same, complete answer without having to -// individually track what's already installed. `agentcall rotate` used to be -// a call site too; Task 12 removed rotate's own installLaunchAgent call, so -// only commands/line.ts's addLine and removeLine remain. -export function launchPathDirs( +// listener service definition and its PATH has to cover every callable line's +// agent binary, not just whichever line happened to be added first or most +// recently. Computing this per-caller (as setup used to, for its own agentKind +// only) silently drops coverage the moment a second line runs a different agent +// — this derives it fresh from machine state instead, so every +// installListenerService call site gets the same, complete answer. +export function listenerPathDirs( m: MachinePaths, resolveBin: (name: string) => string | null = defaultResolveBin, ): string[] { const kinds = new Set(); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 92202730..d8e9443f 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -7,18 +7,13 @@ import { resolveLine } from "./lineContext.js"; import { getMachinePaths } from "./paths.js"; import { ask as ttyAsk } from "./tty.js"; import { addressHost, relayUrl, resolveLineWorkdir, type LineConfig } from "./config.js"; -import { defaultResolveBin } from "./launchPath.js"; +import { defaultResolveBin, listenerPathDirs } from "./listenerPath.js"; +import { isEphemeralDir } from "./bin.js"; import { host } from "./outbound.js"; import { appendSnippet } from "./snippet.js"; -import { installLaunchAgent } from "./launchd.js"; +import { installListenerService } from "./listener-service.js"; import { formatCheck, verifyAgent, type VerifyCheck, type VerifyFns } from "./verify.js"; -// Directories launchd's fixed PATH (see launchd.ts's plistContent) actually -// searches. If claude/codex/npx resolve outside of these, the background -// listener won't find them even though an interactive shell (with nvm/fnm -// on PATH) does. -const LAUNCHD_PATH_DIRS = ["/opt/homebrew/bin", "/usr/local/bin"]; - export interface SetupOpts { invite?: string; handle?: string; @@ -26,7 +21,7 @@ export interface SetupOpts { yes?: boolean; snippet?: boolean; relay?: string; - skipLaunchd?: boolean; + skipService?: boolean; callerOnly?: boolean; // false skips post-setup agent verification (commander's --no-verify). verify?: boolean; @@ -34,7 +29,7 @@ export interface SetupOpts { // Test seams — production callers should leave these as the defaults. hasBin?: (name: string) => boolean; resolveBin?: (name: string) => string | null; - installLaunchAgentFn?: typeof installLaunchAgent; + installListenerServiceFn?: typeof installListenerService; verifyFns?: VerifyFns; addLineFn?: typeof addLine; log?: (s: string) => void; @@ -65,13 +60,13 @@ async function detectAgentKind( ); } -export function warnIfOutsideLaunchdPath(name: string, resolveBin: (n: string) => string | null): void { +export function warnIfEphemeralServiceBin(name: string, resolveBin: (n: string) => string | null): void { const path = resolveBin(name); if (!path) return; // already surfaced via detectAgentKind's error, or not required (e.g. npx) - if (!LAUNCHD_PATH_DIRS.includes(dirname(path))) { + if (isEphemeralDir(dirname(path))) { console.error( - `Warning: ${name} is outside the background listener's PATH — if calls fail with ` + - `"command not found", run: ln -s ${path} ${LAUNCHD_PATH_DIRS[0]}/${name}`, + `Warning: ${name} resolves from an ephemeral directory (${path}); ` + + `install ${name} in a durable location before starting the background listener.`, ); } } @@ -153,6 +148,11 @@ export async function runSetup(opts: SetupOpts): Promise<{ ready: boolean }> { log(` ${row.name.padEnd(10)} ${row.address}${row.primary ? " primary" : ""}`); } log(`\nTo add another address: agentcall line add --handle `); + if (!opts.skipService && ready.some((line) => line.config!.agent_kind)) { + (opts.installListenerServiceFn ?? installListenerService)(machine, { + extraPathDirs: listenerPathDirs(machine, resolveBinFn), + }); + } if (opts.snippet !== false) { appendSnippet(join(homedir(), ".claude", "CLAUDE.md")); appendSnippet(join(homedir(), ".codex", "AGENTS.md")); @@ -172,8 +172,8 @@ export async function runSetup(opts: SetupOpts): Promise<{ ready: boolean }> { // and points at `line add`. const agentKind = callable ? await detectAgentKind(opts, hasBinFn, ask) : undefined; if (agentKind) { - warnIfOutsideLaunchdPath(agentKind, resolveBinFn); - warnIfOutsideLaunchdPath("npx", resolveBinFn); + warnIfEphemeralServiceBin(agentKind, resolveBinFn); + warnIfEphemeralServiceBin("npx", resolveBinFn); } // Checked before the handle prompt so a run that cannot possibly register @@ -188,10 +188,10 @@ export async function runSetup(opts: SetupOpts): Promise<{ ready: boolean }> { const name = agentKind ?? "caller"; log(`Registering ${handle} with ${relay} ...`); - // extraPathDirs (widening the LaunchAgent's PATH past its fixed base dirs + // extraPathDirs (widening the listener service's PATH past its fixed base dirs // for an agent/npx binary resolved outside them, e.g. an nvm/fnm-managed // install) is NOT computed here: addLine derives it itself from every - // ready line on the machine (launchPathDirs), not just the one being + // ready line on the machine (listenerPathDirs), not just the one being // created — one process serves every line, so a single-line computation // would drop coverage the moment a second line runs a different agent. // resolveBin is threaded through so a test override still reaches that @@ -203,7 +203,7 @@ export async function runSetup(opts: SetupOpts): Promise<{ ready: boolean }> { invite, agent: agentKind, callerOnly: !callable, - installLaunchAgentFn: opts.skipLaunchd ? () => {} : opts.installLaunchAgentFn, + installListenerServiceFn: opts.skipService ? () => {} : opts.installListenerServiceFn, resolveBin: resolveBinFn, // addLine has its own post-registration verify step (AddLineOpts.verify, // default on) — always false here because runSetup below does its own, diff --git a/packages/cli/src/systemd.ts b/packages/cli/src/systemd.ts new file mode 100644 index 00000000..42c3ec84 --- /dev/null +++ b/packages/cli/src/systemd.ts @@ -0,0 +1,109 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { MachinePaths } from "./paths.js"; + +export const SYSTEMD_UNIT = "agentcall-listener.service"; +export type ServiceExec = (command: string[]) => void; + +const BASE_PATH_DIRS = ["/usr/local/bin", "/usr/bin", "/bin"]; + +const defaultExec: ServiceExec = (command) => { + execFileSync(command[0]!, command.slice(1), { stdio: "ignore" }); +}; + +function assertUnitValue(value: string): void { + if (/\u0000|\r|\n/.test(value)) { + throw new Error("Cannot create systemd unit: paths must not contain NUL or newlines"); + } +} + +function quoteUnitWord(value: string): string { + assertUnitValue(value); + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`; +} + +function environmentValue(name: string, value: string): string { + return quoteUnitWord(`${name}=${value}`); +} + +function outputPath(value: string): string { + assertUnitValue(value); + return value + .replaceAll("%", "%%") + .replaceAll("\\", "\\\\") + .replaceAll(" ", "\\x20") + .replaceAll("\t", "\\x09"); +} + +export function systemdServiceFile(machine: MachinePaths): string { + return join(machine.userHome, ".config", "systemd", "user", SYSTEMD_UNIT); +} + +export function systemdUnitContent( + nodeBin: string, + cliScript: string, + machine: MachinePaths, + extraPathDirs: string[] = [], +): string { + const pathDirs = [...new Set([...extraPathDirs, dirname(nodeBin), ...BASE_PATH_DIRS])]; + return `[Unit] +Description=AgentCall listener +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +ExecStart=${quoteUnitWord(nodeBin)} ${quoteUnitWord(cliScript)} listen +Restart=always +RestartSec=2 +Environment=${environmentValue("HOME", machine.userHome)} +Environment=${environmentValue("PATH", pathDirs.join(":"))} +StandardOutput=append:${outputPath(machine.listenerLog)} +StandardError=append:${outputPath(machine.listenerLog)} + +[Install] +WantedBy=default.target +`; +} + +export function isSystemdServiceInstalled(machine: MachinePaths): boolean { + return existsSync(systemdServiceFile(machine)); +} + +export function installSystemdService( + machine: MachinePaths, + exec: ServiceExec = defaultExec, + extraPathDirs: string[] = [], +): void { + const cliScript = fileURLToPath(new URL("../dist/index.js", import.meta.url)); + const unitFile = systemdServiceFile(machine); + mkdirSync(dirname(unitFile), { recursive: true }); + writeFileSync(unitFile, systemdUnitContent(process.execPath, cliScript, machine, extraPathDirs), { mode: 0o600 }); + // writeFileSync's mode is ignored when the file already exists. Repair a + // permissive unit from an older/manual install instead of preserving it. + chmodSync(unitFile, 0o600); + exec(["systemctl", "--user", "daemon-reload"]); + exec(["systemctl", "--user", "enable", SYSTEMD_UNIT]); + exec(["systemctl", "--user", "restart", SYSTEMD_UNIT]); +} + +export function uninstallSystemdService( + machine: MachinePaths, + exec: ServiceExec = defaultExec, +): void { + try { + exec(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT]); + } catch { + /* not installed or not running */ + } + const unitFile = systemdServiceFile(machine); + if (existsSync(unitFile)) rmSync(unitFile); + exec(["systemctl", "--user", "daemon-reload"]); + try { + exec(["systemctl", "--user", "reset-failed", SYSTEMD_UNIT]); + } catch { + /* no failed state */ + } +} diff --git a/packages/cli/src/verify.ts b/packages/cli/src/verify.ts index 52e77378..2d886586 100644 --- a/packages/cli/src/verify.ts +++ b/packages/cli/src/verify.ts @@ -32,8 +32,8 @@ export const HINTS = { "(or run `claude setup-token`, or set ANTHROPIC_API_KEY).", codexAuth: "codex is not authenticated — run `codex login` (on a headless machine: `codex login --device-auth`).", pathMissing: - "the agent binary wasn't found when spawned — see setup's PATH warning: " + - "symlink the binary into /opt/homebrew/bin so the background listener can find it.", + "the agent binary wasn't found when spawned — install it in a durable PATH directory, " + + "then re-run `agentcall setup` to rebuild the background listener's PATH.", timeout: "the agent started but didn't finish in time — check your network, then try again.", } as const; @@ -336,7 +336,7 @@ export async function checkAgentSpawn( } // Injection seams for tests and for setup/doctor callers; production leaves -// all three unset (same pattern as SetupOpts.installLaunchAgentFn). +// all three unset (same pattern as SetupOpts.installListenerServiceFn). export interface VerifyFns { runFn?: typeof runAgent; execFn?: ExecFn; diff --git a/packages/cli/test/bin.test.ts b/packages/cli/test/bin.test.ts index 4bd672d4..b27d0a14 100644 --- a/packages/cli/test/bin.test.ts +++ b/packages/cli/test/bin.test.ts @@ -23,8 +23,8 @@ describe("resolveAgentBin", () => { // ahead of the real, durable install on PATH. resolveOnPath used to return // the FIRST PATH match, so the runner spawned the shim — which fails with // exit 127 once the session that created it is gone (confirmed live via - // `which -a claude`). See launchPath.ts's resolveExtraPathDirs, which needs - // the same durable-vs-ephemeral logic when widening the LaunchAgent's PATH. + // `which -a claude`). See listenerPath.ts's resolveExtraPathDirs, which needs + // the same durable-vs-ephemeral logic when widening the listener service's PATH. describe("prefers durable installs over ephemeral session shims", () => { function makeFakeBin(dir: string, name: string): string { mkdirSync(dir, { recursive: true }); diff --git a/packages/cli/test/cli-actions.test.ts b/packages/cli/test/cli-actions.test.ts index 1cd05f7c..ff94fe7c 100644 --- a/packages/cli/test/cli-actions.test.ts +++ b/packages/cli/test/cli-actions.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; -import { runCli } from "../src/index.js"; +import { createProgram, runCli } from "../src/index.js"; import { getLinePaths, getMachinePaths, type LinePaths } from "../src/paths.js"; import { saveLineConfig } from "../src/lines.js"; import { loadMemberships, readCached, saveMembership, writeCached } from "../src/rosters.js"; @@ -77,6 +77,25 @@ function home(): string { return mkdtempSync(join(tmpdir(), "agentcall-cli-")); } +describe("cross-platform listener CLI", () => { + it("describes the platform-neutral service opt-out during setup", () => { + const setup = createProgram().commands.find((command) => command.name() === "setup"); + const options = setup?.options.map((option) => option.long); + + expect(options).toContain("--skip-service"); + expect(options).not.toContain("--skip-launchd"); + }); + + it("uses the same service opt-out when adding another line", () => { + const line = createProgram().commands.find((command) => command.name() === "line"); + const add = line?.commands.find((command) => command.name() === "add"); + const options = add?.options.map((option) => option.long); + + expect(options).toContain("--skip-service"); + expect(options).not.toContain("--skip-launchd"); + }); +}); + function startRelay( handler: (url: string, method: string, body: string) => { status: number; body?: unknown; headers?: Record }, ): Promise { diff --git a/packages/cli/test/container-listener.test.ts b/packages/cli/test/container-listener.test.ts new file mode 100644 index 00000000..162d657f --- /dev/null +++ b/packages/cli/test/container-listener.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const root = join(import.meta.dirname, "../../.."); + +describe("container listener deployment", () => { + it("runs the foreground listener as a non-root process without an in-container service manager", () => { + const dockerfile = readFileSync(join(root, "Dockerfile.listener"), "utf8"); + + expect(dockerfile).toContain("ARG AGENT_PACKAGE"); + expect(dockerfile).toMatch(/USER agentcall\s*\nENTRYPOINT \["agentcall", "listen"\]/); + expect(dockerfile).not.toContain("systemctl"); + expect(dockerfile).not.toContain("launchctl"); + }); + + it("isolates container credentials and mounts the selected workdir read-only by default", () => { + const compose = parse(readFileSync(join(root, "compose.listener.yaml"), "utf8")) as { + services: { listener: { init: boolean; restart: string; volumes: string[] } }; + }; + const listener = compose.services.listener; + + expect(listener.init).toBe(true); + expect(listener.restart).toBe("unless-stopped"); + expect(listener.volumes).toEqual([ + "agentcall-listener-home:/home/agentcall", + "${AGENTCALL_WORKDIR}:${AGENTCALL_WORKDIR}:ro", + ]); + }); +}); diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index 9e66fef7..2918b3c9 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -7,7 +7,6 @@ import { runDoctor } from "../src/doctor.js"; import { saveLineConfig } from "../src/lines.js"; import { getLinePaths, getMachinePaths, type MachinePaths } from "../src/paths.js"; import { GUARD_PROBE_LINE } from "../src/verify.js"; -import { LAUNCH_LABEL } from "../src/launchd.js"; import type { AgentKind } from "../src/runner.js"; import { TelemetryHealthReporter } from "../src/telemetry-health.js"; @@ -45,8 +44,8 @@ const okVerifyFns = { const fakeCall = async () => ({ type: "call_reply", call_id: "c1", text: "hi", task: "ask" }) as never; const baseDeps = { - isDarwin: true, - launchctlList: () => `12345\t0\t${LAUNCH_LABEL}\n`, + platform: "darwin" as const, + inspectListenerServiceFn: () => ({ kind: "launchd" as const, installed: true, running: true }), getStatusFn: async () => ({ online: true }), verifyFns: okVerifyFns, callFn: fakeCall, @@ -74,6 +73,25 @@ function failingVerifyFor(kind: AgentKind) { } describe("runDoctor", () => { + it("reports a running systemd user listener on Linux", async () => { + const m = freshMachine(); + saveLineConfig(getLinePaths(m, LINE), { + org: "acme", handle: "ken", token: "t", agent_kind: "claude", relay: "https://relay.example", + }); + const lines: string[] = []; + + const code = await runDoctor({ + ...baseDeps, + machine: m, + platform: "linux", + inspectListenerServiceFn: () => ({ kind: "systemd", installed: true, running: true }), + log: (line) => lines.push(line), + }); + + expect(code).toBe(0); + expect(lines.join("\n")).toContain("✓ background listener (systemd)"); + }); + it("surfaces persistent local telemetry degradation without failing doctor", async () => { const m = freshMachine(); saveLineConfig(getLinePaths(m, "caller"), { @@ -201,7 +219,12 @@ describe("runDoctor", () => { const m = freshMachine(); saveLineConfig(getLinePaths(m, LINE), { org: "acme", handle: "ken", token: "t", agent_kind: "claude", relay: "https://relay.example" }); const lines: string[] = []; - const code = await runDoctor({ ...baseDeps, machine: m, launchctlList: () => "nothing here\n", log: (l) => lines.push(l) }); + const code = await runDoctor({ + ...baseDeps, + machine: m, + inspectListenerServiceFn: () => ({ kind: "launchd", installed: true, running: false }), + log: (l) => lines.push(l), + }); expect(code).toBe(1); const out = lines.join("\n"); expect(out).toContain("✗ background listener"); @@ -408,9 +431,9 @@ describe("runDoctor across lines", () => { ...baseDeps, machine: m, log: () => {}, - launchctlList: () => { + inspectListenerServiceFn: () => { listed++; - return LAUNCH_LABEL; + return { kind: "launchd", installed: true, running: true }; }, }); expect(listed).toBe(1); diff --git a/packages/cli/test/guard.test.ts b/packages/cli/test/guard.test.ts index 94d02f67..4cf20040 100644 --- a/packages/cli/test/guard.test.ts +++ b/packages/cli/test/guard.test.ts @@ -283,6 +283,14 @@ describe("decide — task envelopes and launch config are protected", () => { expect(v.allow).toBe(false); }); + it("denies writing a systemd user unit, which controls how the Linux listener is launched", () => { + expect(decide( + call("Write", { file_path: "/home/owner/.config/systemd/user/agentcall-listener.service" }), + "/home/owner", + id, + ).allow).toBe(false); + }); + it.each([".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"])( "denies writing %s, a shell startup file", (file) => { diff --git a/packages/cli/test/launchd.test.ts b/packages/cli/test/launchd.test.ts index 6b4c733e..fdfbff98 100644 --- a/packages/cli/test/launchd.test.ts +++ b/packages/cli/test/launchd.test.ts @@ -130,7 +130,8 @@ describe("install/uninstall", () => { // This module is the only thing that should know the listener is a macOS // LaunchAgent — callers ask whether it's installed rather than building a -// plist path themselves, so a non-macOS supervisor can be added alongside. +// plist path themselves; the platform-neutral listener module now selects +// this adapter on macOS and the systemd sibling on Linux. describe("isLaunchAgentInstalled", () => { it("reports false before install and true once the plist exists", () => { const home = mkdtempSync(join(tmpdir(), "agentcall-ld-")); diff --git a/packages/cli/test/line-cmd.test.ts b/packages/cli/test/line-cmd.test.ts index 61cc3365..4b9e5276 100644 --- a/packages/cli/test/line-cmd.test.ts +++ b/packages/cli/test/line-cmd.test.ts @@ -11,9 +11,9 @@ import { type AddLineOpts, type RemoveLineOpts, } from "../src/commands/line.js"; -// addLine/removeLine fall back to the real installLaunchAgent/ -// uninstallLaunchAgent whenever a test omits its opts seam -// (installLaunchAgentFn/uninstallFn/installFn) — and the real ones shell out +// addLine/removeLine fall back to the real installListenerService/ +// uninstallListenerService whenever a test omits its opts seam +// (installListenerServiceFn/uninstallFn/installFn) — and the real ones shell out // to the actual `launchctl bootstrap`/`bootout` on whoever's machine runs // this suite, regardless of how sandboxed MachinePaths.userHome is (the // launchd *session* is the real logged-in user's; only the plist file path @@ -22,12 +22,12 @@ import { // pointing at a since-deleted tmp dir. Mocking the module here turns a // missing seam into an immediate, loud test failure instead of a silent // real-system side effect — every test below must pass its own no-op. -vi.mock("../src/launchd.js", () => ({ - installLaunchAgent: () => { - throw new Error("real installLaunchAgent reached in a test — pass installLaunchAgentFn/installFn"); +vi.mock("../src/listener-service.js", () => ({ + installListenerService: () => { + throw new Error("real installListenerService reached in a test — pass installListenerServiceFn/installFn"); }, - uninstallLaunchAgent: () => { - throw new Error("real uninstallLaunchAgent reached in a test — pass uninstallFn"); + uninstallListenerService: () => { + throw new Error("real uninstallListenerService reached in a test — pass uninstallFn"); }, })); @@ -41,10 +41,10 @@ beforeEach(() => { const ok = async () => ({ org: "acme", token: "tok", address: "ken-cdx@r.example" }); const base = { org: "acme", handle: "ken", token: "t", relay: "https://r.example", agent_kind: "claude" as const }; -// launchPathDirs (addLine's/removeLine's extraPathDirs default — see -// launchPath.ts) falls back to the real `which` via defaultResolveBin +// listenerPathDirs (addLine's/removeLine's extraPathDirs default — see +// listenerPath.ts) falls back to the real `which` via defaultResolveBin // whenever resolveBin/extraPathDirs is omitted, and it's evaluated eagerly -// as an argument expression, so it runs even when installLaunchAgentFn/ +// as an argument expression, so it runs even when installListenerServiceFn/ // installFn is a total no-op. These wrappers default resolveBin to a // deterministic no-op so no test below shells out by accident; the two // tests that assert on the derivation itself pass their own resolveBin, @@ -65,7 +65,7 @@ function removeLine(m: MachinePaths, name: string, opts: RemoveLineOpts = {}): v describe("addLine", () => { it("registers, then writes config.json as the first thing on disk", async () => { await addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", - register: ok, installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, verify: false }); + register: ok, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false }); const l = getLinePaths(m, "codex"); expect(JSON.parse(readFileSync(l.configFile, "utf8")).token).toBe("tok"); }); @@ -73,7 +73,7 @@ describe("addLine", () => { it("leaves the disk untouched when the handle is taken", async () => { const taken = async () => { throw new Error("Handle \"ken-cdx\" is already taken."); }; await expect(addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", - register: taken, installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, verify: false })) + register: taken, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false })) .rejects.toThrow(/already taken/); expect(readdirSync(m.linesDir)).toEqual([]); }); @@ -82,7 +82,7 @@ describe("addLine", () => { let called = false; await expect(addLine(m, { name: "../evil", handle: "x", agent: "codex", relay: "https://r.example", register: async () => { called = true; return { org: "acme", token: "t", address: "a" }; }, - installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, verify: false })) + installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false })) .rejects.toThrow(/line name/i); expect(called).toBe(false); }); @@ -92,7 +92,7 @@ describe("addLine", () => { let called = false; await expect(addLine(m, { name: "codex", handle: "other", agent: "codex", relay: "https://r.example", register: async () => { called = true; return { org: "acme", token: "t", address: "a" }; }, - installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, verify: false })) + installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false })) .rejects.toThrow(/already/); expect(called).toBe(false); }); @@ -102,7 +102,7 @@ describe("addLine", () => { let called = false; await expect(addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", register: async () => { called = true; return { org: "acme", token: "t", address: "a" }; }, - installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, verify: false })) + installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false })) .rejects.toThrow(/ken-cdx/); expect(called).toBe(false); }); @@ -111,7 +111,7 @@ describe("addLine", () => { saveLineConfig(getLinePaths(m, "claude"), { ...base, handle: "ken" }); const warnings: string[] = []; await addLine(m, { name: "codex", handle: "ken-codex", agent: "codex", relay: "https://r.example", - register: ok, installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, verify: false, + register: ok, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false, warn: (s) => warnings.push(s) }); expect(warnings.join(" ")).toMatch(/guess/i); }); @@ -119,27 +119,27 @@ describe("addLine", () => { it("installs no launch agent for a caller-only line", async () => { let installed = false; await addLine(m, { name: "caller", handle: "ken-c", relay: "https://r.example", callerOnly: true, - register: ok, installLaunchAgentFn: () => { installed = true; }, publishCardFn: async () => undefined, verify: false }); + register: ok, installListenerServiceFn: () => { installed = true; }, publishCardFn: async () => undefined, verify: false }); expect(installed).toBe(false); }); // Regression: a nvm/fnm-managed node install (or claude/npx living outside - // /opt/homebrew/bin and /usr/local/bin) needs its dir on the LaunchAgent's + // /opt/homebrew/bin and /usr/local/bin) needs its dir on the listener service's // PATH, or the supervised listener can't find its own agent binary at // spawn time. setup used to compute this and pass it straight through; // addLine must accept and forward it too, or every line loses the fix. - it("forwards extraPathDirs into the installLaunchAgent seam", async () => { + it("forwards extraPathDirs into the installListenerService seam", async () => { let captured: string[] | undefined; await addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", register: ok, publishCardFn: async () => undefined, verify: false, extraPathDirs: ["/Users/x/.nvm/versions/node/v24/bin"], - installLaunchAgentFn: (_m, _execCmd, extraPathDirs) => { captured = extraPathDirs; } }); + installListenerServiceFn: (_m, options) => { captured = options?.extraPathDirs; } }); expect(captured).toEqual(["/Users/x/.nvm/versions/node/v24/bin"]); }); // The motivating case for this whole feature: claude on one line, codex on // another. When extraPathDirs isn't explicitly given, addLine must derive - // it from EVERY ready line on the machine (via launchPathDirs), not just + // it from EVERY ready line on the machine (via listenerPathDirs), not just // the one it's currently adding — otherwise the shared plist only ever // learns about whichever agent's line was created/reinstalled most // recently. @@ -153,7 +153,7 @@ describe("addLine", () => { : name === "codex" ? "/opt/codex-dir/codex" : name === "npx" ? "/opt/npx-dir/npx" : null, - installLaunchAgentFn: (_m, _execCmd, extraPathDirs) => { captured = extraPathDirs; } }); + installListenerServiceFn: (_m, options) => { captured = options?.extraPathDirs; } }); expect(captured?.slice().sort()).toEqual(["/opt/claude-dir", "/opt/codex-dir", "/opt/npx-dir"].sort()); }); @@ -167,7 +167,7 @@ describe("addLine", () => { const warnings: string[] = []; await expect(addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", - register: ok, installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, + register: ok, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, warn: (s) => warnings.push(s), verifyFns: { resolveBin: () => { throw new Error("no codex binary on PATH"); } }, })).resolves.toBeDefined(); @@ -178,7 +178,7 @@ describe("addLine", () => { const logs: string[] = []; await addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", - register: ok, installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, + register: ok, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, log: (s) => logs.push(s), verifyFns: { resolveBin: () => "/fake/bin/codex", execFn: () => {}, runFn: async () => ({ text: "OK" }) }, }); @@ -189,7 +189,7 @@ describe("addLine", () => { let touched = false; await addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", - register: ok, installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, verify: false, + register: ok, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false, verifyFns: { resolveBin: () => { touched = true; throw new Error("must not run"); } }, }); expect(touched).toBe(false); @@ -199,7 +199,7 @@ describe("addLine", () => { let touched = false; await addLine(m, { name: "caller", handle: "ken-c", relay: "https://r.example", callerOnly: true, - register: ok, installLaunchAgentFn: () => {}, publishCardFn: async () => undefined, + register: ok, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verifyFns: { resolveBin: () => { touched = true; throw new Error("must not run"); } }, }); expect(touched).toBe(false); @@ -288,11 +288,11 @@ describe("removeLine", () => { expect(() => removeLine(m, "claude", { confirm: true, uninstallFn: () => {} })).toThrow(/uninstall --purge/); }); - // Regression: the reinstall branch used to call installLaunchAgent(m) with + // Regression: the reinstall branch used to call installListenerService(m) with // no extraPathDirs at all, which rewrites the plist with an EMPTY PATH — // clobbering the surviving line's agent dir, not just failing to add the // removed one's. By the time this branch runs, the removed line's - // directory is already gone, so launchPathDirs(m) here must reflect only + // directory is already gone, so listenerPathDirs(m) here must reflect only // what's left. it("reinstall derives extraPathDirs from the surviving line, not an empty list", () => { saveLineConfig(getLinePaths(m, "claude"), { ...base, agent_kind: "claude" }); @@ -302,8 +302,8 @@ describe("removeLine", () => { removeLine(m, "codex", { confirm: true, uninstallFn: () => {}, - installFn: (_m, _execCmd, extraPathDirs) => { captured = extraPathDirs; }, - // codex resolves to a real dir too, not null — if launchPathDirs ran + installFn: (_m, options) => { captured = options?.extraPathDirs; }, + // codex resolves to a real dir too, not null — if listenerPathDirs ran // BEFORE the archive (i.e. against a machine state that still has // codex), codex's dir would leak into the result and this assertion // would fail. A resolveBin that only resolves the survivor would let diff --git a/packages/cli/test/listener-service.test.ts b/packages/cli/test/listener-service.test.ts new file mode 100644 index 00000000..ebca69a6 --- /dev/null +++ b/packages/cli/test/listener-service.test.ts @@ -0,0 +1,52 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + installListenerService, + listenerServiceFile, +} from "../src/listener-service.js"; +import { getMachinePaths } from "../src/paths.js"; + +describe("background listener service", () => { + it("installs and starts one user-level systemd service on Linux", () => { + const home = mkdtempSync(join(tmpdir(), "agentcall-systemd-")); + const machine = getMachinePaths(home, home, "linux"); + const calls: string[][] = []; + + installListenerService(machine, { + platform: "linux", + execCmd: (command) => calls.push(command), + extraPathDirs: ["/home/ken/.local/bin"], + }); + + const unitFile = listenerServiceFile(machine, "linux"); + expect(unitFile).toBe(join(home, ".config/systemd/user/agentcall-listener.service")); + expect(existsSync(unitFile)).toBe(true); + const unit = readFileSync(unitFile, "utf8"); + expect(unit).toContain("Description=AgentCall listener"); + expect(unit).toContain("ExecStart="); + expect(unit).toContain(" listen"); + expect(unit).toContain("Restart=always"); + expect(unit).toContain(`Environment="HOME=${home}"`); + expect(unit).toContain("/home/ken/.local/bin"); + expect(calls).toEqual([ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", "agentcall-listener.service"], + ["systemctl", "--user", "restart", "agentcall-listener.service"], + ]); + }); + + it("repairs an existing systemd unit to owner-only permissions", () => { + const home = mkdtempSync(join(tmpdir(), "agentcall-systemd-")); + const machine = getMachinePaths(home, home, "linux"); + const unitFile = listenerServiceFile(machine, "linux"); + mkdirSync(join(home, ".config/systemd/user"), { recursive: true }); + writeFileSync(unitFile, "stale"); + chmodSync(unitFile, 0o666); + + installListenerService(machine, { platform: "linux", execCmd: () => {} }); + + expect(statSync(unitFile).mode & 0o777).toBe(0o600); + }); +}); diff --git a/packages/cli/test/launchPath.test.ts b/packages/cli/test/listenerPath.test.ts similarity index 85% rename from packages/cli/test/launchPath.test.ts rename to packages/cli/test/listenerPath.test.ts index df06ca7b..801d3012 100644 --- a/packages/cli/test/launchPath.test.ts +++ b/packages/cli/test/listenerPath.test.ts @@ -2,13 +2,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { launchPathDirs, resolveExtraPathDirs } from "../src/launchPath.js"; +import { listenerPathDirs, resolveExtraPathDirs } from "../src/listenerPath.js"; import { getLinePaths, getMachinePaths, type MachinePaths } from "../src/paths.js"; import { saveLineConfig } from "../src/lines.js"; import type { LineConfig } from "../src/config.js"; function newMachine(): MachinePaths { - const root = mkdtempSync(join(tmpdir(), "agentcall-launchpath-")); + const root = mkdtempSync(join(tmpdir(), "agentcall-listenerpath-")); return getMachinePaths(root, root); } @@ -26,7 +26,7 @@ describe("resolveExtraPathDirs", () => { it("excludes ephemeral temp dirs so session-scoped shims never get baked into the plist PATH", () => { // Regression: setup run inside a cmux session resolved `claude` to a shim // under $TMPDIR/cmux-cli-shims//; that dir got written into the - // LaunchAgent's PATH and shadowed the real binary after the session died. + // listener service's PATH and shadowed the real binary after the session died. const resolveBin = (name: string) => name === "claude" ? "/var/folders/89/xx/T/cmux-cli-shims/AA8B8E91/claude" @@ -37,7 +37,7 @@ describe("resolveExtraPathDirs", () => { }); }); -describe("launchPathDirs", () => { +describe("listenerPathDirs", () => { // The motivating case: claude on one line, codex on another. A per-caller // computation (setup's old approach, scoped to whichever line it happened // to be creating) drops one of these; this derives from every ready line @@ -52,7 +52,7 @@ describe("launchPathDirs", () => { : name === "codex" ? "/opt/codex-dir/codex" : name === "npx" ? "/opt/npx-dir/npx" : null; - expect([...launchPathDirs(m, resolveBin)].sort()).toEqual( + expect([...listenerPathDirs(m, resolveBin)].sort()).toEqual( ["/opt/claude-dir", "/opt/codex-dir", "/opt/npx-dir"].sort(), ); }); @@ -61,12 +61,12 @@ describe("launchPathDirs", () => { const m = newMachine(); saveLineConfig(getLinePaths(m, "caller"), { ...base }); // no agent_kind const resolveBin = (name: string) => (name === "npx" ? "/opt/npx-dir/npx" : null); - expect(launchPathDirs(m, resolveBin)).toEqual(["/opt/npx-dir"]); + expect(listenerPathDirs(m, resolveBin)).toEqual(["/opt/npx-dir"]); }); it("returns [] when nothing resolves, even with no lines at all", () => { const m = newMachine(); - expect(launchPathDirs(m, () => null)).toEqual([]); + expect(listenerPathDirs(m, () => null)).toEqual([]); }); it("dedupes when two lines share an agent kind", () => { @@ -74,7 +74,7 @@ describe("launchPathDirs", () => { saveLineConfig(getLinePaths(m, "claude1"), { ...base, agent_kind: "claude" }); saveLineConfig(getLinePaths(m, "claude2"), { ...base, handle: "ken2", agent_kind: "claude" }); const resolveBin = (name: string) => (name === "claude" ? "/opt/claude-dir/claude" : null); - expect(launchPathDirs(m, resolveBin)).toEqual(["/opt/claude-dir"]); + expect(listenerPathDirs(m, resolveBin)).toEqual(["/opt/claude-dir"]); }); it("defaults resolveBin to the real bin lookup when none is given", () => { @@ -82,6 +82,6 @@ describe("launchPathDirs", () => { // this test) — just that it runs without a resolveBin argument and // returns an array, proving the default parameter is wired up. const m = newMachine(); - expect(Array.isArray(launchPathDirs(m))).toBe(true); + expect(Array.isArray(listenerPathDirs(m))).toBe(true); }); }); diff --git a/packages/cli/test/release-workflow.test.ts b/packages/cli/test/release-workflow.test.ts index ff01a39b..156a9fc4 100644 --- a/packages/cli/test/release-workflow.test.ts +++ b/packages/cli/test/release-workflow.test.ts @@ -47,12 +47,22 @@ function actionReferences(value: unknown): string[] { } describe("npm release workflow", () => { + it("publishes the CLI for both supported listener platforms", () => { + const manifest = JSON.parse(readFileSync(join(root, "packages/cli/package.json"), "utf8")); + expect(manifest.os).toEqual(["darwin", "linux"]); + }); + it("tests the packed CLI and doctor at the declared Node version floor", () => { expect(ciWorkflow).toContain("node: [20, 22, 24]"); expect(ciWorkflow).toContain('"$agentcall_bin" doctor'); expect(ciWorkflow).toContain('grep -F "No agentcall config found" "$RUNNER_TEMP/doctor-output"'); }); + it("installs the packed CLI on macOS and Linux", () => { + expect(ciWorkflow).toContain("os: [macos-latest, ubuntu-latest]"); + expect(ciWorkflow).toContain("runs-on: ${{ matrix.os }}"); + }); + it("binds both published packages to their monorepo source", () => { for (const directory of ["shared", "cli"]) { const manifest = JSON.parse(readFileSync(join(root, "packages", directory, "package.json"), "utf8")); diff --git a/packages/cli/test/rotate.test.ts b/packages/cli/test/rotate.test.ts index 905253bc..1cbd1911 100644 --- a/packages/cli/test/rotate.test.ts +++ b/packages/cli/test/rotate.test.ts @@ -44,6 +44,18 @@ describe("rotateLine", () => { expect(out.join(" ")).toMatch(/restart the listener/i); }); + it("uses systemd restart guidance on Linux", async () => { + saveLineConfig(getLinePaths(m, "claude"), base); + const out: string[] = []; + await rotateLine(resolveLine(m, { line: "claude" }), { + rotate: async () => ({ token: "new" }), + log: (s) => out.push(s), + platform: "linux", + }); + expect(out.join(" ")).toContain("systemctl --user restart agentcall-listener.service"); + expect(out.join(" ")).not.toContain("launchctl"); + }); + // A caller-only line (no agent_kind) has no listener socket of its own — the // pre-lines code guarded this with `else if (cfg.agent_kind)`; lines dropped // it and started printing reconnect/restart guidance unconditionally, which diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index 128596bf..2cb75003 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runSetup, warnIfOutsideLaunchdPath, type SetupOpts } from "../src/setup.js"; +import { runSetup, warnIfEphemeralServiceBin, type SetupOpts } from "../src/setup.js"; import { getLinePaths, getMachinePaths, type MachinePaths } from "../src/paths.js"; import { listLines, saveLineConfig } from "../src/lines.js"; import { loadPerson, savePerson } from "../src/person.js"; @@ -11,21 +11,21 @@ import type { LineConfig } from "../src/config.js"; import { AgentRunError } from "../src/runner.js"; // addLine/removeLine (and so runSetup, which delegates to addLine) fall back -// to the real installLaunchAgent/uninstallLaunchAgent whenever a seam is +// to the real installListenerService/uninstallListenerService whenever a seam is // omitted — and the real ones shell out to the actual `launchctl bootstrap`/ // `bootout` on whoever's machine runs this suite, regardless of how // sandboxed MachinePaths.userHome is (only the plist *file* path is // sandboxed; the launchd *session* is the real logged-in user's). This is // the same guard line-cmd.test.ts carries, added there after this exact // class of bug booted out the developer's real listener — every test below -// must pass its own skipLaunchd/installLaunchAgentFn, or fail loudly here +// must pass its own skipService/installListenerServiceFn, or fail loudly here // instead of silently reaching the real thing. -vi.mock("../src/launchd.js", () => ({ - installLaunchAgent: () => { - throw new Error("real installLaunchAgent reached in a test — pass skipLaunchd or installLaunchAgentFn"); +vi.mock("../src/listener-service.js", () => ({ + installListenerService: () => { + throw new Error("real installListenerService reached in a test — pass skipService or installListenerServiceFn"); }, - uninstallLaunchAgent: () => { - throw new Error("real uninstallLaunchAgent reached in a test — pass an uninstall seam"); + uninstallListenerService: () => { + throw new Error("real uninstallListenerService reached in a test — pass an uninstall seam"); }, })); @@ -43,13 +43,13 @@ const base: LineConfig = { org: "acme", handle: "ken", token: "t", relay: R, age // The real addLine, wired to stubRegister instead of the network. Used as // the `addLineFn` seam by every test below unless a test needs to observe -// one of addLine's own callbacks (publishCardFn, installLaunchAgentFn) — +// one of addLine's own callbacks (publishCardFn, installListenerServiceFn) — // those pass their own wrapper built the same way. const fakeAddLine = (m: MachinePaths, opts: AddLineOpts) => addLine(m, { ...opts, register: stubRegister, publishCardFn: opts.publishCardFn ?? (async () => undefined) }); -// addLine derives extraPathDirs (launchPathDirs, see launchPath.ts) eagerly -// as an argument expression, so it runs even when installLaunchAgentFn is a +// addLine derives extraPathDirs (listenerPathDirs, see listenerPath.ts) eagerly +// as an argument expression, so it runs even when installListenerServiceFn is a // total no-op — and by default that derivation falls back to the real // `which` via defaultResolveBin. run() below defaults resolveBin to this so // no test in this file shells out by accident; "threads its resolveBin @@ -81,7 +81,7 @@ describe("runSetup", () => { it("creates person.json plus one line, and marks it primary", async () => { await run({ handle: "ken", agent: "claude", relay: R, yes: true, snippet: false, verify: false, - addLineFn: fakeAddLine, skipLaunchd: true, + addLineFn: fakeAddLine, skipService: true, }); expect(loadPerson(m).primary_line).toBe("claude"); expect(listLines(m).map((l) => l.name)).toEqual(["claude"]); @@ -94,7 +94,7 @@ describe("runSetup", () => { it("names the line after the agent kind", async () => { await run({ handle: "ken", agent: "codex", relay: R, yes: true, snippet: false, verify: false, - addLineFn: fakeAddLine, skipLaunchd: true, + addLineFn: fakeAddLine, skipService: true, }); expect(listLines(m).map((l) => l.name)).toEqual(["codex"]); }); @@ -124,7 +124,7 @@ describe("runSetup", () => { const res = await run({ handle: base.handle, agent: "codex", relay: base.relay, yes: true, snippet: false, verify: false, addLineFn: () => { throw new Error("must not re-register on a second run"); }, - skipLaunchd: true, log: (s) => out.push(s), + skipService: true, log: (s) => out.push(s), }); expect(listLines(m).map((l) => l.name)).toEqual(["claude"]); expect(readFileSync(lp.configFile, "utf8")).toBe(before); @@ -133,6 +133,22 @@ describe("runSetup", () => { expect(res.ready).toBe(true); }); + it("repairs the background service on a second run for an existing callable line", async () => { + saveLineConfig(getLinePaths(m, "claude"), base); + savePerson(m, { primary_line: "claude" }); + let installed = 0; + + await run({ + relay: R, + snippet: false, + verify: false, + installListenerServiceFn: () => { installed += 1; }, + log: () => {}, + }); + + expect(installed).toBe(1); + }); + // Re-homed from main's "refuses to overwrite a corrupt credential config as // though it were a fresh install". A corrupt line is still a line: listLines // reports it as an orphan rather than throwing, so setup must take the @@ -149,7 +165,7 @@ describe("runSetup", () => { const res = await run({ agent: "claude", yes: true, snippet: false, verify: false, addLineFn: () => { throw new Error("must not register against a corrupt line"); }, - skipLaunchd: true, log: () => {}, + skipService: true, log: () => {}, }); expect(res.ready).toBe(true); @@ -160,7 +176,7 @@ describe("runSetup", () => { it("creates an agentless line under --caller-only", async () => { await run({ handle: "ken", callerOnly: true, relay: R, yes: true, snippet: false, verify: false, - addLineFn: fakeAddLine, skipLaunchd: true, + addLineFn: fakeAddLine, skipService: true, }); expect(listLines(m)[0]!.config!.agent_kind).toBeUndefined(); // The caller-only half of the old flat-config test: no agent means no @@ -182,7 +198,7 @@ describe("runSetup", () => { const before = readFileSync(lp.configFile, "utf8"); await expect(run({ - relay: "https://other.example", snippet: false, verify: false, skipLaunchd: true, + relay: "https://other.example", snippet: false, verify: false, skipService: true, addLineFn: () => { throw new Error("must not register"); }, })).rejects.toThrow(/no line on.*agentcall line add/i); @@ -196,7 +212,7 @@ describe("runSetup", () => { const before = readFileSync(lp.configFile, "utf8"); await expect(run({ - handle: "someone-else", snippet: false, verify: false, skipLaunchd: true, + handle: "someone-else", snippet: false, verify: false, skipService: true, addLineFn: () => { throw new Error("must not register"); }, })).rejects.toThrow(/holds no line for the handle.*agentcall line add/i); @@ -212,7 +228,7 @@ describe("runSetup", () => { savePerson(m, { primary_line: "claude" }); const asked: string[] = []; await run({ - relay: R, snippet: false, verify: false, skipLaunchd: true, addLineFn: fakeAddLine, + relay: R, snippet: false, verify: false, skipService: true, addLineFn: fakeAddLine, hasBin: () => true, // both claude and codex on PATH — would normally prompt io: { ask: async (q) => { asked.push(q); return "claude"; } }, }); @@ -222,7 +238,7 @@ describe("runSetup", () => { it("prompts for a missing handle via io.ask", async () => { const asked: string[] = []; await run({ - agent: "claude", relay: R, snippet: false, verify: false, skipLaunchd: true, addLineFn: fakeAddLine, + agent: "claude", relay: R, snippet: false, verify: false, skipService: true, addLineFn: fakeAddLine, io: { ask: async (q) => { asked.push(q); return "asked-handle"; } }, }); expect(listLines(m)[0]!.config!.handle).toBe("asked-handle"); @@ -243,7 +259,7 @@ describe("runSetup", () => { it("detects the agent kind via injectable hasBin when --agent is omitted", async () => { await run({ - handle: "ken2", relay: R, snippet: false, verify: false, skipLaunchd: true, addLineFn: fakeAddLine, + handle: "ken2", relay: R, snippet: false, verify: false, skipService: true, addLineFn: fakeAddLine, hasBin: (name) => name === "codex", io: { ask: async () => "y" }, }); @@ -257,33 +273,33 @@ describe("runSetup", () => { logs.push(a.map(String).join(" ")); }); try { - let launchdCalled = false; + let serviceInstallCalled = false; await run({ handle: "ken3", relay: R, snippet: false, verify: false, addLineFn: fakeAddLine, hasBin: () => false, - installLaunchAgentFn: () => { launchdCalled = true; }, + installListenerServiceFn: () => { serviceInstallCalled = true; }, }); expect(listLines(m)[0]!.config!.agent_kind).toBeUndefined(); - expect(launchdCalled).toBe(false); + expect(serviceInstallCalled).toBe(false); expect(logs.some((l) => l.includes("caller-only"))).toBe(true); } finally { spy.mockRestore(); } }); - // The extraPathDirs derivation itself lives in launchPath.ts's - // launchPathDirs, exercised directly in launchPath.test.ts and via + // The extraPathDirs derivation itself lives in listenerPath.ts's + // listenerPathDirs, exercised directly in listenerPath.test.ts and via // addLine in line-cmd.test.ts. This just proves setup threads its // resolveBin seam all the way down to that derivation rather than letting // addLine fall back to the real `which` — the fake resolveBin below would // never match a real machine's paths, so a non-empty, exact-match result // is only possible if the seam actually reached addLine. - it("threads its resolveBin seam through to installLaunchAgent's extraPathDirs", async () => { + it("threads its resolveBin seam through to installListenerService's extraPathDirs", async () => { let captured: string[] | undefined; await run({ handle: "ken4", agent: "claude", relay: R, snippet: false, verify: false, addLineFn: fakeAddLine, resolveBin: (name) => (name === "claude" || name === "npx" ? `/Users/x/.local/bin/${name}` : null), - installLaunchAgentFn: (_m, _execCmd, extraPathDirs) => { captured = extraPathDirs; }, + installListenerServiceFn: (_m, options) => { captured = options?.extraPathDirs; }, }); expect(captured).toEqual(["/Users/x/.local/bin"]); }); @@ -294,7 +310,7 @@ describe("runSetup", () => { it("seeds policy.json + tasks dir and publishes the card", async () => { const cardCalls: LineConfig[] = []; await run({ - handle: "ken", agent: "claude", relay: R, snippet: false, verify: false, skipLaunchd: true, + handle: "ken", agent: "claude", relay: R, snippet: false, verify: false, skipService: true, addLineFn: (m2, opts) => addLine(m2, { ...opts, register: stubRegister, publishCardFn: async (cfg) => { cardCalls.push(cfg); } }), }); @@ -320,7 +336,7 @@ describe("setup progress output", () => { }); try { await run({ - handle: "ken9", agent: "claude", relay: R, snippet: false, verify: false, skipLaunchd: true, + handle: "ken9", agent: "claude", relay: R, snippet: false, verify: false, skipService: true, addLineFn: fakeAddLine, }); expect(logs.some((l) => l.includes("Registering ken9"))).toBe(true); @@ -331,26 +347,36 @@ describe("setup progress output", () => { }); }); -describe("warnIfOutsideLaunchdPath", () => { - it("prints one short line with a copy-pasteable symlink fix", () => { +describe("warnIfEphemeralServiceBin", () => { + it("stays silent for a durable bin directory because the service PATH includes it", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + warnIfEphemeralServiceBin("claude", () => "/home/ken/.local/bin/claude"); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it("warns when a binary resolves only from an ephemeral directory", () => { const errors: string[] = []; const spy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errors.push(a.map(String).join(" ")); }); try { - warnIfOutsideLaunchdPath("claude", () => "/Users/x/.local/bin/claude"); + warnIfEphemeralServiceBin("claude", () => "/tmp/session/bin/claude"); } finally { spy.mockRestore(); } expect(errors).toHaveLength(1); - expect(errors[0]).toContain("ln -s /Users/x/.local/bin/claude /opt/homebrew/bin/claude"); + expect(errors[0]).toContain("install claude in a durable location"); expect(errors[0]!.length).toBeLessThan(200); }); - it("stays silent when the binary is inside launchd's search path", () => { + it("stays silent when the binary is in a native service path", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - warnIfOutsideLaunchdPath("claude", () => "/opt/homebrew/bin/claude"); + warnIfEphemeralServiceBin("claude", () => "/opt/homebrew/bin/claude"); expect(spy).not.toHaveBeenCalled(); } finally { spy.mockRestore(); @@ -390,21 +416,21 @@ describe("caller-only setup", () => { it("asks 'Make your agent callable' and answering n yields caller-only", async () => { const asked: string[] = []; - let launchdCalled = false; + let serviceInstallCalled = false; await run({ handle: "asker", relay: R, snippet: false, verify: false, addLineFn: fakeAddLine, hasBin: () => true, // agents ARE installed; user still opts out io: { ask: async (q) => { asked.push(q); return "n"; } }, - installLaunchAgentFn: () => { launchdCalled = true; }, + installListenerServiceFn: () => { serviceInstallCalled = true; }, }); expect(asked.some((q) => q.includes("callable"))).toBe(true); expect(asked.some((q) => /run automatically without per-call approval/i.test(q))).toBe(true); expect(listLines(m)[0]!.config!.agent_kind).toBeUndefined(); - expect(launchdCalled).toBe(false); }); + expect(serviceInstallCalled).toBe(false); }); it("an empty answer defaults to callable", async () => { await run({ - handle: "defaulter", relay: R, snippet: false, verify: false, skipLaunchd: true, addLineFn: fakeAddLine, + handle: "defaulter", relay: R, snippet: false, verify: false, skipService: true, addLineFn: fakeAddLine, hasBin: (name) => name === "claude", io: { ask: async () => "" }, }); @@ -438,7 +464,7 @@ describe("runSetup verification", () => { }); try { const result = await run({ - handle: "ken", agent: "claude", relay: R, snippet: false, skipLaunchd: true, addLineFn: fakeAddLine, + handle: "ken", agent: "claude", relay: R, snippet: false, skipService: true, addLineFn: fakeAddLine, verifyFns: { resolveBin: () => "/fake/bin/claude", runFn: async () => ({ text: "OK" }) }, }); expect(result.ready).toBe(true); @@ -457,7 +483,7 @@ describe("runSetup verification", () => { const installed: string[] = []; const result = await run({ handle: "ken", agent: "claude", relay: R, snippet: false, addLineFn: fakeAddLine, - installLaunchAgentFn: () => { + installListenerServiceFn: () => { installed.push("yes"); }, verifyFns: { @@ -485,7 +511,7 @@ describe("runSetup verification", () => { it("--no-verify (verify:false) skips verification entirely", async () => { let ran = false; const result = await run({ - handle: "ken", agent: "claude", relay: R, snippet: false, skipLaunchd: true, verify: false, + handle: "ken", agent: "claude", relay: R, snippet: false, skipService: true, verify: false, addLineFn: fakeAddLine, verifyFns: { resolveBin: () => "/fake/bin/claude", @@ -502,7 +528,7 @@ describe("runSetup verification", () => { it("caller-only setup never verifies", async () => { let ran = false; const result = await run({ - handle: "solo", relay: R, snippet: false, skipLaunchd: true, callerOnly: true, addLineFn: fakeAddLine, + handle: "solo", relay: R, snippet: false, skipService: true, callerOnly: true, addLineFn: fakeAddLine, verifyFns: { resolveBin: () => "/fake/bin/claude", runFn: async () => {