@pulsarinteractive/geniustasker 0.1.0-beta.2 → 0.1.0-beta.4

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/src/store.mjs CHANGED
@@ -11,56 +11,131 @@ import { fail } from './errors.mjs';
11
11
  * A hard crash deliberately leaves a lock/refresh marker rather than risking a
12
12
  * second use of a rotated refresh token. Doctor explains recovery. */
13
13
  export class CredentialStore {
14
- constructor({ directory = join(homedir(), '.geniustasker'), context = 'default', lockTimeoutMs = 20000, maxBytes = 16384 } = {}) {
15
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$/.test(context)) fail('invalid_arguments', 'Context names use 1–48 letters, numbers, underscores or hyphens.');
16
- this.context = context; this.maxBytes = maxBytes;
17
- this.directory = resolve(directory); this.path = join(this.directory, `${context}.json`); this.lockPath = `${this.path}.lock`; this.lockTimeoutMs = lockTimeoutMs;
14
+ constructor({
15
+ directory = join(homedir(), '.geniustasker'),
16
+ context = 'default',
17
+ lockTimeoutMs = 20000,
18
+ maxBytes = 16384,
19
+ } = {}) {
20
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$/.test(context)) {
21
+ fail('invalid_arguments', 'Context names use 1–48 letters, numbers, underscores or hyphens.');
22
+ }
23
+ this.context = context;
24
+ this.maxBytes = maxBytes;
25
+ this.directory = resolve(directory);
26
+ this.path = join(this.directory, `${context}.json`);
27
+ this.lockPath = `${this.path}.lock`;
28
+ this.lockTimeoutMs = lockTimeoutMs;
18
29
  }
19
30
  async prepare() {
20
31
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
21
32
  const stat = await lstat(this.directory);
22
- if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || process.platform !== 'win32' && (stat.mode & 0o077))
23
- fail('unsafe_credential_store', 'The credential directory must be private and owned by you.', { remediation: 'Use a dedicated directory with permissions 0700.' });
33
+ if (
34
+ !stat.isDirectory() ||
35
+ stat.isSymbolicLink() ||
36
+ (process.getuid && stat.uid !== process.getuid()) ||
37
+ (process.platform !== 'win32' && stat.mode & 0o077)
38
+ ) {
39
+ fail(
40
+ 'unsafe_credential_store',
41
+ 'The credential directory must be private and owned by you.',
42
+ { remediation: 'Use a dedicated directory with permissions 0700.' },
43
+ );
44
+ }
24
45
  }
25
46
  async read() {
26
- await this.prepare(); let handle;
27
- try { handle = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW); }
28
- catch (error) { if (error.code === 'ENOENT') return null; fail('unsafe_credential_store', 'The credential file could not be opened safely.'); }
47
+ await this.prepare();
48
+ let handle;
49
+ try {
50
+ handle = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW);
51
+ } catch (error) {
52
+ if (error.code === 'ENOENT') {
53
+ return null;
54
+ }
55
+ fail('unsafe_credential_store', 'The credential file could not be opened safely.');
56
+ }
29
57
  try {
30
58
  const stat = await handle.stat();
31
- if (!stat.isFile() || stat.nlink !== 1 || stat.size > this.maxBytes || process.platform !== 'win32' && (stat.mode & 0o077) || process.getuid && stat.uid !== process.getuid())
59
+ if (
60
+ !stat.isFile() ||
61
+ stat.nlink !== 1 ||
62
+ stat.size > this.maxBytes ||
63
+ (process.platform !== 'win32' && stat.mode & 0o077) ||
64
+ (process.getuid && stat.uid !== process.getuid())
65
+ ) {
32
66
  fail('unsafe_credential_store', 'The credential file must be private and owned by you.');
33
- try { return JSON.parse(await handle.readFile('utf8')); }
34
- catch { fail('invalid_credential_store', 'The credential file is damaged.', { remediation: 'Revoke the session in Account → Agents, then remove this context and log in again.' }); }
35
- } finally { await handle.close(); }
67
+ }
68
+ try {
69
+ return JSON.parse(await handle.readFile('utf8'));
70
+ } catch {
71
+ fail('invalid_credential_store', 'The credential file is damaged.', {
72
+ remediation:
73
+ 'Revoke the session in Account → Agents, then remove this context and log in again.',
74
+ });
75
+ }
76
+ } finally {
77
+ await handle.close();
78
+ }
36
79
  }
37
80
  async write(data) {
38
81
  const temporary = `${this.path}.${randomUUID()}.tmp`;
39
82
  const handle = await open(temporary, 'wx', 0o600);
40
- try { await handle.writeFile(JSON.stringify(data)); await handle.sync(); }
41
- finally { await handle.close(); }
83
+ try {
84
+ await handle.writeFile(JSON.stringify(data));
85
+ await handle.sync();
86
+ } finally {
87
+ await handle.close();
88
+ }
42
89
  try {
43
90
  await rename(temporary, this.path);
44
91
  if (process.platform !== 'win32') {
45
92
  const directory = await open(this.directory, constants.O_RDONLY);
46
- try { await directory.sync(); } finally { await directory.close(); }
93
+ try {
94
+ await directory.sync();
95
+ } finally {
96
+ await directory.close();
97
+ }
47
98
  }
99
+ } finally {
100
+ await unlink(temporary).catch((error) => {
101
+ if (error.code !== 'ENOENT') {
102
+ throw error;
103
+ }
104
+ });
48
105
  }
49
- finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
50
106
  }
51
- async remove() { await unlink(this.path).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
107
+ async remove() {
108
+ await unlink(this.path).catch((error) => {
109
+ if (error.code !== 'ENOENT') {
110
+ throw error;
111
+ }
112
+ });
113
+ }
52
114
  async locked(callback, signal) {
53
- await this.prepare(); const deadline = Date.now() + this.lockTimeoutMs;
115
+ await this.prepare();
116
+ const deadline = Date.now() + this.lockTimeoutMs;
54
117
  while (true) {
55
- try { await mkdir(this.lockPath, { mode: 0o700 }); break; }
56
- catch (error) {
57
- if (error.code !== 'EEXIST') throw error;
58
- if (Date.now() >= deadline) fail('credential_store_busy', 'Another command owns this credential context.', { retryable: true,
59
- remediation: 'Wait for that command. After a crash, stop all clients using this context, then remove its empty .lock directory.' });
118
+ try {
119
+ await mkdir(this.lockPath, { mode: 0o700 });
120
+ break;
121
+ } catch (error) {
122
+ if (error.code !== 'EEXIST') {
123
+ throw error;
124
+ }
125
+ if (Date.now() >= deadline) {
126
+ fail('credential_store_busy', 'Another command owns this credential context.', {
127
+ retryable: true,
128
+ remediation:
129
+ 'Wait for that command. After a crash, stop all clients using this context, then remove its empty .lock directory.',
130
+ });
131
+ }
60
132
  await delay(50, undefined, { signal });
61
133
  }
62
134
  }
63
- try { return await callback(); }
64
- finally { await rmdir(this.lockPath); }
135
+ try {
136
+ return await callback();
137
+ } finally {
138
+ await rmdir(this.lockPath);
139
+ }
65
140
  }
66
141
  }
package/src/transport.mjs CHANGED
@@ -1,68 +1,201 @@
1
1
  import { AgentError, fail } from './errors.mjs';
2
+ import catalog from './command-catalog.mjs';
2
3
 
3
- export const ORIGINS = Object.freeze({ production: 'https://api-geniustasker.pulsarinteractive.cloud', development: 'https://dev-api-geniustasker.pulsarinteractive.cloud' });
4
+ export const ORIGINS = Object.freeze({
5
+ production: 'https://api-geniustasker.pulsarinteractive.cloud',
6
+ development: 'https://dev-api-geniustasker.pulsarinteractive.cloud',
7
+ });
4
8
  export const CLIENT_ID = 'geniustasker-cli';
9
+ /** Resolve an approved API origin; loopback is available only by explicit opt-in. */
5
10
  export function originOf(value = ORIGINS.production, allowLocal = false) {
6
- let url; try { url = new URL(value); } catch { fail('invalid_origin', 'An absolute API origin is required.'); }
7
- if (url.username || url.password || url.pathname !== '/' || url.search || url.hash ||
8
- !(Object.values(ORIGINS).includes(url.origin) || allowLocal && url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname)))
9
- fail('invalid_origin', 'Use a GeniusTasker API origin or an explicit loopback origin with --allow-local.');
11
+ let url;
12
+ try {
13
+ url = new URL(value);
14
+ } catch {
15
+ fail('invalid_origin', 'An absolute API origin is required.');
16
+ }
17
+ if (
18
+ url.username ||
19
+ url.password ||
20
+ url.pathname !== '/' ||
21
+ url.search ||
22
+ url.hash ||
23
+ !(
24
+ Object.values(ORIGINS).includes(url.origin) ||
25
+ (allowLocal && url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))
26
+ )
27
+ ) {
28
+ fail(
29
+ 'invalid_origin',
30
+ 'Use a GeniusTasker API origin or an explicit loopback origin with --allow-local.',
31
+ );
32
+ }
10
33
  return url.origin;
11
34
  }
12
35
 
13
- const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,100}$/.test(value) ? value : undefined;
14
- const boundedText = value => typeof value === 'string' && value.length <= 500 && !/[\u0000-\u001f\u007f]/u.test(value) && !/gta1\.|wd[ar]_|sk-proj-/u.test(value) ? value : undefined;
36
+ const identifier = (value) =>
37
+ typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,100}$/.test(value) ? value : undefined;
38
+ const boundedText = (value) =>
39
+ typeof value === 'string' &&
40
+ value.length <= 500 &&
41
+ // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
42
+ !/[\u0000-\u001f\u007f]/u.test(value) &&
43
+ !/gta1\.|wd[ar]_|sk-proj-/u.test(value)
44
+ ? value
45
+ : undefined;
46
+ /**
47
+ * Bounded HTTP transport for delegated endpoints, with no implicit retries.
48
+ * Call originOf before construction; inject fetch/signal for controlled tests
49
+ * and operation-level cancellation. Error serialization excludes raw responses.
50
+ */
15
51
  export class Transport {
16
- constructor(origin, { fetchImpl = fetch, timeoutMs = 15000, signal } = {}) { this.origin = origin; this.fetch = fetchImpl; this.timeoutMs = timeoutMs; this.signal = signal; }
17
- async request(path, { method = 'GET', form, json, token, query } = {}) {
18
- if (!(path.startsWith('/api/agents/') || path === '/.well-known/oauth-authorization-server') || path.startsWith('//'))
52
+ constructor(origin, { fetchImpl = fetch, timeoutMs = 15000, signal } = {}) {
53
+ this.origin = origin;
54
+ this.fetch = fetchImpl;
55
+ this.timeoutMs = timeoutMs;
56
+ this.signal = signal;
57
+ }
58
+ async request(path, { method = 'GET', form, json, token, query, bytes, binary = false } = {}) {
59
+ if (
60
+ !(path.startsWith('/api/agents/') || path === '/.well-known/oauth-authorization-server') ||
61
+ path.startsWith('//')
62
+ ) {
19
63
  fail('invalid_arguments', 'Only delegated endpoints are available.');
64
+ }
20
65
  const url = new URL(path, this.origin);
21
- if (url.origin !== this.origin || !(url.pathname.startsWith('/api/agents/') || url.pathname === '/.well-known/oauth-authorization-server'))
66
+ if (
67
+ url.origin !== this.origin ||
68
+ !(
69
+ url.pathname.startsWith('/api/agents/') ||
70
+ url.pathname === '/.well-known/oauth-authorization-server'
71
+ )
72
+ ) {
22
73
  fail('invalid_origin', 'Cross-origin or non-delegated requests are refused.');
23
- if (query) for (const [key, value] of Object.entries(query)) if (value !== undefined) url.searchParams.set(key, String(value));
24
- if (form && json !== undefined) fail('invalid_arguments', 'Choose one request body format.');
74
+ }
75
+ if (query) {
76
+ for (const [key, value] of Object.entries(query)) {
77
+ if (value !== undefined) {
78
+ url.searchParams.set(key, String(value));
79
+ }
80
+ }
81
+ }
82
+ if (
83
+ (bytes !== undefined || binary) &&
84
+ (url.pathname !== '/api/agents/v1/media/content' ||
85
+ (bytes !== undefined && (!Buffer.isBuffer(bytes) || bytes.length > 15000000)) ||
86
+ (bytes !== undefined && (json !== undefined || form)))
87
+ ) {
88
+ fail('invalid_arguments', 'Binary transfer requires the bounded delegated media endpoint.');
89
+ }
90
+ if (form && json !== undefined) {
91
+ fail('invalid_arguments', 'Choose one request body format.');
92
+ }
25
93
  const jsonBody = json === undefined ? undefined : JSON.stringify(json);
26
- if (jsonBody && Buffer.byteLength(jsonBody) > 32768) fail('invalid_arguments', 'Command exceeds the 32 KiB request limit.');
94
+ if (jsonBody && Buffer.byteLength(jsonBody) > catalog.maxCommandBytes) {
95
+ fail(
96
+ 'invalid_arguments',
97
+ `Command exceeds the ${catalog.maxCommandBytes} byte request limit.`,
98
+ );
99
+ }
27
100
  let response;
28
101
  const timeout = AbortSignal.timeout(this.timeoutMs);
29
102
  const signal = this.signal ? AbortSignal.any([timeout, this.signal]) : timeout;
30
103
  try {
31
- response = await this.fetch(url, { method, redirect: 'error', signal,
32
- headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(form ? { 'Content-Type': 'application/x-www-form-urlencoded' } : jsonBody ? { 'Content-Type': 'application/json' } : {}) },
33
- ...(form ? { body: new URLSearchParams(form) } : jsonBody ? { body: jsonBody } : {}) });
104
+ response = await this.fetch(url, {
105
+ method,
106
+ redirect: 'error',
107
+ signal,
108
+ headers: {
109
+ Accept: binary ? 'application/octet-stream' : 'application/json',
110
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
111
+ ...(bytes !== undefined
112
+ ? { 'Content-Type': 'application/octet-stream', 'Content-Length': String(bytes.length) }
113
+ : form
114
+ ? { 'Content-Type': 'application/x-www-form-urlencoded' }
115
+ : jsonBody
116
+ ? { 'Content-Type': 'application/json' }
117
+ : {}),
118
+ },
119
+ ...(bytes !== undefined
120
+ ? { body: bytes }
121
+ : form
122
+ ? { body: new URLSearchParams(form) }
123
+ : jsonBody
124
+ ? { body: jsonBody }
125
+ : {}),
126
+ });
34
127
  } catch {
35
- throw new AgentError('network_unavailable', 'The API did not return a usable response.', { retryable: true, remediation: 'Check connectivity. Authentication exchanges are never retried automatically.', execution: method === 'GET' ? 'not_applicable' : 'unknown' });
128
+ throw new AgentError('network_unavailable', 'The API did not return a usable response.', {
129
+ retryable: true,
130
+ remediation:
131
+ 'Check connectivity. Authentication exchanges are never retried automatically.',
132
+ execution: method === 'GET' ? 'not_applicable' : 'unknown',
133
+ });
36
134
  }
37
- const chunks = []; let size = 0;
135
+ const chunks = [];
136
+ let size = 0;
38
137
  try {
39
- if (response.body) for await (const chunk of response.body) {
40
- size += chunk.length;
41
- if (size > 262144) fail('invalid_response', 'The API response exceeded the size limit.');
42
- chunks.push(chunk);
138
+ if (response.body) {
139
+ for await (const chunk of response.body) {
140
+ size += chunk.length;
141
+ if (size > (binary && response.ok ? 15000000 : 262144)) {
142
+ fail('invalid_response', 'The API response exceeded the size limit.');
143
+ }
144
+ chunks.push(chunk);
145
+ }
43
146
  }
44
147
  } catch (error) {
45
- if (error instanceof AgentError) throw error;
46
- fail('network_unavailable', 'The API response was interrupted.', { retryable: true, execution: method === 'GET' ? 'not_applicable' : 'unknown' });
148
+ if (error instanceof AgentError) {
149
+ throw error;
150
+ }
151
+ fail('network_unavailable', 'The API response was interrupted.', {
152
+ retryable: true,
153
+ execution: method === 'GET' ? 'not_applicable' : 'unknown',
154
+ });
155
+ }
156
+ if (binary && response.ok) {
157
+ return Buffer.concat(chunks);
47
158
  }
48
159
  let data;
49
- try { data = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null; }
50
- catch { fail('invalid_response', 'The API returned an unexpected format.', { status: response.status }); }
160
+ try {
161
+ data = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
162
+ } catch {
163
+ fail('invalid_response', 'The API returned an unexpected format.', {
164
+ status: response.status,
165
+ });
166
+ }
51
167
  if (!response.ok) {
52
168
  const e = data?.error;
53
169
  const code = identifier(typeof e === 'string' ? e : e?.code) ?? 'api_error';
54
170
  const retry = Number(response.headers.get('Retry-After') ?? e?.retryAfterSeconds);
55
171
  throw new AgentError(code, boundedText(e?.message) ?? 'The API refused the request.', {
56
- status: response.status, retryable: typeof e?.retryable === 'boolean' ? e.retryable : response.status === 429 || response.status === 503,
57
- remediation: boundedText(e?.remediation) ?? (response.status === 401 ? 'Run auth login again; check the connection in Account → Agents.' : 'Check permissions, availability and command help.'),
172
+ status: response.status,
173
+ retryable:
174
+ typeof e?.retryable === 'boolean'
175
+ ? e.retryable
176
+ : response.status === 429 || response.status === 503,
177
+ remediation:
178
+ boundedText(e?.remediation) ??
179
+ (response.status === 401
180
+ ? 'Run auth login again; check the connection in Account → Agents.'
181
+ : 'Check permissions, availability and command help.'),
58
182
  ...(identifier(e?.requestId) ? { requestId: e.requestId } : {}),
59
- ...(Number.isFinite(retry) && retry >= 0 && retry <= 86400 ? { retryAfterSeconds: retry } : {}),
60
- ...(Number.isSafeInteger(data?.interval) && data.interval > 0 ? { interval: data.interval } : {}),
183
+ ...(Number.isFinite(retry) && retry >= 0 && retry <= 86400
184
+ ? { retryAfterSeconds: retry }
185
+ : {}),
186
+ ...(Number.isSafeInteger(data?.interval) && data.interval > 0
187
+ ? { interval: data.interval }
188
+ : {}),
61
189
  ...(identifier(e?.execution) ? { execution: e.execution } : {}),
62
190
  ...(Number.isSafeInteger(e?.resetAt) && e.resetAt > 0 ? { resetAt: e.resetAt } : {}),
63
191
  });
64
192
  }
65
193
  return data;
66
194
  }
67
- oauth(endpoint, form) { return this.request(`/api/agents/oauth/${endpoint}`, { method: 'POST', form: { client_id: CLIENT_ID, ...form } }); }
195
+ oauth(endpoint, form) {
196
+ return this.request(`/api/agents/oauth/${endpoint}`, {
197
+ method: 'POST',
198
+ form: { client_id: CLIENT_ID, ...form },
199
+ });
200
+ }
68
201
  }