Linear integration
June 7, 2026 · View on GitHub
Linear is Session 2 of the integrations plan (docs/integrations_backlog.md).
The shared @agentbox/integrations foundation already shipped with the Notion
path (T1–T4, PRs #73–#76). Linear is therefore descriptor-driven: one
connector file, an in-box shim, a config flag, a doctor entry, tests, docs — no
surgery to the relay/ctl core (this is exactly the case that validates the
abstraction the Notion work built).
Backend: @schpet/linear-cli (the linear binary), the planned wrapper.
Installed + authed on the host against the waldosai workspace (admin) for
e2e. v2.0.0 surface (richer than the plan assumed):
auth issue team project cycle milestone initiative label document api schema.
Security notes specific to Linear (drive the allowlist)
linear auth tokenPRINTS the raw API token to stdout — it must never be in the shim allowlist or the connector ops, or a box could exfiltrate the credential. Same forauth login/logout/migrate/default. The only auth op we proxy isauth whoami(identity only).issue delete/team delete/team createexist and are destructive — keep them OFF the allowlist (start conservative; widen only when a real agent flow needs them, and then as gated writes).linear apiis a raw GraphQL endpoint — a single POST that serves both queries (read) and mutations (write). Theapiop is a read passthrough, so it needs arefuseCallthat rejects any GraphQL mutation/subscription operation (the GraphQL analogue of Notion'srefuseApiNonGet), so the "read" classification isn't a hole. Writes must go through the dedicated gated ops.- Credentials live plaintext at
~/.config/linear/credentials.toml(keyring is opt-in, not used) → carries cleanly into a box; no keyring env toggle needed, and the connector declares noenv. Carry entries added toagentbox.yaml.
Proposed connector surface (the implementing box refines this)
| op | read/write | host argv | notes |
|---|---|---|---|
whoami | read | auth whoami | identity only — never auth token |
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 + --variable key=@<path> |
issue.create | write (gated) | issue create | |
issue.update | write (gated) | issue update | status/title/etc. |
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: done (2026-06-06)
packages/integrations/src/connectors/linear.ts(+ register inregistry.ts; widen theIntegrationServiceunion intypes.tsto include'linear').refuseGraphqlNonQuery(or similar) for theapiop — refuse mutation/subscription.packages/sandbox-docker/scripts/linear-shim— strict allowlist mirroringntn-shim; rejectsauth tokenand everything off-list. Installed as/usr/local/bin/linear. Wire intostage-runtime.mjs(contextFiles + execBitFiles + all provider lists),Dockerfile.boxCOPY, and the hetznerinstall-box.shmirror.integrations.linear.enabledtyped config flag (default false) inpackages/config/src/types.ts(+ defaults + the CONFIG_KEYS metadata entry).agentbox doctoralready iteratesALL_CONNECTORS— Linear should appear for free once registered; verify and adjust if needed.- Unit tests (pure): registry resolves linear; ops classified read/write;
apirefuses a mutation but allows a query; shim allowlist rejectsauth token. - Docs:
docs/integrations.md, the public.mdxpage(s),docs/host-relay.md(new methods),docs/features.md, CLI reference — same set the Notion path touched. pnpm typecheck && pnpm test && pnpm buildgreen →/simplify→/review high→ PR intoadd-ticketing-integrations→ fix bugbot → merge.
LT2 — Live e2e against Waldosai + nested-box best-effort + closeout — status: done (2026-06-07)
- Orchestrator prep (host): rebuild + restart relay with LT1 merged; set
integrations.linear.enabled=truein host project config. - Primary e2e from inside a box:
linear whoami(read, no prompt) →linear issue list(read) →linear api '<query>'(read) →linear issue create …(write → host approval prompt → orchestrator approves → issue created) → verify via read →issue updateto mark/close the test issue → no-token assertion (printenv | grep -i linearshows onlyAGENTBOX_RELAY_TOKEN). Verify ground truth, never trust exit codes. - Best-effort nested-box e2e (time-boxed): install
linearin the box, rely on the carried~/.config/linear/credentials.toml, create a nested box, enable the flag, run a read + gated write from the nested box (this box's relay gates it). Document the limitation if too fragile. - Fix any bug the e2e surfaces (keep tight).
- Close out: mark the Linear path done in
docs/integrations_backlog.mdwith evidence; update this file's status log. - Green →
/simplify→/review high→ PR → fix bugbot → merge.
Status log
- 2026-06-06: Backlog created. Host
linear(@schpet/linear-cli@2.0.0) verified authed againstwaldosai(admin, accounts@waldos.ai). Connector surface scouted; security notes captured (auth-token leak, destructive deletes, GraphQL mutation gate). Linear carry entries added toagentbox.yaml. - 2026-06-06: LT1 shipped. Descriptor-only, no relay/ctl core changes.
- Connector at
packages/integrations/src/connectors/linear.tswith opswhoami(auth whoami),issue.list/issue.mine/issue.view/issue.query,team.list,api(+refuseGraphqlNonQueryGraphQL mutation/subscription gate, value-consuming flag walker,--variable key=@<path>host-file-load refusal, Unicode-whitespace + BOM-prefix bypass guard),issue.create/issue.update/issue.comment(gated writes;issue.commentmaps tolinear issue comment add—@schpet/linear-cliv2 usesadd, notcreate).IntegrationServiceunion widened to include'linear'. - Shim at
packages/sandbox-docker/scripts/linear-shim(installed at/usr/local/bin/linear, no symlink alias). Strict allowlist; hard- rejectsauth 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) viastage-runtime.mjs+ each provider'sruntime-assets.ts. - Typed config flag
integrations.linear.enabled(defaultfalse) added toUserConfig/EffectiveConfig/BUILT_IN_DEFAULTS/KEY_REGISTRYinpackages/config/src/types.ts. - Doctor: zero-line change —
ALL_CONNECTORSdrivesintegrationsChecks, 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 resolveslinear, op classification, argv shapes,refuseGraphqlNonQuerycases (mutation refused, query allowed, anonymous{…}allowed, leading whitespace +# commenttolerated,--inputrefused, case-insensitive keyword match).packages/ctl/test/gh-and-shims.test.ts—linear-shimallowlist tests including the explicitauth tokenrejection 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 usedlinearas the "unknown service" example (nowtrello).
pnpm typecheck && pnpm test && pnpm build && pnpm lintall green.- Docs updated in the same change:
docs/integrations.md(design + the GraphQL gate + auth-token exclusion notes), new public page atapps/web/content/docs/integrations-linear.mdx+ meta.json entry,apps/web/content/docs/configuration.mdxrow,cli.mdxdoctor pointer,docs/host-relay.mdbullet extension,docs/features.md"what works today" bullet. Live e2e against the Waldosai workspace is LT2 — deliberately not run in LT1.
- Connector at
- 2026-06-07: LT2 shipped. Live e2e against the
waldosaiworkspace, no code changes — the LT1 surface worked unchanged. Evidence captured from inside an AgentBox box (in-box agent → host relay → hostlinearv2.0.0 → Linear API):- Reads pass with no prompt.
linear whoamireturnsWorkspace: waldosai … User: Marco D'Alia … Role: admin.linear issue mine --team WAL --sort priorityandlinear issue list --team WAL --sort priorityboth exit 0 (empty result onunstarted, a valid filtered read).linear team listreturnsWAL Waldosai(UUID09ca67e1-ccd7-499b-b2fa-63220d56ce08).linear api '{ viewer { id name email } }'returns{"data":{"viewer":{"id":"85d5fa14-…", "name":"Marco D'Alia","email":"accounts@waldos.ai"}}}— therefuseGraphqlNonQuerypredicate correctly classifies the{ … }shorthand as a query and passes it. - GraphQL mutation refused locally.
linear api 'mutation { issueDelete(id: "x") { success } }'exits 65 withlinear api: only GraphQL queries are proxied (use issue.create / issue.update / issue.comment for writes); detected operation 'mutation'— refused before any host process is spawned (verified via both the shim path and the directagentbox-ctl integration linear apipath; the gate lives in the connector, not the shim). linear auth tokenrefused at the shim. Exits 2 with'auth token' leaks the raw API key — refused. Use 'linear whoami' for identity.. The relay's op allowlist would also refuse it (no op maps toauth token); the shim is the first of three defenses.- Gated writes work end-to-end.
linear issue create --team WAL --title "agentbox LT2 e2e 20260607T000618Z" -d "…"round-tripped through the relay'saskPrompt→ orchestrator approve → hostlinear→ Linear API; created WAL-5 (https://linear.app/waldosai/issue/WAL-5/agentbox-lt2-e2e-20260607t000618z). Ground-truth read vialinear issue view WAL-5confirms title + description + Backlog state.linear issue comment add WAL-5 -b "agentbox LT2 e2e comment via host relay (gated write)"added the comment (URL with#comment-3e8fe4e2fragment). Ground-truthlinear api '{ issue(id:"WAL-5") { … comments { nodes { body } } } }'confirms the comment body matches.linear issue update WAL-5 -s "Canceled"moved the state; the post-updatelinear issue view WAL-5shows**State:** Canceledand the comment thread. Three gated writes, three approve→succeed→ground-truth-read cycles. - No-token assertion.
printenv | grep -E '^LINEAR'returns nothing ((no LINEAR_* keys present)). The only token-shaped env var in the box isAGENTBOX_RELAY_TOKEN. The carried~/.config/linear/credentials.tomlis on disk (it's for the nested-box scenario where THIS box would host a nested-box's relay) but no agent process reads it during the primary e2e — the host's ownlineardoes, host-side, via its own~/.config/linear/. - Nested-box e2e — deferred, same architectural reason as Notion.
The in-box
agentbox-ctldaemon forwards/rpcto the original host relay (host.docker.internal:8787), not to a relay running in this box. So a nested box'slinear issue createwould still terminate at the original host's relay spawn, not in this box's daemon — exercising the carry mechanics, not a different connector spawn path. Also: installing the reallinearin this box would shadow the shim —npm i -g @schpet/linear-clilands the binary at/usr/bin/linear(npm prefix here is/usr), but the shim at/usr/local/bin/linearprecedes/usr/binon$PATHand keeps winning resolution, so the in-box agent would still hit the shim and the daemon would need a separately-shaped PATH (or an absolutehostBinpath) to reach the real binary — out of scope here. Documented indocs/integrations.mdunder "Linear → Nested-box e2e — deferred, not blocking" mirroring the Notion sub-section. The carry block +mergeConnectorEnvnamespace guard are validated by the LT1 unit tests; a real nested-box round-trip would require lifting the relay into the box's daemon (cross-cutting follow-up tracked under both connectors' "Nested-box e2e" notes). - No source changes needed — LT1's connector + shim + gate worked
as-shipped against the live host CLI. The pre-merge unit tests
matched live behaviour exactly (no LT4-style
pagesvspagedrift).
- Reads pass with no prompt.