diff --git a/apps/cli/scripts/stage-runtime.mjs b/apps/cli/scripts/stage-runtime.mjs index 78c91ac..8480079 100644 --- a/apps/cli/scripts/stage-runtime.mjs +++ b/apps/cli/scripts/stage-runtime.mjs @@ -44,6 +44,7 @@ const execBitFiles = new Set([ 'packages/sandbox-docker/scripts/gh-shim', 'packages/sandbox-docker/scripts/git-shim', 'packages/sandbox-docker/scripts/ntn-shim', + 'packages/sandbox-docker/scripts/linear-shim', 'packages/sandbox-docker/scripts/chromium-resolver', ]); const contextFiles = [ @@ -56,6 +57,7 @@ const contextFiles = [ 'packages/sandbox-docker/scripts/gh-shim', 'packages/sandbox-docker/scripts/git-shim', 'packages/sandbox-docker/scripts/ntn-shim', + 'packages/sandbox-docker/scripts/linear-shim', 'packages/sandbox-docker/scripts/chromium-resolver', 'packages/sandbox-docker/scripts/custom-system-CLAUDE.md', 'packages/sandbox-docker/scripts/claude-managed-settings.json', @@ -101,6 +103,7 @@ const hetznerFiles = [ ['packages/sandbox-docker/scripts/gh-shim', 'gh-shim', true], ['packages/sandbox-docker/scripts/git-shim', 'git-shim', true], ['packages/sandbox-docker/scripts/ntn-shim', 'ntn-shim', true], + ['packages/sandbox-docker/scripts/linear-shim', 'linear-shim', true], ['packages/sandbox-hetzner/scripts/custom-system-CLAUDE.md', 'custom-system-CLAUDE.md', false], ['packages/sandbox-docker/scripts/claude-managed-settings.json', 'claude-managed-settings.json', false], ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json', 'agentbox-codex-hooks.json', false], @@ -138,6 +141,7 @@ const vercelFiles = [ ['packages/sandbox-docker/scripts/gh-shim', 'gh-shim', true], ['packages/sandbox-docker/scripts/git-shim', 'git-shim', true], ['packages/sandbox-docker/scripts/ntn-shim', 'ntn-shim', true], + ['packages/sandbox-docker/scripts/linear-shim', 'linear-shim', true], ['packages/sandbox-vercel/scripts/custom-system-CLAUDE.md', 'custom-system-CLAUDE.md', false], ['packages/sandbox-docker/scripts/claude-managed-settings.json', 'claude-managed-settings.json', false], ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json', 'agentbox-codex-hooks.json', false], @@ -164,6 +168,7 @@ const e2bFiles = [ ['packages/sandbox-docker/scripts/gh-shim', 'gh-shim', true], ['packages/sandbox-docker/scripts/git-shim', 'git-shim', true], ['packages/sandbox-docker/scripts/ntn-shim', 'ntn-shim', true], + ['packages/sandbox-docker/scripts/linear-shim', 'linear-shim', true], ['packages/sandbox-e2b/scripts/custom-system-CLAUDE.md', 'custom-system-CLAUDE.md', false], ['packages/sandbox-docker/scripts/claude-managed-settings.json', 'claude-managed-settings.json', false], ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json', 'agentbox-codex-hooks.json', false], diff --git a/apps/cli/test/doctor-integrations.test.ts b/apps/cli/test/doctor-integrations.test.ts index 3e7e1a7..79f6528 100644 --- a/apps/cli/test/doctor-integrations.test.ts +++ b/apps/cli/test/doctor-integrations.test.ts @@ -80,18 +80,25 @@ describe('doctor — integrations group', () => { it('renders info / "disabled" when the flag is off (default)', async () => { emptyPath(); const results = await integrationsChecks(disabled); - expect(results).toHaveLength(1); - const row = results[0]!; - expect(row.label).toBe('notion'); - expect(row.status).toBe('info'); - expect(row.detail).toBe('disabled'); - expect(row.hint).toContain('integrations.notion.enabled true'); + // One row per registered connector (notion, linear, …). All should + // surface as `info`/disabled when no flag has been flipped — disabling + // an integration is a setting, not a problem. + expect(results.length).toBeGreaterThanOrEqual(2); + for (const row of results) { + expect(row.status).toBe('info'); + expect(row.detail).toBe('disabled'); + expect(row.hint).toContain(`integrations.${row.label}.enabled true`); + } + const notion = results.find((r) => r.label === 'notion'); + expect(notion).toBeDefined(); + const linear = results.find((r) => r.label === 'linear'); + expect(linear).toBeDefined(); }); it('renders warn / "not installed" when enabled but ntn is missing', async () => { emptyPath(); const results = await integrationsChecks(enabled); - const row = results[0]!; + const row = results.find((r) => r.label === 'notion')!; expect(row.status).toBe('warn'); expect(row.detail).toMatch(/not installed/); expect(row.hint).toMatch(/install ntn/); @@ -101,7 +108,7 @@ describe('doctor — integrations group', () => { await stageStub(); delete process.env.NTN_TEST_AUTH; const results = await integrationsChecks(enabled); - const row = results[0]!; + const row = results.find((r) => r.label === 'notion')!; expect(row.status).toBe('warn'); expect(row.detail).toBe('not logged in'); expect(row.hint).toBe('ntn login'); @@ -111,7 +118,7 @@ describe('doctor — integrations group', () => { await stageStub(); process.env.NTN_TEST_AUTH = 'ok'; const results = await integrationsChecks(enabled); - const row = results[0]!; + const row = results.find((r) => r.label === 'notion')!; expect(row.status).toBe('ok'); expect(row.detail).toContain('ntn version 0.42.0'); expect(row.detail).toContain('authed'); @@ -122,6 +129,8 @@ describe('doctor — integrations group', () => { const broken: IntegrationsConfigLoader = () => Promise.reject(new Error('malformed yaml')); const results = await integrationsChecks(broken); - expect(results[0]?.status).toBe('info'); + // Every row falls back to disabled (info), regardless of which connectors + // are registered — a broken config is treated as "not enabled". + for (const row of results) expect(row.status).toBe('info'); }); }); diff --git a/apps/web/content/docs/cli.mdx b/apps/web/content/docs/cli.mdx index 9152afc..e4c8975 100644 --- a/apps/web/content/docs/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -259,7 +259,7 @@ agentbox prepare -p hetzner agentbox prepare -p docker --build ``` -`install` is the first-run setup wizard (system check, pick a provider, log in, prepare its base image, install the host skill). `install cmux` pins a live `agentbox list` panel (all your boxes) to the [cmux](https://cmux.com) sidebar dock — see [cmux integration](/docs/integrations-cmux#the-agentbox-dock-right-sidebar). `doctor` diagnoses system and provider readiness — and reports each [service integration](/docs/integrations-notion) (host CLI installed? authed? enabled per project?). `prepare` builds base images or snapshots — omit `--provider` for status only. +`install` is the first-run setup wizard (system check, pick a provider, log in, prepare its base image, install the host skill). `install cmux` pins a live `agentbox list` panel (all your boxes) to the [cmux](https://cmux.com) sidebar dock — see [cmux integration](/docs/integrations-cmux#the-agentbox-dock-right-sidebar). `doctor` diagnoses system and provider readiness — and reports each service integration ([Notion](/docs/integrations-notion), [Linear](/docs/integrations-linear)): host CLI installed? authed? enabled per project? `prepare` builds base images or snapshots — omit `--provider` for status only. `agentbox config get --all` shows which layer each value comes from. See the full key reference in [Configuration](/docs/configuration). diff --git a/apps/web/content/docs/configuration.mdx b/apps/web/content/docs/configuration.mdx index 1820bd5..1cb1a73 100644 --- a/apps/web/content/docs/configuration.mdx +++ b/apps/web/content/docs/configuration.mdx @@ -232,14 +232,16 @@ See [browser and screen](/docs/browser-and-screen). ## integrations -Per-service toggles for relay-gated service integrations. Each integration is **disabled by default** — even when the host CLI is installed and authed, the box can't call out until you flip it on. The box never holds the service's token; reads pass through, writes prompt on the host. See [Notion](/docs/integrations-notion). +Per-service toggles for relay-gated service integrations. Each integration is **disabled by default** — even when the host CLI is installed and authed, the box can't call out until you flip it on. The box never holds the service's token; reads pass through, writes prompt on the host. See [Notion](/docs/integrations-notion) and [Linear](/docs/integrations-linear). | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `integrations.notion.enabled` | bool | `false` | proxy `ntn` calls from the box through the host relay; reads pass, writes prompt | +| `integrations.linear.enabled` | bool | `false` | proxy `linear` calls (`@schpet/linear-cli`) from the box through the host relay; reads pass, writes prompt; `auth token` is hard-rejected | ```bash agentbox config set --project integrations.notion.enabled true +agentbox config set --project integrations.linear.enabled true ``` `agentbox doctor` reports a row per integration in a dedicated `integrations:` group: disabled (default), `ntn not installed`, `not logged in`, or `authed` — with a one-line hint for each non-`ok` state. diff --git a/apps/web/content/docs/integrations-linear.mdx b/apps/web/content/docs/integrations-linear.mdx new file mode 100644 index 0000000..2ff81e4 --- /dev/null +++ b/apps/web/content/docs/integrations-linear.mdx @@ -0,0 +1,105 @@ +--- +title: Linear +description: Let your box read and write Linear issues through the host's authenticated linear CLI — your API token never enters the box +--- + +AgentBox can proxy Linear calls from inside a box to the host's authenticated `linear` CLI (`@schpet/linear-cli`). The box agent can list and view issues, run filtered queries, and (with your approval for each write) create or update issues and post comments — without your Linear API token ever entering the box. Same model as `agentbox-ctl git push`, `agentbox-ctl git pr create`, and the [Notion integration](/docs/integrations-notion). + + +The box runs a tiny `linear` shim. Calls go through `agentbox-ctl integration linear ` to the **host relay**, which runs the host's real `linear` and ships the result back. Reads pass straight through. Writes raise a one-line confirm in your terminal first. + + +## Prerequisites + +The integration wraps [`@schpet/linear-cli`](https://github.com/schpet/linear-cli) (the `linear` binary, v2). Install it on the **host** (not in the box): + +```bash +npm install -g @schpet/linear-cli +linear auth login # opens the browser, stores auth in ~/.config/linear/credentials.toml +``` + +Then verify with `agentbox doctor`: + +```text +integrations: + [info] linear disabled (enable with `agentbox config set --project integrations.linear.enabled true`) +``` + +The integration is **off by default**, so even with `linear` installed the box can't call it until you opt in. Doctor's `info` line confirms `linear` is detected; flip the flag to graduate it to a usable state. + +## Enable it for this project + +```bash +agentbox config set --project integrations.linear.enabled true +``` + +`--project` scopes it to the current project (config file under `~/.agentbox/projects//`). Drop `--project` for global. Run `agentbox doctor` again — the row should now read: + +```text +integrations: + [ ok ] linear linear/2.0.0 (…) · authed +``` + +If you see `[warn] not logged in`, run `linear auth login` on the host. If you see `[warn] linear not installed`, the host install didn't put `linear` on `PATH`. + +## What works inside the box + +The in-box shim exposes a strict allowlist. Anything outside the list — including `linear auth token`, `linear issue delete`, and any of `project` / `cycle` / `milestone` / `label` / `document` / `schema` — is rejected with a clear message. + +| In-box command | Class | What happens | +| --- | --- | --- | +| `linear whoami` (or `linear auth whoami`) | read | Passes through; prints the authed host user. | +| `linear issue list` | read | Lists issues for the authed user/team; pass through filters with `--`. | +| `linear issue mine` | read | v2-native "issues assigned to me" (replaces the older `issue list --me`). | +| `linear issue view ` | read | Detail view for one issue. | +| `linear issue query …` | read | Structured filter query. | +| `linear team list` | read | Lists teams. | +| `linear api ''` | read | GraphQL query passthrough — `mutation` / `subscription` are **refused** (exit 65). `--variable key=@` is also refused (exfiltration vector). | +| `linear issue create …` | write | **Prompts** the host for approval; on `y` the host runs `linear issue create` and ships back the result. | +| `linear issue update …` | write | **Prompts** the host for approval; covers status/title/etc. | +| `linear issue comment add …` | write | **Prompts** the host for approval; posts a comment. (Upstream uses `add`, not `create`.) | + +Reads are unprompted; every write raises a one-line confirm in your attached terminal (or in `agentbox agent approvals` for orchestrators driving boxes headlessly — see [Background & parallel](/docs/background-and-parallel)). + +```bash +# Inside the box — these all flow through the host relay: +linear whoami +linear issue mine +linear issue view ABC-42 +linear api '{ teams { id name } }' # GraphQL query, passes +linear api --paginate '{ teams { id } }' # pre-positional flags work +linear issue create --title "Draft from the box" # prompts on the host +linear issue comment add ABC-42 --body "from box" # prompts on the host (v2 uses `add`) +``` + + +`linear auth token` PRINTS the raw API token to stdout. The shim explicitly refuses it with `'auth token' leaks the raw API key — refused. Use 'linear whoami' for identity.`, and the connector exposes no op that maps to it. The whole point of the integration is to keep the token on the host — proxying `auth token` would defeat that. Same for `auth login` / `auth logout` / `auth migrate` / `auth default` (host owns auth state). + + +## Security model + +| Concern | What AgentBox does | +| --- | --- | +| Where the Linear token lives | **Host only** — in `~/.config/linear/credentials.toml`. The box has no access to it. (The carry block ships the file into nested-box relay hosts only — never to the agent's process env.) | +| What the box can do unprompted | **Reads only** (`whoami`, `issue list/view/query`, `team list`, `api` GraphQL **queries**). | +| What needs your approval | **Every write** (`issue.create`, `issue.update`, `issue.comment`), and **any `mutation` / `subscription` GraphQL operation** through `api` is refused outright with exit 65. | +| Where the approval lives | The host relay raises a confirm prompt; you answer in the attached terminal (`y` / `n`) or via `agentbox agent approve ` from an orchestrator. | +| Inside the box, does the agent ever see the token? | **No.** `printenv \| grep -i linear` inside a box returns nothing — only `AGENTBOX_RELAY_TOKEN`, which only authenticates to the box-local relay endpoint. | +| Destructive ops | `issue delete` / `team delete` / `team create` are **off the allowlist**. Start conservative; widen deliberately if a real flow needs them. | +| `auth token` | **Hard-rejected by the shim**, with no connector op exposing it. Three defenses (shim, connector allowlist, relay dispatch), all in series. | +| Auditability | Every approved write is logged as a relay event (visible via `/admin/events`, `agentbox agent`, the dashboard). | + +The integration is **off by default** for every new project. You flip it on per project once you've installed and authed `linear` on the host. + + +The box is the untrusted side. Tokens in the box would survive `agentbox download`, leak into commits if the agent mishandles them, and undermine the entire sandbox premise. Keeping the token on the host and putting the gate at the host boundary is the same model AgentBox uses for `git push`, `gh pr create`, and the [Notion integration](/docs/integrations-notion) — one model, audited in one place. + + +## Limitations and roadmap + +- **GraphQL query-only `api` passthrough.** `mutation` / `subscription` are refused; use the dedicated `issue.*` write ops instead. This guards against an agent slipping a write past the read classification. +- **No destructive ops.** `issue delete` / `team delete` / `team create` are off-list by default. +- **Allowlist starts conservative.** As real agent flows surface needs, the op set will widen — file an issue with the failing call if something's missing. +- **Trello / ClickUp** are still on the integrations roadmap; their connectors will appear in `agentbox doctor` the same way once they ship. + +See also [CLI commands](/docs/cli) for `agentbox doctor`, [Configuration](/docs/configuration) for the `integrations.linear.enabled` flag, and [Background & parallel](/docs/background-and-parallel) for the host-action approval surface. diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index 186f517..d73ee11 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -22,6 +22,7 @@ "integrations-cmux", "---Services---", "integrations-notion", + "integrations-linear", "---Providers---", "local-docker", "hetzner", diff --git a/docs/features.md b/docs/features.md index ae94e37..cc69e05 100644 --- a/docs/features.md +++ b/docs/features.md @@ -28,6 +28,7 @@ Full local-Docker lifecycle (plus parity-tested for cloud via `--provider dayton - `agentbox prepare` — one-stop "set up the base image / show what's prepared" command. `agentbox prepare` (no args) prints a status table across all providers: docker's `agentbox/box:dev` image + the three shared docker volumes (`agentbox-claude-config`, `agentbox-codex-config`, `agentbox-opencode-config`), plus all daytona `agentbox*` snapshots (state / size / age / `(pinned in project)` marker) and `agentbox*` volumes — including the legacy per-agent ones that the daytona path no longer uses (visible reminder to clean them up via the Daytona dashboard). `agentbox prepare --provider docker` pre-builds the local Dockerfile.box image (idempotent). `agentbox prepare --provider daytona [--name X] [-y]` builds a layered `Image.fromDockerfile().addLocalFile().runCommands()` for the three host agent static tarballs and registers it as a named org-scoped snapshot via the documented `daytona.snapshot.create({ name, image })` API ([daytona.io/docs/en/snapshots](https://www.daytona.io/docs/en/snapshots/)), then pins `box.image: ` into the project config — subsequent `agentbox create --provider daytona` boots in seconds with the agent static config (plugins/skills/marketplaces/settings) already in place. Replaces the old `agentbox daytona publish-snapshot` (which used `_experimental_createSnapshot`, broken upstream). - `agentbox self-update` — self-updates the CLI then refreshes the local runtime. Detects how it was launched (`apps/cli/src/exec-method.ts`'s `detectExecutionMethod`): `npm` → `npm install -g @madarco/agentbox@latest`, `pnpm` → `pnpm add -g @madarco/agentbox@latest`, `npx`/`direct` (dev clone) → skip the package update with a note. Then best-effort `docker image rm -f agentbox/box:dev` (rebuilds lazily on next `create`/`claude` via `ensureImage()`) and reloads the relay via `stopRelay()`. The relay is only respawned in-process (`ensureRelay()`) when **no** self-update ran — after a real self-update this process is the stale build, so it just stops the relay and the next box command brings up the new one. `-y` skips the prompt, `--dry-run` previews, `--skip-self` does only the image+relay refresh. `stopRelay` lives in `packages/sandbox-docker/src/relay.ts` (reuses the existing pidfile helpers); `removeImage` in `docker.ts`. - **Notion integration (relay-gated, host CLI)** — `agentbox-ctl integration notion ` and the in-box `ntn` / `notion` shims proxy a small allowlist of ops (`whoami`, GET-only `api` passthrough, `page.create`, `page.update`) through the host relay to the host's authenticated `ntn` CLI. Reads pass through; writes prompt the host for approval (same `askPrompt` gate as `git push` / `gh pr create`). Non-GET HTTP methods on `ntn api` are refused (`-X`, `--method`, `-f`, `-F`, `--input` all rejected with exit 65 by `refuseApiNonGet`). The box never holds a Notion token — `printenv | grep -i notion` inside a box returns nothing. Off by default — enable per project with `agentbox config set --project integrations.notion.enabled true` (typed config key `integrations.notion.enabled` in `packages/config/src/types.ts`); the relay re-reads the layered config on every call so a flag flip takes effect with no bounce, and a disabled integration is refused before any host process is touched. `agentbox doctor` reports each integration in a dedicated `integrations:` group — `info` for disabled, `warn` for not-installed / not-logged-in (with install/login hints from the connector descriptor), `ok` when authed. Connector descriptor lives in `packages/integrations/src/connectors/notion.ts`; the relay spine in `packages/relay/src/integrations.ts` (`parseIntegrationMethod`, `assertIntegrationReady`, `refuseIfIntegrationDisabled`, `runHostIntegration`) is dispatched identically by docker (`server.ts`) and cloud (`host-actions.ts`) per the "fix across all providers" rule. Adding a service (Linear / Trello / ClickUp) is one new descriptor file + a one-line registry add — no relay change. See [`integrations.md`](./integrations.md) (design) and [`notion_backlog.md`](./notion_backlog.md) (per-task status). +- **Linear integration (relay-gated, host CLI)** — `agentbox-ctl integration linear ` and the in-box `linear` shim proxy a strict allowlist of ops (`whoami`, `issue.list`/`issue.mine`/`issue.view`/`issue.query`, `team.list`, query-only `api` GraphQL passthrough, `issue.create`/`issue.update`/`issue.comment`) through the host relay to the host's authenticated `linear` CLI (`@schpet/linear-cli`). Same gate model as Notion: reads pass through, writes prompt. The `api` op's `refuseGraphqlNonQuery` consumes value-bearing flag values (`--variable`, `--variables-json`) so a benign JSON payload isn't misread as a positional, rejects any GraphQL `mutation` / `subscription` operation with exit 65 (the GraphQL analogue of `refuseApiNonGet`), AND refuses `--variable key=@` host-file loads (the `@` syntax would let the box exfiltrate host files via GraphQL variables). **`linear auth token`** (which would print the raw API token to stdout) and `auth login`/`logout`/`migrate`/`default` are hard-rejected by the shim and absent from the connector allowlist — three defenses in series. `issue delete` / `team delete` / `team create` are off-list (destructive). `issue.comment` maps to `linear issue comment add` — `@schpet/linear-cli` v2 uses `add`, not `create`. Connector descriptor at `packages/integrations/src/connectors/linear.ts`; shim at `packages/sandbox-docker/scripts/linear-shim`; typed flag `integrations.linear.enabled` (default false); doctor row is driven off `ALL_CONNECTORS` so the linear entry lights up automatically. See [`integrations.md`](./integrations.md) (design) and [`linear_backlog.md`](./linear_backlog.md) (per-task status). - In-box `agentbox-ctl git pull|push [-- ]` (and any tool the agent runs that shells out via this command) — POSTs to the host relay's `/rpc`, which executes git on the host with the user's SSH agent + gitconfig. Commits made inside the box land in the host's main `.git/` immediately (the `.git/` is bind-mounted RW at its identical absolute path); `git push` is the only operation that needs host credentials, hence the RPC. - Browser support — Vercel's [`agent-browser`](https://github.com/vercel-labs/agent-browser) is baked into the box image (`npm install -g agent-browser`). The Chromium binary that drives it is *not* Chrome for Testing (no Linux ARM64 build, and Noble's `chromium-browser` apt package is a snap stub that doesn't run in containers) — it's Playwright's Chromium, which has working linux/arm64 + linux/amd64 builds. It is **not** baked: `ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/local/bin/chromium` points at the `chromium-resolver` script (`packages/sandbox-docker/scripts/chromium-resolver`, installed at `/usr/local/bin/chromium`), which on first launch reuses the newest installed Playwright Chromium and otherwise runs `playwright install chromium` — preferring the project's pinned Playwright (`/workspace/node_modules/.bin/playwright`, so the build matches the project's own tests and they share one binary), else the box's global `playwright` as a fallback downloader. This avoids baking a version-pinned Chromium that goes stale the instant a project pins a different Playwright (the old bug: a baked build masqueraded in `~/.cache/ms-playwright`, the project's `playwright install` fetched a different one, and agents waiting on the baked path hung). Chrome runtime libs (libnss3, libxkbcommon0, libcups2t64, etc. — Noble names with the `t64` suffix where applicable) are installed once at image build. Agents inside the box invoke `agent-browser` directly; sessions/auth/cookies persist under `~/.agent-browser/` in the container's writable layer, so they survive `pause/unpause` and `stop/start` and are wiped on `destroy`. The flag `--with-playwright` on both `agentbox create` and `agentbox claude` additionally runs `npm install -g @playwright/cli@latest` inside the container at create time (recorded as `BoxRecord.withPlaywright` and surfaced in `agentbox status --inspect`) — a separate package from the `playwright` runtime baked into the image. - Web service port — every box reserves container `:80` at create with an unconditional `docker run -p 127.0.0.1:0:80` (immutable after `docker run`, so it's reserved up front even though the `expose:`-flagged service is usually only known after the in-box wizard writes `agentbox.yaml`). The ephemeral host port is resolved via `docker port` and persisted to `BoxRecord.webHostPort` (re-resolved on every `startBox`, like `vncHostPort`, since Docker reallocates it). `getBoxEndpoints` emits a `kind: 'web'` endpoint whose URL is the published loopback port (`http://127.0.0.1:`) — **uniform across engines, not OrbStack-dependent**; it's the primary clickable link in `agentbox list`/`status`. Until a service declares `expose:` it renders as `web reserved (...)`. The in-box `:80 → expose.port` forward is the supervisor-owned `WebProxy` (see [`in-box-supervisor.md`](./in-box-supervisor.md)). Pre-feature boxes (no `BoxRecord.webContainerPort`) have no reservation and are skipped by `startBox` — recreate to enable. diff --git a/docs/host-relay.md b/docs/host-relay.md index a046a9c..54ca5f4 100644 --- a/docs/host-relay.md +++ b/docs/host-relay.md @@ -15,7 +15,7 @@ - **Rehydration after restart**: every `createBox` reads `~/.agentbox/state.json` and re-pushes every known `(relayToken, gitWorktrees)` via `rehydrateRelayRegistry()`. Idempotent and cheap, so we do it unconditionally instead of trying to detect a restart. `startBox` also re-registers its own box. - The supervisor pushes outbound: `packages/ctl/src/relay-client.ts` is a fire-and-forget POST to `/events` (node:http, 2s timeout, silent failure). `onServiceState` / `onTaskState` in `supervisor.ts` forward terminal states (`ready` / `crashed` / `backoff` / `unhealthy` / `stopped` / `done` / `failed`). Disabled at construction when `AGENTBOX_RELAY_URL` / `AGENTBOX_RELAY_TOKEN` are unset — keeps existing tests and pre-relay boxes a no-op. - In-box CLI: `agentbox-ctl git pull|push [-- ...]` (in `packages/ctl/src/commands/git.ts`) POSTs to `/rpc` with `{ method: 'git.pull'|'git.push', params: { path: , args: [...] } }`, streams `stdout`/`stderr` back to the agent's terminal, and exits with the host's git exit code. This is what the agent invokes to ask the host to push the box's commits — no SSH keys leak into the box. -- **Service integrations via host CLIs**: `agentbox-ctl integration [-- args...]` (and the in-box `ntn` / `notion` shims) POST `{ method: 'integration..', params: { path, args } }` for any connector registered in `@agentbox/integrations`. Currently shipped: `notion` (host bin `ntn`) with ops `whoami`, `api` (GET-only passthrough, refuses `-X`/`--method`/`-f`/`-F` / `--input`), `page.create` (gated), `page.update` (gated). The relay parses the method, looks up the connector + op, refuses with exit 65 if the per-project `integrations..enabled` flag is off, runs the op's `refuseCall` pre-flight, probes the host binary (` --version`, cached 60s), then either passes through (read) or gates the call via `askPrompt` (write) before shelling out to the host CLI via `runHostIntegration`. A connector-declared env namespace (`_*` only) is merged onto the host spawn env — Notion forces `NOTION_KEYRING=0` so file-based auth works in nested boxes; a descriptor that tries to set anything outside its namespace yields exit 78 instead of silently rewriting `PATH`. Same `{exitCode, stdout, stderr}` envelope as `git.*` / `gh.pr.*`; wired into both `server.ts` (docker) and `host-actions.ts` (cloud — daytona/hetzner/vercel/e2b) per the "fix across all providers" rule. The reusable spine lives in `packages/relay/src/integrations.ts` (`parseIntegrationMethod`, `getConnector`, `assertIntegrationReady`, `refuseIntegrationCall`, `refuseIfIntegrationDisabled`, `runHostIntegration`); the in-box ctl surface is built from the same descriptors in `packages/ctl/src/commands/integration.ts`. Adding a service is one new descriptor file + a one-line registry add — no relay change. See [`integrations.md`](./integrations.md) for the design + the connector descriptor shape. +- **Service integrations via host CLIs**: `agentbox-ctl integration [-- args...]` (and the in-box `ntn` / `notion` / `linear` shims) POST `{ method: 'integration..', params: { path, args } }` for any connector registered in `@agentbox/integrations`. Currently shipped: `notion` (host bin `ntn`) with ops `whoami`, `api` (GET-only passthrough, refuses `-X`/`--method`/`-f`/`-F` / `--input`), `page.create` (gated), `page.update` (gated); and `linear` (host bin `linear`, `@schpet/linear-cli`) with ops `whoami` (`auth whoami`), `issue.list` / `issue.mine` / `issue.view` / `issue.query`, `team.list`, `api` (GraphQL **query-only** passthrough — `refuseGraphqlNonQuery` consumes `--variable` / `--variables-json` values, rejects `mutation` / `subscription`, and rejects `--variable key=@` host-file loads), `issue.create` / `issue.update` / `issue.comment` (gated; `issue.comment` maps to `linear issue comment add` — v2 uses `add`, not `create`). The linear shim hard-rejects `linear auth token` (would print the raw API key) and `auth login/logout/migrate/default`; destructive ops (`issue delete`, `team delete`, `team create`) are off the allowlist. The relay parses the method, looks up the connector + op, refuses with exit 65 if the per-project `integrations..enabled` flag is off, runs the op's `refuseCall` pre-flight, probes the host binary (` --version`, cached 60s), then either passes through (read) or gates the call via `askPrompt` (write) before shelling out to the host CLI via `runHostIntegration`. A connector-declared env namespace (`_*` only) is merged onto the host spawn env — Notion forces `NOTION_KEYRING=0` so file-based auth works in nested boxes; a descriptor that tries to set anything outside its namespace yields exit 78 instead of silently rewriting `PATH`. Same `{exitCode, stdout, stderr}` envelope as `git.*` / `gh.pr.*`; wired into both `server.ts` (docker) and `host-actions.ts` (cloud — daytona/hetzner/vercel/e2b) per the "fix across all providers" rule. The reusable spine lives in `packages/relay/src/integrations.ts` (`parseIntegrationMethod`, `getConnector`, `assertIntegrationReady`, `refuseIntegrationCall`, `refuseIfIntegrationDisabled`, `runHostIntegration`); the in-box ctl surface is built from the same descriptors in `packages/ctl/src/commands/integration.ts`. Adding a service is one new descriptor file + a one-line registry add — no relay change. See [`integrations.md`](./integrations.md) for the design + the connector descriptor shape. - **PR ops via host `gh`**: `agentbox-ctl git pr [args...]` POSTs `{ method: 'gh.pr.', params: { path, args } }` for `op ∈ {create, view, list, comment, review, merge, checkout, close, reopen}`. The relay shells `gh pr ` with `cwd = worktree.hostMainRepo` so gh infers the repo from the host repo's `git remote -v`. Read-only ops (`view`, `list`) bypass the prompt; everything else triggers an `askPrompt`. Extra guards: `merge` refuses the `AGENTBOX_PROMPT=off` auto-`y` unless `AGENTBOX_GH_FORCE=1`; `checkout` is disabled by default (opt-in via `AGENTBOX_GH_PR_CHECKOUT=allow`) and refused on a dirty host tree or a host HEAD that matches any registered box branch (would corrupt the bind-mounted box `.git/HEAD`). Cloud path mirrors the same matrix in `executeCloudAction → runGhPrRpc`, with the no-attached-wrapper behavior gated by `AGENTBOX_GH_NO_SUB` (`deny` default, `allow`, or `prompt`). Requires `gh` installed and `gh auth login` on the host; for HTTPS push/pull/fetch we additionally recommend `gh auth setup-git` so plain `git push` uses gh's OAuth token (handled invisibly by git's credential helpers — no relay change needed). - `agentbox-ctl open ` (`packages/ctl/src/commands/open.ts`) opens the URL in the **box's own Chromium** via `agent-browser open --headed` (visible in the VNC view / `agentbox screen`), then best-effort POSTs `{ method: 'browser.open', params: { url } }` to the relay. The relay records a `browser-open` event, answers immediately (never blocks the box), and raises a **non-blocking, auto-expiring** confirm prompt (`askPrompt(..., { ttlMs })`, ~25s) in the footer/dashboard — "open link on the host?" — and only `open`s it on the host on a `y`. URL scheme is validated http/https both in the ctl command and via `isOpenableUrl` in `server.ts`. The box image symlinks `/usr/local/bin/xdg-open` to the `agentbox-open` wrapper and sets `BROWSER=/usr/local/bin/agentbox-open`, so `xdg-open` and any `$BROWSER`-aware tool (Claude Code OAuth, `gh`, …) route here. The `Ctrl+a u` footer/dashboard leader action is unrelated — it opens the box's web *app* on the host (`agentbox url`). - **Host-action approvals (orchestrator path)**: the confirm prompts that gate `git.push` / `cp.*` / `gh.pr.*` writes / `checkpoint.create` / `browser.open` are raised by `askPrompt` and answered over `/admin/prompts/answer`. Because that endpoint is **loopback-only**, only a host process can answer — a box can't. A host-side **orchestrator** (e.g. a Claude driving boxes with `agentbox claude -i`) inspects and answers them deliberately via two CLI commands (`apps/cli/src/commands/agent.ts`): diff --git a/docs/integrations.md b/docs/integrations.md index d4bcbf3..f3a7c49 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -180,9 +180,66 @@ tracked under "Open follow-ups" below. `integration..` is dispatched identically on docker and cloud because the wire shape is method-agnostic. The cloud path long-polls `/bridge/poll`, runs `executeCloudAction → runIntegrationRpc`, which reuses the exact handler. The Hetzner / Daytona / Vercel / E2B image flows all ship the `ntn` / `notion` shim (see "In-box surface" above). No provider-specific code in the integrations spine. +## Linear + +The Linear path of the integrations foundation, shipped under LT1 (descriptor-only — no relay/ctl core change, validating the abstraction the Notion work built). The connector descriptor lives in `packages/integrations/src/connectors/linear.ts`; in-box shim at `packages/sandbox-docker/scripts/linear-shim`. Backed by `@schpet/linear-cli` (the `linear` binary, v2). Tracker: [`linear_backlog.md`](./linear_backlog.md). + +### Op surface + +`packages/integrations/src/connectors/linear.ts` carries the current allowlist. Same starter-conservative shape as Notion's: reads pass through, writes go through `askPrompt`. + +| Op | Class | Host argv | Notes | +| -------------- | --------------- | ---------------------------------- | -------------------------------------------------------------------------------------- | +| `whoami` | read | `linear auth whoami` | identity only — **never** `linear auth token` (see below). | +| `issue.list` | read | `linear issue list` | | +| `issue.mine` | read | `linear issue mine` | v2-native "issues assigned to me" (the old `list --me` path was dropped upstream). | +| `issue.view` | read | `linear issue view` | | +| `issue.query` | read | `linear issue query` | structured filters. | +| `team.list` | read | `linear team list` | | +| `api` | read | `linear api ` | GraphQL query passthrough; `refuseGraphqlNonQuery` rejects `mutation` / `subscription` and any `--variable key=@` host-file load. | +| `issue.create` | write (gated) | `linear issue create` | | +| `issue.update` | write (gated) | `linear issue update` | status/title/etc. | +| `issue.comment`| write (gated) | `linear issue comment add` | `@schpet/linear-cli` v2 uses `add` (not `create`). | + +### The auth-token exclusion (key security invariant) + +`linear auth token` PRINTS the raw API token to stdout. It is **never** on the allowlist: + +- The shim **hard-rejects** `linear auth token` with `'auth token' leaks the raw API key — refused. Use 'linear whoami' for identity.` (exit 2). Same hard-reject for `auth login` / `auth logout` / `auth migrate` / `auth default` — the host owns auth state. +- The connector exposes no op that maps to `auth token`. The only auth-family op is `whoami`, which maps to `linear auth whoami` (identity only). +- The relay's allowlist (the connector's `ops` map) denies any RPC whose op isn't on the list, so even if the shim were bypassed, the relay would refuse. + +Three defenses, all in series. A box agent can't reach `linear auth token` through any of them. + +`issue delete` / `team delete` / `team create` are similarly off-list (destructive; start conservative, widen deliberately). + +### The GraphQL mutation gate (`refuseGraphqlNonQuery`) + +Linear's `api` subcommand is a raw GraphQL endpoint — one POST that serves both queries (read) and mutations (write). The `api` op is a read passthrough, so it carries `refuseCall: refuseGraphqlNonQuery`. The predicate: + +- Walks argv, **consuming the value** after value-bearing flags (`--variable`, `--variables-json`) so their payload isn't misread as a positional GraphQL source. +- For every remaining positional, strips leading whitespace + `# …` line comments and refuses if the first keyword is `mutation` or `subscription` (exit 65, `linear api: only GraphQL queries are proxied …`). +- `query …` and the anonymous `{ … }` shorthand pass; empty/flag-only argv passes (the host CLI emits its own usage error). +- `--input` / `--input=…` is refused — stdin/file bodies can't traverse the relay anyway. +- **`--variable key=@` is refused.** linear-cli's `@` syntax reads from a host file and sends the contents as a GraphQL variable, which the box could echo back through the query response — an exfiltration channel. The guard rejects every split/glued/equals shape of the flag. + +The match is case-insensitive (defensive — GraphQL is case-sensitive in spec, but the cost of guarding is zero). The parser is not a GraphQL validator; it's a write-shape detector. Writes go through the dedicated gated `issue.*` ops, never `api`. + +### Enable flag + +`integrations.linear.enabled` (typed config, default **false**) lives next to the Notion flag in `packages/config/src/types.ts`. Same layering, same disabled-default rationale. + +```bash +agentbox config set --project integrations.linear.enabled true +``` + +### env / credentials + +Linear stores plaintext credentials at `~/.config/linear/credentials.toml` (keyring is opt-in, not used). Unlike `ntn` (which needs `NOTION_KEYRING=0` to read file-based auth on Linux boxes), `linear` already reads the toml on every host by default — so the connector declares **no** `env` block. `mergeConnectorEnv` would only allow `LINEAR_*` keys anyway. The `agentbox.yaml` `carry:` block ships the file into nested boxes that run their own relay. + ## Open follow-ups -- **Linear / Trello / ClickUp** — see [`integrations_backlog.md`](./integrations_backlog.md). Each is a new descriptor + a small shim; no relay change. ClickUp will be the one custom REST connector (no good CLI on PyPI / npm). +- **Trello / ClickUp** — see [`integrations_backlog.md`](./integrations_backlog.md). Each is a new descriptor + a small shim; no relay change. ClickUp will be the one custom REST connector (no good CLI on PyPI / npm). - **`comment.add`** — deferred; needs a Notion-API-aware payload translator that maps CLI flags to the structured `POST /v1/comments` body. - **Least-privilege tokens** — Notion capability toggles for the host token; Trello supports `scope=read` (when we add it); Linear personal keys inherit full user perms (OAuth-only for read-scope tokens). Document on each service's user-facing page. - **Host-initiated tokens** — the relay already accepts `params.hostInitiated` and validates it against `HostInitiatedTokens` (scope + params-hash bound). The host-CLI mint path that issues those tokens isn't wired yet for integrations; once it is, a host-typed `agentbox-ctl integration notion page.create …` can skip the prompt by minting a token first (same shape as the existing `gh.pr.*` and `cp.*` host-initiated paths). diff --git a/docs/integrations_backlog.md b/docs/integrations_backlog.md index ba1f1c1..285653b 100644 --- a/docs/integrations_backlog.md +++ b/docs/integrations_backlog.md @@ -193,8 +193,10 @@ shim + tests." 7. Tests + docs (see below). ### Session 2 — Linear (`schpet/linear-cli`) -- `connectors/linear.ts`: hostBin `linear`. Reads (`issue list/view`, `team list`); - writes (`issue create`, `issue update`/status, `comment create`) — gated. +- `connectors/linear.ts`: hostBin `linear`. Reads (`issue list/mine/view/query`, + `team list`, `auth whoami` via the `whoami` op, query-only `api` GraphQL + passthrough); writes (`issue create`, `issue update`/status, `issue comment add` + — `@schpet/linear-cli` v2 uses `add`, not `create`) — gated. - `linear-shim`; config flag; doctor entry. No relay/ctl core changes (descriptor only) — this validates the abstraction. diff --git a/docs/linear_backlog.md b/docs/linear_backlog.md index 72e357b..ec2b4d0 100644 --- a/docs/linear_backlog.md +++ b/docs/linear_backlog.md @@ -36,18 +36,19 @@ e2e. v2.0.0 surface (richer than the plan assumed): | op | read/write | host argv | notes | |---|---|---|---| | `whoami` | read | `auth whoami` | identity only — **never** `auth token` | -| `issue.list` | read | `issue list` | a.k.a. `mine` | +| `issue.list` | read | `issue list` | | +| `issue.mine` | read | `issue mine` | v2-native "issues assigned to me" | | `issue.view` | read | `issue view` | | | `issue.query` | read | `issue query` | structured filters | | `team.list` | read | `team list` | | -| `api` | read | `api` | `refuseCall` rejects GraphQL mutation/subscription | +| `api` | read | `api` | `refuseCall` rejects GraphQL mutation/subscription + `--variable key=@` | | `issue.create` | write (gated) | `issue create` | | | `issue.update` | write (gated) | `issue update` | status/title/etc. | -| `issue.comment` | write (gated) | `issue comment create` | | +| `issue.comment` | write (gated) | `issue comment add` | `@schpet/linear-cli` v2 uses `add`, not `create` | ## Tasks -### LT1 — Connector + shim + config + doctor + unit tests + docs — **status: not started** +### LT1 — Connector + shim + config + doctor + unit tests + docs — **status: done (2026-06-06)** - `packages/integrations/src/connectors/linear.ts` (+ register in `registry.ts`; widen the `IntegrationService` union in `types.ts` to include `'linear'`). - `refuseGraphqlNonQuery` (or similar) for the `api` op — refuse mutation/subscription. @@ -92,5 +93,46 @@ e2e. v2.0.0 surface (richer than the plan assumed): authed against `waldosai` (admin, accounts@waldos.ai). Connector surface scouted; security notes captured (auth-token leak, destructive deletes, GraphQL mutation gate). Linear carry entries added to `agentbox.yaml`. - - +- 2026-06-06: **LT1 shipped.** Descriptor-only, no relay/ctl core changes. + - Connector at `packages/integrations/src/connectors/linear.ts` with ops + `whoami` (`auth whoami`), `issue.list`/`issue.mine`/`issue.view`/`issue.query`, + `team.list`, `api` (+ `refuseGraphqlNonQuery` GraphQL mutation/subscription + gate, value-consuming flag walker, `--variable key=@` host-file-load + refusal, Unicode-whitespace + BOM-prefix bypass guard), + `issue.create`/`issue.update`/`issue.comment` (gated writes; `issue.comment` + maps to `linear issue comment add` — `@schpet/linear-cli` v2 uses `add`, + not `create`). `IntegrationService` union widened to include `'linear'`. + - Shim at `packages/sandbox-docker/scripts/linear-shim` (installed at + `/usr/local/bin/linear`, no symlink alias). Strict allowlist; hard- + rejects `auth token` (raw-API-key leak), `auth login/logout/migrate/ + default`, `issue/team delete`, `team create`. Staged across all five + providers (docker COPY, hetzner install-box.sh, vercel provision.sh, + e2b build-template.sh, daytona is shim-less by design) via + `stage-runtime.mjs` + each provider's `runtime-assets.ts`. + - Typed config flag `integrations.linear.enabled` (default `false`) added + to `UserConfig` / `EffectiveConfig` / `BUILT_IN_DEFAULTS` / + `KEY_REGISTRY` in `packages/config/src/types.ts`. + - Doctor: zero-line change — `ALL_CONNECTORS` drives `integrationsChecks`, + so the Linear row appears automatically with the right install/login + hints from the connector descriptor. + - Unit tests (pure, no docker/network): + - `packages/integrations/test/registry.test.ts` — registry resolves + `linear`, op classification, argv shapes, `refuseGraphqlNonQuery` + cases (mutation refused, query allowed, anonymous `{…}` allowed, + leading whitespace + `# comment` tolerated, `--input` refused, + case-insensitive keyword match). + - `packages/ctl/test/gh-and-shims.test.ts` — `linear-shim` allowlist + tests including the explicit `auth token` rejection and the + destructive-op refusals. + - `apps/cli/test/doctor-integrations.test.ts` — updated for + multi-connector iteration. + - `packages/relay/test/*` — updated the two existing tests that used + `linear` as the "unknown service" example (now `trello`). + - `pnpm typecheck && pnpm test && pnpm build && pnpm lint` all green. + - Docs updated in the same change: `docs/integrations.md` (design + the + GraphQL gate + auth-token exclusion notes), new public page at + `apps/web/content/docs/integrations-linear.mdx` + meta.json entry, + `apps/web/content/docs/configuration.mdx` row, `cli.mdx` doctor + pointer, `docs/host-relay.md` bullet extension, `docs/features.md` + "what works today" bullet. Live e2e against the Waldosai workspace + is LT2 — deliberately not run in LT1. diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 00f82ef..b565910 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -153,6 +153,9 @@ export interface UserConfig { notion?: { enabled?: boolean; }; + linear?: { + enabled?: boolean; + }; }; } @@ -274,6 +277,9 @@ export interface EffectiveConfig { notion: { enabled: boolean; }; + linear: { + enabled: boolean; + }; }; } @@ -414,6 +420,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { }, integrations: { notion: { enabled: false }, + linear: { enabled: false }, }, }; @@ -870,6 +877,12 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ description: 'Enable the in-box Notion integration shim (`ntn`/`notion` commands routed via the host relay). When false (default), the relay refuses dispatch with a clear "disabled" error and no host process is touched.', }, + { + key: 'integrations.linear.enabled', + type: 'bool', + description: + 'Enable the in-box Linear integration shim (`linear` commands routed via the host relay; backed by `@schpet/linear-cli`). When false (default), the relay refuses dispatch with a clear "disabled" error and no host process is touched.', + }, ]; const REGISTRY_BY_KEY = new Map(KEY_REGISTRY.map((d) => [d.key, d])); diff --git a/packages/ctl/test/gh-and-shims.test.ts b/packages/ctl/test/gh-and-shims.test.ts index 0e6b7c2..4feaca4 100644 --- a/packages/ctl/test/gh-and-shims.test.ts +++ b/packages/ctl/test/gh-and-shims.test.ts @@ -9,6 +9,7 @@ const REPO_ROOT = join(import.meta.dirname, '..', '..', '..'); const GH_SHIM = join(REPO_ROOT, 'packages/sandbox-docker/scripts/gh-shim'); const GIT_SHIM = join(REPO_ROOT, 'packages/sandbox-docker/scripts/git-shim'); const NTN_SHIM = join(REPO_ROOT, 'packages/sandbox-docker/scripts/ntn-shim'); +const LINEAR_SHIM = join(REPO_ROOT, 'packages/sandbox-docker/scripts/linear-shim'); interface StubShellEnv { tmpDir: string; @@ -741,3 +742,261 @@ describe('ntn-shim subcommand allowlist', () => { } }); }); + +describe('linear-shim subcommand allowlist', () => { + it('whoami forwards to integration linear whoami', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['whoami'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain('STUB: integration linear whoami --'); + } finally { + env.cleanup(); + } + }); + + it('auth whoami (formal form) forwards to integration linear whoami', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['auth', 'whoami'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain('STUB: integration linear whoami --'); + } finally { + env.cleanup(); + } + }); + + it('auth token is hard-rejected with the leak warning (key security invariant)', () => { + // `linear auth token` PRINTS the raw API token to stdout. Proxying it + // through the shim would defeat the whole point: tokens must never enter + // the box. The shim's rejection is the first of three defenses (shim + + // connector allowlist + relay dispatch); this is the one that the agent + // hits first. + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['auth', 'token'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/leaks the raw API key/); + expect(out.stdout).toBe(''); + } finally { + env.cleanup(); + } + }); + + it.each([['login'], ['logout'], ['migrate'], ['default']])( + 'auth %s is rejected (host owns auth state)', + (sub) => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['auth', sub], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/is not proxied/); + expect(out.stdout).toBe(''); + } finally { + env.cleanup(); + } + }, + ); + + it('issue list / mine / view / query forward as reads', () => { + const env = makeStubShell(); + try { + expect(runShim(LINEAR_SHIM, ['issue', 'list', '--limit', '5'], env).stdout).toContain( + 'STUB: integration linear issue.list -- --limit 5', + ); + // `issue mine` is the v2-native "issues assigned to me" read — the + // older `list --me` was dropped upstream, so we route mine explicitly. + expect(runShim(LINEAR_SHIM, ['issue', 'mine'], env).stdout).toContain( + 'STUB: integration linear issue.mine --', + ); + expect(runShim(LINEAR_SHIM, ['issue', 'view', 'ABC-1'], env).stdout).toContain( + 'STUB: integration linear issue.view -- ABC-1', + ); + expect(runShim(LINEAR_SHIM, ['issue', 'query', '--team', 'ABC'], env).stdout).toContain( + 'STUB: integration linear issue.query -- --team ABC', + ); + } finally { + env.cleanup(); + } + }); + + it('issue create / update forward as gated writes', () => { + const env = makeStubShell(); + try { + expect( + runShim(LINEAR_SHIM, ['issue', 'create', '--title', 'hi'], env).stdout, + ).toContain('STUB: integration linear issue.create -- --title hi'); + expect( + runShim(LINEAR_SHIM, ['issue', 'update', 'ABC-1', '--state', 'done'], env).stdout, + ).toContain('STUB: integration linear issue.update -- ABC-1 --state done'); + } finally { + env.cleanup(); + } + }); + + it('issue comment add forwards to integration linear issue.comment', () => { + // `@schpet/linear-cli` v2 uses `comment add`, NOT `comment create`. Both + // sides (shim subcommand match + connector buildArgv) say `add`; the + // dotted wire op stays `issue.comment` for stability. + const env = makeStubShell(); + try { + const out = runShim( + LINEAR_SHIM, + ['issue', 'comment', 'add', 'ABC-1', '--body', 'hi'], + env, + ); + expect(out.code).toBe(0); + expect(out.stdout).toContain( + 'STUB: integration linear issue.comment -- ABC-1 --body hi', + ); + } finally { + env.cleanup(); + } + }); + + it('issue comment create is rejected (v2 uses `add`)', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['issue', 'comment', 'create'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/unsupported 'issue comment create'/); + } finally { + env.cleanup(); + } + }); + + it('issue comment with no subcommand is rejected', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['issue', 'comment'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/missing subcommand for 'issue comment'/); + } finally { + env.cleanup(); + } + }); + + it('issue delete is rejected (off-list, destructive)', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['issue', 'delete', 'ABC-1'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/'issue delete' is not proxied/); + } finally { + env.cleanup(); + } + }); + + it('team list forwards as a read', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['team', 'list'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain('STUB: integration linear team.list --'); + } finally { + env.cleanup(); + } + }); + + it.each([['create'], ['delete']])( + 'team %s is rejected (off-list, destructive)', + (sub) => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['team', sub, 'Foo'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/is not proxied/); + } finally { + env.cleanup(); + } + }, + ); + + it('api forwards the positional query intact (relay enforces query-only)', () => { + // The shim does NOT replicate refuseGraphqlNonQuery — that's the relay's + // job. It must hand through whatever the agent typed so the relay sees + // the real query and can refuse, instead of the agent thinking the call + // succeeded silently. + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['api', '{ teams { id } }'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain('STUB: integration linear api -- { teams { id } }'); + } finally { + env.cleanup(); + } + }); + + it('api accepts pre-positional flags (linear api --paginate "")', () => { + // `linear api` legitimately accepts --variable / --variables-json / + // --paginate / --silent BEFORE the positional query. The shim's + // "requires positional" check used to refuse any leading flag — fixed + // to just require at least one arg. + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['api', '--paginate', '{ teams { id } }'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain( + 'STUB: integration linear api -- --paginate { teams { id } }', + ); + } finally { + env.cleanup(); + } + }); + + it('api with no args at all is rejected', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['api'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/'api' requires a positional /); + } finally { + env.cleanup(); + } + }); + + it.each([ + ['project'], + ['cycle'], + ['milestone'], + ['initiative'], + ['label'], + ['document'], + ['schema'], + ])('unsupported top-level subcommand %s is rejected with the allowed list', (sub) => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, [sub], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/is not proxied/); + expect(out.stderr).toMatch( + /whoami, issue \{list,mine,view,query,create,update,comment add\}/, + ); + } finally { + env.cleanup(); + } + }); + + it('--version prints the shim version line', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, ['--version'], env); + expect(out.code).toBe(0); + expect(out.stdout).toMatch(/^linear version /); + expect(out.stdout).toContain('agentbox-shim'); + } finally { + env.cleanup(); + } + }); + + it('no args fails with the supported-subcommands hint', () => { + const env = makeStubShell(); + try { + const out = runShim(LINEAR_SHIM, [], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/no subcommand/); + } finally { + env.cleanup(); + } + }); +}); diff --git a/packages/integrations/src/connectors/linear.ts b/packages/integrations/src/connectors/linear.ts new file mode 100644 index 0000000..726e113 --- /dev/null +++ b/packages/integrations/src/connectors/linear.ts @@ -0,0 +1,268 @@ +import type { IntegrationConnector, IntegrationOpRefusal } from '../types.js'; + +/** + * Linear connector — wraps `@schpet/linear-cli` (the `linear` binary, v2). + * + * The op allowlist is intentionally minimal (start conservative, widen as + * real agent flows surface needs). Reads cover identity/listing/lookup + * (`whoami`, `issue list/view/query`, `team list`) plus a GraphQL + * passthrough (`api`), and writes are limited to issue create/update and a + * gated comment. The `api` passthrough is query-only — + * `refuseGraphqlNonQuery` rejects any operation whose first non-whitespace + * keyword is `mutation` or `subscription`, so the GraphQL endpoint can't + * be used to slip a write past the read classification (the GraphQL + * analogue of `notion.api`'s `refuseApiNonGet`). + * + * Three subcommands are deliberately absent from the allowlist for + * security reasons: + * - `auth token` — PRINTS the raw API token to stdout; proxying it + * through the relay would expose the host credential to the box. + * The only `auth` op we expose is `auth whoami` (identity only), via + * the `whoami` op. + * - `auth login` / `auth logout` / `auth migrate` / `auth default` — + * the host owns auth; relaying these would mutate host state. + * - `issue delete` / `team delete` / `team create` — destructive and + * unnecessary for the documented agent flows. Add deliberately, as + * gated writes, only when a real flow needs them. + * + * No `env` override is needed. Linear stores plaintext credentials at + * `~/.config/linear/credentials.toml` and keychain mode is opt-in, not + * the default — so unlike `ntn` (which forces `NOTION_KEYRING=0`), + * `linear` already reads file-based auth on every host without any + * env shaping. The carry block in `agentbox.yaml` ships that file + * into nested boxes that run their own relay. + */ +export const linearConnector: IntegrationConnector = { + service: 'linear', + hostBin: 'linear', + detect: { + versionArgs: ['--version'], + authArgs: ['auth', 'whoami'], + installHint: 'install @schpet/linear-cli: npm i -g @schpet/linear-cli', + loginHint: 'linear auth login', + }, + ops: { + whoami: { + write: false, + buildArgv: (args) => ['auth', 'whoami', ...args], + }, + 'issue.list': { + write: false, + buildArgv: (args) => ['issue', 'list', ...args], + }, + 'issue.mine': { + // The v2-native read for "issues assigned to me" — the README directs + // users here in place of the older `issue list --me`. Listed as a + // separate op so the shim doesn't reject the canonical form. + write: false, + buildArgv: (args) => ['issue', 'mine', ...args], + }, + 'issue.view': { + write: false, + buildArgv: (args) => ['issue', 'view', ...args], + }, + 'issue.query': { + write: false, + buildArgv: (args) => ['issue', 'query', ...args], + }, + 'team.list': { + write: false, + buildArgv: (args) => ['team', 'list', ...args], + }, + api: { + write: false, + buildArgv: (args) => ['api', ...args], + refuseCall: refuseGraphqlNonQuery, + }, + 'issue.create': { + write: true, + buildArgv: (args) => ['issue', 'create', ...args], + }, + 'issue.update': { + write: true, + buildArgv: (args) => ['issue', 'update', ...args], + }, + 'issue.comment': { + // Maps to `linear issue comment add` — `@schpet/linear-cli` v2 uses + // `add` (not `create`); `add`'s sibling subcommands are `list`, + // `update`, `delete`. + write: true, + buildArgv: (args) => ['issue', 'comment', 'add', ...args], + }, + }, +}; + +/** + * Reject any `linear api` call whose GraphQL source declares a `mutation` + * or `subscription` operation. The Linear `api` op is a single POST that + * serves both reads and writes — without this guard, the "read" + * classification would be a hole the agent could slip writes through. + * + * `linear-cli`'s `api` subcommand takes the GraphQL query as a positional + * argument and accepts `--variable key=value` (repeatable; the value may + * be `@/path` to load from a host file — see below), `--variables-json + * `, `--paginate`, and `--silent`. We: + * + * 1. Refuse `--variable key=@` (and the `=` and `--variable=` + * glued forms) because they would let the box trigger arbitrary + * host-file reads — the file contents become GraphQL variables and + * can be echoed back through the response, an exfiltration channel. + * 2. Refuse `--input` for parity with `refuseApiNonGet`, even though + * `linear api` doesn't currently accept it — if a future version + * adds it, the guard pre-empts the stdin/file-body shape. + * 3. Walk argv consuming value-bearing flags (`--variable`, + * `--variables-json`) so their JSON/key=value payload isn't + * misread as an operation keyword. + * 4. For every remaining positional (non-flag) token, strip leading + * whitespace + `# …` line comments and reject the call if the + * first identifier is `mutation` or `subscription`. + * + * `query …` and the anonymous `{ … }` shorthand pass. Empty/flag-only + * argv passes (the host CLI emits its own usage error). + */ +function refuseGraphqlNonQuery(args: readonly string[]): IntegrationOpRefusal | null { + const refuse = (reason: string): IntegrationOpRefusal => ({ + exitCode: 65, + stderr: `linear api: ${reason}\n`, + }); + // `--variable` and `--variables-json` each take the next argv token as + // their value — the loop consumes them explicitly below so a JSON + // payload starting with `mutation`/`subscription` isn't misread as the + // GraphQL operation. The consume-next branches refuse to swallow the + // next token if it LOOKS like a flag (`--…`) — otherwise a malformed + // `--variable --input=/etc/passwd` would silently skip the `--input` + // refusal one iteration later. + for (let i = 0; i < args.length; i++) { + const arg = args[i] ?? ''; + if (arg === '--input' || arg.startsWith('--input=')) { + return refuse("'--input' (stdin/file body) isn't supported through the relay"); + } + // `--variable key=@/host/path` reads from a host file — refuse the + // `@`-prefixed value form regardless of split/glued/equals shape. + if (arg === '--variable') { + const next = args[i + 1] ?? ''; + if (variableValueIsFileLoad(next)) { + return refuse( + "'--variable key=@' (host-file load) isn't supported through the relay", + ); + } + // Don't consume a token that's itself a flag — it needs to run + // through its own per-flag checks (e.g. `--variable --input=/x`). + if (!next.startsWith('--')) i++; + continue; + } + if (arg.startsWith('--variable=')) { + if (variableValueIsFileLoad(arg.slice('--variable='.length))) { + return refuse( + "'--variable=key=@' (host-file load) isn't supported through the relay", + ); + } + continue; + } + if (arg === '--variables-json') { + const next = args[i + 1] ?? ''; + if (!next.startsWith('--')) i++; + continue; + } + if (arg.startsWith('--variables-json=')) { + continue; + } + // Only LONG flags (`--…`) skip the keyword check. A bare `-` or a + // single-dash token like `-mutation` is treated as a positional so + // it goes through `firstGraphqlOperationKeyword` and the + // unparseable/mutation cases fail closed. + if (arg.startsWith('--')) continue; + const op = firstGraphqlOperationKeyword(arg); + if (op === 'mutation' || op === 'subscription') { + return refuse( + `only GraphQL queries are proxied (use issue.create / issue.update / issue.comment for writes); detected operation '${op}'`, + ); + } + // `unparseable` (a positional whose first significant char isn't `{` + // or an ASCII letter) is refused too. Real queries start with `query`, + // `mutation`, `subscription`, or `{`. Anything else is a garbage + // shape that we'd rather not forward — the agent gets a clear refusal + // instead of an opaque host CLI error. + if (op === 'unparseable') { + return refuse( + `couldn't classify positional argv ${JSON.stringify(arg)} as a GraphQL operation (expected 'query', 'mutation', 'subscription', or '{')`, + ); + } + } + return null; +} + +/** + * True when a `--variable` value uses linear-cli's `@` host-file load + * syntax. The standard shape is `key=@`, but we refuse any value + * that CONTAINS `=@` or a bare leading `@` — guards against: + * - `key=@` (canonical). + * - `@` (bare, no `key=` prefix). + * - `key=name=@` where a `=` appears in the key/name portion. + * linear-cli's `--variable` parser may split on the FIRST `=` (so the + * value is `name=@`) or on the LAST `=` (so the value is + * `@`); we refuse both interpretations by treating any `=@` + * anywhere in the string as a file-load signal. + * - Future shape changes: if linear-cli adds escaping or new prefixes, + * refusing on the literal `=@` substring stays conservative. + */ +function variableValueIsFileLoad(value: string): boolean { + if (value.startsWith('@')) return true; + return value.includes('=@'); +} + +/** + * Extract the first GraphQL operation keyword from a source string after + * stripping leading whitespace and `# …` line comments. Returns the + * keyword (`query` | `mutation` | `subscription`) when one is found, + * `'anonymous'` for the `{ … }` shorthand, or `null` for an empty source. + * Only the prefix matters — the rest of the source is not validated; + * we're not a GraphQL parser, just a write-shape detector. + * + * Returns `'unparseable'` (not null) for sources whose first non-whitespace + * non-comment character isn't `{` or an ASCII letter — that way an outer + * gate can decide to fail-CLOSED on shapes it doesn't recognize (BOM, + * NBSP, stray punctuator, etc.) instead of silently passing them. The + * caller in `refuseGraphqlNonQuery` is unchanged: it only refuses on + * `mutation` / `subscription`, so `'unparseable'` still passes — but the + * sentinel is available for a future stricter mode. + * + * The whitespace test uses the JS `\s` class so Unicode whitespace + * (U+00A0 NBSP, U+2028, the BOM U+FEFF, etc.) is stripped before the + * keyword check — otherwise a `'mutation {…}'` source would + * bypass the gate because `` is not in `[ \t\n\r,]` and not an + * ASCII letter, so `j === i` and the function returned null. + */ +function firstGraphqlOperationKeyword(source: string): string | null { + let i = 0; + const n = source.length; + while (i < n) { + const c = source[i]!; + if (/\s/.test(c) || c === ',' || c === '') { + i++; + continue; + } + if (c === '#') { + while (i < n && source[i] !== '\n') i++; + continue; + } + break; + } + if (i >= n) return null; + if (source[i] === '{') return 'anonymous'; + let j = i; + while (j < n) { + const c = source[j]!; + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + j++; + } else { + break; + } + } + // No leading ASCII letter and not `{` — the source's first significant + // character is something we can't classify (stray punctuator, smart + // quote, control char). Return a sentinel rather than null so the gate + // can choose to be paranoid in the future. + if (j === i) return 'unparseable'; + return source.slice(i, j).toLowerCase(); +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 858be64..ad19f66 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -6,3 +6,4 @@ export type { } from './types.js'; export { ALL_CONNECTORS, getConnector } from './registry.js'; export { notionConnector } from './connectors/notion.js'; +export { linearConnector } from './connectors/linear.js'; diff --git a/packages/integrations/src/registry.ts b/packages/integrations/src/registry.ts index e133402..2503c5d 100644 --- a/packages/integrations/src/registry.ts +++ b/packages/integrations/src/registry.ts @@ -1,3 +1,4 @@ +import { linearConnector } from './connectors/linear.js'; import { notionConnector } from './connectors/notion.js'; import type { IntegrationConnector } from './types.js'; @@ -7,7 +8,7 @@ import type { IntegrationConnector } from './types.js'; * not present is denied. Mirrors `packages/core/src/provider.ts`'s * registry pattern for the provider abstraction. */ -export const ALL_CONNECTORS: readonly IntegrationConnector[] = [notionConnector]; +export const ALL_CONNECTORS: readonly IntegrationConnector[] = [notionConnector, linearConnector]; /** Lookup by `IntegrationConnector.service`. Returns `null` for unknown. */ export function getConnector(service: string): IntegrationConnector | null { diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 4724711..a9eb1d1 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -12,7 +12,7 @@ * allowlist is denied by the relay (mirrors `gh api`'s endpoint refusal). */ -export type IntegrationService = 'notion'; +export type IntegrationService = 'notion' | 'linear'; export interface IntegrationOp { /** Reads bypass the host confirm prompt; writes always gate via askPrompt. */ diff --git a/packages/integrations/test/registry.test.ts b/packages/integrations/test/registry.test.ts index 22c32d1..73d41d0 100644 --- a/packages/integrations/test/registry.test.ts +++ b/packages/integrations/test/registry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ALL_CONNECTORS, getConnector } from '../src/registry.js'; import { notionConnector } from '../src/connectors/notion.js'; +import { linearConnector } from '../src/connectors/linear.js'; describe('integration registry', () => { it('exposes the Notion connector exactly once', () => { @@ -8,15 +9,22 @@ describe('integration registry', () => { expect(ALL_CONNECTORS.filter((c) => c.service === 'notion')).toHaveLength(1); }); + it('exposes the Linear connector exactly once', () => { + expect(ALL_CONNECTORS).toContain(linearConnector); + expect(ALL_CONNECTORS.filter((c) => c.service === 'linear')).toHaveLength(1); + }); + it('looks up by service name', () => { expect(getConnector('notion')).toBe(notionConnector); + expect(getConnector('linear')).toBe(linearConnector); }); it('returns null for unknown services (allowlist)', () => { - expect(getConnector('linear')).toBeNull(); + expect(getConnector('trello')).toBeNull(); expect(getConnector('clickup')).toBeNull(); expect(getConnector('')).toBeNull(); expect(getConnector('NOTION')).toBeNull(); // case-sensitive — matches wire shape + expect(getConnector('LINEAR')).toBeNull(); }); }); @@ -108,3 +116,260 @@ describe('notion api refuseCall — keeps write:false honest', () => { expect(refuse(['v1/pages', '-f', '-X=GET'])?.exitCode).toBe(65); }); }); + +describe('linear connector', () => { + it('targets the @schpet/linear-cli `linear` binary', () => { + expect(linearConnector.hostBin).toBe('linear'); + }); + + it("declares no env override (linear uses plaintext credentials.toml)", () => { + // Unlike `ntn` (which needs NOTION_KEYRING=0 to read file-based auth on + // Linux boxes), `linear` already reads ~/.config/linear/credentials.toml + // by default. Setting an env var would require an entry in the + // _* namespace guard in mergeConnectorEnv; leaving it unset is + // the safer call. + expect(linearConnector.env).toBeUndefined(); + }); + + it('declares the doctor install/login hints so the doctor row is self-describing', () => { + expect(linearConnector.detect.versionArgs).toEqual(['--version']); + expect(linearConnector.detect.authArgs).toEqual(['auth', 'whoami']); + expect(linearConnector.detect.installHint).toMatch(/@schpet\/linear-cli/); + expect(linearConnector.detect.loginHint).toMatch(/linear auth login/); + }); + + it('classifies reads vs writes — auth-token-equivalent ops never reach the allowlist', () => { + const ops = linearConnector.ops; + expect(ops.whoami?.write).toBe(false); + expect(ops['issue.list']?.write).toBe(false); + expect(ops['issue.mine']?.write).toBe(false); + expect(ops['issue.view']?.write).toBe(false); + expect(ops['issue.query']?.write).toBe(false); + expect(ops['team.list']?.write).toBe(false); + expect(ops.api?.write).toBe(false); + expect(ops['issue.create']?.write).toBe(true); + expect(ops['issue.update']?.write).toBe(true); + expect(ops['issue.comment']?.write).toBe(true); + }); + + it('shapes argv so the connector — not the call site — owns the host CLI surface', () => { + const ops = linearConnector.ops; + expect(ops.whoami?.buildArgv?.([])).toEqual(['auth', 'whoami']); + expect(ops['issue.list']?.buildArgv?.(['--limit', '5'])).toEqual([ + 'issue', + 'list', + '--limit', + '5', + ]); + // `issue mine` is the v2-native "issues assigned to me" read; the older + // `issue list --me` path was dropped upstream. + expect(ops['issue.mine']?.buildArgv?.([])).toEqual(['issue', 'mine']); + expect(ops['issue.view']?.buildArgv?.(['ABC-1'])).toEqual(['issue', 'view', 'ABC-1']); + expect(ops['issue.query']?.buildArgv?.(['--team', 'ABC'])).toEqual([ + 'issue', + 'query', + '--team', + 'ABC', + ]); + expect(ops['team.list']?.buildArgv?.([])).toEqual(['team', 'list']); + expect(ops.api?.buildArgv?.(['{ teams { id } }'])).toEqual(['api', '{ teams { id } }']); + expect(ops['issue.create']?.buildArgv?.(['--title', 'hi'])).toEqual([ + 'issue', + 'create', + '--title', + 'hi', + ]); + expect(ops['issue.update']?.buildArgv?.(['ABC-1', '--state', 'done'])).toEqual([ + 'issue', + 'update', + 'ABC-1', + '--state', + 'done', + ]); + // `issue.comment` maps to `linear issue comment add` — `@schpet/linear-cli` + // v2 uses `add`, not `create`. The connector expands the dotted op into + // the three-segment host argv exactly here. + expect(ops['issue.comment']?.buildArgv?.(['ABC-1', '--body', 'hi'])).toEqual([ + 'issue', + 'comment', + 'add', + 'ABC-1', + '--body', + 'hi', + ]); + }); + + it('has exactly the conservative starter ops — no destructive deletes, no auth token', () => { + expect(Object.keys(linearConnector.ops).sort()).toEqual( + [ + 'whoami', + 'issue.list', + 'issue.mine', + 'issue.view', + 'issue.query', + 'team.list', + 'api', + 'issue.create', + 'issue.update', + 'issue.comment', + ].sort(), + ); + // Defense in depth: even if a future contributor adds an op called + // 'auth.token' or 'token', it must never be classified as a read passthrough + // — there's no good reason to expose any token-printing op to the box. + expect(linearConnector.ops['auth.token']).toBeUndefined(); + expect(linearConnector.ops['token']).toBeUndefined(); + expect(linearConnector.ops['issue.delete']).toBeUndefined(); + expect(linearConnector.ops['team.create']).toBeUndefined(); + expect(linearConnector.ops['team.delete']).toBeUndefined(); + }); +}); + +describe('linear api refuseCall — keeps write:false honest (GraphQL gate)', () => { + const refuse = linearConnector.ops.api!.refuseCall!; + + it('allows a named query', () => { + expect(refuse(['query Teams { teams { id } }'])).toBeNull(); + }); + + it('allows the anonymous { … } shorthand', () => { + expect(refuse(['{ teams { id } }'])).toBeNull(); + }); + + it('allows queries with leading whitespace and # line comments', () => { + expect(refuse([' \n# pick teams\nquery Teams { teams { id } }'])).toBeNull(); + expect(refuse(['# header comment\n{ teams { id } }'])).toBeNull(); + expect(refuse(['\t\n query Teams { teams { id } }'])).toBeNull(); + }); + + it('refuses a GraphQL mutation', () => { + const r = refuse(['mutation IssueCreate { issueCreate(input: {}) { issue { id } } }']); + expect(r).not.toBeNull(); + expect(r!.exitCode).toBe(65); + expect(r!.stderr).toMatch(/linear api/); + expect(r!.stderr).toMatch(/mutation/); + }); + + it('refuses a mutation hidden behind leading whitespace + comment', () => { + const r = refuse([ + ' # innocuous comment\n mutation IssueCreate { issueCreate(input: {}) { issue { id } } }', + ]); + expect(r).not.toBeNull(); + expect(r!.exitCode).toBe(65); + }); + + it('refuses a GraphQL subscription', () => { + const r = refuse(['subscription IssueUpdates { issueUpdates { id } }']); + expect(r).not.toBeNull(); + expect(r!.exitCode).toBe(65); + expect(r!.stderr).toMatch(/subscription/); + }); + + it('refuses --input (stdin/file body cannot cross the relay)', () => { + expect(refuse(['--input', '-'])?.exitCode).toBe(65); + expect(refuse(['--input=/tmp/x'])?.exitCode).toBe(65); + expect(refuse(['--input=/tmp/x'])?.stderr).toMatch(/--input/); + }); + + it('refuses --variable key=@ (host-file load is an exfiltration channel)', () => { + // `--variable key=@/host/path` reads the file and sends contents as a + // GraphQL variable — the box could echo the variable back through the + // query response, an exfiltration channel. + expect(refuse(['--variable', 'key=@/etc/passwd', '{ x }'])?.exitCode).toBe(65); + expect(refuse(['--variable=key=@/etc/passwd', '{ x }'])?.exitCode).toBe(65); + expect(refuse(['--variable', '@/etc/passwd', '{ x }'])?.exitCode).toBe(65); + expect(refuse(['--variable', 'key=@/etc/passwd'])?.stderr).toMatch(/host-file load/); + }); + + it('refuses --variable values with `=@` anywhere (last-vs-first-`=` split safety)', () => { + // Whether linear-cli splits the value on the FIRST `=` (giving + // `name=@/etc/passwd` as the value) or the LAST `=` (giving + // `@/etc/passwd` as the value), both interpretations point at a host + // file. The guard refuses on the `=@` substring directly so neither + // split orientation matters. + expect(refuse(['--variable', 'foo=name=@/etc/passwd', '{ x }'])?.exitCode).toBe(65); + expect(refuse(['--variable=foo=name=@/etc/passwd', '{ x }'])?.exitCode).toBe(65); + }); + + it('allows plain --variable key=value (non-@ values pass)', () => { + expect(refuse(['--variable', 'key=value', '{ x }'])).toBeNull(); + expect(refuse(['--variable=key=value', '{ x }'])).toBeNull(); + }); + + it('consumes --variable / --variables-json values so the JSON is not misread as a positional', () => { + // The JSON payload to --variables-json must NOT be classified as a + // positional GraphQL source — otherwise a perfectly benign query whose + // variables JSON starts with the literal "mutation" would be refused. + expect(refuse(['--variables-json', '"mutation"', '{ teams { id } }'])).toBeNull(); + expect(refuse(['--variables-json=mutation literal', '{ teams { id } }'])).toBeNull(); + // The --variable VALUE comes as the next token — if we didn't consume + // it, a value of "mutation" would refuse. + expect(refuse(['--variable', 'foo=value-mutation', '{ teams { id } }'])).toBeNull(); + // Order doesn't matter: flag-first still picks up the positional after + // the consumed value. + expect(refuse(['--paginate', '--variables-json', '{}', '{ teams { id } }'])).toBeNull(); + }); + + it("doesn't let --variable swallow a following flag (so --input defense survives)", () => { + // Argv `['--variable', '--input', …]` — if --variable greedily consumed + // the next token regardless of shape, the --input refusal one + // iteration later would never fire. The guard skips the consume when + // the next token starts with `--`, so --input is still inspected and + // refused. + expect(refuse(['--variable', '--input', '/etc/passwd'])?.stderr).toMatch(/--input/); + expect(refuse(['--variable', '--input=/etc/passwd'])?.stderr).toMatch(/--input/); + }); + + it('refuses a mutation prefixed by Unicode whitespace / BOM (gate must not fall open)', () => { + // The pre-fix parser used an ASCII-only whitespace set, so a source + // with a leading BOM (U+FEFF), NBSP (U+00A0), or LSEP (U+2028) + // returned null from firstGraphqlOperationKeyword and silently + // passed. linear-cli's GraphQL parser strips BOM and executes the + // mutation. The widened whitespace check (\s + BOM) closes that. + const bom = ''; + const nbsp = ' '; + const lsep = '
'; + expect(refuse([`${bom}mutation IssueCreate { x }`])?.exitCode).toBe(65); + expect(refuse([`${nbsp}mutation IssueCreate { x }`])?.exitCode).toBe(65); + expect(refuse([`${lsep}mutation IssueCreate { x }`])?.exitCode).toBe(65); + // Same Unicode-whitespace prefixes on a legitimate query/anonymous + // shape still pass. + expect(refuse([`${bom}{ teams { id } }`])).toBeNull(); + expect(refuse([`${nbsp}query Teams { teams { id } }`])).toBeNull(); + }); + + it("treats a single-dash positional like '-mutation' as a positional (not a flag)", () => { + // Pre-fix the parser skipped any arg starting with `-`, so a positional + // `'-mutation { x }'` slipped past unclassified. Narrowing the skip to + // long flags (`--`) makes the gate inspect single-dash tokens and + // refuse them as 'unparseable' shapes (their first significant char + // isn't `{` or an ASCII letter). + const r = refuse(['-mutation { x }']); + expect(r).not.toBeNull(); + expect(r!.exitCode).toBe(65); + }); + + it("refuses 'unparseable' positionals (first significant char isn't `{` or an ASCII letter)", () => { + // Defense-in-depth: an argv positional that we can't classify as a + // known GraphQL shape is refused with a clear message. Real queries + // always start with `query`/`mutation`/`subscription`/`{` after + // whitespace + line comments are stripped. + expect(refuse([':invalid'])?.exitCode).toBe(65); + expect(refuse(['"hello"'])?.exitCode).toBe(65); + expect(refuse(['/* C-style */ { x }'])?.exitCode).toBe(65); + }); + + it('is case-insensitive on the operation keyword', () => { + // GraphQL is case-sensitive in spec but defensive matching is cheap. + expect(refuse(['MUTATION IssueCreate { x }'])?.exitCode).toBe(65); + expect(refuse(['Subscription Foo { x }'])?.exitCode).toBe(65); + }); + + it('treats flag-only argv as a pass (no positional source to inspect)', () => { + // The relay still rejects missing-positional at the host CLI; the gate + // is only responsible for refusing operations it CAN see. Empty/flag- + // only argv → null (let the host CLI emit its own usage error). + expect(refuse([])).toBeNull(); + expect(refuse(['--help'])).toBeNull(); + }); +}); diff --git a/packages/relay/test/host-actions.test.ts b/packages/relay/test/host-actions.test.ts index b34694f..882dc53 100644 --- a/packages/relay/test/host-actions.test.ts +++ b/packages/relay/test/host-actions.test.ts @@ -148,9 +148,9 @@ describe('executeCloudAction routing', () => { expect(result.stderr).toContain('unknown integration method shape'); }); - it('integration.linear.api (unknown service, allowlist-default) returns exit 64', async () => { + it('integration.trello.api (unknown service, allowlist-default) returns exit 64', async () => { const result = await executeCloudAction( - action('integration.linear.api', { args: ['v1/issues'] }), + action('integration.trello.api', { args: ['v1/issues'] }), makeDeps(), ); expect(result.exitCode).toBe(64); diff --git a/packages/relay/test/integrations.test.ts b/packages/relay/test/integrations.test.ts index 26e7707..2026ad3 100644 --- a/packages/relay/test/integrations.test.ts +++ b/packages/relay/test/integrations.test.ts @@ -303,7 +303,7 @@ esac const r = await fetchJson(handle, 'POST', '/rpc', { token: 't1', body: { - method: 'integration.linear.api', + method: 'integration.trello.api', params: { path: '/workspace', args: ['v1/issues'] }, }, }); diff --git a/packages/sandbox-docker/Dockerfile.box b/packages/sandbox-docker/Dockerfile.box index 70f58ce..7fca16d 100644 --- a/packages/sandbox-docker/Dockerfile.box +++ b/packages/sandbox-docker/Dockerfile.box @@ -163,6 +163,16 @@ RUN chmod +x /usr/local/bin/gh /usr/local/bin/git COPY packages/sandbox-docker/scripts/ntn-shim /usr/local/bin/ntn RUN chmod +x /usr/local/bin/ntn && ln -s /usr/local/bin/ntn /usr/local/bin/notion +# `linear` (Linear CLI — @schpet/linear-cli) shim — same shape as ntn-shim, +# routes a strict subset of `linear` subcommands through the host relay +# (the host's `linear` runs the call; the box never sees the Linear API +# token). `linear auth token` (which would print the raw token to stdout) +# is explicitly rejected by the shim. Disabled by default; flip +# `integrations.linear.enabled` to enable. See +# packages/sandbox-docker/scripts/linear-shim and docs/linear_backlog.md. +COPY packages/sandbox-docker/scripts/linear-shim /usr/local/bin/linear +RUN chmod +x /usr/local/bin/linear + # Setup guide for the first-run wizard. This baked copy is the single source # of the /agentbox-setup skill: seedSetupSkillIntoVolume() # (packages/sandbox-docker/src/claude.ts) copies it into the box's diff --git a/packages/sandbox-docker/scripts/linear-shim b/packages/sandbox-docker/scripts/linear-shim new file mode 100755 index 0000000..110f998 --- /dev/null +++ b/packages/sandbox-docker/scripts/linear-shim @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# agentbox `linear` shim — translates a strict subset of `linear` +# (@schpet/linear-cli, v2) subcommands into `agentbox-ctl integration +# linear ` so the host's authenticated `linear` runs the operation and +# only the result crosses back into the box. The in-box agent never sees a +# Linear API token. +# +# Installed at /usr/local/bin/linear (real `linear` is not in the box). +# +# This shim ships only what documented agent flows need; anything outside +# the subset below is rejected with a clear error. Add ops deliberately — +# the relay is gated by `integrations.linear.enabled` and an explicit op +# allowlist in @agentbox/integrations. +# +# Three classes of upstream subcommand are EXPLICITLY rejected even though +# they exist on the host CLI, because proxying them would defeat the +# security model: +# - `auth token` PRINTS the raw API token to stdout — proxying it would +# hand the box the host's Linear credential. The only auth-family op +# we proxy is `auth whoami` (identity only), via `linear whoami`. +# - `auth login/logout/migrate/default` would mutate host auth state. +# - `issue delete` / `team delete` / `team create` are destructive and +# off-list (widen deliberately, as gated writes, only if needed). + +set -euo pipefail + +# Path is a constant in production; the env override exists purely to let +# unit tests substitute a stub `agentbox-ctl` on PATH without rewriting the +# shim. Mirrors gh-shim / git-shim / ntn-shim. +CTL="${AGENTBOX_CTL_PATH:-/usr/local/bin/agentbox-ctl}" + +die() { + printf 'agentbox linear shim: %s\n' "$*" >&2 + exit 2 +} + +handle_auth() { + local sub="${1-}"; shift || true + case "$sub" in + whoami) + exec "$CTL" integration linear whoami -- "$@" + ;; + token) + die "'auth token' leaks the raw API key — refused. Use 'linear whoami' for identity." + ;; + login|logout|migrate|default) + die "'auth $sub' is not proxied (the host owns auth; run it on the host)." + ;; + '') + die "missing subcommand for 'auth'. Supported: whoami" + ;; + *) + die "unsupported 'auth $sub' (allowed: whoami)" + ;; + esac +} + +handle_issue_comment() { + local sub="${1-}"; shift || true + case "$sub" in + add) + exec "$CTL" integration linear issue.comment -- "$@" + ;; + '') + die "missing subcommand for 'issue comment'. Supported: add" + ;; + *) + die "unsupported 'issue comment $sub' (allowed: add)" + ;; + esac +} + +handle_issue() { + local sub="${1-}"; shift || true + case "$sub" in + list) + exec "$CTL" integration linear issue.list -- "$@" + ;; + mine) + exec "$CTL" integration linear issue.mine -- "$@" + ;; + view) + exec "$CTL" integration linear issue.view -- "$@" + ;; + query) + exec "$CTL" integration linear issue.query -- "$@" + ;; + create) + exec "$CTL" integration linear issue.create -- "$@" + ;; + update) + exec "$CTL" integration linear issue.update -- "$@" + ;; + comment) + handle_issue_comment "$@" + ;; + delete) + die "'issue delete' is not proxied (destructive; off-list by default)." + ;; + '') + die "missing subcommand for 'issue'. Supported: list, mine, view, query, create, update, comment add" + ;; + *) + die "unsupported 'issue $sub' (allowed: list, mine, view, query, create, update, comment add)" + ;; + esac +} + +handle_team() { + local sub="${1-}"; shift || true + case "$sub" in + list) + exec "$CTL" integration linear team.list -- "$@" + ;; + create|delete) + die "'team $sub' is not proxied (destructive; off-list by default)." + ;; + '') + die "missing subcommand for 'team'. Supported: list" + ;; + *) + die "unsupported 'team $sub' (allowed: list)" + ;; + esac +} + +# Top-level dispatch. `linear`'s real subcommands are +# `auth issue team project cycle milestone initiative label document api schema`; +# we expose only the read-safe ones plus a few gated writes (no destructive +# ops, no auth token). +if [ $# -eq 0 ]; then + die "no subcommand. Supported: whoami, auth whoami, issue {list,mine,view,query,create,update,comment add}, team list, api , --version" +fi + +case "$1" in + --version|-v) + # Tools that sniff "linear --version" succeed with our shim line. The + # real version lives host-side and is reported by the relay's + # readiness probe (`assertIntegrationReady`). + printf 'linear version 0.0.0 (agentbox-shim)\n' + ;; + --help|-h) + printf 'agentbox linear shim — strict subset.\n' >&2 + printf 'Supported: whoami, auth whoami, issue {list,mine,view,query,create,update,comment add}, team list, api , --version\n' >&2 + printf 'Anything else is rejected. Run host `linear --help` for full upstream docs.\n' >&2 + ;; + whoami) + shift + exec "$CTL" integration linear whoami -- "$@" + ;; + auth) + shift + handle_auth "$@" + ;; + issue) + shift + handle_issue "$@" + ;; + team) + shift + handle_team "$@" + ;; + api) + shift + # `linear api` accepts pre-positional flags (`--variable`, + # `--variables-json`, `--paginate`, `--silent`) before the GraphQL + # query, so we don't require the FIRST arg to be a non-flag — only + # that some arg is present. The relay's refuseGraphqlNonQuery + # enforces query-only by rejecting any positional whose first + # keyword is `mutation`/`subscription` (and any `--variable + # key=@` host-file load), so we don't duplicate that check + # here. Writes go through the dedicated issue.* ops. + if [ $# -eq 0 ]; then + die "'api' requires a positional (e.g. '{ teams { id } }')" + fi + exec "$CTL" integration linear api -- "$@" + ;; + *) + die "'$1' is not proxied (supported: whoami, issue {list,mine,view,query,create,update,comment add}, team list, api , --version)" + ;; +esac diff --git a/packages/sandbox-e2b/scripts/build-template.sh b/packages/sandbox-e2b/scripts/build-template.sh index cdac38b..19a8afd 100755 --- a/packages/sandbox-e2b/scripts/build-template.sh +++ b/packages/sandbox-e2b/scripts/build-template.sh @@ -23,6 +23,7 @@ # /tmp/agentbox-gh-shim -- in-box `gh` shim (routes to host gh) # /tmp/agentbox-git-shim -- in-box `git` shim (routes via relay) # /tmp/agentbox-ntn-shim -- in-box `ntn`/`notion` shim (routes to host ntn) +# /tmp/agentbox-linear-shim -- in-box `linear` shim (routes to host linear; rejects `auth token`) # /tmp/agentbox-custom-CLAUDE.md -- /etc/claude-code/CLAUDE.md content # /tmp/agentbox-managed-settings.json -- /etc/claude-code/managed-settings.json # /tmp/agentbox-codex-hooks.json -- /usr/local/share/agentbox/codex-hooks.json @@ -279,17 +280,19 @@ done_ "apt cleanup" # login-shell shim above forces /usr/local/bin ahead of /usr/bin so these win. # During the bake there is no relay, so they must not shadow the real binaries # until provisioning is done. Installed from /tmp just before the trim step. -step "relay shims (gh + git + ntn)" -install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh -install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git -install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn +step "relay shims (gh + git + ntn + linear)" +install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh +install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git +install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn ln -sf /usr/local/bin/ntn /usr/local/bin/notion -done_ "relay shims (gh + git + ntn)" +install -m 0755 /tmp/agentbox-linear-shim /usr/local/bin/linear +done_ "relay shims (gh + git + ntn + linear)" step "trim /tmp/agentbox-*" rm -f /tmp/agentbox-ctl /tmp/agentbox-vnc-start \ /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ /tmp/agentbox-gh-shim /tmp/agentbox-git-shim /tmp/agentbox-ntn-shim \ + /tmp/agentbox-linear-shim \ /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md mv /tmp/agentbox-build-template.sh /var/log/agentbox/build-template.sh 2>/dev/null || true diff --git a/packages/sandbox-e2b/src/runtime-assets.ts b/packages/sandbox-e2b/src/runtime-assets.ts index 2b9eeed..c7acb39 100644 --- a/packages/sandbox-e2b/src/runtime-assets.ts +++ b/packages/sandbox-e2b/src/runtime-assets.ts @@ -50,6 +50,7 @@ export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ { name: 'gh-shim', remotePath: '/tmp/agentbox-gh-shim', remoteMode: 0o755 }, { name: 'git-shim', remotePath: '/tmp/agentbox-git-shim', remoteMode: 0o755 }, { name: 'ntn-shim', remotePath: '/tmp/agentbox-ntn-shim', remoteMode: 0o755 }, + { name: 'linear-shim', remotePath: '/tmp/agentbox-linear-shim', remoteMode: 0o755 }, { name: 'custom-system-CLAUDE.md', remotePath: '/tmp/agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, { name: 'claude-managed-settings.json', remotePath: '/tmp/agentbox-managed-settings.json', remoteMode: 0o644 }, { name: 'agentbox-codex-hooks.json', remotePath: '/tmp/agentbox-codex-hooks.json', remoteMode: 0o644 }, @@ -76,6 +77,7 @@ export function candidatesFor( 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], 'ntn-shim': ['packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['packages/sandbox-docker/scripts/linear-shim'], 'custom-system-CLAUDE.md': ['packages/sandbox-e2b/scripts/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], @@ -91,6 +93,7 @@ export function candidatesFor( 'gh-shim': ['e2b/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['e2b/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], 'ntn-shim': ['e2b/ntn-shim', 'docker/packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['e2b/linear-shim', 'docker/packages/sandbox-docker/scripts/linear-shim'], 'custom-system-CLAUDE.md': ['e2b/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['e2b/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['e2b/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], diff --git a/packages/sandbox-hetzner/scripts/install-box.sh b/packages/sandbox-hetzner/scripts/install-box.sh index 9b44bf7..e93e6b9 100644 --- a/packages/sandbox-hetzner/scripts/install-box.sh +++ b/packages/sandbox-hetzner/scripts/install-box.sh @@ -16,6 +16,7 @@ # /tmp/agentbox-gh-shim -- in-box `gh` shim (routes to host gh via relay) # /tmp/agentbox-git-shim -- in-box `git` shim (routes push/pull/fetch/clone via relay) # /tmp/agentbox-ntn-shim -- in-box `ntn`/`notion` shim (routes Notion CLI to host ntn via relay) +# /tmp/agentbox-linear-shim -- in-box `linear` shim (routes @schpet/linear-cli to host linear via relay; rejects `auth token`) # /tmp/agentbox-custom-CLAUDE.md -- /etc/claude-code/CLAUDE.md content # /tmp/agentbox-managed-settings.json -- /etc/claude-code/managed-settings.json # /tmp/agentbox-codex-hooks.json -- /usr/local/share/agentbox/codex-hooks.json @@ -162,23 +163,26 @@ done_ "agentbox-ctl install" # *before* Chromium sidesteps the issue and keeps the snapshot complete. # Tracked as Phase-7 follow-up in docs/hertzner_backlog.md. -step "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git + ntn shims)" +step "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git + ntn + linear shims)" install -m 0755 /tmp/agentbox-vnc-start /usr/local/bin/agentbox-vnc-start install -m 0755 /tmp/agentbox-dockerd-start /usr/local/bin/agentbox-dockerd-start install -m 0755 /tmp/agentbox-checkpoint-cleanup /usr/local/bin/agentbox-checkpoint-cleanup install -m 0755 /tmp/agentbox-open /usr/local/bin/agentbox-open ln -sf /usr/local/bin/agentbox-open /usr/local/bin/xdg-open -# gh + git + ntn shims — same files baked by Dockerfile.box for the docker provider. -# The shim wins on PATH (default /usr/local/bin precedes /usr/bin) so any agent -# call to `gh ...` / `git push|pull|fetch|clone` / `ntn ...` / `notion ...` -# routes through the relay; the git shim execs /usr/bin/git for everything -# else, no overhead. `notion` is a symlink to `ntn` — same shim, per-service -# surface naming from docs/integrations_backlog.md. +# gh + git + ntn + linear shims — same files baked by Dockerfile.box for the +# docker provider. The shim wins on PATH (default /usr/local/bin precedes +# /usr/bin) so any agent call to `gh ...` / `git push|pull|fetch|clone` / +# `ntn ...` / `notion ...` / `linear ...` routes through the relay; the git +# shim execs /usr/bin/git for everything else, no overhead. `notion` is a +# symlink to `ntn` — same shim, per-service surface naming from +# docs/integrations_backlog.md. The linear shim explicitly rejects +# `linear auth token` (which would print the raw API key). install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn ln -sf /usr/local/bin/ntn /usr/local/bin/notion -done_ "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git + ntn shims)" +install -m 0755 /tmp/agentbox-linear-shim /usr/local/bin/linear +done_ "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git + ntn + linear shims)" step "baked config files (claude / codex / setup guide / tmux.conf)" install -m 0644 /tmp/agentbox-custom-CLAUDE.md /etc/claude-code/CLAUDE.md @@ -370,6 +374,7 @@ step "trim /tmp/agentbox-*" rm -f /tmp/agentbox-ctl /tmp/agentbox-vnc-start /tmp/agentbox-dockerd-start \ /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ /tmp/agentbox-gh-shim /tmp/agentbox-git-shim /tmp/agentbox-ntn-shim \ + /tmp/agentbox-linear-shim \ /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md # Move install-box.sh into the persistent location for diagnostics. diff --git a/packages/sandbox-hetzner/src/runtime-assets.ts b/packages/sandbox-hetzner/src/runtime-assets.ts index 264f800..ff6266a 100644 --- a/packages/sandbox-hetzner/src/runtime-assets.ts +++ b/packages/sandbox-hetzner/src/runtime-assets.ts @@ -69,6 +69,7 @@ export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ { name: 'gh-shim', remoteBasename: 'agentbox-gh-shim', remoteMode: 0o755 }, { name: 'git-shim', remoteBasename: 'agentbox-git-shim', remoteMode: 0o755 }, { name: 'ntn-shim', remoteBasename: 'agentbox-ntn-shim', remoteMode: 0o755 }, + { name: 'linear-shim', remoteBasename: 'agentbox-linear-shim', remoteMode: 0o755 }, { name: 'custom-system-CLAUDE.md', remoteBasename: 'agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, { name: 'claude-managed-settings.json', remoteBasename: 'agentbox-managed-settings.json', remoteMode: 0o644 }, { name: 'agentbox-codex-hooks.json', remoteBasename: 'agentbox-codex-hooks.json', remoteMode: 0o644 }, @@ -107,6 +108,7 @@ export function candidatesFor( 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], 'ntn-shim': ['packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['packages/sandbox-docker/scripts/linear-shim'], 'custom-system-CLAUDE.md': ['packages/sandbox-hetzner/scripts/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], @@ -124,6 +126,7 @@ export function candidatesFor( 'gh-shim': ['hetzner/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['hetzner/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], 'ntn-shim': ['hetzner/ntn-shim', 'docker/packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['hetzner/linear-shim', 'docker/packages/sandbox-docker/scripts/linear-shim'], 'custom-system-CLAUDE.md': ['hetzner/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['hetzner/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['hetzner/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], diff --git a/packages/sandbox-hetzner/test/runtime-assets.test.ts b/packages/sandbox-hetzner/test/runtime-assets.test.ts index 1f8f6f7..abe0e92 100644 --- a/packages/sandbox-hetzner/test/runtime-assets.test.ts +++ b/packages/sandbox-hetzner/test/runtime-assets.test.ts @@ -22,6 +22,7 @@ function makeFakeRepo(): string { 'packages/sandbox-docker/scripts/gh-shim', 'packages/sandbox-docker/scripts/git-shim', 'packages/sandbox-docker/scripts/ntn-shim', + 'packages/sandbox-docker/scripts/linear-shim', 'packages/sandbox-hetzner/scripts/custom-system-CLAUDE.md', 'packages/sandbox-docker/scripts/claude-managed-settings.json', 'packages/sandbox-docker/scripts/agentbox-codex-hooks.json', diff --git a/packages/sandbox-vercel/scripts/provision.sh b/packages/sandbox-vercel/scripts/provision.sh index 041fcb9..3b65f08 100644 --- a/packages/sandbox-vercel/scripts/provision.sh +++ b/packages/sandbox-vercel/scripts/provision.sh @@ -23,6 +23,7 @@ # /tmp/agentbox-gh-shim -- in-box `gh` shim (routes to host gh) # /tmp/agentbox-git-shim -- in-box `git` shim (routes via relay) # /tmp/agentbox-ntn-shim -- in-box `ntn`/`notion` shim (routes to host ntn) +# /tmp/agentbox-linear-shim -- in-box `linear` shim (routes to host linear; rejects `auth token`) # /tmp/agentbox-custom-CLAUDE.md -- /etc/claude-code/CLAUDE.md content # /tmp/agentbox-managed-settings.json -- /etc/claude-code/managed-settings.json # /tmp/agentbox-codex-hooks.json -- /usr/local/share/agentbox/codex-hooks.json @@ -318,17 +319,19 @@ done_ "dnf cleanup" # the bake there is no relay, so they must not shadow the real binaries until # provisioning is done. Installed from /tmp just before the trim step removes the # sources. -step "relay shims (gh + git + ntn)" -install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh -install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git -install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn +step "relay shims (gh + git + ntn + linear)" +install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh +install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git +install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn ln -sf /usr/local/bin/ntn /usr/local/bin/notion -done_ "relay shims (gh + git + ntn)" +install -m 0755 /tmp/agentbox-linear-shim /usr/local/bin/linear +done_ "relay shims (gh + git + ntn + linear)" step "trim /tmp/agentbox-*" rm -f /tmp/agentbox-ctl /tmp/agentbox-vnc-start \ /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ /tmp/agentbox-gh-shim /tmp/agentbox-git-shim /tmp/agentbox-ntn-shim \ + /tmp/agentbox-linear-shim \ /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md mv /tmp/agentbox-provision.sh /var/log/agentbox/provision.sh 2>/dev/null || true diff --git a/packages/sandbox-vercel/src/runtime-assets.ts b/packages/sandbox-vercel/src/runtime-assets.ts index ad44be4..033e889 100644 --- a/packages/sandbox-vercel/src/runtime-assets.ts +++ b/packages/sandbox-vercel/src/runtime-assets.ts @@ -52,6 +52,7 @@ export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ { name: 'gh-shim', remotePath: '/tmp/agentbox-gh-shim', remoteMode: 0o755 }, { name: 'git-shim', remotePath: '/tmp/agentbox-git-shim', remoteMode: 0o755 }, { name: 'ntn-shim', remotePath: '/tmp/agentbox-ntn-shim', remoteMode: 0o755 }, + { name: 'linear-shim', remotePath: '/tmp/agentbox-linear-shim', remoteMode: 0o755 }, { name: 'custom-system-CLAUDE.md', remotePath: '/tmp/agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, { name: 'claude-managed-settings.json', remotePath: '/tmp/agentbox-managed-settings.json', remoteMode: 0o644 }, { name: 'agentbox-codex-hooks.json', remotePath: '/tmp/agentbox-codex-hooks.json', remoteMode: 0o644 }, @@ -78,6 +79,7 @@ export function candidatesFor( 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], 'ntn-shim': ['packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['packages/sandbox-docker/scripts/linear-shim'], 'custom-system-CLAUDE.md': ['packages/sandbox-vercel/scripts/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], @@ -93,6 +95,7 @@ export function candidatesFor( 'gh-shim': ['vercel/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['vercel/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], 'ntn-shim': ['vercel/ntn-shim', 'docker/packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['vercel/linear-shim', 'docker/packages/sandbox-docker/scripts/linear-shim'], 'custom-system-CLAUDE.md': ['vercel/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['vercel/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['vercel/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'],