@pulsarinteractive/geniustasker 0.1.0-beta.1 → 0.1.0-beta.10

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 (67) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CONTRIBUTING.md +72 -0
  3. package/LICENSE +21 -0
  4. package/LICENSES/Apache-2.0.txt +202 -0
  5. package/README.md +47 -370
  6. package/SECURITY.md +14 -0
  7. package/THIRD_PARTY_NOTICES.md +15 -0
  8. package/docs/ARCHITECTURE.md +144 -0
  9. package/docs/CHATGPT.md +86 -0
  10. package/docs/CLI.md +444 -0
  11. package/docs/DOCUMENTATION_SYNC.md +144 -0
  12. package/docs/INTEGRATIONS.md +62 -0
  13. package/docs/LIMITS.md +62 -0
  14. package/docs/MEMORY.md +38 -0
  15. package/docs/OPENAI.md +21 -0
  16. package/docs/OPENAI_PUBLICATION.md +22 -0
  17. package/docs/PLANNING.md +79 -0
  18. package/docs/PROJECT_FILTERS.md +77 -0
  19. package/docs/RELEASING.md +23 -0
  20. package/docs/TESTING.md +52 -0
  21. package/docs/WORKFLOWS.md +37 -0
  22. package/examples/codex.toml +3 -0
  23. package/examples/mcp.json +8 -0
  24. package/mcp.json +10 -0
  25. package/package.json +45 -9
  26. package/plugin.json +24 -0
  27. package/remote/tool-catalog.json +1374 -0
  28. package/remote/workflows.json +67 -0
  29. package/skills/geniustasker/SKILL.md +49 -116
  30. package/skills/geniustasker/agents/openai.yaml +4 -0
  31. package/skills/geniustasker/references/collaboration.md +70 -0
  32. package/skills/geniustasker/references/incremental-reads.md +16 -0
  33. package/skills/geniustasker/references/profiles.md +22 -0
  34. package/skills/geniustasker/references/project-reads.md +43 -0
  35. package/skills/geniustasker-documentation/SKILL.md +39 -0
  36. package/skills/geniustasker-documentation/agents/openai.yaml +4 -0
  37. package/skills/geniustasker-documentation/references/structure.md +29 -0
  38. package/skills/geniustasker-graph/SKILL.md +31 -0
  39. package/skills/geniustasker-graph/agents/openai.yaml +4 -0
  40. package/skills/geniustasker-media/SKILL.md +33 -0
  41. package/skills/geniustasker-media/agents/openai.yaml +4 -0
  42. package/skills/geniustasker-memory/SKILL.md +120 -0
  43. package/skills/geniustasker-memory/agents/openai.yaml +4 -0
  44. package/skills/geniustasker-memory/references/knowledge-workflows.md +57 -0
  45. package/skills/geniustasker-planning/SKILL.md +28 -0
  46. package/skills/geniustasker-planning/agents/openai.yaml +4 -0
  47. package/src/changes.mjs +104 -28
  48. package/src/cli.mjs +453 -77
  49. package/src/client.mjs +203 -34
  50. package/src/command-catalog.mjs +545 -263
  51. package/src/commands.mjs +215 -52
  52. package/src/documentation-graph.mjs +251 -0
  53. package/src/documentation-store.mjs +589 -0
  54. package/src/documentation-sync.mjs +517 -0
  55. package/src/documentation.mjs +136 -0
  56. package/src/errors.mjs +55 -12
  57. package/src/knowledge.mjs +109 -0
  58. package/src/login.mjs +190 -47
  59. package/src/mcp.mjs +310 -43
  60. package/src/media.mjs +307 -0
  61. package/src/presentation.d.mts +1 -0
  62. package/src/presentation.mjs +12 -0
  63. package/src/profiles.mjs +123 -32
  64. package/src/read-cache.mjs +111 -0
  65. package/src/store.mjs +101 -26
  66. package/src/transport.mjs +165 -32
  67. package/src/workflows.mjs +9 -0
package/src/errors.mjs CHANGED
@@ -1,22 +1,65 @@
1
1
  /** Never forward raw network exceptions: URLs, HTML and provider diagnostics
2
2
  * can contain credentials. API messages are data, never terminal control. */
3
3
  export class AgentError extends Error {
4
- constructor(code, message, { status = 0, retryable = false, remediation = 'Check help and retry.', ...details } = {}) {
5
- super(message); this.code = code; this.status = status; this.retryable = retryable;
6
- this.remediation = remediation; this.details = details;
4
+ constructor(
5
+ code,
6
+ message,
7
+ { status = 0, retryable = false, remediation = 'Check help and retry.', ...details } = {},
8
+ ) {
9
+ super(message);
10
+ this.code = code;
11
+ this.status = status;
12
+ this.retryable = retryable;
13
+ this.remediation = remediation;
14
+ this.details = details;
15
+ }
16
+ toJSON() {
17
+ return {
18
+ code: this.code,
19
+ message: this.message,
20
+ retryable: this.retryable,
21
+ remediation: this.remediation,
22
+ ...this.details,
23
+ };
7
24
  }
8
- toJSON() { return { code: this.code, message: this.message, retryable: this.retryable, remediation: this.remediation, ...this.details }; }
9
25
  }
10
- export const fail = (code, message, options) => { throw new AgentError(code, message, options); };
26
+ export const fail = (code, message, options) => {
27
+ throw new AgentError(code, message, options);
28
+ };
11
29
  export function safeError(error) {
12
- return error instanceof AgentError ? error : new AgentError('internal_error', 'The command could not complete.', { remediation: 'Retry once; run doctor if the problem persists.' });
30
+ return error instanceof AgentError
31
+ ? error
32
+ : new AgentError('internal_error', 'The command could not complete.', {
33
+ remediation: 'Retry once; run doctor if the problem persists.',
34
+ });
13
35
  }
14
36
  export function exitCode(error) {
15
- if (['invalid_arguments', 'invalid_origin'].includes(error.code)) return 2;
16
- if (error.status === 401 || ['not_authenticated', 'reauthentication_required', 'access_denied', 'expired_token', 'invalid_grant'].includes(error.code)) return 3;
17
- if (error.status === 403 || error.code === 'profile_selection_denied') return 4;
18
- if (error.status === 409 || error.code === 'profile_selection_conflict') return 5;
19
- if (error.status === 429) return 6;
20
- if (error.retryable) return 7;
37
+ if (['invalid_arguments', 'invalid_origin'].includes(error.code)) {
38
+ return 2;
39
+ }
40
+ if (
41
+ error.status === 401 ||
42
+ [
43
+ 'not_authenticated',
44
+ 'reauthentication_required',
45
+ 'access_denied',
46
+ 'expired_token',
47
+ 'invalid_grant',
48
+ ].includes(error.code)
49
+ ) {
50
+ return 3;
51
+ }
52
+ if (error.status === 403 || error.code === 'profile_selection_denied') {
53
+ return 4;
54
+ }
55
+ if (error.status === 409 || error.code === 'profile_selection_conflict') {
56
+ return 5;
57
+ }
58
+ if (error.status === 429) {
59
+ return 6;
60
+ }
61
+ if (error.retryable) {
62
+ return 7;
63
+ }
21
64
  return 1;
22
65
  }
@@ -0,0 +1,109 @@
1
+ import * as z from 'zod/v4';
2
+ import { fail } from './errors.mjs';
3
+
4
+ const id = z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/);
5
+ const scope = z.string().regex(/^memory:[a-zA-Z0-9_-]{1,128}$/);
6
+ const cursor = {
7
+ epoch: z.string().min(1).max(128).optional(),
8
+ checkpoint: z
9
+ .string()
10
+ .regex(/^(0|[1-9][0-9]*)$/)
11
+ .max(32)
12
+ .optional(),
13
+ };
14
+ export const searchSchema = z
15
+ .object({
16
+ scope,
17
+ query: z.string().trim().min(1).max(200),
18
+ collection: z.enum(['pages', 'nodes']).optional(),
19
+ after: id.optional(),
20
+ ...cursor,
21
+ })
22
+ .strict()
23
+ .refine((value) => !value.after || !!(value.epoch && value.checkpoint), {
24
+ message: 'Continuation needs epoch and checkpoint.',
25
+ });
26
+ export const graphSchema = z
27
+ .object({ scope, center: id, depth: z.number().int().min(0).max(3).optional(), ...cursor })
28
+ .strict();
29
+ export const referenceSchema = z
30
+ .object({
31
+ scope,
32
+ pageId: id,
33
+ blockId: z
34
+ .string()
35
+ .regex(/^[a-zA-Z0-9_-]{1,80}$/)
36
+ .optional(),
37
+ })
38
+ .strict();
39
+
40
+ function input(schema, value) {
41
+ const parsed = schema.safeParse(value);
42
+ if (!parsed.success) {
43
+ fail(
44
+ 'invalid_arguments',
45
+ 'Use a memory scope and the documented search, Graph or reference parameters.',
46
+ );
47
+ }
48
+ return parsed.data;
49
+ }
50
+
51
+ /** Query one bounded page without following cursors or retrying automatically. */
52
+ export async function searchMemory(client, value) {
53
+ const query = input(searchSchema, value);
54
+ const result = await client.get('knowledge', { ...query, mode: 'search' });
55
+ if (
56
+ result?.scope !== query.scope ||
57
+ result.mode !== 'search' ||
58
+ !Array.isArray(result.items) ||
59
+ result.items.length > 25
60
+ ) {
61
+ fail('invalid_response', 'The search response does not match the requested scope.');
62
+ }
63
+ return result;
64
+ }
65
+
66
+ /** Read a compact neighborhood; full node text remains a separate resource read. */
67
+ export async function exploreGraph(client, value) {
68
+ const query = input(graphSchema, value);
69
+ const result = await client.get('knowledge', { ...query, mode: 'graph' });
70
+ if (
71
+ result?.scope !== query.scope ||
72
+ result.mode !== 'graph' ||
73
+ !Array.isArray(result.nodes) ||
74
+ !Array.isArray(result.edges) ||
75
+ result.nodes.length > 80 ||
76
+ result.edges.length > 160
77
+ ) {
78
+ fail('invalid_response', 'The Graph response does not match the requested scope or bounds.');
79
+ }
80
+ const ids = new Set(result.nodes.map((node) => node.id));
81
+ if (
82
+ !ids.has(query.center) ||
83
+ result.edges.some((edge) => !ids.has(edge.data?.source) || !ids.has(edge.data?.target))
84
+ ) {
85
+ fail('invalid_response', 'The Graph response contains unresolved endpoints.');
86
+ }
87
+ return result;
88
+ }
89
+
90
+ /** Linked content is never persisted by this client. Resolve again after access
91
+ * changes; unavailable content must not delete the user's node or relations. */
92
+ export async function resolveMemory(client, value) {
93
+ const query = input(referenceSchema, value);
94
+ const result = await client.get('reference', query);
95
+ if (
96
+ result?.scope !== query.scope ||
97
+ result.pageId !== query.pageId ||
98
+ result.blockId !== (query.blockId ?? null) ||
99
+ !['available', 'unavailable', 'missing'].includes(result.status) ||
100
+ (result.status === 'available' ? result.page?.id !== query.pageId : result.page !== null) ||
101
+ (result.status === 'unavailable' && (result.epoch !== null || result.accessRevision !== null))
102
+ ) {
103
+ fail(
104
+ 'invalid_response',
105
+ 'The reference response does not match the requested target or current access.',
106
+ );
107
+ }
108
+ return result;
109
+ }
package/src/login.mjs CHANGED
@@ -8,21 +8,47 @@ import { CLIENT_ID, ORIGINS } from './transport.mjs';
8
8
  // Fixed copy only: callback codes, state and private account data never enter HTML.
9
9
  function callbackPage(response, approved) {
10
10
  response.setHeader('Content-Type', 'text/html; charset=utf-8');
11
- response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'");
12
- response.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>GeniusTasker · ${approved ? 'Authorization received' : 'Access declined'}</title><style>:root{font-family:system-ui,sans-serif;color:#18232b;background:#f4f6f7}body{margin:0;padding:80px 20px}main{max-width:440px;margin:auto;padding:36px;border:1px solid #d5dde2;border-radius:22px;background:#fff}b{color:#6139d7;font-size:14px}h1{font-size:26px;line-height:1.3;margin:28px 0 12px;letter-spacing:-.6px}p{font-size:15px;line-height:1.7;color:#52616b}small{display:block;margin-top:28px;color:#52616b}@media(prefers-color-scheme:dark){:root{background:#080b10;color:#edf1f3}main{background:#12171f;border-color:#303b47}p,small{color:#a7b3bd}b{color:#b19aff}}</style><main><b>GeniusTasker</b><h1>${approved ? 'Authorization received.' : 'Access declined.'}</h1><p>${approved ? 'Return to your terminal. It is securely completing the connection with the access you chose.' : 'This request has no access to your account. You can return to your terminal.'}</p><small>You can close this tab.</small></main></html>`);
11
+ response.setHeader(
12
+ 'Content-Security-Policy',
13
+ "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
14
+ );
15
+ response.end(
16
+ `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>GeniusTasker · ${approved ? 'Authorization received' : 'Access declined'}</title><style>:root{font-family:system-ui,sans-serif;color:#18232b;background:#f4f6f7}body{margin:0;padding:80px 20px}main{max-width:440px;margin:auto;padding:36px;border:1px solid #d5dde2;border-radius:22px;background:#fff}b{color:#6139d7;font-size:14px}h1{font-size:26px;line-height:1.3;margin:28px 0 12px;letter-spacing:-.6px}p{font-size:15px;line-height:1.7;color:#52616b}small{display:block;margin-top:28px;color:#52616b}@media(prefers-color-scheme:dark){:root{background:#080b10;color:#edf1f3}main{background:#12171f;border-color:#303b47}p,small{color:#a7b3bd}b{color:#b19aff}}</style><main><b>GeniusTasker</b><h1>${approved ? 'Authorization received.' : 'Access declined.'}</h1><p>${approved ? 'Return to your terminal. It is securely completing the connection with the access you chose.' : 'This request has no access to your account. You can return to your terminal.'}</p><small>You can close this tab.</small></main></html>`,
17
+ );
13
18
  }
14
19
 
15
- function loginResult(session) { return { connected: true, origin: session.origin, connectionId: session.connection_id, profileId: session.profile_id, sessionId: session.session_id }; }
20
+ function loginResult(session) {
21
+ return {
22
+ connected: true,
23
+ origin: session.origin,
24
+ connectionId: session.connection_id,
25
+ profileId: session.profile_id,
26
+ sessionId: session.session_id,
27
+ };
28
+ }
16
29
  async function saveLogin(client, exchange) {
17
30
  // Hold the context lock throughout consent so two terminals cannot overwrite
18
31
  // independent connections and leave an inaccessible active session behind.
19
32
  return client.store.locked(async () => {
20
- if (await client.store.read()) fail('already_authenticated', 'This context already has a session.', { remediation: 'Run auth logout first, or choose another --context.' });
21
- const result = await exchange(); const session = credentials(result, client.transport.origin);
22
- try { await client.store.write(session); }
23
- catch {
24
- try { await client.transport.oauth('revoke', { token: session.refresh_token }); }
25
- catch { fail('credential_save_failed', 'Login succeeded but local storage failed; server revocation could not be confirmed.', { remediation: 'Revoke the new connection in Account → Agents before trying again.' }); }
33
+ if (await client.store.read()) {
34
+ fail('already_authenticated', 'This context already has a session.', {
35
+ remediation: 'Run auth logout first, or choose another --context.',
36
+ });
37
+ }
38
+ const result = await exchange();
39
+ const session = credentials(result, client.transport.origin);
40
+ try {
41
+ await client.store.write(session);
42
+ } catch {
43
+ try {
44
+ await client.transport.oauth('revoke', { token: session.refresh_token });
45
+ } catch {
46
+ fail(
47
+ 'credential_save_failed',
48
+ 'Login succeeded but local storage failed; server revocation could not be confirmed.',
49
+ { remediation: 'Revoke the new connection in Account → Agents before trying again.' },
50
+ );
51
+ }
26
52
  fail('credential_save_failed', 'Local storage failed. The new server session was revoked.');
27
53
  }
28
54
  return loginResult(session);
@@ -32,84 +58,201 @@ async function saveLogin(client, exchange) {
32
58
  /** Consent URLs are server data. Restrict automatic navigation to the product
33
59
  * app paired with this API (or the explicitly opted-in local development app). */
34
60
  export function consentUrl(value, origin) {
35
- let url; try { url = new URL(value); } catch { fail('invalid_response', 'Invalid consent URL.'); }
36
- const expected = origin === ORIGINS.production ? 'https://app-geniustasker.pulsarinteractive.cloud' :
37
- origin === ORIGINS.development ? 'https://dev-app-geniustasker.pulsarinteractive.cloud' : null;
38
- if (url.username || url.password || url.hash || url.pathname !== '/agents/connect' ||
39
- (expected ? url.origin !== expected : url.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)))
61
+ let url;
62
+ try {
63
+ url = new URL(value);
64
+ } catch {
65
+ fail('invalid_response', 'Invalid consent URL.');
66
+ }
67
+ const expected =
68
+ origin === ORIGINS.production
69
+ ? 'https://app-geniustasker.pulsarinteractive.cloud'
70
+ : origin === ORIGINS.development
71
+ ? 'https://dev-app-geniustasker.pulsarinteractive.cloud'
72
+ : null;
73
+ if (
74
+ url.username ||
75
+ url.password ||
76
+ url.hash ||
77
+ url.pathname !== '/agents/connect' ||
78
+ (expected
79
+ ? url.origin !== expected
80
+ : url.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname))
81
+ ) {
40
82
  fail('invalid_response', 'The consent URL is not a trusted GeniusTasker application.');
83
+ }
41
84
  return url.href;
42
85
  }
43
86
 
44
- export function deviceLogin(client, { scope, notify, openBrowser, wait = delay, now = Date.now } = {}) {
87
+ /**
88
+ * Complete device consent within the server's expiry and polling bounds.
89
+ * The context lock covers the full exchange; credentials are saved only after
90
+ * a validated response. notify/openBrowser are presentation callbacks.
91
+ */
92
+ export function deviceLogin(
93
+ client,
94
+ { scope, notify, openBrowser, wait = delay, now = Date.now } = {},
95
+ ) {
45
96
  return saveLogin(client, async () => {
46
97
  const startedAt = now();
47
98
  const device = await client.transport.oauth('device', scope ? { scope } : {});
48
- if (!device || typeof device.device_code !== 'string' || device.device_code.length > 256 ||
49
- !/^[0-9A-F]{2}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/.test(device.user_code) ||
50
- !Number.isSafeInteger(device.expires_in) || device.expires_in < 1 || device.expires_in > 900 ||
51
- !Number.isSafeInteger(device.interval) || device.interval < 1 || device.interval > 60)
99
+ if (
100
+ !device ||
101
+ typeof device.device_code !== 'string' ||
102
+ device.device_code.length > 256 ||
103
+ !/^[0-9A-F]{2}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/.test(device.user_code) ||
104
+ !Number.isSafeInteger(device.expires_in) ||
105
+ device.expires_in < 1 ||
106
+ device.expires_in > 900 ||
107
+ !Number.isSafeInteger(device.interval) ||
108
+ device.interval < 1 ||
109
+ device.interval > 60
110
+ ) {
52
111
  fail('invalid_response', 'The device authorization response was invalid.');
112
+ }
53
113
  const url = consentUrl(device.verification_uri_complete, client.transport.origin);
54
- notify?.({ event: 'consent_required', url, userCode: device.user_code, expiresIn: device.expires_in });
114
+ notify?.({
115
+ event: 'consent_required',
116
+ url,
117
+ userCode: device.user_code,
118
+ expiresIn: device.expires_in,
119
+ });
55
120
  await openBrowser?.(url);
56
- const deadline = startedAt + device.expires_in * 1000; let interval = device.interval;
121
+ const deadline = startedAt + device.expires_in * 1000;
122
+ let interval = device.interval;
57
123
  while (now() + interval * 1000 < deadline) {
58
124
  await wait(interval * 1000, undefined, { signal: client.transport.signal });
59
- try { return await client.transport.oauth('token', { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: device.device_code }); }
60
- catch (error) {
61
- if (!(error instanceof AgentError)) throw error;
62
- if (error.code === 'authorization_pending') continue;
63
- if (error.code === 'slow_down') { interval = Math.max(interval + 5, error.details.interval ?? 0); continue; }
125
+ try {
126
+ return await client.transport.oauth('token', {
127
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
128
+ device_code: device.device_code,
129
+ });
130
+ } catch (error) {
131
+ if (!(error instanceof AgentError)) {
132
+ throw error;
133
+ }
134
+ if (error.code === 'authorization_pending') {
135
+ continue;
136
+ }
137
+ if (error.code === 'slow_down') {
138
+ interval = Math.max(interval + 5, error.details.interval ?? 0);
139
+ continue;
140
+ }
64
141
  // Rate limits are explicit; no endless polling or accidental login loop.
65
142
  throw error;
66
143
  }
67
144
  }
68
- fail('expired_token', 'The authorization request expired.', { remediation: 'Start auth login again and confirm it in the application.' });
145
+ fail('expired_token', 'The authorization request expired.', {
146
+ remediation: 'Start auth login again and confirm it in the application.',
147
+ });
69
148
  });
70
149
  }
71
150
 
151
+ /**
152
+ * Complete browser PKCE consent through one validated loopback callback.
153
+ * The ephemeral listener closes on success, denial, cancellation or expiry.
154
+ * Authentication codes and tokens never enter the callback's HTML response.
155
+ */
72
156
  export function browserLogin(client, { scope, notify, openBrowser, lifetimeMs = 600000 } = {}) {
73
157
  return saveLogin(client, async () => {
74
- const verifier = randomBytes(48).toString('base64url'), state = randomBytes(32).toString('base64url');
75
- let resolveCode, rejectCode, used = false, redirect;
76
- const codePromise = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
158
+ const verifier = randomBytes(48).toString('base64url'),
159
+ state = randomBytes(32).toString('base64url');
160
+ let resolveCode,
161
+ rejectCode,
162
+ used = false,
163
+ redirect;
164
+ const codePromise = new Promise((resolve, reject) => {
165
+ resolveCode = resolve;
166
+ rejectCode = reject;
167
+ });
77
168
  // Attach immediately so abort during navigation cannot become unhandled.
78
169
  codePromise.catch(() => {});
79
170
  const server = createServer((request, response) => {
80
- response.setHeader('Cache-Control', 'no-store'); response.setHeader('Referrer-Policy', 'no-referrer');
171
+ response.setHeader('Cache-Control', 'no-store');
172
+ response.setHeader('Referrer-Policy', 'no-referrer');
81
173
  response.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
82
174
  response.setHeader('Content-Type', 'text/plain; charset=utf-8');
83
- let url; try { url = new URL(request.url, redirect); } catch { response.writeHead(400).end('Invalid callback.'); return; }
175
+ let url;
176
+ try {
177
+ url = new URL(request.url, redirect);
178
+ } catch {
179
+ response.writeHead(400).end('Invalid callback.');
180
+ return;
181
+ }
84
182
  const received = url.searchParams.get('state') ?? '';
85
- const validState = Buffer.byteLength(received) === Buffer.byteLength(state) && timingSafeEqual(Buffer.from(received), Buffer.from(state));
86
- if (used || request.method !== 'GET' || request.headers.host !== new URL(redirect).host || url.origin !== new URL(redirect).origin || url.pathname !== '/callback' || !validState ||
87
- [...url.searchParams.keys()].some(key => !['state', 'code', 'error'].includes(key) || url.searchParams.getAll(key).length !== 1)) {
88
- response.writeHead(400).end('Invalid callback. Return to your terminal.'); return;
183
+ const validState =
184
+ Buffer.byteLength(received) === Buffer.byteLength(state) &&
185
+ timingSafeEqual(Buffer.from(received), Buffer.from(state));
186
+ if (
187
+ used ||
188
+ request.method !== 'GET' ||
189
+ request.headers.host !== new URL(redirect).host ||
190
+ url.origin !== new URL(redirect).origin ||
191
+ url.pathname !== '/callback' ||
192
+ !validState ||
193
+ [...url.searchParams.keys()].some(
194
+ (key) =>
195
+ !['state', 'code', 'error'].includes(key) || url.searchParams.getAll(key).length !== 1,
196
+ )
197
+ ) {
198
+ response.writeHead(400).end('Invalid callback. Return to your terminal.');
199
+ return;
89
200
  }
90
201
  const code = url.searchParams.get('code');
91
- if (url.searchParams.has('error')) { used = true; callbackPage(response, false); rejectCode(new AgentError('access_denied', 'Connection declined.')); return; }
92
- if (!code || !/^[A-Za-z0-9_.-]{1,256}$/.test(code)) { response.writeHead(400).end('Invalid callback.'); return; }
93
- used = true; callbackPage(response, true); resolveCode(code);
202
+ if (url.searchParams.has('error')) {
203
+ used = true;
204
+ callbackPage(response, false);
205
+ rejectCode(new AgentError('access_denied', 'Connection declined.'));
206
+ return;
207
+ }
208
+ if (!code || !/^[A-Za-z0-9_.-]{1,256}$/.test(code)) {
209
+ response.writeHead(400).end('Invalid callback.');
210
+ return;
211
+ }
212
+ used = true;
213
+ callbackPage(response, true);
214
+ resolveCode(code);
94
215
  });
95
216
  const abort = () => rejectCode(new AgentError('login_cancelled', 'Login was cancelled.'));
96
217
  let timer;
97
218
  try {
98
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
219
+ await new Promise((resolve, reject) => {
220
+ server.once('error', reject);
221
+ server.listen(0, '127.0.0.1', resolve);
222
+ });
99
223
  redirect = `http://127.0.0.1:${server.address().port}/callback`;
100
- timer = setTimeout(() => rejectCode(new AgentError('expired_token', 'The authorization request expired.')), lifetimeMs);
224
+ timer = setTimeout(
225
+ () => rejectCode(new AgentError('expired_token', 'The authorization request expired.')),
226
+ lifetimeMs,
227
+ );
101
228
  client.transport.signal?.addEventListener('abort', abort, { once: true });
102
- if (client.transport.signal?.aborted) abort();
229
+ if (client.transport.signal?.aborted) {
230
+ abort();
231
+ }
103
232
  const url = new URL('/api/agents/oauth/authorize', client.transport.origin);
104
- url.search = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: redirect, state,
105
- code_challenge_method: 'S256', code_challenge: createHash('sha256').update(verifier).digest('base64url'), ...(scope ? { scope } : {}) }).toString();
233
+ url.search = new URLSearchParams({
234
+ client_id: CLIENT_ID,
235
+ response_type: 'code',
236
+ redirect_uri: redirect,
237
+ state,
238
+ code_challenge_method: 'S256',
239
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
240
+ ...(scope ? { scope } : {}),
241
+ }).toString();
106
242
  notify?.({ event: 'consent_required', url: url.href, expiresIn: lifetimeMs / 1000 });
107
243
  await openBrowser?.(url.href);
108
244
  const code = await codePromise;
109
- return await client.transport.oauth('token', { grant_type: 'authorization_code', code, redirect_uri: redirect, code_verifier: verifier });
245
+ return await client.transport.oauth('token', {
246
+ grant_type: 'authorization_code',
247
+ code,
248
+ redirect_uri: redirect,
249
+ code_verifier: verifier,
250
+ });
110
251
  } finally {
111
- clearTimeout(timer); client.transport.signal?.removeEventListener('abort', abort);
112
- server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
252
+ clearTimeout(timer);
253
+ client.transport.signal?.removeEventListener('abort', abort);
254
+ server.closeAllConnections();
255
+ await new Promise((resolve) => server.close(resolve));
113
256
  }
114
257
  });
115
258
  }