-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add signed agent sessions and governance commands #182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| || !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'), | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body) | ||
| return res.body as T | ||
| } | ||
|
|
@@ -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))) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| const res = await this.fetchImpl(this.apiUrl + path, { | ||
| method, | ||
| headers, | ||
|
|
@@ -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() | ||
|
|
||
| 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) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: When the platform policy payload lacks Prompt for AI agents
Suggested change
|
||||||||
| }, 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) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: When the platform policy payload lacks Prompt for AI agents
Suggested change
|
||||||||
| } | ||||||||
| 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`) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When Prompt for AI agents |
||||||||
| if (opts.json) return printJson(out) | ||||||||
| info(`all project agent sessions revoked (epoch ${out.agentSessionEpoch})`) | ||||||||
| } | ||||||||
| 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' | ||
|
|
@@ -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 { | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: In Prompt for AI agents |
||
| 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' } }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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') | ||
|
|
@@ -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)') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Under agent mode, Prompt for AI agents |
||
| } 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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') | ||
|
|
@@ -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') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This unconditionally creates/updates Prompt for AI agents |
||
| await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2)) | ||
| } | ||
There was a problem hiding this comment.
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.jsonis partially corrupted or edited,loadAgentSessionaccepts it unless token/privateKey/expiry are also missing.agentHeadersthen sends anundefinedsession/client value and fails with an opaque server or fetch error instead of the documentedinsta setup agentguidance; validate every field used to build the proof, includingagentSessionIdandclient, before returning the session.Prompt for AI agents