@open-autonomy/sdk 2.2.0 → 2.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-autonomy/sdk",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "The Open Autonomy SDK: the roadmap model and its codec, the development-stream client (sessions, turns, updates), and the key helpers. Everything it does is a documented HTTP wire any language can speak without it.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/rails.ts CHANGED
@@ -47,3 +47,27 @@ export function parseRailsConfig(yaml: string): RailsConfig {
47
47
  }
48
48
  return cfg;
49
49
  }
50
+
51
+ // The models the project's funds may buy on the model rail, from the same `.open-autonomy/config.yaml`:
52
+ // models: [zai/glm-5.3-flash] or a block list. Empty or absent: no bound beyond the key's own list.
53
+ // The bound is the owner's, read by the platform from the repository, and holds whatever a key was minted with.
54
+ export function parseModelsBound(yaml: string): string[] {
55
+ const out: string[] = [];
56
+ let inList = false;
57
+ for (const raw of yaml.split('\n')) {
58
+ const line = raw.replace(/\s+#.*$/, '').trimEnd();
59
+ if (!line.trim() || line.trim().startsWith('#')) continue;
60
+ const top = /^([a-z_]+):\s*(.*)$/.exec(line);
61
+ if (top) {
62
+ inList = false;
63
+ if (top[1] !== 'models') continue;
64
+ const inline = /^\[(.*)\]$/.exec(top[2].trim());
65
+ if (inline) out.push(...inline[1].split(',').map((x) => x.trim().replace(/^["']|["']$/g, '')).filter(Boolean));
66
+ else if (top[2] === '') inList = true;
67
+ continue;
68
+ }
69
+ const item = inList ? /^\s+-\s+(.+)$/.exec(line) : null;
70
+ if (item) out.push(item[1].trim().replace(/^["']|["']$/g, ''));
71
+ }
72
+ return out;
73
+ }
package/src/valve.ts CHANGED
@@ -6,16 +6,23 @@
6
6
  // platform from the agent's side. Held outside the agent's process, the key survives anything the agent prints
7
7
  // or commits, which is the whole point: everything the agent produces is public.
8
8
  //
9
- // open-autonomy-valve --key /secrets/agent.env:8787 [--key /secrets/treasurer.env:8788]
10
- // (each file `OPEN_AUTONOMY_BASE_URL=…` and `OPEN_AUTONOMY_KEY=…`, re-read when it changes: a rotated key is
9
+ // open-autonomy-valve --key /secrets/agent.env:8787 [--key /secrets/treasurer.env:8788] [--codex /secrets/codex.json:8789]
10
+ // (each key file `OPEN_AUTONOMY_BASE_URL=…` and `OPEN_AUTONOMY_KEY=…`, re-read when it changes: a rotated key is
11
11
  // picked up without a restart; /healthz on each port says when its key expires)
12
- import { existsSync, readFileSync, statSync } from 'node:fs';
12
+ //
13
+ // --codex: the owner's ChatGPT/Codex subscription login, held here the same way — the file as the Codex CLI keeps it
14
+ // (`tokens.access_token`, `refresh_token`, `id_token`, `account_id`), served under /backend-api/codex/* on its own port
15
+ // and forwarded to chatgpt.com's Codex backend with the bearer and the account header; the access token is refreshed
16
+ // ahead of its expiry (auth.openai.com, the Codex client id) and written back. Hermes's `openai-codex` provider is
17
+ // pointed at this port (HERMES_CODEX_BASE_URL) with a placeholder credential, so the login never enters the agent.
18
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
13
19
 
14
20
  // Each key file is served on its own port: `--key <file>:<port>`, repeatable. A file is re-read when it changes,
15
21
  // so a rotated key is picked up without a restart.
16
22
  const keys: Array<{ file: string; port: number }> = [];
17
23
  for (let i = 0; i < process.argv.length; i++) if (process.argv[i] === '--key') { const [file, port] = String(process.argv[i + 1]).split(':'); keys.push({ file, port: Number(port || 8787 + keys.length) }); }
18
- if (!keys.length) { console.error('usage: open-autonomy-valve --key <file>:<port> [--key <file>:<port> ]'); process.exit(2); }
24
+ const codexArg = process.argv.includes('--codex') ? String(process.argv[process.argv.indexOf('--codex') + 1]) : undefined;
25
+ if (!keys.length && !codexArg) { console.error('usage: open-autonomy-valve --key <file>:<port> [--key <file>:<port> …] [--codex <tokens.json>:<port>]'); process.exit(2); }
19
26
  const caches = new Map<string, { at: number; env: Record<string, string> }>();
20
27
  function keyEnv(file: string): Record<string, string> {
21
28
  if (!existsSync(file)) return {};
@@ -48,7 +55,7 @@ const base = (file: string): string => (keyEnv(file).OPEN_AUTONOMY_BASE_URL || '
48
55
  const key = (file: string): string | undefined => keyEnv(file).OPEN_AUTONOMY_KEY;
49
56
  // The model routes, the narration routes (the stream and the roadmap), and the two other rails (a card, a partner charge): the platform
50
57
  // bounds each rail by the owner's config, and every settlement lands on the public audit trail.
51
- const FORWARDED = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses', '/v1/models', '/v1/agent/events', '/v1/agent/roadmap', '/v1/rails/card', '/v1/rails/partner']);
58
+ const FORWARDED = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses', '/v1/models', '/v1/catalog', '/v1/agent/events', '/v1/agent/roadmap', '/v1/rails/card', '/v1/rails/partner']);
52
59
  // Public reads the reporter needs to resume where the platform is (its own account's sessions).
53
60
  const isPublicRead = (path: string, method: string) => method === 'GET' && /^\/v1\/accounts\/[^/]+\/(sessions|items)(\/|$)/.test(path);
54
61
 
@@ -76,3 +83,62 @@ for (const { file, port } of keys) Bun.serve({
76
83
  },
77
84
  });
78
85
  for (const { file, port } of keys) console.log(`valve: ${file} → ${base(file)} on :${port}; forwarding ${[...FORWARDED].join(', ')}`);
86
+
87
+ // ── The Codex subscription ─────────────────────────────────────────────────────────────────────────────────────
88
+ const CODEX_UPSTREAM = 'https://chatgpt.com/backend-api/codex';
89
+ const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
90
+ const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
91
+ interface CodexTokens { access_token: string; refresh_token: string; id_token?: string; account_id?: string }
92
+ const jwtClaims = (token: string): Record<string, any> => { try { const p = token.split('.')[1] ?? ''; return JSON.parse(Buffer.from(p + '='.repeat((4 - (p.length % 4)) % 4), 'base64url').toString('utf8')); } catch { return {}; } };
93
+ if (codexArg) {
94
+ const [file, portRaw] = codexArg.split(':');
95
+ const port = Number(portRaw || 8789);
96
+ const read = (): { tokens: CodexTokens; [k: string]: unknown } => {
97
+ const doc = JSON.parse(readFileSync(file, 'utf8')) as { tokens?: CodexTokens };
98
+ if (!doc.tokens?.access_token || !doc.tokens.refresh_token) throw new Error(`${file}: no tokens.access_token / tokens.refresh_token (the Codex CLI's auth.json shape)`);
99
+ return doc as { tokens: CodexTokens };
100
+ };
101
+ const accountOf = (t: CodexTokens): string | undefined => t.account_id ?? jwtClaims(t.access_token)['https://api.openai.com/auth']?.chatgpt_account_id;
102
+ const expiresAt = (t: CodexTokens): number => Number(jwtClaims(t.access_token).exp ?? 0) * 1000;
103
+ let refreshing: Promise<CodexTokens> | undefined;
104
+ // One refresh at a time; the new tokens (the refresh token rotates too) go back to the file before anyone uses them.
105
+ const refresh = (): Promise<CodexTokens> => (refreshing ??= (async () => {
106
+ try {
107
+ const doc = read();
108
+ const res = await fetch(CODEX_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: doc.tokens.refresh_token, client_id: CODEX_CLIENT_ID }) });
109
+ const body = await res.json().catch(() => ({})) as { access_token?: string; refresh_token?: string; id_token?: string; error?: string };
110
+ if (!res.ok || !body.access_token) throw new Error(`codex: refresh refused (${res.status} ${body.error ?? ''}) — log in again with the Codex CLI and copy its auth.json tokens to ${file}`);
111
+ const tokens: CodexTokens = { ...doc.tokens, access_token: body.access_token, refresh_token: body.refresh_token ?? doc.tokens.refresh_token, ...(body.id_token ? { id_token: body.id_token } : {}) };
112
+ writeFileSync(file, `${JSON.stringify({ ...doc, tokens, last_refresh: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
113
+ console.log(`codex: access token refreshed, expires ${new Date(expiresAt(tokens)).toISOString()}`);
114
+ return tokens;
115
+ } finally { refreshing = undefined; }
116
+ })());
117
+ const fresh = async (): Promise<CodexTokens> => { const t = read().tokens; return expiresAt(t) - Date.now() < 5 * 60_000 ? refresh() : t; };
118
+ const forward = async (req: Request, path: string, tokens: CodexTokens): Promise<Response> => {
119
+ const headers = new Headers(req.headers);
120
+ for (const h of ['host', 'authorization', 'chatgpt-account-id', 'content-length', 'connection', 'accept-encoding']) headers.delete(h);
121
+ headers.set('authorization', `Bearer ${tokens.access_token}`);
122
+ const account = accountOf(tokens); if (account) headers.set('ChatGPT-Account-Id', account);
123
+ headers.set('originator', 'codex_cli_rs');
124
+ headers.set('user-agent', 'codex_cli_rs/0.153.2');
125
+ return fetch(`${CODEX_UPSTREAM}${path}`, { method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body, redirect: 'manual' });
126
+ };
127
+ Bun.serve({
128
+ hostname: '127.0.0.1', port,
129
+ async fetch(req) {
130
+ const u = new URL(req.url);
131
+ if (u.pathname === '/healthz') { try { const t = read().tokens; return new Response(`ok · codex account ${accountOf(t) ?? '?'} · access token expires ${new Date(expiresAt(t)).toISOString()}\n`); } catch (e) { return new Response(`unavailable: ${(e as Error).message}\n`, { status: 503 }); } }
132
+ if (!u.pathname.startsWith('/backend-api/codex/')) return new Response('not found: the Codex backend lives under /backend-api/codex/\n', { status: 404 });
133
+ const path = u.pathname.slice('/backend-api/codex'.length) + u.search;
134
+ try {
135
+ let tokens = await fresh();
136
+ let res = await forward(req, path, tokens);
137
+ if (res.status === 401) { tokens = await refresh(); res = await forward(req.clone(), path, tokens); }
138
+ const out = new Headers(res.headers); for (const h of ['content-encoding', 'content-length', 'transfer-encoding']) out.delete(h);
139
+ return new Response(res.body, { status: res.status, headers: out });
140
+ } catch (e) { return new Response(JSON.stringify({ error: { code: 'codex_unavailable', message: (e as Error).message } }), { status: 502, headers: { 'content-type': 'application/json' } }); }
141
+ },
142
+ });
143
+ try { const t = read().tokens; console.log(`codex: ${file} → ${CODEX_UPSTREAM} on :${port} (account ${accountOf(t) ?? '?'}, access token expires ${new Date(expiresAt(t)).toISOString()})`); } catch (e) { console.error(`codex: ${(e as Error).message}`); process.exit(2); }
144
+ }