@openape/apes 1.30.0 → 1.31.1

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.
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/errors.ts
4
+ var CliError = class extends Error {
5
+ constructor(message, exitCode = 1) {
6
+ super(message);
7
+ this.exitCode = exitCode;
8
+ this.name = "CliError";
9
+ }
10
+ exitCode;
11
+ };
12
+ var CliExit = class extends Error {
13
+ constructor(exitCode = 0) {
14
+ super("");
15
+ this.exitCode = exitCode;
16
+ this.name = "CliExit";
17
+ }
18
+ exitCode;
19
+ };
20
+
21
+ // src/duration.ts
22
+ function parseDuration(value) {
23
+ const match = value.match(/^(\d+)\s*([smhd])$/);
24
+ if (!match) {
25
+ throw new Error(`Invalid duration format: "${value}". Use e.g. 30m, 1h, 7d`);
26
+ }
27
+ const amount = Number.parseInt(match[1], 10);
28
+ switch (match[2]) {
29
+ case "s":
30
+ return amount;
31
+ case "m":
32
+ return amount * 60;
33
+ case "h":
34
+ return amount * 3600;
35
+ case "d":
36
+ return amount * 86400;
37
+ default:
38
+ throw new Error(`Unknown duration unit: ${match[2]}`);
39
+ }
40
+ }
41
+
42
+ // src/lib/agent-secrets-runtime.ts
43
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from "fs";
44
+ import { homedir } from "os";
45
+ import { dirname, join } from "path";
46
+ import { openString } from "@openape/core";
47
+ var CONFIG_DIR = join(homedir(), ".config", "openape");
48
+ var SECRETS_DIR = join(CONFIG_DIR, "secrets.d");
49
+ var X25519_KEY_PATH = join(CONFIG_DIR, "agent-x25519.key");
50
+ var X25519_PUBKEY_PATH = `${X25519_KEY_PATH}.pub`;
51
+ function envNameFromFile(file) {
52
+ if (!file.endsWith(".blob")) return null;
53
+ const env = file.slice(0, -".blob".length);
54
+ return /^[A-Z][A-Z0-9_]*$/.test(env) ? env : null;
55
+ }
56
+ function readAgentEncryptionKey(keyPath = X25519_KEY_PATH) {
57
+ if (!existsSync(keyPath)) return null;
58
+ const k = readFileSync(keyPath, "utf8").trim();
59
+ return k.length > 0 ? k : null;
60
+ }
61
+ function readAgentEncryptionPublicKey(pubPath = X25519_PUBKEY_PATH) {
62
+ if (!existsSync(pubPath)) return null;
63
+ const k = readFileSync(pubPath, "utf8").trim();
64
+ return k.length > 0 ? k : null;
65
+ }
66
+ function materializeSecrets(opts = {}) {
67
+ const dir = opts.dir ?? SECRETS_DIR;
68
+ const env = opts.env ?? process.env;
69
+ const log = opts.log ?? (() => {
70
+ });
71
+ const applied = [];
72
+ const failed = [];
73
+ const key = readAgentEncryptionKey(opts.keyPath);
74
+ const files = key && existsSync(dir) ? readdirSync(dir) : [];
75
+ for (const file of files) {
76
+ const name = envNameFromFile(file);
77
+ if (!name) continue;
78
+ try {
79
+ const box = JSON.parse(readFileSync(join(dir, file), "utf8"));
80
+ const plaintext = openString(box, key);
81
+ const target = typeof box.materializeTo === "string" ? box.materializeTo : null;
82
+ if (target) {
83
+ const blobMtime = statSync(join(dir, file)).mtimeMs;
84
+ if (!existsSync(target) || statSync(target).mtimeMs < blobMtime) {
85
+ mkdirSync(dirname(target), { recursive: true });
86
+ writeFileSync(target, plaintext, { mode: 384 });
87
+ }
88
+ } else {
89
+ env[name] = plaintext;
90
+ }
91
+ applied.push(name);
92
+ } catch (e) {
93
+ failed.push(file);
94
+ log(`secrets: failed to open ${file}: ${e.message}`);
95
+ }
96
+ }
97
+ const live = new Set(applied);
98
+ for (const prev of opts.previouslyApplied ?? []) {
99
+ if (!live.has(prev)) {
100
+ delete env[prev];
101
+ log(`secrets: revoked ${prev}`);
102
+ }
103
+ }
104
+ return { applied, failed };
105
+ }
106
+ function startSecretsWatcher(opts = {}) {
107
+ const dir = opts.dir ?? SECRETS_DIR;
108
+ const log = opts.log ?? (() => {
109
+ });
110
+ let appliedNames = /* @__PURE__ */ new Set();
111
+ const run = () => {
112
+ const r = materializeSecrets({ ...opts, previouslyApplied: appliedNames });
113
+ appliedNames = new Set(r.applied);
114
+ };
115
+ run();
116
+ if (!existsSync(dir)) return () => {
117
+ };
118
+ let timer = null;
119
+ const watcher = watch(dir, () => {
120
+ if (timer) clearTimeout(timer);
121
+ timer = setTimeout(run, 150);
122
+ });
123
+ watcher.on("error", (err) => log(`secrets: watcher error: ${err.message}`));
124
+ return () => {
125
+ if (timer) clearTimeout(timer);
126
+ watcher.close();
127
+ };
128
+ }
129
+
130
+ export {
131
+ CliError,
132
+ CliExit,
133
+ parseDuration,
134
+ readAgentEncryptionPublicKey,
135
+ materializeSecrets,
136
+ startSecretsWatcher
137
+ };
138
+ //# sourceMappingURL=chunk-3LH4FT4R.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/duration.ts","../src/lib/agent-secrets-runtime.ts"],"sourcesContent":["export class CliError extends Error {\n constructor(message: string, public exitCode: number = 1) {\n super(message)\n this.name = 'CliError'\n }\n}\n\nexport class CliExit extends Error {\n constructor(public exitCode: number = 0) {\n super('')\n this.name = 'CliExit'\n }\n}\n","/**\n * Parse a human-readable duration string into seconds.\n * Supported formats: 30s, 5m, 1h, 7d\n */\nexport function parseDuration(value: string): number {\n const match = value.match(/^(\\d+)\\s*([smhd])$/)\n if (!match) {\n throw new Error(`Invalid duration format: \"${value}\". Use e.g. 30m, 1h, 7d`)\n }\n const amount = Number.parseInt(match[1]!, 10)\n switch (match[2]) {\n case 's': return amount\n case 'm': return amount * 60\n case 'h': return amount * 3600\n case 'd': return amount * 86400\n default: throw new Error(`Unknown duration unit: ${match[2]}`)\n }\n}\n","import type { SealedBox } from '@openape/core'\nimport { existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { openString } from '@openape/core'\n\n// Agent-side of the capability broker. troop seals a secret to this\n// agent's X25519 pubkey (M2a/M2c); nest drops the opaque blob into\n// ~/.config/openape/secrets.d/<ENV>.blob (M2d). Here the agent — the\n// ONLY place plaintext exists — opens it with its private key and\n// injects it into process.env so its tools (bash etc.) see it. Revoke\n// = blob removed → the env var is dropped on the next materialize.\n// See plans.openape.ai 01KRTAE8 (M2e).\n\nconst CONFIG_DIR = join(homedir(), '.config', 'openape')\nexport const SECRETS_DIR = join(CONFIG_DIR, 'secrets.d')\nexport const X25519_KEY_PATH = join(CONFIG_DIR, 'agent-x25519.key')\n// Public half written alongside the private key at spawn (agent-bootstrap).\n// Reported to troop on sync so the capability broker can seal secrets to it.\nexport const X25519_PUBKEY_PATH = `${X25519_KEY_PATH}.pub`\n\nfunction envNameFromFile(file: string): string | null {\n if (!file.endsWith('.blob')) return null\n const env = file.slice(0, -'.blob'.length)\n return /^[A-Z][A-Z0-9_]*$/.test(env) ? env : null\n}\n\nexport function readAgentEncryptionKey(keyPath = X25519_KEY_PATH): string | null {\n if (!existsSync(keyPath)) return null\n const k = readFileSync(keyPath, 'utf8').trim()\n return k.length > 0 ? k : null\n}\n\nexport function readAgentEncryptionPublicKey(pubPath = X25519_PUBKEY_PATH): string | null {\n if (!existsSync(pubPath)) return null\n const k = readFileSync(pubPath, 'utf8').trim()\n return k.length > 0 ? k : null\n}\n\nexport interface MaterializeOptions {\n dir?: string\n keyPath?: string\n /** Target env map (defaults to process.env). Injected in tests. */\n env?: NodeJS.ProcessEnv\n /** Env names applied by a previous materialize, to drop on revoke. */\n previouslyApplied?: Iterable<string>\n log?: (line: string) => void\n}\n\nexport interface MaterializeResult {\n applied: string[]\n failed: string[]\n}\n\n/**\n * Open every sealed blob in the secrets dir and set the corresponding\n * env var. Any env that was applied before but whose blob is now gone\n * (revoke) is deleted. Best-effort per blob — a corrupt/foreign blob\n * is logged and skipped, never throws.\n */\nexport function materializeSecrets(opts: MaterializeOptions = {}): MaterializeResult {\n const dir = opts.dir ?? SECRETS_DIR\n const env = opts.env ?? process.env\n const log = opts.log ?? (() => {})\n const applied: string[] = []\n const failed: string[] = []\n\n const key = readAgentEncryptionKey(opts.keyPath)\n const files = key && existsSync(dir) ? readdirSync(dir) : []\n\n for (const file of files) {\n const name = envNameFromFile(file)\n if (!name) continue\n try {\n const box = JSON.parse(readFileSync(join(dir, file), 'utf8')) as SealedBox & { materializeTo?: unknown }\n const plaintext = openString(box, key!)\n const target = typeof box.materializeTo === 'string' ? box.materializeTo : null\n if (target) {\n // Seed-once: write only on first seed or a newer blob (re-verify).\n // Never clobber a file litellm refreshed in place (file > blob mtime).\n const blobMtime = statSync(join(dir, file)).mtimeMs\n if (!existsSync(target) || statSync(target).mtimeMs < blobMtime) {\n mkdirSync(dirname(target), { recursive: true })\n writeFileSync(target, plaintext, { mode: 0o600 })\n }\n }\n else {\n env[name] = plaintext\n }\n applied.push(name)\n }\n catch (e) {\n failed.push(file)\n log(`secrets: failed to open ${file}: ${(e as Error).message}`)\n }\n }\n\n // Revoke: env names we set last time but that have no blob now.\n const live = new Set(applied)\n for (const prev of opts.previouslyApplied ?? []) {\n if (!live.has(prev)) {\n delete env[prev]\n log(`secrets: revoked ${prev}`)\n }\n }\n\n return { applied, failed }\n}\n\n/**\n * Materialize once, then fs.watch the secrets dir and re-materialize\n * on any change (rotate/revoke take effect live — the user's M2 v1\n * choice). Returns a stop function. Safe to call when the dir doesn't\n * exist yet (watch attaches once it appears on the next agent start).\n */\nexport function startSecretsWatcher(opts: MaterializeOptions = {}): () => void {\n const dir = opts.dir ?? SECRETS_DIR\n const log = opts.log ?? (() => {})\n let appliedNames = new Set<string>()\n\n const run = () => {\n const r = materializeSecrets({ ...opts, previouslyApplied: appliedNames })\n appliedNames = new Set(r.applied)\n }\n\n run()\n if (!existsSync(dir)) return () => {}\n\n let timer: NodeJS.Timeout | null = null\n const watcher = watch(dir, () => {\n // Debounce — a single rotate is several fs events (create, write).\n if (timer) clearTimeout(timer)\n timer = setTimeout(run, 150)\n })\n watcher.on('error', err => log(`secrets: watcher error: ${err.message}`))\n\n return () => {\n if (timer) clearTimeout(timer)\n watcher.close()\n }\n}\n"],"mappings":"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","names":[]}
@@ -161,6 +161,8 @@ async function ensureFreshToken() {
161
161
  const oauthToken = await refreshOAuthToken();
162
162
  return oauthToken ?? null;
163
163
  }
164
+ var MAX_RATE_LIMIT_RETRIES = 3;
165
+ var RATE_LIMIT_WAIT_CAP_MS = 12e3;
164
166
  async function apiFetch(path, options = {}) {
165
167
  const token = options.token ?? await ensureFreshToken();
166
168
  if (!token) {
@@ -185,11 +187,17 @@ async function apiFetch(path, options = {}) {
185
187
  consola.debug(`${method} ${url}`);
186
188
  consola.debug(`Token: ${token.substring(0, 20)}...${token.substring(token.length - 10)}`);
187
189
  }
188
- const response = await fetch(url, {
189
- method,
190
- headers,
191
- body: options.body ? JSON.stringify(options.body) : void 0
192
- });
190
+ const fetchInit = { method, headers, body: options.body ? JSON.stringify(options.body) : void 0 };
191
+ let response = await fetch(url, fetchInit);
192
+ for (let rlAttempt = 0; response.status === 429 && rlAttempt < MAX_RATE_LIMIT_RETRIES; rlAttempt++) {
193
+ const retryAfter = Number(response.headers.get("retry-after"));
194
+ const waitMs = Math.min((Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 2 ** rlAttempt) * 1e3, RATE_LIMIT_WAIT_CAP_MS);
195
+ await response.text().catch(() => {
196
+ });
197
+ consola.info(`Rate limited by IdP \u2014 retrying in ${Math.round(waitMs / 1e3)}s (${rlAttempt + 1}/${MAX_RATE_LIMIT_RETRIES})`);
198
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
199
+ response = await fetch(url, fetchInit);
200
+ }
193
201
  if (debug) {
194
202
  consola.debug(`Response: ${response.status} ${response.statusText}`);
195
203
  }
@@ -221,4 +229,4 @@ export {
221
229
  ensureFreshToken,
222
230
  apiFetch
223
231
  };
224
- //# sourceMappingURL=chunk-NYJSBFLG.js.map
232
+ //# sourceMappingURL=chunk-MMBFV5WN.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/http.ts"],"sourcesContent":["import consola from 'consola'\nimport { getAuthToken, getIdpUrl, loadAuth, loadConfig, saveAuth } from './config'\n\nconst debug = process.argv.includes('--debug')\n\nexport class ApiError extends Error {\n constructor(public statusCode: number, message: string, public problemDetails?: Record<string, unknown>) {\n super(message)\n this.name = 'ApiError'\n }\n}\n\n// OIDC Discovery cache (one-time per CLI invocation)\nconst _discoveryCache: Record<string, Record<string, unknown>> = {}\n\nexport async function discoverEndpoints(idpUrl: string): Promise<Record<string, unknown>> {\n if (_discoveryCache[idpUrl]) {\n return _discoveryCache[idpUrl]\n }\n\n try {\n const response = await fetch(`${idpUrl}/.well-known/openid-configuration`)\n if (response.ok) {\n const data = await response.json() as Record<string, unknown>\n _discoveryCache[idpUrl] = data\n return data\n }\n }\n catch {}\n\n // Return empty if discovery fails (graceful degradation)\n _discoveryCache[idpUrl] = {}\n return {}\n}\n\nexport async function getGrantsEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n return (disco.openape_grants_endpoint as string) || `${idpUrl}/api/grants`\n}\n\nexport async function getAgentChallengeEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n // Read canonical ddisa_auth_challenge_endpoint (emitted by server since M3).\n // Fall back to legacy ddisa_agent_challenge_endpoint for backward-compat with older IdPs.\n return (disco.ddisa_auth_challenge_endpoint as string)\n || (disco.ddisa_agent_challenge_endpoint as string)\n || `${idpUrl}/api/auth/challenge`\n}\n\nexport async function getAgentAuthenticateEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n // Read canonical ddisa_auth_authenticate_endpoint (emitted by server since M3).\n // Fall back to legacy ddisa_agent_authenticate_endpoint for backward-compat with older IdPs.\n return (disco.ddisa_auth_authenticate_endpoint as string)\n || (disco.ddisa_agent_authenticate_endpoint as string)\n || `${idpUrl}/api/auth/authenticate`\n}\n\nexport async function getDelegationsEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n return (disco.openape_delegations_endpoint as string) || `${idpUrl}/api/delegations`\n}\n\n/**\n * Re-authenticate an agent using Ed25519 challenge-response.\n * Called automatically when the token is expired.\n */\nasync function refreshAgentToken(): Promise<string | null> {\n const auth = loadAuth()\n if (!auth)\n return null\n\n const config = loadConfig()\n const keyPath = config.agent?.key\n if (!keyPath)\n return null\n\n try {\n const { readFileSync } = await import('node:fs')\n const { sign } = await import('node:crypto')\n const { homedir } = await import('node:os')\n const { loadEd25519PrivateKey } = await import('./ssh-key.js')\n\n const resolved = keyPath.replace(/^~/, homedir())\n const keyContent = readFileSync(resolved, 'utf-8')\n const privateKey = loadEd25519PrivateKey(keyContent)\n\n const challengeUrl = await getAgentChallengeEndpoint(auth.idp)\n const challengeResp = await fetch(challengeUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n // Use canonical `id` field (the server's /api/auth/challenge handler expects `id`).\n body: JSON.stringify({ id: auth.email }),\n })\n\n if (!challengeResp.ok)\n return null\n\n const { challenge } = await challengeResp.json() as { challenge: string }\n const { Buffer } = await import('node:buffer')\n const signature = sign(null, Buffer.from(challenge), privateKey).toString('base64')\n\n const authenticateUrl = await getAgentAuthenticateEndpoint(auth.idp)\n const authResp = await fetch(authenticateUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n // Use canonical `id` field (the server's /api/auth/authenticate handler expects `id`).\n body: JSON.stringify({ id: auth.email, challenge, signature }),\n })\n\n if (!authResp.ok)\n return null\n\n const { token, expires_in } = await authResp.json() as { token: string, expires_in: number }\n\n saveAuth({\n ...auth,\n access_token: token,\n expires_at: Math.floor(Date.now() / 1000) + (expires_in || 3600),\n })\n\n if (debug) {\n consola.debug('Token refreshed via Ed25519 challenge-response')\n }\n\n return token\n }\n catch {\n return null\n }\n}\n\n/**\n * Refresh an OAuth2 access token using a stored refresh_token. Used for\n * PKCE/browser login sessions where no Ed25519 agent key is configured.\n *\n * Serialized via a file lock so concurrent apes/ape-shell invocations don't\n * both consume the same rotating refresh token (which would revoke the\n * entire family server-side).\n */\nasync function refreshOAuthToken(): Promise<string | null> {\n const auth = loadAuth()\n if (!auth?.refresh_token)\n return null\n\n const { acquireAuthLock, releaseAuthLock } = await import('./auth-lock.js')\n const lock = await acquireAuthLock({ timeoutMs: 5000 })\n if (!lock) {\n // Another process is refreshing. It should have updated auth.json by now;\n // re-read and return whatever fresh token is there (may still be null).\n return getAuthToken()\n }\n\n try {\n // Re-read auth.json inside the lock — another holder may already have\n // refreshed while we were waiting, in which case we reuse the new token.\n const latest = loadAuth()\n if (latest?.expires_at && Date.now() / 1000 < latest.expires_at - 30)\n return latest.access_token\n\n const activeRefreshToken = latest?.refresh_token ?? auth.refresh_token\n if (!activeRefreshToken)\n return null\n\n const disco = await discoverEndpoints(auth.idp)\n const tokenEndpoint = (disco.token_endpoint as string) || `${auth.idp}/token`\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: activeRefreshToken,\n })\n\n const resp = await fetch(tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n })\n\n if (!resp.ok) {\n // Family may have been revoked server-side — clear refresh_token to\n // prevent an infinite retry loop on every subsequent apes invocation.\n if (resp.status === 400 || resp.status === 401) {\n const base = latest ?? auth\n saveAuth({ ...base, refresh_token: undefined })\n }\n return null\n }\n\n const tokens = await resp.json() as {\n access_token: string\n refresh_token?: string\n expires_in?: number\n }\n\n const base = latest ?? auth\n saveAuth({\n ...base,\n access_token: tokens.access_token,\n refresh_token: tokens.refresh_token ?? base.refresh_token,\n expires_at: Math.floor(Date.now() / 1000) + (tokens.expires_in || 300),\n })\n\n if (debug)\n consola.debug('Token refreshed via OAuth refresh_token')\n\n return tokens.access_token\n }\n finally {\n await releaseAuthLock(lock)\n }\n}\n\n/**\n * Refresh the local IdP token if it has expired. Used by every code path\n * that needs an access token — `apiFetch` for IdP-bound HTTP calls, plus\n * any pure-introspection command (`apes whoami`, `apes config get …`)\n * that previously only read local auth state and surfaced a stale\n * \"expired\" without attempting renewal.\n *\n * Returns the fresh access token on success, or null when no refresh\n * path is available (no agent key AND no refresh_token, or both refresh\n * attempts failed). Callers decide what to do with null — `apiFetch`\n * throws \"Not authenticated\", `whoami` falls back to the on-disk state.\n */\nexport async function ensureFreshToken(): Promise<string | null> {\n const cached = getAuthToken()\n if (cached) return cached\n\n // Auto-refresh: priority (1) ed25519 agent key, (2) OAuth refresh_token.\n // Agent-key first because it is concurrency-safe — every challenge is\n // independent server-side, so parallel ape-shell spawns don't race.\n const agentToken = await refreshAgentToken()\n if (agentToken) return agentToken\n const oauthToken = await refreshOAuthToken()\n return oauthToken ?? null\n}\n\nexport async function apiFetch<T = unknown>(\n path: string,\n options: {\n method?: string\n body?: unknown\n idp?: string\n token?: string\n } = {},\n): Promise<T> {\n const token = options.token ?? await ensureFreshToken()\n\n if (!token) {\n throw new Error('Not authenticated (token expired). Run `apes login` first.')\n }\n\n let url: string\n if (path.startsWith('http')) {\n url = path\n }\n else {\n const idp = options.idp || getIdpUrl()\n if (!idp) {\n throw new Error('No IdP URL configured. Run `apes login` first or pass --idp.')\n }\n url = `${idp}${path}`\n }\n const method = options.method || 'GET'\n const headers: Record<string, string> = {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json',\n }\n\n if (debug) {\n consola.debug(`${method} ${url}`)\n consola.debug(`Token: ${token.substring(0, 20)}...${token.substring(token.length - 10)}`)\n }\n\n const response = await fetch(url, {\n method,\n headers,\n body: options.body ? JSON.stringify(options.body) : undefined,\n })\n\n if (debug) {\n consola.debug(`Response: ${response.status} ${response.statusText}`)\n }\n\n if (!response.ok) {\n const contentType = response.headers.get('content-type') || ''\n\n // Parse RFC 7807 Problem Details\n if (contentType.includes('application/problem+json') || contentType.includes('application/json')) {\n try {\n const problem = await response.json() as Record<string, unknown>\n const message = (problem.detail as string) || (problem.title as string) || (problem.statusMessage as string) || (problem.message as string) || `${response.status} ${response.statusText}`\n throw new ApiError(response.status, message, problem)\n }\n catch (e) {\n if (e instanceof ApiError)\n throw e\n }\n }\n\n const text = await response.text()\n throw new ApiError(response.status, text || `${response.status} ${response.statusText}`)\n }\n\n return response.json() as Promise<T>\n}\n"],"mappings":"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","names":["base"]}
1
+ {"version":3,"sources":["../src/http.ts"],"sourcesContent":["import consola from 'consola'\nimport { getAuthToken, getIdpUrl, loadAuth, loadConfig, saveAuth } from './config'\n\nconst debug = process.argv.includes('--debug')\n\nexport class ApiError extends Error {\n constructor(public statusCode: number, message: string, public problemDetails?: Record<string, unknown>) {\n super(message)\n this.name = 'ApiError'\n }\n}\n\n// OIDC Discovery cache (one-time per CLI invocation)\nconst _discoveryCache: Record<string, Record<string, unknown>> = {}\n\nexport async function discoverEndpoints(idpUrl: string): Promise<Record<string, unknown>> {\n if (_discoveryCache[idpUrl]) {\n return _discoveryCache[idpUrl]\n }\n\n try {\n const response = await fetch(`${idpUrl}/.well-known/openid-configuration`)\n if (response.ok) {\n const data = await response.json() as Record<string, unknown>\n _discoveryCache[idpUrl] = data\n return data\n }\n }\n catch {}\n\n // Return empty if discovery fails (graceful degradation)\n _discoveryCache[idpUrl] = {}\n return {}\n}\n\nexport async function getGrantsEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n return (disco.openape_grants_endpoint as string) || `${idpUrl}/api/grants`\n}\n\nexport async function getAgentChallengeEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n // Read canonical ddisa_auth_challenge_endpoint (emitted by server since M3).\n // Fall back to legacy ddisa_agent_challenge_endpoint for backward-compat with older IdPs.\n return (disco.ddisa_auth_challenge_endpoint as string)\n || (disco.ddisa_agent_challenge_endpoint as string)\n || `${idpUrl}/api/auth/challenge`\n}\n\nexport async function getAgentAuthenticateEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n // Read canonical ddisa_auth_authenticate_endpoint (emitted by server since M3).\n // Fall back to legacy ddisa_agent_authenticate_endpoint for backward-compat with older IdPs.\n return (disco.ddisa_auth_authenticate_endpoint as string)\n || (disco.ddisa_agent_authenticate_endpoint as string)\n || `${idpUrl}/api/auth/authenticate`\n}\n\nexport async function getDelegationsEndpoint(idpUrl: string): Promise<string> {\n const disco = await discoverEndpoints(idpUrl)\n return (disco.openape_delegations_endpoint as string) || `${idpUrl}/api/delegations`\n}\n\n/**\n * Re-authenticate an agent using Ed25519 challenge-response.\n * Called automatically when the token is expired.\n */\nasync function refreshAgentToken(): Promise<string | null> {\n const auth = loadAuth()\n if (!auth)\n return null\n\n const config = loadConfig()\n const keyPath = config.agent?.key\n if (!keyPath)\n return null\n\n try {\n const { readFileSync } = await import('node:fs')\n const { sign } = await import('node:crypto')\n const { homedir } = await import('node:os')\n const { loadEd25519PrivateKey } = await import('./ssh-key.js')\n\n const resolved = keyPath.replace(/^~/, homedir())\n const keyContent = readFileSync(resolved, 'utf-8')\n const privateKey = loadEd25519PrivateKey(keyContent)\n\n const challengeUrl = await getAgentChallengeEndpoint(auth.idp)\n const challengeResp = await fetch(challengeUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n // Use canonical `id` field (the server's /api/auth/challenge handler expects `id`).\n body: JSON.stringify({ id: auth.email }),\n })\n\n if (!challengeResp.ok)\n return null\n\n const { challenge } = await challengeResp.json() as { challenge: string }\n const { Buffer } = await import('node:buffer')\n const signature = sign(null, Buffer.from(challenge), privateKey).toString('base64')\n\n const authenticateUrl = await getAgentAuthenticateEndpoint(auth.idp)\n const authResp = await fetch(authenticateUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n // Use canonical `id` field (the server's /api/auth/authenticate handler expects `id`).\n body: JSON.stringify({ id: auth.email, challenge, signature }),\n })\n\n if (!authResp.ok)\n return null\n\n const { token, expires_in } = await authResp.json() as { token: string, expires_in: number }\n\n saveAuth({\n ...auth,\n access_token: token,\n expires_at: Math.floor(Date.now() / 1000) + (expires_in || 3600),\n })\n\n if (debug) {\n consola.debug('Token refreshed via Ed25519 challenge-response')\n }\n\n return token\n }\n catch {\n return null\n }\n}\n\n/**\n * Refresh an OAuth2 access token using a stored refresh_token. Used for\n * PKCE/browser login sessions where no Ed25519 agent key is configured.\n *\n * Serialized via a file lock so concurrent apes/ape-shell invocations don't\n * both consume the same rotating refresh token (which would revoke the\n * entire family server-side).\n */\nasync function refreshOAuthToken(): Promise<string | null> {\n const auth = loadAuth()\n if (!auth?.refresh_token)\n return null\n\n const { acquireAuthLock, releaseAuthLock } = await import('./auth-lock.js')\n const lock = await acquireAuthLock({ timeoutMs: 5000 })\n if (!lock) {\n // Another process is refreshing. It should have updated auth.json by now;\n // re-read and return whatever fresh token is there (may still be null).\n return getAuthToken()\n }\n\n try {\n // Re-read auth.json inside the lock — another holder may already have\n // refreshed while we were waiting, in which case we reuse the new token.\n const latest = loadAuth()\n if (latest?.expires_at && Date.now() / 1000 < latest.expires_at - 30)\n return latest.access_token\n\n const activeRefreshToken = latest?.refresh_token ?? auth.refresh_token\n if (!activeRefreshToken)\n return null\n\n const disco = await discoverEndpoints(auth.idp)\n const tokenEndpoint = (disco.token_endpoint as string) || `${auth.idp}/token`\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: activeRefreshToken,\n })\n\n const resp = await fetch(tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n })\n\n if (!resp.ok) {\n // Family may have been revoked server-side — clear refresh_token to\n // prevent an infinite retry loop on every subsequent apes invocation.\n if (resp.status === 400 || resp.status === 401) {\n const base = latest ?? auth\n saveAuth({ ...base, refresh_token: undefined })\n }\n return null\n }\n\n const tokens = await resp.json() as {\n access_token: string\n refresh_token?: string\n expires_in?: number\n }\n\n const base = latest ?? auth\n saveAuth({\n ...base,\n access_token: tokens.access_token,\n refresh_token: tokens.refresh_token ?? base.refresh_token,\n expires_at: Math.floor(Date.now() / 1000) + (tokens.expires_in || 300),\n })\n\n if (debug)\n consola.debug('Token refreshed via OAuth refresh_token')\n\n return tokens.access_token\n }\n finally {\n await releaseAuthLock(lock)\n }\n}\n\n/**\n * Refresh the local IdP token if it has expired. Used by every code path\n * that needs an access token — `apiFetch` for IdP-bound HTTP calls, plus\n * any pure-introspection command (`apes whoami`, `apes config get …`)\n * that previously only read local auth state and surfaced a stale\n * \"expired\" without attempting renewal.\n *\n * Returns the fresh access token on success, or null when no refresh\n * path is available (no agent key AND no refresh_token, or both refresh\n * attempts failed). Callers decide what to do with null — `apiFetch`\n * throws \"Not authenticated\", `whoami` falls back to the on-disk state.\n */\nexport async function ensureFreshToken(): Promise<string | null> {\n const cached = getAuthToken()\n if (cached) return cached\n\n // Auto-refresh: priority (1) ed25519 agent key, (2) OAuth refresh_token.\n // Agent-key first because it is concurrency-safe — every challenge is\n // independent server-side, so parallel ape-shell spawns don't race.\n const agentToken = await refreshAgentToken()\n if (agentToken) return agentToken\n const oauthToken = await refreshOAuthToken()\n return oauthToken ?? null\n}\n\n// Bounded 429 retry/backoff for the IdP's per-IP auth-endpoint rate limit.\nconst MAX_RATE_LIMIT_RETRIES = 3\nconst RATE_LIMIT_WAIT_CAP_MS = 12_000\n\nexport async function apiFetch<T = unknown>(\n path: string,\n options: {\n method?: string\n body?: unknown\n idp?: string\n token?: string\n } = {},\n): Promise<T> {\n const token = options.token ?? await ensureFreshToken()\n\n if (!token) {\n throw new Error('Not authenticated (token expired). Run `apes login` first.')\n }\n\n let url: string\n if (path.startsWith('http')) {\n url = path\n }\n else {\n const idp = options.idp || getIdpUrl()\n if (!idp) {\n throw new Error('No IdP URL configured. Run `apes login` first or pass --idp.')\n }\n url = `${idp}${path}`\n }\n const method = options.method || 'GET'\n const headers: Record<string, string> = {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json',\n }\n\n if (debug) {\n consola.debug(`${method} ${url}`)\n consola.debug(`Token: ${token.substring(0, 20)}...${token.substring(token.length - 10)}`)\n }\n\n // Retry on 429 (the IdP's per-IP rate limit on auth endpoints). Rapid\n // sequences bunch on one IP and the last call trips the cap — e.g. a nest's\n // `agents spawn` (enroll + challenge + authenticate) immediately followed by\n // `agents destroy` (de-register). Honour Retry-After, bounded, so the call\n // rides it out instead of failing. This is well-behaved backoff, not bypass.\n const fetchInit = { method, headers, body: options.body ? JSON.stringify(options.body) : undefined }\n let response = await fetch(url, fetchInit)\n for (let rlAttempt = 0; response.status === 429 && rlAttempt < MAX_RATE_LIMIT_RETRIES; rlAttempt++) {\n const retryAfter = Number(response.headers.get('retry-after'))\n const waitMs = Math.min((Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 2 ** rlAttempt) * 1000, RATE_LIMIT_WAIT_CAP_MS)\n await response.text().catch(() => {}) // drain so the socket frees before the retry\n consola.info(`Rate limited by IdP — retrying in ${Math.round(waitMs / 1000)}s (${rlAttempt + 1}/${MAX_RATE_LIMIT_RETRIES})`)\n await new Promise(resolve => setTimeout(resolve, waitMs))\n response = await fetch(url, fetchInit)\n }\n\n if (debug) {\n consola.debug(`Response: ${response.status} ${response.statusText}`)\n }\n\n if (!response.ok) {\n const contentType = response.headers.get('content-type') || ''\n\n // Parse RFC 7807 Problem Details\n if (contentType.includes('application/problem+json') || contentType.includes('application/json')) {\n try {\n const problem = await response.json() as Record<string, unknown>\n const message = (problem.detail as string) || (problem.title as string) || (problem.statusMessage as string) || (problem.message as string) || `${response.status} ${response.statusText}`\n throw new ApiError(response.status, message, problem)\n }\n catch (e) {\n if (e instanceof ApiError)\n throw e\n }\n }\n\n const text = await response.text()\n throw new ApiError(response.status, text || `${response.status} ${response.statusText}`)\n }\n\n return response.json() as Promise<T>\n}\n"],"mappings":";;;;;;;;;;AAAA,OAAO,aAAa;AAGpB,IAAM,QAAQ,QAAQ,KAAK,SAAS,SAAS;AAEtC,IAAM,WAAN,cAAuB,MAAM;AAAA,EAClC,YAAmB,YAAoB,SAAwB,gBAA0C;AACvG,UAAM,OAAO;AADI;AAA4C;AAE7D,SAAK,OAAO;AAAA,EACd;AAAA,EAHmB;AAAA,EAA4C;AAIjE;AAGA,IAAM,kBAA2D,CAAC;AAElE,eAAsB,kBAAkB,QAAkD;AACxF,MAAI,gBAAgB,MAAM,GAAG;AAC3B,WAAO,gBAAgB,MAAM;AAAA,EAC/B;AAEA,MAAI;AACF,UAAM,WAAW,MAAM,MAAM,GAAG,MAAM,mCAAmC;AACzE,QAAI,SAAS,IAAI;AACf,YAAM,OAAO,MAAM,SAAS,KAAK;AACjC,sBAAgB,MAAM,IAAI;AAC1B,aAAO;AAAA,IACT;AAAA,EACF,QACM;AAAA,EAAC;AAGP,kBAAgB,MAAM,IAAI,CAAC;AAC3B,SAAO,CAAC;AACV;AAEA,eAAsB,kBAAkB,QAAiC;AACvE,QAAM,QAAQ,MAAM,kBAAkB,MAAM;AAC5C,SAAQ,MAAM,2BAAsC,GAAG,MAAM;AAC/D;AAEA,eAAsB,0BAA0B,QAAiC;AAC/E,QAAM,QAAQ,MAAM,kBAAkB,MAAM;AAG5C,SAAQ,MAAM,iCACR,MAAM,kCACP,GAAG,MAAM;AAChB;AAEA,eAAsB,6BAA6B,QAAiC;AAClF,QAAM,QAAQ,MAAM,kBAAkB,MAAM;AAG5C,SAAQ,MAAM,oCACR,MAAM,qCACP,GAAG,MAAM;AAChB;AAEA,eAAsB,uBAAuB,QAAiC;AAC5E,QAAM,QAAQ,MAAM,kBAAkB,MAAM;AAC5C,SAAQ,MAAM,gCAA2C,GAAG,MAAM;AACpE;AAMA,eAAe,oBAA4C;AACzD,QAAM,OAAO,SAAS;AACtB,MAAI,CAAC;AACH,WAAO;AAET,QAAM,SAAS,WAAW;AAC1B,QAAM,UAAU,OAAO,OAAO;AAC9B,MAAI,CAAC;AACH,WAAO;AAET,MAAI;AACF,UAAM,EAAE,aAAa,IAAI,MAAM,OAAO,IAAS;AAC/C,UAAM,EAAE,KAAK,IAAI,MAAM,OAAO,QAAa;AAC3C,UAAM,EAAE,QAAQ,IAAI,MAAM,OAAO,IAAS;AAC1C,UAAM,EAAE,sBAAsB,IAAI,MAAM,OAAO,uBAAc;AAE7D,UAAM,WAAW,QAAQ,QAAQ,MAAM,QAAQ,CAAC;AAChD,UAAM,aAAa,aAAa,UAAU,OAAO;AACjD,UAAM,aAAa,sBAAsB,UAAU;AAEnD,UAAM,eAAe,MAAM,0BAA0B,KAAK,GAAG;AAC7D,UAAM,gBAAgB,MAAM,MAAM,cAAc;AAAA,MAC9C,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA;AAAA,MAE9C,MAAM,KAAK,UAAU,EAAE,IAAI,KAAK,MAAM,CAAC;AAAA,IACzC,CAAC;AAED,QAAI,CAAC,cAAc;AACjB,aAAO;AAET,UAAM,EAAE,UAAU,IAAI,MAAM,cAAc,KAAK;AAC/C,UAAM,EAAE,OAAO,IAAI,MAAM,OAAO,QAAa;AAC7C,UAAM,YAAY,KAAK,MAAM,OAAO,KAAK,SAAS,GAAG,UAAU,EAAE,SAAS,QAAQ;AAElF,UAAM,kBAAkB,MAAM,6BAA6B,KAAK,GAAG;AACnE,UAAM,WAAW,MAAM,MAAM,iBAAiB;AAAA,MAC5C,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA;AAAA,MAE9C,MAAM,KAAK,UAAU,EAAE,IAAI,KAAK,OAAO,WAAW,UAAU,CAAC;AAAA,IAC/D,CAAC;AAED,QAAI,CAAC,SAAS;AACZ,aAAO;AAET,UAAM,EAAE,OAAO,WAAW,IAAI,MAAM,SAAS,KAAK;AAElD,aAAS;AAAA,MACP,GAAG;AAAA,MACH,cAAc;AAAA,MACd,YAAY,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,KAAK,cAAc;AAAA,IAC7D,CAAC;AAED,QAAI,OAAO;AACT,cAAQ,MAAM,gDAAgD;AAAA,IAChE;AAEA,WAAO;AAAA,EACT,QACM;AACJ,WAAO;AAAA,EACT;AACF;AAUA,eAAe,oBAA4C;AACzD,QAAM,OAAO,SAAS;AACtB,MAAI,CAAC,MAAM;AACT,WAAO;AAET,QAAM,EAAE,iBAAiB,gBAAgB,IAAI,MAAM,OAAO,yBAAgB;AAC1E,QAAM,OAAO,MAAM,gBAAgB,EAAE,WAAW,IAAK,CAAC;AACtD,MAAI,CAAC,MAAM;AAGT,WAAO,aAAa;AAAA,EACtB;AAEA,MAAI;AAGF,UAAM,SAAS,SAAS;AACxB,QAAI,QAAQ,cAAc,KAAK,IAAI,IAAI,MAAO,OAAO,aAAa;AAChE,aAAO,OAAO;AAEhB,UAAM,qBAAqB,QAAQ,iBAAiB,KAAK;AACzD,QAAI,CAAC;AACH,aAAO;AAET,UAAM,QAAQ,MAAM,kBAAkB,KAAK,GAAG;AAC9C,UAAM,gBAAiB,MAAM,kBAA6B,GAAG,KAAK,GAAG;AAErE,UAAM,OAAO,IAAI,gBAAgB;AAAA,MAC/B,YAAY;AAAA,MACZ,eAAe;AAAA,IACjB,CAAC;AAED,UAAM,OAAO,MAAM,MAAM,eAAe;AAAA,MACtC,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,MAC/D,MAAM,KAAK,SAAS;AAAA,IACtB,CAAC;AAED,QAAI,CAAC,KAAK,IAAI;AAGZ,UAAI,KAAK,WAAW,OAAO,KAAK,WAAW,KAAK;AAC9C,cAAMA,QAAO,UAAU;AACvB,iBAAS,EAAE,GAAGA,OAAM,eAAe,OAAU,CAAC;AAAA,MAChD;AACA,aAAO;AAAA,IACT;AAEA,UAAM,SAAS,MAAM,KAAK,KAAK;AAM/B,UAAM,OAAO,UAAU;AACvB,aAAS;AAAA,MACP,GAAG;AAAA,MACH,cAAc,OAAO;AAAA,MACrB,eAAe,OAAO,iBAAiB,KAAK;AAAA,MAC5C,YAAY,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,KAAK,OAAO,cAAc;AAAA,IACpE,CAAC;AAED,QAAI;AACF,cAAQ,MAAM,yCAAyC;AAEzD,WAAO,OAAO;AAAA,EAChB,UACA;AACE,UAAM,gBAAgB,IAAI;AAAA,EAC5B;AACF;AAcA,eAAsB,mBAA2C;AAC/D,QAAM,SAAS,aAAa;AAC5B,MAAI,OAAQ,QAAO;AAKnB,QAAM,aAAa,MAAM,kBAAkB;AAC3C,MAAI,WAAY,QAAO;AACvB,QAAM,aAAa,MAAM,kBAAkB;AAC3C,SAAO,cAAc;AACvB;AAGA,IAAM,yBAAyB;AAC/B,IAAM,yBAAyB;AAE/B,eAAsB,SACpB,MACA,UAKI,CAAC,GACO;AACZ,QAAM,QAAQ,QAAQ,SAAS,MAAM,iBAAiB;AAEtD,MAAI,CAAC,OAAO;AACV,UAAM,IAAI,MAAM,4DAA4D;AAAA,EAC9E;AAEA,MAAI;AACJ,MAAI,KAAK,WAAW,MAAM,GAAG;AAC3B,UAAM;AAAA,EACR,OACK;AACH,UAAM,MAAM,QAAQ,OAAO,UAAU;AACrC,QAAI,CAAC,KAAK;AACR,YAAM,IAAI,MAAM,8DAA8D;AAAA,IAChF;AACA,UAAM,GAAG,GAAG,GAAG,IAAI;AAAA,EACrB;AACA,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,UAAkC;AAAA,IACtC,iBAAiB,UAAU,KAAK;AAAA,IAChC,gBAAgB;AAAA,EAClB;AAEA,MAAI,OAAO;AACT,YAAQ,MAAM,GAAG,MAAM,IAAI,GAAG,EAAE;AAChC,YAAQ,MAAM,UAAU,MAAM,UAAU,GAAG,EAAE,CAAC,MAAM,MAAM,UAAU,MAAM,SAAS,EAAE,CAAC,EAAE;AAAA,EAC1F;AAOA,QAAM,YAAY,EAAE,QAAQ,SAAS,MAAM,QAAQ,OAAO,KAAK,UAAU,QAAQ,IAAI,IAAI,OAAU;AACnG,MAAI,WAAW,MAAM,MAAM,KAAK,SAAS;AACzC,WAAS,YAAY,GAAG,SAAS,WAAW,OAAO,YAAY,wBAAwB,aAAa;AAClG,UAAM,aAAa,OAAO,SAAS,QAAQ,IAAI,aAAa,CAAC;AAC7D,UAAM,SAAS,KAAK,KAAK,OAAO,SAAS,UAAU,KAAK,aAAa,IAAI,aAAa,KAAK,aAAa,KAAM,sBAAsB;AACpI,UAAM,SAAS,KAAK,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACpC,YAAQ,KAAK,0CAAqC,KAAK,MAAM,SAAS,GAAI,CAAC,MAAM,YAAY,CAAC,IAAI,sBAAsB,GAAG;AAC3H,UAAM,IAAI,QAAQ,aAAW,WAAW,SAAS,MAAM,CAAC;AACxD,eAAW,MAAM,MAAM,KAAK,SAAS;AAAA,EACvC;AAEA,MAAI,OAAO;AACT,YAAQ,MAAM,aAAa,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE;AAAA,EACrE;AAEA,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;AAG5D,QAAI,YAAY,SAAS,0BAA0B,KAAK,YAAY,SAAS,kBAAkB,GAAG;AAChG,UAAI;AACF,cAAM,UAAU,MAAM,SAAS,KAAK;AACpC,cAAM,UAAW,QAAQ,UAAsB,QAAQ,SAAqB,QAAQ,iBAA6B,QAAQ,WAAsB,GAAG,SAAS,MAAM,IAAI,SAAS,UAAU;AACxL,cAAM,IAAI,SAAS,SAAS,QAAQ,SAAS,OAAO;AAAA,MACtD,SACO,GAAG;AACR,YAAI,aAAa;AACf,gBAAM;AAAA,MACV;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,SAAS,KAAK;AACjC,UAAM,IAAI,SAAS,SAAS,QAAQ,QAAQ,GAAG,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE;AAAA,EACzF;AAEA,SAAO,SAAS,KAAK;AACvB;","names":["base"]}