@open-autonomy/sdk 2.1.0 → 2.2.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/package.json +4 -1
  3. package/src/valve.ts +78 -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.1.0",
3
+ "version": "2.2.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/valve.ts ADDED
@@ -0,0 +1,78 @@
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]
10
+ // (each 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
+ import { existsSync, readFileSync, statSync } from 'node:fs';
13
+
14
+ // Each key file is served on its own port: `--key <file>:<port>`, repeatable. A file is re-read when it changes,
15
+ // so a rotated key is picked up without a restart.
16
+ const keys: Array<{ file: string; port: number }> = [];
17
+ 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); }
19
+ const caches = new Map<string, { at: number; env: Record<string, string> }>();
20
+ function keyEnv(file: string): Record<string, string> {
21
+ if (!existsSync(file)) return {};
22
+ const at = statSync(file).mtimeMs;
23
+ const cached = caches.get(file);
24
+ if (!cached || at !== cached.at) {
25
+ const env: Record<string, string> = {};
26
+ for (const line of readFileSync(file, 'utf8').split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (m) env[m[1]] = m[2]; }
27
+ caches.set(file, { at, env });
28
+ announce(file, env.OPEN_AUTONOMY_KEY);
29
+ }
30
+ return caches.get(file)!.env;
31
+ }
32
+ // The key says when it expires (its claims are readable; only the signature is not). Announced whenever the
33
+ // file changes, warned inside fourteen days, and answered on /healthz so the reporter can log it too.
34
+ function expiry(token: string | undefined): { kid: string; account: string; exp: string; days: number } | undefined {
35
+ try {
36
+ const claims = JSON.parse(Buffer.from((token ?? '').split('.')[0], 'base64url').toString('utf8')) as { kid?: string; account?: string; exp?: string };
37
+ if (!claims.exp) return undefined;
38
+ return { kid: claims.kid ?? '?', account: claims.account ?? '?', exp: claims.exp, days: Math.floor((Date.parse(claims.exp) - Date.now()) / 86_400_000) };
39
+ } catch { return undefined; }
40
+ }
41
+ 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'; };
42
+ function announce(file: string, token: string | undefined): void {
43
+ const e = expiry(token);
44
+ console.log(`valve: ${file}: ${e ? status(file) : 'no readable key in the file'}`);
45
+ if (e && e.days < 14) console.warn(`valve: WARNING the key in ${file} expires in ${e.days} day${e.days === 1 ? '' : 's'}`);
46
+ }
47
+ const base = (file: string): string => (keyEnv(file).OPEN_AUTONOMY_BASE_URL || 'https://open-autonomy.org/v1').replace(/\/$/, '');
48
+ const key = (file: string): string | undefined => keyEnv(file).OPEN_AUTONOMY_KEY;
49
+ // The model routes, the narration routes (the stream and the roadmap), and the two other rails (a card, a partner charge): the platform
50
+ // 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']);
52
+ // Public reads the reporter needs to resume where the platform is (its own account's sessions).
53
+ const isPublicRead = (path: string, method: string) => method === 'GET' && /^\/v1\/accounts\/[^/]+\/(sessions|items)(\/|$)/.test(path);
54
+
55
+ for (const { file, port } of keys) Bun.serve({
56
+ hostname: '0.0.0.0',
57
+ port,
58
+ idleTimeout: 255,
59
+ async fetch(req) {
60
+ const url = new URL(req.url);
61
+ if (url.pathname === '/healthz') return new Response(key(file) ? `ok · ${status(file)}` : 'no key yet');
62
+ 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 });
63
+ const bearer = key(file);
64
+ if (!bearer) return Response.json({ error: { code: 'no_key', message: 'the valve has no key yet' } }, { status: 503 });
65
+ // A clean request: the body buffered (one honest Content-Length), only the headers that carry meaning.
66
+ const headers = new Headers();
67
+ 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); }
68
+ headers.set('authorization', `Bearer ${bearer}`);
69
+ headers.set('user-agent', 'open-autonomy-valve');
70
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer();
71
+ const upstream = await fetch(`${base(file)}${url.pathname.replace(/^\/v1/, '')}${url.search}`, { method: req.method, headers, body });
72
+ const out = new Headers(upstream.headers);
73
+ out.delete('content-encoding');
74
+ out.delete('content-length');
75
+ return new Response(upstream.body, { status: upstream.status, headers: out });
76
+ },
77
+ });
78
+ for (const { file, port } of keys) console.log(`valve: ${file} → ${base(file)} on :${port}; forwarding ${[...FORWARDED].join(', ')}`);