insta 0.0.72 → 0.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -42,6 +42,8 @@ export async function saveAgentSession(session, cwd = process.cwd()) {
42
42
  await chmod(join(root, rel), 0o600);
43
43
  }
44
44
  export async function setupProjectAgentSession(api, projectId) {
45
+ if (api.agentCredential)
46
+ return false; // Nothing to enroll: the key itself is the agent identity.
45
47
  const id = projectId ?? (await readProject())?.projectId;
46
48
  if (!id)
47
49
  return false;
@@ -68,7 +70,8 @@ export async function loadAgentSession(apiUrl, projectId, cwd = process.cwd()) {
68
70
  // account-level paths in src/commands/.
69
71
  export const ACCOUNT_ROUTES = new Set(['agent', 'auth', 'me', 'orgs', 'regions', 'templates', 'tokens']);
70
72
  export async function agentHeaders(api, method, path, rawBody, scope = {}) {
71
- if (!mode)
73
+ // A key minted by agent auth is already an agent principal on the platform and may not enroll a session.
74
+ if (!mode || api.agentCredential)
72
75
  return {};
73
76
  if (canonicalTarget(path) === '/agent/sessions' && method === 'POST')
74
77
  return {
package/dist/api.js CHANGED
@@ -25,11 +25,15 @@ export class AgentApprovalRequired extends Error {
25
25
  }
26
26
  }
27
27
  // 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).
28
- export function storeApiKeyCredential(cfg, token, user) {
28
+ export function storeApiKeyCredential(cfg, token, user, agentCredential = false) {
29
29
  cfg.accessToken = token;
30
30
  delete cfg.refreshToken;
31
31
  if (user)
32
32
  cfg.user = user;
33
+ if (agentCredential)
34
+ cfg.agentCredential = true;
35
+ else
36
+ delete cfg.agentCredential;
33
37
  }
34
38
  export class ApiClient {
35
39
  cfg;
@@ -48,15 +52,18 @@ export class ApiClient {
48
52
  this.cfg.refreshToken = tokens.refreshToken;
49
53
  if (user)
50
54
  this.cfg.user = user;
55
+ delete this.cfg.agentCredential;
51
56
  }
52
57
  // Adopt a durable insta_ key as the credential (non-interactive `login --api-key`).
53
- setApiKey(token, user) {
54
- storeApiKeyCredential(this.cfg, token, user);
58
+ setApiKey(token, user, agentCredential) {
59
+ storeApiKeyCredential(this.cfg, token, user, agentCredential);
55
60
  }
61
+ get agentCredential() { return this.cfg.agentCredential === true; }
56
62
  clearSession() {
57
63
  delete this.cfg.accessToken;
58
64
  delete this.cfg.refreshToken;
59
65
  delete this.cfg.user;
66
+ delete this.cfg.agentCredential;
60
67
  }
61
68
  // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
62
69
  async request(method, path, body, opts = {}) {
@@ -86,7 +93,7 @@ export class ApiClient {
86
93
  const headers = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT };
87
94
  if (auth && this.cfg.accessToken)
88
95
  headers.Authorization = `Bearer ${this.cfg.accessToken}`;
89
- if (auth)
96
+ if (auth && scope.evidence !== false)
90
97
  Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body), scope));
91
98
  const res = await this.fetchImpl(this.apiUrl + path, {
92
99
  method,
@@ -1,10 +1,21 @@
1
1
  import { ApiClient, requireProject } from '../api.js';
2
2
  import { handleApproval, info, printJson } from '../util.js';
3
- async function current() {
4
- const api = await ApiClient.load();
5
- const project = await requireProject();
3
+ const PRESETS = ['full_access', 'read_only', 'branch_specific'];
4
+ const DECISIONS = ['allow', 'deny', 'approve'];
5
+ async function current(deps) {
6
+ const api = deps?.api ?? await ApiClient.load();
7
+ const project = deps?.project ?? await requireProject();
6
8
  const path = `/projects/${project.projectId}/agent-policy`;
7
9
  const out = await api.request('GET', path);
10
+ // `branchDeveloperRules` is a deprecated mirror of `rules`, kept in the response so that a CLI
11
+ // predating the rename can still edit it in place. Platform takes the legacy field over `rules`
12
+ // when a body carries both -- exactly so that old CLI's edit is not dropped -- so echoing the
13
+ // mirror back would silently discard everything this version writes. Only where Platform speaks
14
+ // the new contract, though: `actionCatalog` is how it announces that, and without one the mirror
15
+ // is the only rule set that exists, so dropping it would erase the project's overrides on the
16
+ // next PUT -- from `rule set`, but also from a `protect-branch` that never mentions rules.
17
+ if (out.policy && out.actionCatalog)
18
+ delete out.policy.branchDeveloperRules;
8
19
  return { api, project, path, policy: out.policy, out };
9
20
  }
10
21
  export function displayPolicy(out, opts, output = { info, printJson }) {
@@ -12,7 +23,9 @@ export function displayPolicy(out, opts, output = { info, printJson }) {
12
23
  const { policy, agentSessionEpoch } = out;
13
24
  if (opts.json)
14
25
  return printJson(out);
15
- info(`agent policy: ${policy.mode}\nprotected branches: ${policy.protectedBranchIds.join(', ') || '(none)'}\nsession epoch: ${agentSessionEpoch}`);
26
+ const overrides = Object.keys(policy.rules ?? policy.branchDeveloperRules ?? {}).length;
27
+ info(`agent policy: ${policy.mode}${overrides ? ` (${overrides} rule${overrides === 1 ? '' : 's'})` : ''}`
28
+ + `\nprotected branches: ${policy.protectedBranchIds.join(', ') || '(none)'}\nsession epoch: ${agentSessionEpoch}`);
16
29
  if (out.effectiveRules) {
17
30
  for (const [scope, rules] of Object.entries(out.effectiveRules)) {
18
31
  info(`\n${scope}:`);
@@ -31,35 +44,122 @@ export async function get(opts) {
31
44
  // Forward the public response, never the internal API client (which holds credentials).
32
45
  displayPolicy(out, opts);
33
46
  }
34
- async function update(change, opts) {
35
- const state = await current();
36
- await change(state.policy, state);
37
- const result = await state.api.rawRequest('PUT', state.path, state.policy);
47
+ /**
48
+ * The decisions that moved but were NOT asked for. Switching off `full_access` is the case that
49
+ * needs this: the fixed invariants (project.delete, agent_policy.update, …) stop being allowed and
50
+ * protected branches begin to apply, so a one-rule edit can move a dozen decisions. Diffed from
51
+ * Platform's own resolved view before and after, never re-derived here — the same reason this CLI
52
+ * does not evaluate a policy locally.
53
+ */
54
+ export function sideEffects(before, after, asked) {
55
+ const from = before.effectiveRules, to = after.effectiveRules;
56
+ if (!from || !to)
57
+ return [];
58
+ const lines = [];
59
+ for (const scope of Object.keys(to)) {
60
+ for (const [action, decision] of Object.entries(to[scope])) {
61
+ // The named action is suppressed only in the context the rule governs. That same action
62
+ // moving in another context is a consequence of the mode change, not the edit that was asked
63
+ // for — `rule set deploy deny` off full_access also starts denying deploy on protected
64
+ // branches, and that is precisely what the caller needs told.
65
+ if ((scope === 'unprotectedBranch' && asked.includes(action)) || from[scope]?.[action] === decision)
66
+ continue;
67
+ lines.push(` ${scope}.${action}: ${from[scope]?.[action] ?? '(unknown)'} -> ${decision}`);
68
+ }
69
+ }
70
+ return lines;
71
+ }
72
+ /**
73
+ * The policy a `rule set` should PUT. Exported because the interesting part is not the request:
74
+ * leaving a preset has to snapshot every decision it was already making, or setting one rule
75
+ * silently rewrites all the others to whatever the new mode's defaults happen to be.
76
+ */
77
+ export function applyRule(policy, out, action, decision) {
78
+ const catalog = out.actionCatalog;
79
+ // A Platform predating the rename has neither a catalog nor a `rules` field, and reads only the
80
+ // old one. Nothing below applies there.
81
+ if (!catalog)
82
+ return { ...policy, branchDeveloperRules: { ...(policy.branchDeveloperRules ?? {}), [action]: decision } };
83
+ const entry = catalog.find(e => e.action === action);
84
+ if (!entry)
85
+ throw new Error(`unknown action: ${action}\nrun: insta agent-policy get --json`);
86
+ if (!entry.editable)
87
+ throw new Error(`${action} is a fixed policy invariant and cannot be overridden`);
88
+ // Snapshot taken from the resolved unprotected-branch view, which is the context these rules
89
+ // apply to; protected branches stay a separate, fixed denial.
90
+ const base = policy.mode === 'customize'
91
+ ? policy.rules
92
+ : Object.fromEntries(catalog.filter(e => e.editable).map(e => [e.action, out.effectiveRules.unprotectedBranch[e.action]]));
93
+ return { ...policy, mode: 'customize', rules: { ...base, [action]: decision } };
94
+ }
95
+ /** `change` either mutates the policy in place or returns the replacement. */
96
+ // `asked` opts into the side-effect report and names the actions the caller chose. Only `rule set`
97
+ // passes it: a mode change is expected to move everything, so listing the whole table is noise.
98
+ async function update(change, opts, asked, deps) {
99
+ const state = await current(deps);
100
+ const next = (await change(state.policy, state)) ?? state.policy;
101
+ const result = await state.api.rawRequest('PUT', state.path, next);
38
102
  if (handleApproval(result, opts.json))
39
103
  return;
104
+ // Printing does not end the command: the side-effect report below is owed to `--json` too, since
105
+ // a scripted caller is the one least able to notice for itself that leaving a preset moved
106
+ // decisions it never named. The report is on stderr, so stdout stays a parseable document.
40
107
  if (opts.json)
41
- return printJson(result.body);
42
- info(`agent policy updated: ${result.body.policy.mode}`);
108
+ printJson(result.body);
109
+ else
110
+ info(`agent policy updated: ${result.body.policy.mode}`);
111
+ if (!asked)
112
+ return;
113
+ // The PUT already landed. A failed diagnostic GET costs the comparison, not the update: rejecting
114
+ // here would report a mutation that happened as a failure, and invite a retry of a done edit.
115
+ let after;
116
+ try {
117
+ after = await state.api.request('GET', state.path);
118
+ }
119
+ catch {
120
+ process.stderr.write('note: could not determine whether this changed decisions you did not name\n');
121
+ return;
122
+ }
123
+ const moved = sideEffects(state.out, after, asked);
124
+ if (moved.length)
125
+ process.stderr.write(`note: this also changed decisions you did not name:\n${moved.join('\n')}\n`);
43
126
  }
44
- export async function set(mode, opts) {
127
+ export async function set(mode, opts, deps) {
45
128
  const normalized = mode.replace(/-/g, '_');
46
- if (!['full_access', 'read_only', 'branch_developer'].includes(normalized))
47
- throw new Error('mode must be full-access, read-only, or branch-developer');
48
- return update(policy => { policy.mode = normalized; }, opts);
129
+ // `branch_developer` is the pre-rename name for `branch_specific`, still accepted here so a
130
+ // script written against the old CLI keeps working. `customize` is deliberately NOT settable:
131
+ // it means "carrying overrides", which is what `agent-policy rule set` produces.
132
+ const resolved = normalized === 'branch_developer' ? 'branch_specific' : normalized;
133
+ if (!PRESETS.includes(resolved))
134
+ throw new Error('mode must be full-access, read-only, or branch-specific');
135
+ // A preset carries no rules; Platform refuses one that does. Which field carries "no rules"
136
+ // depends on the same capability check `applyRule` makes: without an `actionCatalog` Platform
137
+ // predates the rename, so it only knows the old mode name and only reads the old rule field —
138
+ // clearing `rules` there would leave every override standing under the new mode.
139
+ return update((policy, { out }) => {
140
+ if (out.actionCatalog) {
141
+ policy.mode = resolved;
142
+ policy.rules = {};
143
+ }
144
+ else {
145
+ policy.mode = resolved === 'branch_specific' ? 'branch_developer' : resolved;
146
+ policy.branchDeveloperRules = {};
147
+ }
148
+ }, opts, undefined, deps);
49
149
  }
50
- export async function protect(branch, enabled, opts) {
150
+ export async function protect(branch, enabled, opts, deps) {
51
151
  return update(async (policy, { api, project }) => {
52
152
  const { branches } = await api.request('GET', `/projects/${project.projectId}/branches`);
53
153
  const found = branches.find((b) => b.id === branch || b.name === branch);
54
154
  if (!found)
55
155
  throw new Error('branch not found');
56
156
  policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id) => id !== found.id);
57
- }, opts);
157
+ }, opts, undefined, deps);
58
158
  }
59
- export async function rule(action, decision, opts) {
60
- if (!['allow', 'deny', 'approve'].includes(decision))
159
+ export async function rule(action, decision, opts, deps) {
160
+ if (!DECISIONS.includes(decision))
61
161
  throw new Error('decision must be allow, deny, or approve');
62
- return update(policy => { policy.branchDeveloperRules[action] = decision; }, opts);
162
+ return update((policy, { out }) => applyRule(policy, out, action, decision), opts, [action], deps);
63
163
  }
64
164
  export async function revoke(opts) {
65
165
  const api = await ApiClient.load();
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { ApiClient, ApiError, linkedProject } from '../api.js';
4
+ import { agentMode } from '../agent.js';
4
5
  import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js';
5
6
  import { info, die, printJson, promptPassword, openUrl } from '../util.js';
6
7
  /** --api-url and --env both set the target host; --api-url wins (more specific), matching the
@@ -16,15 +17,23 @@ function targetApiUrl(opts) {
16
17
  die(`unknown --env "${opts.env}" — expected one of: ${ENV_NAMES.join(', ')}`);
17
18
  return ENVS[want].api;
18
19
  }
19
- // `device` is injectable so the dispatch itself is testable (repo pattern: DI fakes, no mocks).
20
- export async function login(opts, device = loginDevice) {
20
+ // `device`/`claim` are injectable so the dispatch itself is testable (repo pattern: DI fakes, no mocks).
21
+ export async function login(opts, device = loginDevice, claim = loginClaim) {
21
22
  // Login modes are exclusive — pick one. Check presence (not truthiness) so an explicit
22
23
  // empty --api-key= is rejected by validation rather than silently falling through.
23
24
  if (opts.apiKey !== undefined) {
24
- if (opts.device || opts.oauth || opts.email)
25
- die('choose one login mode: --api-key, --device, --oauth, or --email');
25
+ if (opts.device || opts.oauth !== undefined || opts.email !== undefined || opts.claim !== undefined || opts.password !== undefined || process.env.INSTA_PASSWORD !== undefined)
26
+ die('choose one login mode: --api-key, --claim, --device, --oauth, or --email');
26
27
  return loginApiKey(opts.apiKey, opts);
27
28
  }
29
+ if (opts.claim !== undefined) {
30
+ if (opts.device || opts.oauth !== undefined || opts.email !== undefined || opts.password !== undefined || process.env.INSTA_PASSWORD !== undefined)
31
+ die('choose one login mode: --api-key, --claim, --device, --oauth, or --email (a password belongs to --email)');
32
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(opts.claim))
33
+ die('--claim needs an email address: the account that will authorize this agent');
34
+ // The human types the code on the console; open it here only when a browser is on this machine.
35
+ return claim(opts.claim, opts, agentMode() ? undefined : openUrl);
36
+ }
28
37
  if (opts.device)
29
38
  return device(opts);
30
39
  if (opts.oauth)
@@ -83,6 +92,19 @@ export async function loginDevice(opts, open) {
83
92
  await api.persist();
84
93
  info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`);
85
94
  }
95
+ // `insta login --claim <email>`: the auth.md user claimed flow. The named user confirms a code on
96
+ // the console, the platform mints an insta_ key, and it is stored exactly as --api-key stores one.
97
+ export async function loginClaim(email, opts, open, grant = claimGrant) {
98
+ const api = await ApiClient.load();
99
+ const target = targetApiUrl(opts);
100
+ if (target)
101
+ api.setApiUrl(target);
102
+ const client = agentMode()?.client ?? 'unknown';
103
+ const key = await grant(email, client, (path, body, signal) => api.request('POST', path, body, { auth: false, signal }), sleepSeconds, open);
104
+ const user = await applyApiKeyLogin(api, key);
105
+ await api.persist();
106
+ info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`);
107
+ }
86
108
  // Non-interactive login with a durable insta_ key (minted via POST /tokens): store it and confirm against /me. No browser, no polling.
87
109
  export async function loginApiKey(key, opts) {
88
110
  const api = await ApiClient.load();
@@ -93,7 +115,7 @@ export async function loginApiKey(key, opts) {
93
115
  await api.persist();
94
116
  info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`);
95
117
  }
96
- // Verify an insta_ key and store it: set it first so the /me probe is authed with the key itself, then re-store with the resolved user (401 → bad/revoked).
118
+ // Verify an insta_ key with a bare /me probe (an agent-minted key cannot enroll a session, and /me says which kind this is), then store it with the user and that kind.
97
119
  export async function applyApiKeyLogin(client, key) {
98
120
  key = key.trim(); // tolerate a trailing newline / stray whitespace from `--api-key "$(cat token)"`
99
121
  if (!key.startsWith('insta_'))
@@ -101,7 +123,7 @@ export async function applyApiKeyLogin(client, key) {
101
123
  client.setApiKey(key);
102
124
  let me;
103
125
  try {
104
- me = await client.request('GET', '/me');
126
+ me = await client.request('GET', '/me', undefined, { evidence: false });
105
127
  }
106
128
  catch (e) {
107
129
  if (e instanceof ApiError && e.status === 401)
@@ -110,7 +132,7 @@ export async function applyApiKeyLogin(client, key) {
110
132
  }
111
133
  if (!me?.user)
112
134
  throw new Error('unexpected response while verifying the API key');
113
- client.setApiKey(key, me.user);
135
+ client.setApiKey(key, me.user, me.agentCredential === true);
114
136
  return me.user;
115
137
  }
116
138
  const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
@@ -192,6 +214,92 @@ export async function deviceGrant(post, wait = sleepSeconds, open) {
192
214
  }
193
215
  throw new Error(`device login expired before it was approved — run \`insta login${open ? '' : ' --device'}\` again`);
194
216
  }
217
+ // auth.md user claimed flow (service_auth). The agent knows the user's email; InstaCloud gives a
218
+ // 6-digit code and a console link; only a session for that email can type the code. We poll the
219
+ // standard token endpoint with the WorkOS claim grant and get an insta_ key back. Poster + wait
220
+ // are injected like deviceGrant's. JSON bodies: the platform's token route accepts them.
221
+ const CLAIM_GRANT = 'urn:workos:agent-auth:grant-type:claim';
222
+ // A poll interval is seconds, from the server: clamp it so a silly value cannot become a ~1 ms timer.
223
+ const pollInterval = (value, fallback) => {
224
+ const n = Number(value);
225
+ return Number.isFinite(n) ? Math.min(Math.max(n, 1), 3600) : fallback;
226
+ };
227
+ export async function claimGrant(email, client, post, wait = sleepSeconds, open, now = Date.now) {
228
+ const start = (await post('/agent/auth', { type: 'service_auth', login_hint: email, client }));
229
+ if (!start?.claim_token || !start.claim?.user_code || !start.claim.verification_uri) {
230
+ throw new Error('malformed registration response (missing claim) — is the platform up to date?');
231
+ }
232
+ const expiresAt = Date.parse(start.claim_token_expires);
233
+ const deadline = Math.min(Number.isFinite(expiresAt) ? expiresAt : Infinity, now() + 86_400_000);
234
+ const show = (block, fresh) => {
235
+ if (fresh)
236
+ info('the code expired — here is a new one.');
237
+ if (open) {
238
+ info('opening your browser…');
239
+ open(block.verification_uri);
240
+ }
241
+ info(`to authorize this agent, open this link, sign in as ${email}, and enter this code: ${block.user_code}`);
242
+ info(` ${block.verification_uri}`);
243
+ };
244
+ show(start.claim, false);
245
+ info(`waiting for ${email} to confirm… (ctrl-c to abort)`);
246
+ let interval = pollInterval(start.claim.interval, 5);
247
+ let reminted = false;
248
+ const expired = () => new Error(`the request expired before ${email} confirmed it — run \`insta login --claim ${email}\` again`);
249
+ while (now() < deadline) {
250
+ await wait(interval);
251
+ const remaining = deadline - now();
252
+ if (remaining <= 0)
253
+ throw expired();
254
+ const signal = AbortSignal.timeout(Math.ceil(Math.min(remaining, 30_000)));
255
+ let grant = null;
256
+ try {
257
+ grant = (await post('/api/auth/oauth2/token', { grant_type: CLAIM_GRANT, claim_token: start.claim_token }, signal));
258
+ }
259
+ catch (e) {
260
+ if (e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError')) {
261
+ if (now() >= deadline)
262
+ throw expired();
263
+ continue;
264
+ }
265
+ if (!(e instanceof ApiError))
266
+ continue; // transport blip — keep polling until the deadline
267
+ const code = e.message;
268
+ if (code === 'authorization_pending')
269
+ continue;
270
+ if (code === 'slow_down' || e.status === 429) {
271
+ interval = Math.min(interval + 5, 3600);
272
+ continue;
273
+ }
274
+ if (code === 'expired_token') {
275
+ if (reminted)
276
+ throw expired();
277
+ let again;
278
+ try {
279
+ again = (await post('/agent/auth/claim', { claim_token: start.claim_token, email }, signal));
280
+ }
281
+ catch (re) {
282
+ if (re instanceof ApiError && re.message === 'claim_expired')
283
+ throw expired();
284
+ if (!(re instanceof ApiError))
285
+ continue; // re-mint timeout or transport blip: the next expired_token asks again
286
+ throw re;
287
+ }
288
+ reminted = true;
289
+ if (!again?.claim_attempt?.user_code || !again.claim_attempt.verification_uri)
290
+ throw new Error('malformed claim response (missing claim_attempt)');
291
+ interval = pollInterval(again.claim_attempt.interval, interval);
292
+ show(again.claim_attempt, true);
293
+ continue;
294
+ }
295
+ throw e; // invalid_grant and friends: not retryable
296
+ }
297
+ if (!grant?.access_token)
298
+ throw new Error('malformed token response (missing access_token)');
299
+ return grant.access_token;
300
+ }
301
+ throw expired();
302
+ }
195
303
  // Start a loopback server, open the browser at the platform bridge, and await the token.
196
304
  function browserOauth(apiUrl, provider) {
197
305
  return new Promise((resolve, reject) => {