@open-autonomy/sdk 2.1.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/README.md +14 -0
- package/package.json +4 -1
- package/src/rails.ts +24 -0
- package/src/valve.ts +144 -0
package/README.md
CHANGED
|
@@ -121,3 +121,17 @@ Keys, the adopter way: `GET /v1/keys/challenge?account=owner/repo` names a claim
|
|
|
121
121
|
is at HEAD; `POST /v1/keys/rotate` with the current key mints a successor and leaves the old one a day of
|
|
122
122
|
grace. A key is verified by its signature and expiry alone, so it survives every redeploy; the platform's
|
|
123
123
|
registry can only revoke it or shorten it.
|
|
124
|
+
|
|
125
|
+
## The valve
|
|
126
|
+
|
|
127
|
+
`open-autonomy-valve`, the package's one binary, is a credential-injecting sidecar for the project's key: the key
|
|
128
|
+
lives in a file only the valve reads, the agent is pointed at the valve's address with the literal word `valve`
|
|
129
|
+
as its key, and the valve adds the real bearer at the edge. It forwards the model routes, the narration routes
|
|
130
|
+
(`/v1/agent/events`, `/v1/agent/roadmap`), the rails and public reads of the account, and refuses the rest, so an
|
|
131
|
+
agent whose output is public never possesses the one credential that spends its sponsors' money.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
open-autonomy-valve --key ~/.config/open-autonomy/agent.env:8787 --key ~/.config/open-autonomy/treasurer.env:8788
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
One port per key file; each file re-read when it changes; `/healthz` on each port names the key's expiry.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-autonomy/sdk",
|
|
3
|
-
"version": "2.
|
|
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": {
|
|
@@ -31,5 +31,8 @@
|
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/bun": "^1.3.10",
|
|
33
33
|
"typescript": "^5.9.0"
|
|
34
|
+
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"open-autonomy-valve": "src/valve.ts"
|
|
34
37
|
}
|
|
35
38
|
}
|
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
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// The valve: a credential-injecting sidecar for one Open Autonomy key per port. The key lives in a file only this
|
|
3
|
+
// process reads; the agent is configured with this address and the literal word `valve` as its key, and sees no
|
|
4
|
+
// credential. Only the routes an agent legitimately uses pass — the model routes, the narration routes (the stream,
|
|
5
|
+
// the roadmap), the rails, and public reads of its own account — so key management and admin never reach the
|
|
6
|
+
// platform from the agent's side. Held outside the agent's process, the key survives anything the agent prints
|
|
7
|
+
// or commits, which is the whole point: everything the agent produces is public.
|
|
8
|
+
//
|
|
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
|
+
// picked up without a restart; /healthz on each port says when its key expires)
|
|
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';
|
|
19
|
+
|
|
20
|
+
// Each key file is served on its own port: `--key <file>:<port>`, repeatable. A file is re-read when it changes,
|
|
21
|
+
// so a rotated key is picked up without a restart.
|
|
22
|
+
const keys: Array<{ file: string; port: number }> = [];
|
|
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) }); }
|
|
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); }
|
|
26
|
+
const caches = new Map<string, { at: number; env: Record<string, string> }>();
|
|
27
|
+
function keyEnv(file: string): Record<string, string> {
|
|
28
|
+
if (!existsSync(file)) return {};
|
|
29
|
+
const at = statSync(file).mtimeMs;
|
|
30
|
+
const cached = caches.get(file);
|
|
31
|
+
if (!cached || at !== cached.at) {
|
|
32
|
+
const env: Record<string, string> = {};
|
|
33
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (m) env[m[1]] = m[2]; }
|
|
34
|
+
caches.set(file, { at, env });
|
|
35
|
+
announce(file, env.OPEN_AUTONOMY_KEY);
|
|
36
|
+
}
|
|
37
|
+
return caches.get(file)!.env;
|
|
38
|
+
}
|
|
39
|
+
// The key says when it expires (its claims are readable; only the signature is not). Announced whenever the
|
|
40
|
+
// file changes, warned inside fourteen days, and answered on /healthz so the reporter can log it too.
|
|
41
|
+
function expiry(token: string | undefined): { kid: string; account: string; exp: string; days: number } | undefined {
|
|
42
|
+
try {
|
|
43
|
+
const claims = JSON.parse(Buffer.from((token ?? '').split('.')[0], 'base64url').toString('utf8')) as { kid?: string; account?: string; exp?: string };
|
|
44
|
+
if (!claims.exp) return undefined;
|
|
45
|
+
return { kid: claims.kid ?? '?', account: claims.account ?? '?', exp: claims.exp, days: Math.floor((Date.parse(claims.exp) - Date.now()) / 86_400_000) };
|
|
46
|
+
} catch { return undefined; }
|
|
47
|
+
}
|
|
48
|
+
const status = (file: string): string => { const e = expiry(keyEnv(file).OPEN_AUTONOMY_KEY); return e ? `key ${e.kid} for ${e.account} expires ${e.exp} (${e.days} day${e.days === 1 ? '' : 's'})${e.days < 14 ? ' — rotate it: bun .open-autonomy/mint-key.ts --rotate' : ''}` : 'no key yet'; };
|
|
49
|
+
function announce(file: string, token: string | undefined): void {
|
|
50
|
+
const e = expiry(token);
|
|
51
|
+
console.log(`valve: ${file}: ${e ? status(file) : 'no readable key in the file'}`);
|
|
52
|
+
if (e && e.days < 14) console.warn(`valve: WARNING the key in ${file} expires in ${e.days} day${e.days === 1 ? '' : 's'}`);
|
|
53
|
+
}
|
|
54
|
+
const base = (file: string): string => (keyEnv(file).OPEN_AUTONOMY_BASE_URL || 'https://open-autonomy.org/v1').replace(/\/$/, '');
|
|
55
|
+
const key = (file: string): string | undefined => keyEnv(file).OPEN_AUTONOMY_KEY;
|
|
56
|
+
// The model routes, the narration routes (the stream and the roadmap), and the two other rails (a card, a partner charge): the platform
|
|
57
|
+
// bounds each rail by the owner's config, and every settlement lands on the public audit trail.
|
|
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']);
|
|
59
|
+
// Public reads the reporter needs to resume where the platform is (its own account's sessions).
|
|
60
|
+
const isPublicRead = (path: string, method: string) => method === 'GET' && /^\/v1\/accounts\/[^/]+\/(sessions|items)(\/|$)/.test(path);
|
|
61
|
+
|
|
62
|
+
for (const { file, port } of keys) Bun.serve({
|
|
63
|
+
hostname: '0.0.0.0',
|
|
64
|
+
port,
|
|
65
|
+
idleTimeout: 255,
|
|
66
|
+
async fetch(req) {
|
|
67
|
+
const url = new URL(req.url);
|
|
68
|
+
if (url.pathname === '/healthz') return new Response(key(file) ? `ok · ${status(file)}` : 'no key yet');
|
|
69
|
+
if (!FORWARDED.has(url.pathname) && !isPublicRead(url.pathname, req.method)) return Response.json({ error: { code: 'not_forwarded', message: 'the valve forwards the model routes, the narration route, the rails and public reads of this account only' } }, { status: 403 });
|
|
70
|
+
const bearer = key(file);
|
|
71
|
+
if (!bearer) return Response.json({ error: { code: 'no_key', message: 'the valve has no key yet' } }, { status: 503 });
|
|
72
|
+
// A clean request: the body buffered (one honest Content-Length), only the headers that carry meaning.
|
|
73
|
+
const headers = new Headers();
|
|
74
|
+
for (const h of ['content-type', 'accept', 'anthropic-version', 'anthropic-beta', 'last-event-id']) { const v = req.headers.get(h); if (v) headers.set(h, v); }
|
|
75
|
+
headers.set('authorization', `Bearer ${bearer}`);
|
|
76
|
+
headers.set('user-agent', 'open-autonomy-valve');
|
|
77
|
+
const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer();
|
|
78
|
+
const upstream = await fetch(`${base(file)}${url.pathname.replace(/^\/v1/, '')}${url.search}`, { method: req.method, headers, body });
|
|
79
|
+
const out = new Headers(upstream.headers);
|
|
80
|
+
out.delete('content-encoding');
|
|
81
|
+
out.delete('content-length');
|
|
82
|
+
return new Response(upstream.body, { status: upstream.status, headers: out });
|
|
83
|
+
},
|
|
84
|
+
});
|
|
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
|
+
}
|