Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createHash, generateKeyPairSync, randomUUID, sign } from 'node:crypto'
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { findProjectRoot, readProject } from './config.js'
import { alreadyTracked, ensureGitignore } from './gitignore.js'

export type AgentMode = { source: 'cli-explicit' | 'cli-detected'; client: 'codex' | 'claude-code' | 'cursor' | 'unknown' }
let mode: AgentMode | null = null
export function detectAgent(explicit: boolean, env: NodeJS.ProcessEnv = process.env): AgentMode | null {
const client = env.CODEX_THREAD_ID || env.CODEX_CI === '1' ? 'codex'
: env.CLAUDECODE === '1' ? 'claude-code'
: env.CURSOR_AGENT === '1' ? 'cursor' : 'unknown'
return explicit || client !== 'unknown' ? { source: explicit ? 'cli-explicit' : 'cli-detected', client } : null
}
export function configureAgent(value: AgentMode | null): void { mode = value }
export function agentMode(): AgentMode | null { return mode }
type Session = { token: string; agentSessionId: string; projectId: string | null; expiresAt: string; privateKey: string; client: AgentMode['client']; apiUrl: string }
export type SessionApi = { apiUrl: string; request<T = any>(method: string, path: string, body?: unknown): Promise<T> }
const hash = (v: string): string => createHash('sha256').update(v).digest('hex')
export function canonicalTarget(path: string): string {
const url = new URL(path, 'https://platform.invalid')
return url.pathname + url.search
}
const guidance = 'agent session missing, expired, or for another project/environment — run `insta setup agent`'

export async function issueAgentSession(api: SessionApi, projectId?: string): Promise<Session> {
const pair = generateKeyPairSync('ed25519')
const client = mode?.client ?? detectAgent(false)?.client ?? 'unknown'
const out = await api.request('POST', '/agent/sessions', {
projectId, client, publicKey: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
})
return { ...out, client, apiUrl: api.apiUrl.replace(/\/+$/, ''), privateKey: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() }
}

export async function saveAgentSession(session: Session, cwd = process.cwd()): Promise<void> {
const root = await findProjectRoot(cwd) ?? cwd
const rel = '.insta/agent-session.json'
if (alreadyTracked(root, [rel]).length) throw new Error('agent-session.json is tracked by Git; untrack it before running insta setup agent')
ensureGitignore(root, [rel], '# Local agent credentials')
const dir = join(root, '.insta')
await mkdir(dir, { recursive: true })
const temp = join(dir, `.agent-session-${randomUUID()}.tmp`)
// Ignore crash leftovers too; temporary files contain the same private material.
ensureGitignore(root, ['.insta/.agent-session-*.tmp'])
await writeFile(temp, JSON.stringify(session, null, 2), { mode: 0o600 })
await rename(temp, join(root, rel))
await chmod(join(root, rel), 0o600)
}

export async function setupProjectAgentSession(api: SessionApi, projectId?: string): Promise<boolean> {
const id = projectId ?? (await readProject())?.projectId
if (!id) return false
await saveAgentSession(await issueAgentSession(api, id))
return true
}

export async function loadAgentSession(apiUrl: string, projectId: string, cwd = process.cwd()): Promise<Session> {
try {
const root = await findProjectRoot(cwd) ?? cwd
const session = JSON.parse(await readFile(join(root, '.insta/agent-session.json'), 'utf8')) as Session
if (session.projectId !== projectId || session.apiUrl !== apiUrl.replace(/\/+$/, '') || !session.token || !session.privateKey

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When .insta/agent-session.json is partially corrupted or edited, loadAgentSession accepts it unless token/privateKey/expiry are also missing. agentHeaders then sends an undefined session/client value and fails with an opaque server or fetch error instead of the documented insta setup agent guidance; validate every field used to build the proof, including agentSessionId and client, before returning the session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agent.ts, line 61:

<comment>When `.insta/agent-session.json` is partially corrupted or edited, `loadAgentSession` accepts it unless token/privateKey/expiry are also missing. `agentHeaders` then sends an `undefined` session/client value and fails with an opaque server or fetch error instead of the documented `insta setup agent` guidance; validate every field used to build the proof, including `agentSessionId` and `client`, before returning the session.</comment>

<file context>
@@ -0,0 +1,86 @@
+  try {
+    const root = await findProjectRoot(cwd) ?? cwd
+    const session = JSON.parse(await readFile(join(root, '.insta/agent-session.json'), 'utf8')) as Session
+    if (session.projectId !== projectId || session.apiUrl !== apiUrl.replace(/\/+$/, '') || !session.token || !session.privateKey
+      || !Number.isFinite(Date.parse(session.expiresAt)) || Date.parse(session.expiresAt) <= Date.now()) throw new Error()
+    return session
</file context>

|| !Number.isFinite(Date.parse(session.expiresAt)) || Date.parse(session.expiresAt) <= Date.now()) throw new Error()
return session
} catch { throw new Error(guidance) }
}

export async function agentHeaders(api: SessionApi, method: string, path: string, rawBody: string): Promise<Record<string, string>> {
if (!mode) return {}
if (canonicalTarget(path) === '/agent/sessions' && method === 'POST') return {
'Insta-Actor-Type': 'agent', 'Insta-Agent-Source': mode.source, 'Insta-Agent-Client': mode.client,
}
const target = canonicalTarget(path)
const match = target.match(/^\/projects\/([^/?]+)/)
// Account reads/project creation have no project policy yet. Mint a short-lived bootstrap
// assertion in memory. It cannot access project routes; never downgrade to a human request.
const session = match ? await loadAgentSession(api.apiUrl, decodeURIComponent(match[1]!)) : await issueAgentSession(api)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomUUID()
const proof = [method.toUpperCase(), target, hash(rawBody), session.agentSessionId, timestamp, nonce, mode.source, session.client].join('\n')
return {
'Insta-Actor-Type': 'agent', 'Insta-Agent-Session': session.agentSessionId,
'Insta-Agent-Session-Token': session.token, 'Insta-Agent-Source': mode.source,
'Insta-Agent-Client': session.client, 'Insta-Agent-Timestamp': timestamp,
'Insta-Agent-Nonce': nonce, 'Insta-Agent-Signature': sign(null, Buffer.from(proof), session.privateKey).toString('base64url'),
}
}
7 changes: 7 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import { readGlobal, writeGlobal, readProject, writeProject, type GlobalConfig,
import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-project.js'
import { die } from './util.js'
import { USER_AGENT } from './version.js'
import { agentHeaders, agentMode } from './agent.js'

export class ApiError extends Error {
// body carries the parsed error payload for callers that branch on machine-readable errors
// (e.g. template deploy's missing_variables); the message stays the human line.
constructor(public status: number, msg: string, public body?: any) { super(msg); this.name = 'ApiError' }
}
export class AgentApprovalRequired extends Error {
constructor(public body: any) { super(body.message ?? `approval required: ${body.approvalId}`) }
}

// Store a durable insta_ key as the credential: set it as the bearer and drop any refresh token (an insta_ key never rotates; a stale one would leak to /auth/refresh on a 401).
export function storeApiKeyCredential(cfg: GlobalConfig, token: string, user?: GlobalConfig['user']): void {
Expand Down Expand Up @@ -52,6 +56,7 @@ export class ApiClient {
// Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
async request<T = any>(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<T> {
const res = await this.raw(method, path, body, opts.auth ?? true)
if (agentMode() && res.status === 202 && res.body?.status === 'approval_required') throw new AgentApprovalRequired(res.body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: request()'s docstring and the file-header comment say 202/approval_required returns the parsed body, but the new line throws AgentApprovalRequired for 202 in agent mode. Update the comment to note the 202-throws condition in agent mode so the contract isn't misleading.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api.ts, line 59:

<comment>request()'s docstring and the file-header comment say 202/approval_required returns the parsed body, but the new line throws AgentApprovalRequired for 202 in agent mode. Update the comment to note the 202-throws condition in agent mode so the contract isn't misleading.</comment>

<file context>
@@ -52,6 +56,7 @@ export class ApiClient {
   // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
   async request<T = any>(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<T> {
     const res = await this.raw(method, path, body, opts.auth ?? true)
+    if (agentMode() && res.status === 202 && res.body?.status === 'approval_required') throw new AgentApprovalRequired(res.body)
     if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body)
     return res.body as T
</file context>

if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body)
return res.body as T
}
Expand All @@ -74,6 +79,7 @@ export class ApiClient {
private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT }
if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}`
if (auth) Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: In agent mode every authenticated non-project API request mints a fresh bootstrap agent session — an ED25519 keypair and a new POST /agent/sessions round-trip — because agentHeaders() calls issueAgentSession() with no caching on each fetch(). This adds keypair generation plus an extra network request to every such API call (and double-mints after a 401 refresh). Cache the bootstrap session after first issuance (or scope session minting to command start) instead of per request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api.ts, line 82:

<comment>In agent mode every authenticated non-project API request mints a fresh bootstrap agent session — an ED25519 keypair and a new POST /agent/sessions round-trip — because agentHeaders() calls issueAgentSession() with no caching on each fetch(). This adds keypair generation plus an extra network request to every such API call (and double-mints after a 401 refresh). Cache the bootstrap session after first issuance (or scope session minting to command start) instead of per request.</comment>

<file context>
@@ -74,6 +79,7 @@ export class ApiClient {
   private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
     const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT }
     if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}`
+    if (auth) Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body)))
     const res = await this.fetchImpl(this.apiUrl + path, {
       method,
</file context>

const res = await this.fetchImpl(this.apiUrl + path, {
method,
headers,
Expand Down Expand Up @@ -106,6 +112,7 @@ export async function linkedProject(): Promise<ProjectConfig | null> { return re
export async function requireProject(): Promise<ProjectConfig> {
const p = await readProject()
if (p) return p
if (agentMode()) die('agent mode requires a linked project — run `insta setup agent --project <id>`')
// One command, just works: unlinked ≠ error. Resolve the project (auto when there's one,
// one-keystroke picker when several) and persist the choice so this happens once per dir.
const api = await ApiClient.load()
Expand Down
47 changes: 47 additions & 0 deletions src/commands/agent-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ApiClient, requireProject } from '../api.js'
import { handleApproval, info, printJson } from '../util.js'

async function current() {
const api = await ApiClient.load()
const project = await requireProject()
const path = `/projects/${project.projectId}/agent-policy`
const out = await api.request('GET', path)
return { api, project, path, ...out }
}
export async function get(opts: { json?: boolean }) {
const { policy, agentSessionEpoch, actions } = await current()
if (opts.json) return printJson({ policy, agentSessionEpoch, actions })
info(`agent policy: ${policy.mode}\nprotected branches: ${policy.protectedBranchIds.join(', ') || '(none)'}\nsession epoch: ${agentSessionEpoch}`)
}
async function update(change: (policy: any, state: Awaited<ReturnType<typeof current>>) => Promise<void> | void, opts: { json?: boolean }) {
const state = await current()
await change(state.policy, state)
const result = await state.api.rawRequest('PUT', state.path, state.policy)
if (handleApproval(result, opts.json)) return
if (opts.json) return printJson(result.body)
info(`agent policy updated: ${result.body.policy.mode}`)
}
export async function set(mode: string, opts: { json?: boolean }) {
const normalized = mode.replace(/-/g, '_')
if (!['full_access', 'read_only', 'branch_developer'].includes(normalized)) throw new Error('mode must be full-access, read-only, or branch-developer')
return update(policy => { policy.mode = normalized }, opts)
}
export async function protect(branch: string, enabled: boolean, opts: { json?: boolean }) {
return update(async (policy, { api, project }) => {
const { branches } = await api.request('GET', `/projects/${project.projectId}/branches`)
const found = branches.find((b: any) => b.id === branch || b.name === branch)
if (!found) throw new Error('branch not found')
policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id: string) => id !== found.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When the platform policy payload lacks protectedBranchIds, protect() throws on [...policy.protectedBranchIds, ...] / policy.protectedBranchIds.filter(...). Default the field to an array before reading it, matching the get() output that already treats an empty list as '(none)'.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/agent-policy.ts, line 34:

<comment>When the platform policy payload lacks `protectedBranchIds`, `protect()` throws on `[...policy.protectedBranchIds, ...]` / `policy.protectedBranchIds.filter(...)`. Default the field to an array before reading it, matching the `get()` output that already treats an empty list as '(none)'.</comment>

<file context>
@@ -0,0 +1,47 @@
+    const { branches } = await api.request('GET', `/projects/${project.projectId}/branches`)
+    const found = branches.find((b: any) => b.id === branch || b.name === branch)
+    if (!found) throw new Error('branch not found')
+    policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id: string) => id !== found.id)
+  }, opts)
+}
</file context>
Suggested change
policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id: string) => id !== found.id)
const ids = policy.protectedBranchIds ?? []
policy.protectedBranchIds = enabled ? [...new Set([...ids, found.id])] : ids.filter((id: string) => id !== found.id)

}, opts)
}
export async function rule(action: string, decision: string, opts: { json?: boolean }) {
if (!['allow', 'deny', 'approve'].includes(decision)) throw new Error('decision must be allow, deny, or approve')
return update(policy => { policy.branchDeveloperRules[action] = decision }, opts)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When the platform policy payload lacks branchDeveloperRules, rule() throws a TypeError instead of setting the rule. Guard the field before assigning: policy.branchDeveloperRules ??= {} so the command works on any policy shape.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/agent-policy.ts, line 39:

<comment>When the platform policy payload lacks `branchDeveloperRules`, `rule()` throws a TypeError instead of setting the rule. Guard the field before assigning: `policy.branchDeveloperRules ??= {}` so the command works on any policy shape.</comment>

<file context>
@@ -0,0 +1,47 @@
+}
+export async function rule(action: string, decision: string, opts: { json?: boolean }) {
+  if (!['allow', 'deny', 'approve'].includes(decision)) throw new Error('decision must be allow, deny, or approve')
+  return update(policy => { policy.branchDeveloperRules[action] = decision }, opts)
+}
+export async function revoke(opts: { json?: boolean }) {
</file context>
Suggested change
return update(policy => { policy.branchDeveloperRules[action] = decision }, opts)
return update(policy => { (policy.branchDeveloperRules ??= {})[action] = decision }, opts)

}
export async function revoke(opts: { json?: boolean }) {
const api = await ApiClient.load()
const project = await requireProject()
const out = await api.request('POST', `/projects/${project.projectId}/agent-sessions/revoke`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When revoke-sessions receives HTTP 202 outside agent mode, request() returns the approval envelope, so this branch reports success with an undefined epoch (or exits 0 with JSON) without revoking sessions. Use rawRequest() and handleApproval() before formatting the response, as update() does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/agent-policy.ts, line 44:

<comment>When `revoke-sessions` receives HTTP 202 outside agent mode, `request()` returns the approval envelope, so this branch reports success with an undefined epoch (or exits 0 with JSON) without revoking sessions. Use `rawRequest()` and `handleApproval()` before formatting the response, as `update()` does.</comment>

<file context>
@@ -0,0 +1,47 @@
+export async function revoke(opts: { json?: boolean }) {
+  const api = await ApiClient.load()
+  const project = await requireProject()
+  const out = await api.request('POST', `/projects/${project.projectId}/agent-sessions/revoke`)
+  if (opts.json) return printJson(out)
+  info(`all project agent sessions revoked (epoch ${out.agentSessionEpoch})`)
</file context>

if (opts.json) return printJson(out)
info(`all project agent sessions revoked (epoch ${out.agentSessionEpoch})`)
}
1 change: 1 addition & 0 deletions src/commands/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ function operandIndices(argv: string[], from: number, to: number = argv.length):
function execCommandIndex(argv: string[]): number {
for (let cursor = 2; cursor < argv.length; cursor++) {
const token = argv[cursor]!
if (token === '--agent') continue
if (token.startsWith('-')) return -1 // a global flag, or `--`: either way not our command path
return token === 'compute' && argv[cursor + 1] === 'exec' ? cursor : -1
}
Expand Down
3 changes: 3 additions & 0 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { homedir } from 'node:os'
import { agentMode, setupProjectAgentSession } from '../agent.js'
import { ApiClient, requireProject } from '../api.js'
import { writeProject } from '../config.js'
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js'
Expand Down Expand Up @@ -74,6 +75,7 @@ export async function projectCreate(name: string | undefined, opts: { org?: stri
const orgId = await resolveOrg(api, opts.org)
const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved })
await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name })
if (agentMode()) await setupProjectAgentSession(api, out.project.id)
if (opts.json) {
printJson({ ...out, linked: { projectId: out.project.id, orgId, branch: out.defaultBranch.name } })
} else {
Expand All @@ -97,6 +99,7 @@ export async function projectList(opts: { org?: string; json?: boolean }): Promi

export async function projectLink(id: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
if (agentMode()) await setupProjectAgentSession(api, id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: In projectLink, setupProjectAgentSession(api, id) runs before the GET /projects/{id} validates the id and before writeProject succeeds, so a failed link (typo'd id, no access, network error) still calls POST /agent/sessions and writes a signed .insta/agent-session.json on disk. projectCreate establishes the session only after a successful create+write. Move the call below the GET/link so a failed link doesn't mint/persist agent credentials; it also keeps the two commands' failure behavior consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/project.ts, line 102:

<comment>In `projectLink`, `setupProjectAgentSession(api, id)` runs before the `GET /projects/{id}` validates the id and before `writeProject` succeeds, so a failed link (typo'd id, no access, network error) still calls `POST /agent/sessions` and writes a signed `.insta/agent-session.json` on disk. `projectCreate` establishes the session only after a successful create+write. Move the call below the GET/link so a failed link doesn't mint/persist agent credentials; it also keeps the two commands' failure behavior consistent.</comment>

<file context>
@@ -97,6 +99,7 @@ export async function projectList(opts: { org?: string; json?: boolean }): Promi
 
 export async function projectLink(id: string, opts: { json?: boolean } = {}): Promise<void> {
   const api = await ApiClient.load()
+  if (agentMode()) await setupProjectAgentSession(api, id)
   const { project } = await api.request('GET', `/projects/${id}`)
   await writeProject({ projectId: project.id, orgId: project.org_id, branch: 'main' })
</file context>

const { project } = await api.request('GET', `/projects/${id}`)
await writeProject({ projectId: project.id, orgId: project.org_id, branch: 'main' })
if (opts.json) printJson({ project, linked: { projectId: project.id, orgId: project.org_id, branch: 'main' } })
Expand Down
7 changes: 7 additions & 0 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { join } from 'node:path'
import os from 'node:os'
import { createInterface } from 'node:readline'
import { ApiClient } from '../api.js'
import { setupProjectAgentSession } from '../agent.js'
import { readPersistedGlobal, resolveEnv, type GlobalConfig } from '../config.js'
import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName, type EnvName } from '../env.js'
import { info, openUrl } from '../util.js'
Expand Down Expand Up @@ -398,6 +399,7 @@ export async function setupAgent(
},
link: (id: string) => Promise<void> = projectLink,
create: (name?: string) => Promise<void> = (n) => projectCreate(n, {}),
enroll: () => Promise<boolean> = async () => setupProjectAgentSession(await ApiClient.load()),
): Promise<void> {
if (!opts.yes && !process.stdout.isTTY) {
info('non-interactive shell — assuming -y')
Expand Down Expand Up @@ -485,6 +487,11 @@ export async function setupAgent(
}
}
}
if (loggedIn) {
if (await enroll()) info('✓ Project agent session ready (expires in 24 hours; refresh with insta setup agent)')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Under agent mode, insta setup agent --project/--create enrolls twice: projectLink/projectCreate already call setupProjectAgentSession(api, id) (src/commands/project.ts:78/:102), and then setup.ts calls enroll() again for the same project, issuing a second /agent/sessions request/keypair and overwriting the session file. Skip the project-step enrollment when the later enroll() will run, or skip enroll() when the project step already enrolled, to avoid the duplicate issuance.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/setup.ts, line 491:

<comment>Under agent mode, `insta setup agent --project/--create` enrolls twice: projectLink/projectCreate already call setupProjectAgentSession(api, id) (src/commands/project.ts:78/:102), and then setup.ts calls enroll() again for the same project, issuing a second `/agent/sessions` request/keypair and overwriting the session file. Skip the project-step enrollment when the later enroll() will run, or skip enroll() when the project step already enrolled, to avoid the duplicate issuance.</comment>

<file context>
@@ -485,6 +487,11 @@ export async function setupAgent(
     }
   }
+  if (loggedIn) {
+    if (await enroll()) info('✓ Project agent session ready (expires in 24 hours; refresh with insta setup agent)')
+  } else {
+    info('  project agent session not created — run `insta login`, then `insta setup agent`')
</file context>

} else {
info(' project agent session not created — run `insta login`, then `insta setup agent`')
}
// THE summary line. The restart note exists because config-file agents only read their MCP
// config at startup; the skill files need no restart.
const mcpOk = claude === 'new' || claude === 'existing' || others.length > 0
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { ensureGitignore } from './gitignore.js'
import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl, type EnvName } from './env.js'

const GLOBAL_DIR = join(homedir(), '.insta')
Expand Down Expand Up @@ -143,5 +144,6 @@ export async function readProject(cwd = process.cwd()): Promise<ProjectConfig |
export async function writeProject(c: ProjectConfig, cwd = process.cwd()): Promise<void> {
const target = (await findProjectRoot(cwd)) ?? cwd
await mkdir(join(target, PROJECT_DIR), { recursive: true })
ensureGitignore(target, ['.insta/agent-session.json'], '# Local agent credentials')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This unconditionally creates/updates .gitignore (and creates the file if absent) on every project create/link/auto-link, even for human users who never use agent sessions. The agent-session path (saveAgentSession) already adds this exact ignore entry when a session is actually written, so this forces a repository side effect on all non-agent flows. Consider adding the ignore entry only at session-write time.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/config.ts, line 147:

<comment>This unconditionally creates/updates `.gitignore` (and creates the file if absent) on every project create/link/auto-link, even for human users who never use agent sessions. The agent-session path (saveAgentSession) already adds this exact ignore entry when a session is actually written, so this forces a repository side effect on all non-agent flows. Consider adding the ignore entry only at session-write time.</comment>

<file context>
@@ -143,5 +144,6 @@ export async function readProject(cwd = process.cwd()): Promise<ProjectConfig |
 export async function writeProject(c: ProjectConfig, cwd = process.cwd()): Promise<void> {
   const target = (await findProjectRoot(cwd)) ?? cwd
   await mkdir(join(target, PROJECT_DIR), { recursive: true })
+  ensureGitignore(target, ['.insta/agent-session.json'], '# Local agent credentials')
   await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2))
 }
</file context>

await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2))
}
22 changes: 21 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { Command } from 'commander'
import { ApiError } from './api.js'
import { configureAgent, detectAgent } from './agent.js'
import * as agentPolicy from './commands/agent-policy.js'
import { ApiError, AgentApprovalRequired } from './api.js'
import { CliCancel, CliExit, fail, relayedExitCode } from './util.js'
import { trackCommand } from './telemetry.js'
import { cliVersion } from './version.js'
Expand Down Expand Up @@ -33,6 +35,12 @@ import * as selfUpdate from './commands/upgrade.js'
import * as feedbackCmd from './commands/feedback.js'

function onError(e: unknown): void {
if (e instanceof AgentApprovalRequired) {
if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(e.body) + '\n')
else process.stderr.write(e.message + '\n')
process.exitCode = 2
return
}
if (e instanceof CliExit || e instanceof CliCancel) return
if (e instanceof ApiError) return fail(`${e.message} (HTTP ${e.status})`)
fail(e instanceof Error ? e.message : String(e))
Expand All @@ -59,6 +67,8 @@ const program = new Command()
// against the subcommand's own (identically-named) option instead.
program.enablePositionalOptions()
program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(cliVersion())
program.option('--agent', 'run as an agent with a verified project session and project agent policy')
program.hook('preAction', () => configureAgent(detectAgent(!!program.opts().agent)))

// ---- auth ----
program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email <email> + password, --oauth <github|google>, --device (headless), --api-key <insta_…> (headless, durable token)')
Expand Down Expand Up @@ -354,6 +364,16 @@ ob.command('report').description('Render the local credential audit').option('--
ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync()))

// ---- policy ----
const agentPol = program.command('agent-policy').description('Project agent access policy (separate from human governance)')
agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o)))
agentPol.command('set <mode>').description('full-access | read-only | branch-developer')
.option('--json').action(guard((mode, o) => agentPolicy.set(mode, o)))
agentPol.command('protect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, true, o)))
agentPol.command('unprotect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, false, o)))
agentPol.command('rule').command('set <action> <decision>').description('Set an unprotected-branch rule: allow | deny | approve')
.option('--json').action(guard((action, decision, o) => agentPolicy.rule(action, decision, o)))
agentPol.command('revoke-sessions').description('Revoke ALL CLI agent sessions for this project')
.option('--json').action(guard((o) => agentPolicy.revoke(o)))
const pol = program.command('policy').description('Governance policy')
pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)))
pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').option('--json').action(guard((a, d, o) => govern.policySet(a, d, o)))
Expand Down
Loading
Loading