@open-autonomy/sdk 2.3.2 → 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/README.md CHANGED
@@ -29,8 +29,8 @@ await s.end({ outcome: 'done', report: 'Done. add — committed 7d30729.', commi
29
29
  page parses through the same code. There is no write API: the file in git is the only roadmap surface.
30
30
  Adapters that mirror it to a tracker are what the shape is for.
31
31
 
32
- Spend is attributed by the platform: a metered call settles on the one session live at that moment, so
33
- an item's page shows every session, update and settled cent that touched it.
32
+ Spend is attributed by the platform: Hermes names its session on each model request, so overlapping sessions
33
+ each receive their own settled calls and cents and an item's page shows everything that touched it.
34
34
 
35
35
  ## Drivers
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-autonomy/sdk",
3
- "version": "2.3.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/client.ts CHANGED
@@ -19,7 +19,7 @@ export type TurnRole = 'user' | 'assistant' | 'tool' | 'system';
19
19
  export interface Turn { ts?: string; role: TurnRole; text?: string; tool?: string; args?: string; result?: string }
20
20
  export type SessionOutcome = 'done' | 'failed';
21
21
 
22
- export interface SessionStart { key: string; kind?: string; title?: string; item?: string; source?: string; startedAt?: string }
22
+ export interface SessionStart { key: string; kind?: string; title?: string; item?: string; source?: string; modelProvider?: string; startedAt?: string }
23
23
  export interface SessionEnd { key: string; outcome?: SessionOutcome; report?: string; commit?: string; item?: string; endedAt?: string }
24
24
  export interface Update { item: string; text: string; session?: string; at?: string }
25
25
  // The board's state for a roadmap item, as the agent's harness keeps it: the task's lane, every attempt at
@@ -57,7 +57,7 @@ export const EVENT_TYPES = {
57
57
  } as const;
58
58
 
59
59
  export function sessionStartedEvent(s: SessionStart, source = 'open-autonomy-sdk'): CloudEvent {
60
- return event(EVENT_TYPES.started, s.key, { session_kind: s.kind, title: s.title, item_id: s.item, source: s.source }, s.startedAt, source);
60
+ return event(EVENT_TYPES.started, s.key, { session_kind: s.kind, title: s.title, item_id: s.item, source: s.source, model_provider: s.modelProvider }, s.startedAt, source);
61
61
  }
62
62
  export function sessionTurnsEvent(key: string, seq: number, turns: Turn[], item?: string, source = 'open-autonomy-sdk'): CloudEvent {
63
63
  return event(EVENT_TYPES.turns, key, { seq, turns, item_id: item }, undefined, source);
@@ -76,7 +76,7 @@ function event(type: string, subject: string, data: Record<string, unknown>, tim
76
76
  export interface EventResult { id?: string; ok: boolean; error?: string; idempotent?: boolean; session?: SessionSummary; update?: UpdateRecord }
77
77
  export interface SessionSummary {
78
78
  key: string; account: string; kind: string; status: 'live' | 'ended'; outcome?: SessionOutcome; title?: string; item_id?: string; source?: string;
79
- started_at: string; ended_at?: string; report?: string; commit_sha?: string; turn_count: number; next_seq: number; tool_calls: number; usd_cents: number; calls: number; updated_at: string;
79
+ model_provider?: string; started_at: string; ended_at?: string; report?: string; commit_sha?: string; turn_count: number; next_seq: number; tool_calls: number; usd_cents: number; calls: number; updated_at: string;
80
80
  }
81
81
  export interface SessionRecord extends Omit<SessionSummary, 'tool_calls'> { turns: Array<Turn & { seq?: number }> }
82
82
  export interface UpdateRecord { id: string; account: string; item_id: string; ts: string; text: string; session?: string }
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
- if (!keys.length && !codexArg) { console.error('usage: open-autonomy-valve --key <file>:<port> [--key <file>:<port> ] [--codex <tokens.json>:<port>]'); process.exit(2); }
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
+ }