fix(template): poll the deployment with the project's agent session, not a bootstrap one
Fixes agent-mode template deploy dying on its first progress poll:
agent session is for a different project; run insta setup agent (HTTP 403)
The deployment itself was created and kept running on the platform; only the CLI's watch loop failed, and the hint was wrong — re-running insta setup agent cannot fix this.
Why it 403s
agentHeaders (src/agent.ts) chooses which session signs a request by matching the path against ^/projects/:id. The deploy POST is /projects/:id/template-deployments, so it signs with the project-bound session. The poll is GET /template-deployments/:deploymentId — no /projects/ prefix — so the same code mints a projectless bootstrap session. The platform deliberately resolves that route's owner from the template_deployments row (PROJECT_OWNED_ROUTES in insta-platform/src/govern/agent-routes.ts) and evaluate() rejects any CLI session not bound to that project, bootstrap included. The server is right; the CLI never told it which project it meant.
This is the only CLI call site with that shape (every other api.request path starts with /projects/, /orgs/, or is account-level). The MCP polls the same URL but signs with a per-request mcp assertion that the project check does not apply to, so it was never affected. Landed in 4480626 (signed agent sessions, 2026-09-05), which post-dates template deploy (2026-08-19) — agent-mode template deploy has been broken since.
The fix
agentHeaders(..., scope?: { projectId })— an explicit scope wins over the path regex. Comment explains the class of route it exists for.ApiClient.request/rawRequestacceptprojectIdin their options and thread it down toagentHeaders(through the 401-refresh retry too).- The template poll passes
{ projectId: p.projectId }— the project it just deployed into.
The scope is a selector, not a bypass. It still loads the saved session for that project (a session for any other project is refused locally, as before), and the platform still compares the session's project to the row's. Nothing an agent could not already do becomes possible.
I chose this over a CLI-side route→project table (mirrors server state, drifts) and over a platform alias /projects/:id/template-deployments/:id (more surface, and the CLI needs a release either way). Callers know which project they are acting on; the client should let them say so.
Second commit: fail closed on the class of bug, not just this instance (8489e8c)
With only the first commit, a future id-addressed project route would silently fall back to a bootstrap session and hit the same misleading 403 in a user's agent session. agentHeaders now knows the account-level routes by first path segment — agent, auth, me, orgs, regions, templates, tokens, github, which is every non-/projects/ path the CLI calls today (inventoried from src/) — and refuses any other path that has neither a /projects/ prefix nor an explicit scope:
GET /template-deployments/d1 is a project route but no project was given — this is an insta CLI bug; please report it with `insta feedback`
So a miss fails loudly at the client, during development, with the route named. The list is a CLI-side statement about the CLI's own calls, not a mirror of the platform's PROJECT_OWNED_ROUTES: adding a call to a new account-level route means adding its segment here, and the test tells you. /templates/… and /template-deployments/… differ in first segment, so the catalog read stays account-level.
Tests
test/template.test.ts: the deploy flow's poll is asserted to carry{ projectId: 'proj_1' }(the fake API now records the scope).test/agent.test.ts: on a/template-deployments/:idpath, an unscoped call now throws the message above and never mints; every entry of the exportedACCOUNT_ROUTESset (table-driven: bare, with a sub-path, and with a query string) still mints a bootstrap session (POST /agent/sessionswithprojectId: undefined); a scoped call signs with the savedags_testsession without minting, and the signature verifies over the exact request fields;ApiClient.request(..., { projectId })puts that session on the wire; a scope naming another project is still refused.
Both new tests are red on origin/main (negative-controlled by reverting src/ and re-running: expected [ undefined ] to deeply equal [ { projectId: 'proj_1' } ] and expected "spy" to not be called).
npm run typecheck, npm run build clean; 853 tests pass. test/github-source.integration.test.ts fails in my sandbox because it runs git init in a temp dir the sandbox forbids — unrelated to this change; expect it green in CI.
Reviewer suggestions, recorded (non-blocking, both bots approved 9b30535)
Not pushed onto the approved head — each is a follow-up PR after the 0.0.66 release, so the broken command ships now:
- 401-refresh retry has no test binding the scope. True: deleting
scopefrom the retryfetchleaves the suite green. Follow-up adds the 401-then-200 fetcher test assertingInsta-Agent-Sessionis the project session on both calls. - Watch loop can now outlive a 24h project session (previously impossible: the bootstrap one was minted per poll). Follow-up catches the expiry around the poll and says the deploy is still running (
insta events), like the timeout path does. - Poll cadence now hits governance + one audit row per poll (2s for up to 15 min). Follow-up adds a 2s→5s→10s backoff.
- Also noted: the
.split('?')[0]guard is defensive and unexercised; scope-over-path precedence is unobservable (platform still refuses a wrong session);deviceGrantposts to/api/auth/...safely only because it passesauth: false— a comment onACCOUNT_ROUTESwill say so.
Follow-up, not in this PR
The platform's 403 text ("agent session is for a different project; run insta setup agent") is misleading when the session is a bootstrap one (bound to no project). Older CLIs will keep hitting it until upgraded; a message that distinguishes "not bound to a project" from "bound to another project" would point at the actual fix (upgrade the CLI). Small change in insta-platform/src/govern/agent-service.ts if wanted.
🤖 Generated with Claude Code