@open-autonomy/sdk 2.3.3 → 2.4.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 +1 -1
- package/src/valve.ts +83 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-autonomy/sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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/valve.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// or commits, which is the whole point: everything the agent produces is public.
|
|
8
8
|
//
|
|
9
9
|
// open-autonomy-valve --key /secrets/agent.env:8787 [--key /secrets/treasurer.env:8788] [--codex /secrets/codex.json:8789]
|
|
10
|
+
// [--github-app /secrets/github-app.json:8790]
|
|
10
11
|
// (each key file `OPEN_AUTONOMY_BASE_URL=…` and `OPEN_AUTONOMY_KEY=…`, re-read when it changes: a rotated key is
|
|
11
12
|
// picked up without a restart; /healthz on each port says when its key expires)
|
|
12
13
|
//
|
|
@@ -15,6 +16,14 @@
|
|
|
15
16
|
// and forwarded to chatgpt.com's Codex backend with the bearer and the account header; the access token is refreshed
|
|
16
17
|
// ahead of its expiry (auth.openai.com, the Codex client id) and written back. Hermes's `openai-codex` provider is
|
|
17
18
|
// pointed at this port (HERMES_CODEX_BASE_URL) with a placeholder credential, so the login never enters the agent.
|
|
19
|
+
//
|
|
20
|
+
// --github-app: the agent's own GitHub identity for its community desk — a GitHub App installed on the project's
|
|
21
|
+
// repository, its file `{app_id, installation_id, repository, private_key}` (the PEM the app's settings page issues).
|
|
22
|
+
// Served on its own port as api.github.com is: the valve signs the app's JWT, mints an installation token scoped
|
|
23
|
+
// to that one repository ahead of every expiry, and forwards the desk's routes (the repository's issues and
|
|
24
|
+
// their comments, GraphQL for its discussions) with it. The agent is configured with GITHUB_API_URL at this port
|
|
25
|
+
// and GITHUB_TOKEN=valve; every comment it posts is the app's, and the key never enters it.
|
|
26
|
+
import { createPrivateKey, sign } from 'node:crypto';
|
|
18
27
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
19
28
|
|
|
20
29
|
// Each key file is served on its own port: `--key <file>:<port>`, repeatable. A file is re-read when it changes,
|
|
@@ -22,7 +31,8 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
|
22
31
|
const keys: Array<{ file: string; port: number }> = [];
|
|
23
32
|
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
33
|
const codexArg = process.argv.includes('--codex') ? String(process.argv[process.argv.indexOf('--codex') + 1]) : undefined;
|
|
25
|
-
|
|
34
|
+
const githubArg = process.argv.includes('--github-app') ? String(process.argv[process.argv.indexOf('--github-app') + 1]) : undefined;
|
|
35
|
+
if (!keys.length && !codexArg && !githubArg) { console.error('usage: open-autonomy-valve --key <file>:<port> [--key <file>:<port> …] [--codex <tokens.json>:<port>] [--github-app <app.json>:<port>]'); process.exit(2); }
|
|
26
36
|
const caches = new Map<string, { at: number; env: Record<string, string> }>();
|
|
27
37
|
function keyEnv(file: string): Record<string, string> {
|
|
28
38
|
if (!existsSync(file)) return {};
|
|
@@ -142,3 +152,75 @@ if (codexArg) {
|
|
|
142
152
|
});
|
|
143
153
|
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
154
|
}
|
|
155
|
+
|
|
156
|
+
// ── The GitHub App ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
interface GitHubApp { app_id: number | string; installation_id: number | string; repository: string; private_key: string; api?: string }
|
|
158
|
+
if (githubArg) {
|
|
159
|
+
const [file, portRaw] = githubArg.split(':');
|
|
160
|
+
const port = Number(portRaw || 8790);
|
|
161
|
+
const read = (): GitHubApp => {
|
|
162
|
+
const doc = JSON.parse(readFileSync(file, 'utf8')) as Partial<GitHubApp>;
|
|
163
|
+
if (!doc.app_id || !doc.installation_id || !doc.repository || !doc.private_key) throw new Error(`${file}: needs app_id, installation_id, repository (owner/name) and private_key (the app's PEM)`);
|
|
164
|
+
return doc as GitHubApp;
|
|
165
|
+
};
|
|
166
|
+
const upstream = (): string => (read().api ?? 'https://api.github.com').replace(/\/$/, '');
|
|
167
|
+
const b64 = (v: string | Buffer): string => Buffer.from(v).toString('base64url');
|
|
168
|
+
// The app's JWT: RS256 over {iat, exp, iss}, ten minutes, the app id as the issuer.
|
|
169
|
+
const appJwt = (app: GitHubApp): string => {
|
|
170
|
+
const now = Math.floor(Date.now() / 1000);
|
|
171
|
+
const head = b64(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
172
|
+
const body = b64(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: String(app.app_id) }));
|
|
173
|
+
return `${head}.${body}.${b64(sign('sha256', Buffer.from(`${head}.${body}`), createPrivateKey(app.private_key)))}`;
|
|
174
|
+
};
|
|
175
|
+
let token: { value: string; expiresAt: number } | undefined;
|
|
176
|
+
let minting: Promise<{ value: string; expiresAt: number }> | undefined;
|
|
177
|
+
// One installation token at a time, scoped to the one repository the file names, an hour long, renewed with five
|
|
178
|
+
// minutes to spare.
|
|
179
|
+
const mint = (): Promise<{ value: string; expiresAt: number }> => (minting ??= (async () => {
|
|
180
|
+
try {
|
|
181
|
+
const app = read();
|
|
182
|
+
const [, name] = app.repository.split('/');
|
|
183
|
+
const res = await fetch(`${upstream()}/app/installations/${app.installation_id}/access_tokens`, { method: 'POST', headers: { authorization: `Bearer ${appJwt(app)}`, accept: 'application/vnd.github+json', 'user-agent': 'open-autonomy-valve', 'content-type': 'application/json' }, body: JSON.stringify({ repositories: [name] }) });
|
|
184
|
+
const body = await res.json().catch(() => ({})) as { token?: string; expires_at?: string; message?: string };
|
|
185
|
+
if (!res.ok || !body.token) throw new Error(`github-app: installation token refused (${res.status} ${body.message ?? ''})`);
|
|
186
|
+
token = { value: body.token, expiresAt: Date.parse(body.expires_at ?? '') || Date.now() + 55 * 60_000 };
|
|
187
|
+
console.log(`github-app: installation token minted for ${app.repository}, expires ${new Date(token.expiresAt).toISOString()}`);
|
|
188
|
+
return token;
|
|
189
|
+
} finally { minting = undefined; }
|
|
190
|
+
})());
|
|
191
|
+
const fresh = (): Promise<{ value: string; expiresAt: number }> => (token && token.expiresAt - Date.now() > 5 * 60_000 ? Promise.resolve(token) : mint());
|
|
192
|
+
// The desk's routes and no others: the repository's issues and their comments (read and comment), GraphQL (its
|
|
193
|
+
// discussions), and the repository itself. Nothing that changes code, settings or collaborators passes.
|
|
194
|
+
const allowed = (app: GitHubApp, method: string, path: string): boolean => {
|
|
195
|
+
const repo = `/repos/${app.repository}`;
|
|
196
|
+
if (path === '/graphql') return method === 'POST';
|
|
197
|
+
if (path === repo) return method === 'GET';
|
|
198
|
+
if (path.startsWith(`${repo}/issues`) || path.startsWith(`${repo}/discussions`)) return method === 'GET' || method === 'POST';
|
|
199
|
+
return false;
|
|
200
|
+
};
|
|
201
|
+
Bun.serve({
|
|
202
|
+
hostname: '127.0.0.1', port,
|
|
203
|
+
async fetch(req) {
|
|
204
|
+
const u = new URL(req.url);
|
|
205
|
+
if (u.pathname === '/healthz') { try { const app = read(); return new Response(`ok · github app ${app.app_id} on ${app.repository}${token ? ` · installation token expires ${new Date(token.expiresAt).toISOString()}` : ''}\n`); } catch (e) { return new Response(`unavailable: ${(e as Error).message}\n`, { status: 503 }); } }
|
|
206
|
+
try {
|
|
207
|
+
const app = read();
|
|
208
|
+
if (!allowed(app, req.method, u.pathname)) return new Response(JSON.stringify({ message: `the valve forwards the community desk's routes of ${app.repository} only` }), { status: 403, headers: { 'content-type': 'application/json' } });
|
|
209
|
+
let t = await fresh();
|
|
210
|
+
const forward = async (tok: string): Promise<Response> => {
|
|
211
|
+
const headers = new Headers(req.headers);
|
|
212
|
+
for (const h of ['host', 'authorization', 'content-length', 'connection', 'accept-encoding']) headers.delete(h);
|
|
213
|
+
headers.set('authorization', `Bearer ${tok}`);
|
|
214
|
+
headers.set('user-agent', 'open-autonomy-valve');
|
|
215
|
+
if (!headers.has('accept')) headers.set('accept', 'application/vnd.github+json');
|
|
216
|
+
return fetch(`${upstream()}${u.pathname}${u.search}`, { method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body, redirect: 'manual' });
|
|
217
|
+
};
|
|
218
|
+
let res = await forward(t.value);
|
|
219
|
+
if (res.status === 401) { t = await mint(); res = await forward(t.value); }
|
|
220
|
+
const out = new Headers(res.headers); for (const h of ['content-encoding', 'content-length', 'transfer-encoding']) out.delete(h);
|
|
221
|
+
return new Response(res.body, { status: res.status, headers: out });
|
|
222
|
+
} catch (e) { return new Response(JSON.stringify({ message: (e as Error).message }), { status: 502, headers: { 'content-type': 'application/json' } }); }
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
try { const app = read(); console.log(`github-app: ${file} → ${upstream()} on :${port} (app ${app.app_id}, installation ${app.installation_id}, ${app.repository})`); } catch (e) { console.error(`github-app: ${(e as Error).message}`); process.exit(2); }
|
|
226
|
+
}
|