melchizedek-agents 1.1.0 → 1.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.
Files changed (146) hide show
  1. package/.env.example +19 -0
  2. package/AGENT_SETUP.md +3 -1
  3. package/CHANGELOG.md +373 -0
  4. package/DOCUMENTATION.md +193 -21
  5. package/ONBOARDING.md +378 -0
  6. package/QUICKSTART.md +15 -2
  7. package/README.md +26 -3
  8. package/config/agents/examples/connectors.yaml +82 -0
  9. package/config/agents/examples/council.yaml +1 -1
  10. package/config/agents/examples/critic.yaml +8 -8
  11. package/config/agents/examples/image_production.yaml +4 -3
  12. package/config/agents/syndicate.schema.json +389 -33
  13. package/config/agents/syndicateSchema.yaml +18 -6
  14. package/config/agents/templates/intake_extractor.yaml +3 -2
  15. package/config/agents/templates/systems_operator.yaml +5 -2
  16. package/dist/lib/a2a/app.d.ts +12 -0
  17. package/dist/lib/a2a/app.js +25 -11
  18. package/dist/lib/a2a/executor.js +14 -6
  19. package/dist/lib/a2a/oauthSetup.d.ts +16 -0
  20. package/dist/lib/a2a/oauthSetup.js +77 -8
  21. package/dist/lib/agentDialect.d.ts +18 -8
  22. package/dist/lib/agentDialect.js +36 -19
  23. package/dist/lib/chatgpt/adapter.d.ts +50 -0
  24. package/dist/lib/chatgpt/adapter.js +107 -0
  25. package/dist/lib/chatgpt/cli.d.ts +23 -0
  26. package/dist/lib/chatgpt/cli.js +77 -0
  27. package/dist/lib/chatgpt/oauth.d.ts +103 -0
  28. package/dist/lib/chatgpt/oauth.js +448 -0
  29. package/dist/lib/chatgpt/state.d.ts +98 -0
  30. package/dist/lib/chatgpt/state.js +145 -0
  31. package/dist/lib/chatgpt/store.d.ts +24 -0
  32. package/dist/lib/chatgpt/store.js +94 -0
  33. package/dist/lib/compile.d.ts +38 -7
  34. package/dist/lib/compile.js +97 -62
  35. package/dist/lib/compileNative.d.ts +11 -3
  36. package/dist/lib/compileNative.js +18 -1
  37. package/dist/lib/dispatch.js +6 -2
  38. package/dist/lib/doctor.d.ts +107 -0
  39. package/dist/lib/doctor.js +217 -7
  40. package/dist/lib/loadSyndicate.d.ts +15 -0
  41. package/dist/lib/mcp/server.d.ts +195 -0
  42. package/dist/lib/mcp/server.js +564 -0
  43. package/dist/lib/memory/supabaseMemoryService.js +2 -2
  44. package/dist/lib/models/adapterResolver.js +6 -1
  45. package/dist/lib/models/capabilities.d.ts +9 -0
  46. package/dist/lib/models/capabilities.js +24 -0
  47. package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
  48. package/dist/lib/models/chatCompletionsAdapter.js +19 -5
  49. package/dist/lib/models/claudeAdapter.js +4 -0
  50. package/dist/lib/models/contract.d.ts +5 -2
  51. package/dist/lib/models/gateway.d.ts +3 -2
  52. package/dist/lib/models/gateway.js +8 -4
  53. package/dist/lib/models/gatewayAdapter.d.ts +7 -1
  54. package/dist/lib/models/gatewayAdapter.js +8 -0
  55. package/dist/lib/models/geminiAdapter.js +8 -2
  56. package/dist/lib/models/genaiMapping.d.ts +8 -4
  57. package/dist/lib/models/genaiMapping.js +23 -9
  58. package/dist/lib/models/gptAdapter.d.ts +4 -2
  59. package/dist/lib/models/gptAdapter.js +11 -4
  60. package/dist/lib/models/grokAdapter.d.ts +3 -1
  61. package/dist/lib/models/grokAdapter.js +11 -3
  62. package/dist/lib/models/kimiAdapter.d.ts +2 -1
  63. package/dist/lib/models/kimiAdapter.js +3 -3
  64. package/dist/lib/models/reasoning.d.ts +30 -2
  65. package/dist/lib/models/reasoning.js +56 -5
  66. package/dist/lib/onboarding.d.ts +135 -0
  67. package/dist/lib/onboarding.js +578 -0
  68. package/dist/lib/runtime/approvals.d.ts +2 -0
  69. package/dist/lib/runtime/approvals.js +4 -3
  70. package/dist/lib/runtime/credentials.d.ts +6 -0
  71. package/dist/lib/runtime/native/agentLoop.d.ts +5 -2
  72. package/dist/lib/runtime/native/agentLoop.js +2 -0
  73. package/dist/lib/runtime/native/checkpoint.d.ts +12 -5
  74. package/dist/lib/runtime/native/checkpoint.js +13 -19
  75. package/dist/lib/runtime/native/delegate.d.ts +82 -1
  76. package/dist/lib/runtime/native/delegate.js +157 -2
  77. package/dist/lib/runtime/native/interrupts.d.ts +88 -5
  78. package/dist/lib/runtime/native/interrupts.js +220 -30
  79. package/dist/lib/runtime/syndicateTurn.d.ts +4 -2
  80. package/dist/lib/runtime/syndicateTurn.js +324 -77
  81. package/dist/lib/runtime/turnControl.d.ts +41 -0
  82. package/dist/lib/session/transcript.d.ts +9 -0
  83. package/dist/lib/session/transcript.js +20 -1
  84. package/dist/lib/skills.d.ts +18 -0
  85. package/dist/lib/skills.js +73 -1
  86. package/dist/lib/storage/postgres/index.d.ts +6 -1
  87. package/dist/lib/storage/postgres/index.js +1 -1
  88. package/dist/lib/storage/postgres/taskQueue.d.ts +7 -3
  89. package/dist/lib/storage/postgres/taskQueue.js +10 -3
  90. package/dist/lib/syndicateSchema.d.ts +184 -9
  91. package/dist/lib/syndicateSchema.js +143 -29
  92. package/dist/lib/tools/auth.d.ts +13 -1
  93. package/dist/lib/tools/auth.js +10 -2
  94. package/dist/lib/tools/credentialHosts.d.ts +88 -0
  95. package/dist/lib/tools/credentialHosts.js +165 -0
  96. package/dist/lib/tools/credentialUses.d.ts +98 -0
  97. package/dist/lib/tools/credentialUses.js +120 -0
  98. package/dist/lib/tools/generateImageTool.js +2 -1
  99. package/dist/lib/tools/inspectImageTool.js +1 -1
  100. package/dist/lib/tools/mcpToolFactory.d.ts +29 -1
  101. package/dist/lib/tools/mcpToolFactory.js +72 -26
  102. package/dist/lib/tools/mcpTransports.d.ts +12 -0
  103. package/dist/lib/tools/mcpTransports.js +11 -0
  104. package/dist/lib/tools/oauthConsent.d.ts +18 -3
  105. package/dist/lib/tools/oauthConsent.js +57 -15
  106. package/dist/lib/tools/oauthDiscovery.d.ts +202 -0
  107. package/dist/lib/tools/oauthDiscovery.js +616 -0
  108. package/dist/lib/tools/oauthHosts.d.ts +2 -0
  109. package/dist/lib/tools/oauthHosts.js +2 -2
  110. package/dist/lib/tools/oauthTools.d.ts +54 -13
  111. package/dist/lib/tools/oauthTools.js +106 -23
  112. package/dist/lib/tools/openapiTools.d.ts +7 -0
  113. package/dist/lib/tools/openapiTools.js +49 -3
  114. package/dist/lib/tools/taskTools.d.ts +32 -2
  115. package/dist/lib/tools/taskTools.js +93 -33
  116. package/dist/lib/tools/xApiSearchTool.js +2 -2
  117. package/dist/lib/workflow/turn.d.ts +22 -3
  118. package/dist/lib/workflow/turn.js +151 -31
  119. package/dist/lib/workflow.d.ts +10 -7
  120. package/dist/lib/workflow.js +10 -7
  121. package/dist/scripts/a2a_server.d.ts +2 -2
  122. package/dist/scripts/a2a_server.js +19 -2
  123. package/dist/scripts/assistant_worker.d.ts +2 -0
  124. package/dist/scripts/assistant_worker.js +24 -5
  125. package/dist/scripts/doctor.d.ts +4 -2
  126. package/dist/scripts/doctor.js +6 -4
  127. package/dist/scripts/mcp_server.d.ts +12 -0
  128. package/dist/scripts/mcp_server.js +345 -0
  129. package/dist/scripts/setup.d.ts +25 -0
  130. package/dist/scripts/setup.js +181 -0
  131. package/dist/scripts/skills_install.d.ts +3 -1
  132. package/dist/scripts/skills_install.js +17 -2
  133. package/dist/scripts/syndicate_chat.js +12 -3
  134. package/dist/scripts/yaml_codemod.d.ts +12 -2
  135. package/dist/scripts/yaml_codemod.js +115 -33
  136. package/package.json +8 -2
  137. package/skills/README.md +11 -2
  138. package/skills/briefs/_shared.md +1 -1
  139. package/skills/briefs/melchizedek-models.md +4 -2
  140. package/skills/briefs/skills-readme.md +3 -1
  141. package/skills/melchizedek-models/SKILL.md +6 -2
  142. package/skills/melchizedek-onboard/SKILL.md +71 -0
  143. package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
  144. package/skills/melchizedek-onboard-keys/SKILL.md +47 -0
  145. package/skills/melchizedek-onboard-local/SKILL.md +41 -0
  146. package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
@@ -0,0 +1,448 @@
1
+ /**
2
+ * lib/chatgpt/oauth.ts — OpenAI's Sign in with ChatGPT, plan-usage flow, for a
3
+ * local app (ADR 0126).
4
+ *
5
+ * Implemented from OpenAI's documentation of the flow for open-source apps
6
+ * that run on the user's machine:
7
+ * https://developers.openai.com/siwc/token-sharing-open-source
8
+ * https://developers.openai.com/siwc/token-sharing-open-source/sign-in
9
+ * https://developers.openai.com/siwc/token-sharing-open-source/profiles-and-sessions
10
+ * https://developers.openai.com/siwc/token-sharing-open-source/token-reference
11
+ * https://developers.openai.com/siwc/token-sharing-open-source/errors-and-recovery
12
+ *
13
+ * THE FLOW: an authorization-code grant with PKCE (S256), a fresh state and
14
+ * nonce per attempt, and a loopback redirect on http://127.0.0.1:<port>/auth/callback.
15
+ * The app registers itself dynamically: the first authorization sends
16
+ * `client_id=dynamic_agent_client` with an `agent_name_hint`, and the callback
17
+ * returns the client id OpenAI issued, which is kept for every later sign-in
18
+ * and refresh. No client secret, no pre-registration, and never another
19
+ * app's client id or stored token. The token request names the resource
20
+ * `https://api.openai.com/v1`; the ID token is verified (signature against
21
+ * the issuer's JWKS, iss, aud = the issued client id, exp, nonce) before
22
+ * anything is saved, and the plan scope must be granted.
23
+ *
24
+ * TOKENS: an access token lasts an hour and the refresh token rotates on every
25
+ * refresh. Refreshes are serialized through a lock file (lib/chatgpt/store.ts)
26
+ * and an unusable refresh token clears the tokens and asks for a new sign-in.
27
+ * No token is ever logged or put in an error message.
28
+ */
29
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
30
+ import { spawn } from 'node:child_process';
31
+ import { createServer } from 'node:http';
32
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
33
+ import { trimTrailingSlashes } from '../models/urls.js';
34
+ import { PLAN_SCOPE, isSignedIn, readStoredSignIn } from './state.js';
35
+ import { clearTokens, withSignInLock, writeStoredSignIn } from './store.js';
36
+ export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com';
37
+ /** The resource the tokens are for: the public API, where the Responses endpoint lives. */
38
+ export const OPENAI_API_RESOURCE = 'https://api.openai.com/v1';
39
+ export const SIGNIN_SCOPES = `openid profile email offline_access resource.invoke ${PLAN_SCOPE}`;
40
+ export const CALLBACK_PATH = '/auth/callback';
41
+ /** The client id an app sends before OpenAI has issued it one. */
42
+ export const DYNAMIC_CLIENT_ID = 'dynamic_agent_client';
43
+ export const APP_NAME = 'Melchizedek';
44
+ /** Refresh error codes after which the refresh token is unusable (errors-and-recovery). */
45
+ const UNUSABLE_REFRESH = new Set([
46
+ 'invalid_grant',
47
+ 'invalid_refresh_token',
48
+ 'token_expired',
49
+ 'refresh_token_expired',
50
+ 'refresh_token_invalidated',
51
+ 'refresh_token_reused',
52
+ ]);
53
+ /** A sign-in failure: a code for the program, a message for the person, never a token. */
54
+ export class SignInError extends Error {
55
+ code;
56
+ constructor(code, message) {
57
+ super(message);
58
+ this.name = 'SignInError';
59
+ this.code = code;
60
+ }
61
+ }
62
+ function isLoopbackHost(host) {
63
+ return host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
64
+ }
65
+ function issuerOf(opts) {
66
+ const issuer = trimTrailingSlashes(opts.issuer ?? OPENAI_AUTH_ISSUER);
67
+ const u = new URL(issuer);
68
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopbackHost(u.hostname))) {
69
+ throw new SignInError('bad_issuer', 'The sign-in issuer must be https.');
70
+ }
71
+ return issuer;
72
+ }
73
+ /** The issuer's OpenID configuration; every endpoint must be on the issuer's own origin. */
74
+ export async function discover(opts = {}) {
75
+ const issuer = issuerOf(opts);
76
+ let data;
77
+ try {
78
+ const res = await fetch(`${issuer}/.well-known/openid-configuration`, { redirect: 'error', signal: AbortSignal.timeout(15_000) });
79
+ data = res.ok ? await res.json() : undefined;
80
+ }
81
+ catch {
82
+ data = undefined;
83
+ }
84
+ if (!data || data.issuer !== issuer)
85
+ throw new SignInError('discovery_failed', 'The ChatGPT sign-in configuration could not be read or verified.');
86
+ const origin = new URL(issuer).origin;
87
+ for (const key of ['authorization_endpoint', 'token_endpoint', 'jwks_uri', 'revocation_endpoint']) {
88
+ const v = data[key];
89
+ if (v === undefined && key === 'revocation_endpoint')
90
+ continue;
91
+ if (typeof v !== 'string' || new URL(v).origin !== origin) {
92
+ throw new SignInError('discovery_failed', 'The ChatGPT sign-in configuration names an endpoint off the issuer.');
93
+ }
94
+ }
95
+ return data;
96
+ }
97
+ const randomValue = () => randomBytes(32).toString('base64url');
98
+ export const pkceChallenge = (verifier) => createHash('sha256').update(verifier).digest('base64url');
99
+ /** A token-endpoint call: form-encoded, JSON back, the error code (never a token) on failure. */
100
+ async function tokenRequest(endpoint, form) {
101
+ let res;
102
+ try {
103
+ res = await fetch(endpoint, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
106
+ body: new URLSearchParams(form),
107
+ redirect: 'error',
108
+ signal: AbortSignal.timeout(30_000),
109
+ });
110
+ }
111
+ catch {
112
+ throw new SignInError('network_error', 'Could not reach the ChatGPT sign-in service.');
113
+ }
114
+ let data;
115
+ try {
116
+ data = await res.json();
117
+ }
118
+ catch {
119
+ data = undefined;
120
+ }
121
+ if (!res.ok) {
122
+ const raw = typeof data?.error === 'string' ? data.error : typeof data?.error?.code === 'string' ? data.error.code : `http_${res.status}`;
123
+ const code = /^[a-z0-9_.-]{1,64}$/i.test(raw) ? raw : `http_${res.status}`;
124
+ throw new SignInError(code, `The ChatGPT sign-in service refused the request (${code}).`);
125
+ }
126
+ if (!data || typeof data !== 'object')
127
+ throw new SignInError('invalid_token_response', 'The ChatGPT sign-in service returned an unreadable token response.');
128
+ return data;
129
+ }
130
+ /** An `earliest_refresh_at` as epoch milliseconds (seconds, milliseconds or an ISO date), when present. */
131
+ function epochMs(v) {
132
+ if (typeof v === 'number' && Number.isFinite(v))
133
+ return v < 1e12 ? v * 1000 : v;
134
+ if (typeof v === 'string') {
135
+ const t = Date.parse(v);
136
+ return Number.isFinite(t) ? t : undefined;
137
+ }
138
+ return undefined;
139
+ }
140
+ /** The token fields a response carries, validated. */
141
+ function tokenFields(data, previousScopes) {
142
+ const scope = typeof data.scope === 'string' ? data.scope : previousScopes?.join(' ');
143
+ if (typeof scope !== 'string')
144
+ throw new SignInError('invalid_token_response', 'The ChatGPT sign-in service did not confirm the granted permissions.');
145
+ const scopes = scope.split(' ').filter(Boolean);
146
+ if (typeof data.access_token !== 'string' || !data.access_token ||
147
+ typeof data.token_type !== 'string' || data.token_type.toLowerCase() !== 'bearer' ||
148
+ typeof data.expires_in !== 'number' || !(data.expires_in > 0) ||
149
+ typeof data.refresh_token !== 'string' || !data.refresh_token) {
150
+ throw new SignInError('invalid_token_response', 'The ChatGPT sign-in service returned incomplete credentials.');
151
+ }
152
+ const earliest = epochMs(data.earliest_refresh_at);
153
+ return {
154
+ accessToken: data.access_token,
155
+ refreshToken: data.refresh_token,
156
+ expiresAt: Date.now() + data.expires_in * 1000,
157
+ ...(earliest !== undefined ? { earliestRefreshAt: earliest } : {}),
158
+ scopes,
159
+ };
160
+ }
161
+ /** Verify an ID token against the issuer's keys; the verified subject. */
162
+ async function verifyIdToken(idToken, d, clientId, nonce) {
163
+ if (typeof idToken !== 'string' || !idToken)
164
+ throw new SignInError('invalid_id_token', 'The ChatGPT sign-in returned no verifiable identity.');
165
+ try {
166
+ const { payload } = await jwtVerify(idToken, createRemoteJWKSet(new URL(d.jwks_uri)), {
167
+ issuer: d.issuer,
168
+ audience: clientId,
169
+ algorithms: ['RS256'],
170
+ clockTolerance: 5,
171
+ requiredClaims: ['iss', 'aud', 'exp', 'iat', 'sub'],
172
+ });
173
+ if (typeof payload.sub !== 'string' || !payload.sub)
174
+ throw new Error('no subject');
175
+ if (nonce !== undefined && payload.nonce !== nonce)
176
+ throw new Error('nonce');
177
+ if (payload.azp !== undefined && payload.azp !== clientId)
178
+ throw new Error('azp');
179
+ return payload.sub;
180
+ }
181
+ catch {
182
+ throw new SignInError('invalid_id_token', 'The ChatGPT identity could not be verified. Sign in again.');
183
+ }
184
+ }
185
+ // ── The loopback listener ────────────────────────────────────────────────────
186
+ const CLIENT_ID_SHAPE = /^[A-Za-z0-9_-]{1,200}$/;
187
+ const PAGE_HEADERS = {
188
+ 'content-type': 'text/html; charset=utf-8',
189
+ 'cache-control': 'no-store',
190
+ 'referrer-policy': 'no-referrer',
191
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
192
+ };
193
+ const page = (title, body) => `<!doctype html><html lang="en"><meta charset="utf-8"><title>${title}</title><style>body{font:16px system-ui;max-width:32rem;margin:15vh auto;padding:24px}</style><h1>${title}</h1><p>${body}</p></html>`;
194
+ /**
195
+ * A one-shot listener on 127.0.0.1. A request with the wrong host, path,
196
+ * method or state is refused (400 or 404) and does not end the wait: an
197
+ * unrelated loopback request must not cancel a sign-in in progress.
198
+ */
199
+ async function listen(port, state, savedClientId, timeoutMs, signal) {
200
+ let settle;
201
+ let done = false;
202
+ const result = new Promise((resolve, reject) => {
203
+ settle = (v) => {
204
+ if (done)
205
+ return;
206
+ done = true;
207
+ if (v instanceof SignInError)
208
+ reject(v);
209
+ else
210
+ resolve(v);
211
+ };
212
+ });
213
+ result.catch(() => undefined);
214
+ let bound = port;
215
+ const expected = Buffer.from(state);
216
+ const handler = (req, res) => {
217
+ let url;
218
+ try {
219
+ url = new URL(req.url ?? '/', `http://127.0.0.1:${bound}`);
220
+ }
221
+ catch {
222
+ res.writeHead(400).end();
223
+ return;
224
+ }
225
+ if (done || req.method !== 'GET' || req.headers.host !== `127.0.0.1:${bound}` || url.pathname !== CALLBACK_PATH) {
226
+ res.writeHead(404).end();
227
+ return;
228
+ }
229
+ const states = url.searchParams.getAll('state');
230
+ const got = Buffer.from(states[0] ?? '');
231
+ if (states.length !== 1 || got.length !== expected.length || !timingSafeEqual(got, expected)) {
232
+ res.writeHead(400, PAGE_HEADERS).end(page('Sign-in not recognized', 'This sign-in response does not match the one Melchizedek started. Return to the terminal and try again.'));
233
+ return;
234
+ }
235
+ if (url.searchParams.has('error')) {
236
+ res.writeHead(200, PAGE_HEADERS).end(page('Sign-in cancelled', 'You can close this tab and return to the terminal.'));
237
+ const raw = url.searchParams.get('error') ?? '';
238
+ const code = /^[a-z0-9_.-]{1,64}$/i.test(raw) ? raw : 'authorization_failed';
239
+ settle(new SignInError(code, `ChatGPT did not authorize the sign-in (${code}).`));
240
+ return;
241
+ }
242
+ const codes = url.searchParams.getAll('code');
243
+ const ids = url.searchParams.getAll('client_id');
244
+ const clientId = ids[0] ?? savedClientId;
245
+ res.writeHead(200, PAGE_HEADERS).end(page('Return to the terminal', 'Melchizedek is finishing your ChatGPT sign-in. You can close this tab.'));
246
+ if (codes.length !== 1 || !codes[0] || ids.length > 1 ||
247
+ !clientId || !CLIENT_ID_SHAPE.test(clientId) || clientId === DYNAMIC_CLIENT_ID ||
248
+ (savedClientId !== undefined && ids.length === 1 && ids[0] !== savedClientId)) {
249
+ settle(new SignInError('registration_incomplete', 'ChatGPT did not complete the app registration. Sign in again.'));
250
+ return;
251
+ }
252
+ settle({ code: codes[0], clientId });
253
+ };
254
+ const server = createServer(handler);
255
+ server.requestTimeout = 15_000;
256
+ server.headersTimeout = 10_000;
257
+ await new Promise((resolve, reject) => {
258
+ server.once('error', () => reject(new SignInError('callback_port_unavailable', `Port ${port} on 127.0.0.1 is unavailable for the sign-in callback; pass another with --port.`)));
259
+ server.listen({ port, host: '127.0.0.1' }, () => {
260
+ const a = server.address();
261
+ if (a && typeof a !== 'string')
262
+ bound = a.port;
263
+ resolve();
264
+ });
265
+ });
266
+ const timer = setTimeout(() => settle(new SignInError('timeout', 'The ChatGPT sign-in was not completed in time.')), timeoutMs);
267
+ const abort = () => settle(new SignInError('cancelled', 'The ChatGPT sign-in was cancelled.'));
268
+ signal?.addEventListener('abort', abort, { once: true });
269
+ const close = () => {
270
+ clearTimeout(timer);
271
+ signal?.removeEventListener('abort', abort);
272
+ server.close();
273
+ server.closeAllConnections();
274
+ };
275
+ return { redirectUri: `http://127.0.0.1:${bound}${CALLBACK_PATH}`, result, close };
276
+ }
277
+ /** Open a URL in the default browser, with no shell. */
278
+ export async function openInBrowser(url) {
279
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
280
+ await new Promise((resolve, reject) => {
281
+ const child = spawn(cmd, args, { stdio: 'ignore', shell: false });
282
+ child.once('error', () => reject(new SignInError('browser_unavailable', 'The browser could not be opened; open the printed link yourself.')));
283
+ child.once('exit', (code) => (code === 0 ? resolve() : reject(new SignInError('browser_unavailable', 'The browser could not be opened; open the printed link yourself.'))));
284
+ });
285
+ }
286
+ /**
287
+ * The browser sign-in. Stores the registration (host id, issued client id)
288
+ * before the code exchange, and the tokens after the ID token verifies and
289
+ * the plan scope is confirmed.
290
+ */
291
+ export async function signIn(opts) {
292
+ const issuer = issuerOf(opts);
293
+ const d = await discover({ issuer });
294
+ const previous = readStoredSignIn(opts.file);
295
+ const sameIssuer = previous?.issuer === issuer;
296
+ const hostId = previous?.hostId ?? `urn:uuid:${randomUUID()}`;
297
+ const savedClientId = sameIssuer ? previous?.clientId : undefined;
298
+ const state = randomValue();
299
+ const nonce = randomValue();
300
+ const verifier = randomValue();
301
+ const listener = await listen(opts.port ?? 0, state, savedClientId, opts.timeoutMs ?? 5 * 60_000, opts.signal);
302
+ try {
303
+ const auth = new URL(d.authorization_endpoint);
304
+ auth.search = new URLSearchParams({
305
+ client_id: savedClientId ?? DYNAMIC_CLIENT_ID,
306
+ response_type: 'code',
307
+ redirect_uri: listener.redirectUri,
308
+ scope: SIGNIN_SCOPES,
309
+ resource: OPENAI_API_RESOURCE,
310
+ state,
311
+ nonce,
312
+ code_challenge_method: 'S256',
313
+ code_challenge: pkceChallenge(verifier),
314
+ ext_agent_host_id: hostId,
315
+ ...(savedClientId ? {} : { agent_name_hint: APP_NAME }),
316
+ }).toString();
317
+ opts.onUrl?.(auth.toString());
318
+ await (opts.openBrowser ?? openInBrowser)(auth.toString()).catch((err) => {
319
+ // The printed link still works; only a cancelled sign-in ends here.
320
+ if (!(err instanceof SignInError) || err.code !== 'browser_unavailable')
321
+ throw err;
322
+ });
323
+ const cb = await listener.result;
324
+ // Keep the issued registration even if the one-time code fails below.
325
+ const registration = sameIssuer && previous
326
+ ? { ...previous, clientId: cb.clientId, hostId }
327
+ : { version: 1, issuer, hostId, clientId: cb.clientId };
328
+ writeStoredSignIn(opts.file, registration);
329
+ const data = await tokenRequest(d.token_endpoint, {
330
+ grant_type: 'authorization_code',
331
+ client_id: cb.clientId,
332
+ code: cb.code,
333
+ code_verifier: verifier,
334
+ redirect_uri: listener.redirectUri,
335
+ resource: OPENAI_API_RESOURCE,
336
+ });
337
+ const subject = await verifyIdToken(data.id_token, d, cb.clientId, nonce);
338
+ const fields = tokenFields(data);
339
+ const planUsage = fields.scopes.includes(PLAN_SCOPE);
340
+ if (!planUsage) {
341
+ throw new SignInError('plan_not_granted', 'The sign-in did not grant use of your ChatGPT plan; sign in again and allow it.');
342
+ }
343
+ writeStoredSignIn(opts.file, { ...registration, subject, ...fields, savedAt: new Date().toISOString() });
344
+ return { subject, scopes: fields.scopes, expiresAt: fields.expiresAt, planUsage };
345
+ }
346
+ finally {
347
+ listener.close();
348
+ }
349
+ }
350
+ /** One refresh of the stored session; the caller holds the lock. */
351
+ async function refreshOnce(file, s, opts) {
352
+ const issuer = issuerOf(opts);
353
+ if (s.issuer !== issuer || !s.clientId || !s.refreshToken) {
354
+ throw new SignInError('not_signed_in', 'The stored ChatGPT sign-in cannot be refreshed here; sign in again.');
355
+ }
356
+ const d = await discover({ issuer });
357
+ let data;
358
+ try {
359
+ data = await tokenRequest(d.token_endpoint, {
360
+ grant_type: 'refresh_token',
361
+ client_id: s.clientId,
362
+ refresh_token: s.refreshToken,
363
+ resource: OPENAI_API_RESOURCE,
364
+ });
365
+ }
366
+ catch (err) {
367
+ if (err instanceof SignInError && UNUSABLE_REFRESH.has(err.code)) {
368
+ clearTokens(file);
369
+ throw new SignInError('signin_expired', 'Your ChatGPT sign-in has expired or was revoked; run `melchizedek-setup --chatgpt-signin` again.');
370
+ }
371
+ throw err;
372
+ }
373
+ const fields = tokenFields(data, s.scopes);
374
+ if (data.id_token !== undefined) {
375
+ const subject = await verifyIdToken(data.id_token, d, s.clientId);
376
+ if (s.subject && subject !== s.subject)
377
+ throw new SignInError('account_mismatch', 'The refreshed ChatGPT identity is a different account; sign in again.');
378
+ }
379
+ const next = { ...s, ...fields, savedAt: new Date().toISOString() };
380
+ writeStoredSignIn(file, next);
381
+ return next;
382
+ }
383
+ const SKEW_MS = 120_000;
384
+ /**
385
+ * A usable access token from the credential file, refreshed when it is within
386
+ * two minutes of expiry (and past `earliest_refresh_at`). Refreshes take the
387
+ * file's lock and re-read it first, so a refresh another process just made is
388
+ * used rather than repeated.
389
+ */
390
+ export async function freshAccessToken(file, opts = {}) {
391
+ const now = opts.now ?? Date.now;
392
+ const fresh = (s) => isSignedIn(s) && (s.expiresAt ?? 0) - SKEW_MS > now();
393
+ const notSignedIn = () => new SignInError('not_signed_in', `No ChatGPT sign-in is stored at ${file}; run \`melchizedek-setup --chatgpt-signin\`.`);
394
+ const first = readStoredSignIn(file);
395
+ if (!isSignedIn(first))
396
+ throw notSignedIn();
397
+ if (fresh(first))
398
+ return first.accessToken;
399
+ return withSignInLock(file, async () => {
400
+ const s = readStoredSignIn(file);
401
+ if (!isSignedIn(s))
402
+ throw notSignedIn();
403
+ if (fresh(s))
404
+ return s.accessToken;
405
+ const valid = (s.expiresAt ?? 0) > now();
406
+ if (valid && s.earliestRefreshAt !== undefined && now() < s.earliestRefreshAt)
407
+ return s.accessToken;
408
+ return (await refreshOnce(file, s, opts)).accessToken;
409
+ });
410
+ }
411
+ /**
412
+ * Sign out: the tokens leave the file (the host id and issued client id
413
+ * stay, for the next sign-in), and the refresh token is revoked at the
414
+ * issuer. `revoked: false` means the local tokens are gone but the remote
415
+ * revocation was not confirmed: disconnect the app in ChatGPT's settings.
416
+ */
417
+ export async function signOut(file, opts = {}) {
418
+ const s = readStoredSignIn(file);
419
+ if (!s?.refreshToken) {
420
+ if (s)
421
+ clearTokens(file);
422
+ return { hadTokens: false, revoked: false };
423
+ }
424
+ clearTokens(file);
425
+ try {
426
+ const d = await discover(opts);
427
+ if (!d.revocation_endpoint || !s.clientId)
428
+ return { hadTokens: true, revoked: false };
429
+ for (let attempt = 0; attempt < 2; attempt++) {
430
+ const res = await fetch(d.revocation_endpoint, {
431
+ method: 'POST',
432
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
433
+ body: new URLSearchParams({ token: s.refreshToken, token_type_hint: 'refresh_token', client_id: s.clientId }),
434
+ redirect: 'error',
435
+ signal: AbortSignal.timeout(10_000),
436
+ }).catch(() => undefined);
437
+ await res?.body?.cancel().catch(() => undefined);
438
+ if (res?.status === 200)
439
+ return { hadTokens: true, revoked: true };
440
+ if (res && res.status < 500)
441
+ break;
442
+ }
443
+ }
444
+ catch {
445
+ // The local tokens are gone either way.
446
+ }
447
+ return { hadTokens: true, revoked: false };
448
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * lib/chatgpt/state.ts — where a Sign in with ChatGPT credential lives, whether
3
+ * it is the OpenAI path, and the served-surface refusal (ADR 0126).
4
+ *
5
+ * WHY a leaf (node:fs, node:os, node:path only):
6
+ * The gateway's transport rule (lib/models/gateway.ts), the doctor, the
7
+ * resolver and every served bin ask the same two questions — is a sign-in
8
+ * stored, and does it carry OpenAI ids here — so the answer lives in one
9
+ * module with no adapter or SDK in its import graph.
10
+ *
11
+ * LOCAL ONLY:
12
+ * OpenAI's plan-usage flow is for open-source apps that run on the user's
13
+ * own machine (developers.openai.com/siwc/token-sharing-open-source). The
14
+ * A2A server, the worker and any other served surface call
15
+ * `refuseChatGptSignInOnServedSurface` at startup and stop while the
16
+ * sign-in would carry OpenAI ids; `markServedSurface` also makes the
17
+ * adapter refuse at call time, so a surface that skipped the startup check
18
+ * still never spends a person's ChatGPT plan on someone else's request.
19
+ *
20
+ * WHAT IT NEVER DOES: return or print a token. The status carries presence,
21
+ * times and the file's path.
22
+ */
23
+ /** Where the credential file lives, when not the default (a path; never a value). */
24
+ export declare const CHATGPT_SIGNIN_FILE_ENV = "MELCHIZEDEK_CHATGPT_SIGNIN_FILE";
25
+ /** `off` ignores a stored sign-in (a served process on a machine that also signed in). */
26
+ export declare const CHATGPT_SIGNIN_ENV = "MELCHIZEDEK_CHATGPT_SIGNIN";
27
+ /** The scope that lets the token use the person's ChatGPT plan. */
28
+ export declare const PLAN_SCOPE = "chatgpt.tokens.use.direct";
29
+ /** What the credential file holds (version 1). Tokens are present only while signed in. */
30
+ export interface StoredSignIn {
31
+ version: 1;
32
+ /** The authorization server the registration belongs to. */
33
+ issuer: string;
34
+ /** This installation's opaque host id (`urn:uuid:…`), kept across sign-outs. */
35
+ hostId: string;
36
+ /** The client id OpenAI issued at the first sign-in, kept across sign-outs. */
37
+ clientId?: string;
38
+ /** The verified account subject of the last sign-in. */
39
+ subject?: string;
40
+ scopes?: string[];
41
+ accessToken?: string;
42
+ refreshToken?: string;
43
+ /** Epoch milliseconds. */
44
+ expiresAt?: number;
45
+ /** Epoch milliseconds before which a refresh is not attempted while the token is valid. */
46
+ earliestRefreshAt?: number;
47
+ savedAt?: string;
48
+ }
49
+ /** The sign-in as the doctor and the bins see it: presence and times, never a token. */
50
+ export interface ChatGptSignInStatus {
51
+ /** False when MELCHIZEDEK_CHATGPT_SIGNIN=off. */
52
+ enabled: boolean;
53
+ file: string;
54
+ /** A credential file exists at `file`. */
55
+ present: boolean;
56
+ /** It holds a refresh token and the plan scope. */
57
+ signedIn: boolean;
58
+ /** Epoch milliseconds the access token expires, when signed in. */
59
+ expiresAt?: number;
60
+ /** Why a present file cannot be used (permissions, shape). */
61
+ problem?: string;
62
+ }
63
+ /** The credential file: MELCHIZEDEK_CHATGPT_SIGNIN_FILE, else ~/.melchizedek/chatgpt-signin.json. */
64
+ export declare function signInFile(env?: NodeJS.ProcessEnv): string;
65
+ /** The git work tree a path sits inside, if any (an ancestor holding `.git`). */
66
+ export declare function insideGitWorkTree(file: string): string | undefined;
67
+ /** Whether a parsed value has the stored shape. */
68
+ export declare function isStoredSignIn(v: unknown): v is StoredSignIn;
69
+ /**
70
+ * Read the credential file: undefined when absent; throws, naming the file
71
+ * and never its content, when it is readable by others or malformed.
72
+ */
73
+ export declare function readStoredSignIn(file: string): StoredSignIn | undefined;
74
+ /** Signed in: a refresh token, and the plan scope granted. */
75
+ export declare function isSignedIn(s: StoredSignIn | undefined): boolean;
76
+ /** The stored sign-in's status under this environment. */
77
+ export declare function chatGptSignInStatus(env?: NodeJS.ProcessEnv): ChatGptSignInStatus;
78
+ /**
79
+ * Whether OpenAI ids would run on the sign-in here: it is enabled and signed
80
+ * in, no OPENAI_API_KEY is set, and OpenAI is on its own API (no
81
+ * OPENAI_PLATFORM other than direct, no OPENAI_BASE_URL proxy, which must
82
+ * never receive the token).
83
+ */
84
+ export declare function chatGptSignInRoutesOpenAi(env?: NodeJS.ProcessEnv): boolean;
85
+ /** Mark this process a served surface: the sign-in adapter refuses every call from here on. */
86
+ export declare function markServedSurface(surface: string): void;
87
+ /** The served surface this process was marked as, if any. */
88
+ export declare function servedSurface(): string | undefined;
89
+ /** Test seam: forget the mark. */
90
+ export declare function resetServedSurfaceForTests(): void;
91
+ /** The message a served surface stops with. */
92
+ export declare function servedSurfaceMessage(surface: string, env?: NodeJS.ProcessEnv): string;
93
+ /**
94
+ * Called by every served surface at startup: marks the process served, and
95
+ * throws when the sign-in would carry OpenAI ids here. The A2A app, the A2A
96
+ * server bin and the worker call it; a new served surface calls it too.
97
+ */
98
+ export declare function refuseChatGptSignInOnServedSurface(surface: string, env?: NodeJS.ProcessEnv): void;