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

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/client.mjs CHANGED
@@ -1,34 +1,87 @@
1
1
  import { fail } from './errors.mjs';
2
2
 
3
+ /** Validate a token response before it can replace an origin-bound local session. */
3
4
  export function credentials(result, origin, now = Date.now()) {
4
- const token = (value, kind) => typeof value === 'string' && new RegExp(`^gta1\\.[a-f0-9]{64}\\.wd${kind}_[A-Za-z0-9_-]{43}$`).test(value);
5
- if (!token(result?.access_token, 'a') || !token(result?.refresh_token, 'r') || result.token_type !== 'Bearer' ||
6
- !Number.isSafeInteger(result.expires_in) || result.expires_in < 1 || result.expires_in > 3600 ||
7
- ['connection_id', 'profile_id', 'session_id'].some(key => typeof result[key] !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(result[key])))
8
- fail('invalid_response', 'The authorization response was invalid.', { remediation: 'Revoke the new session in Account → Agents and retry login.' });
9
- return { formatVersion: 1, origin, clientId: 'geniustasker-cli',
10
- access_token: result.access_token, refresh_token: result.refresh_token, token_type: 'Bearer', expires_in: result.expires_in,
11
- connection_id: result.connection_id, profile_id: result.profile_id, session_id: result.session_id,
12
- expiresAt: now + result.expires_in * 1000, refreshPending: false };
5
+ const token = (value, kind) =>
6
+ typeof value === 'string' &&
7
+ new RegExp(`^gta1\\.[a-f0-9]{64}\\.wd${kind}_[A-Za-z0-9_-]{43}$`).test(value);
8
+ if (
9
+ !token(result?.access_token, 'a') ||
10
+ !token(result?.refresh_token, 'r') ||
11
+ result.token_type !== 'Bearer' ||
12
+ !Number.isSafeInteger(result.expires_in) ||
13
+ result.expires_in < 1 ||
14
+ result.expires_in > 3600 ||
15
+ ['connection_id', 'profile_id', 'session_id'].some(
16
+ (key) => typeof result[key] !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(result[key]),
17
+ )
18
+ ) {
19
+ fail('invalid_response', 'The authorization response was invalid.', {
20
+ remediation: 'Revoke the new session in Account → Agents and retry login.',
21
+ });
22
+ }
23
+ return {
24
+ formatVersion: 1,
25
+ origin,
26
+ clientId: 'geniustasker-cli',
27
+ access_token: result.access_token,
28
+ refresh_token: result.refresh_token,
29
+ token_type: 'Bearer',
30
+ expires_in: result.expires_in,
31
+ connection_id: result.connection_id,
32
+ profile_id: result.profile_id,
33
+ session_id: result.session_id,
34
+ expiresAt: now + result.expires_in * 1000,
35
+ refreshPending: false,
36
+ };
13
37
  }
14
38
 
39
+ /**
40
+ * Shared delegated client for CLI and MCP operations.
41
+ * Renewal and profile selection serialize on the context lock; domain writes
42
+ * use CommandJournal so their identity and immutable request survive failures.
43
+ */
15
44
  export class AgentClient {
16
- constructor(transport, store) { this.transport = transport; this.store = store; }
45
+ constructor(transport, store) {
46
+ this.transport = transport;
47
+ this.store = store;
48
+ }
17
49
  async session() {
18
50
  const session = await this.store.read();
19
- if (!session) fail('not_authenticated', 'This context is not connected.', { remediation: 'Run auth login with this context and environment.' });
20
- if (session.formatVersion !== 1 || session.origin !== this.transport.origin || session.clientId !== 'geniustasker-cli')
21
- fail('credential_origin_mismatch', 'This context belongs to another API environment.', { remediation: 'Select its original environment, or use a different --context for the new environment.' });
51
+ if (!session) {
52
+ fail('not_authenticated', 'This context is not connected.', {
53
+ remediation: 'Run auth login with this context and environment.',
54
+ });
55
+ }
56
+ if (
57
+ session.formatVersion !== 1 ||
58
+ session.origin !== this.transport.origin ||
59
+ session.clientId !== 'geniustasker-cli'
60
+ ) {
61
+ fail('credential_origin_mismatch', 'This context belongs to another API environment.', {
62
+ remediation:
63
+ 'Select its original environment, or use a different --context for the new environment.',
64
+ });
65
+ }
22
66
  // Validate private storage before ever sending any token to the network.
23
67
  credentials(session, session.origin);
24
- if (!Number.isSafeInteger(session.expiresAt)) fail('invalid_credential_store', 'The session expiry is invalid.');
68
+ if (!Number.isSafeInteger(session.expiresAt)) {
69
+ fail('invalid_credential_store', 'The session expiry is invalid.');
70
+ }
25
71
  return session;
26
72
  }
27
73
  async authorizedSession() {
28
74
  return this.store.locked(async () => {
29
75
  const session = await this.session();
30
- if (session.refreshPending) fail('reauthentication_required', 'The previous renewal outcome is unknown.', { remediation: 'Run auth logout, then auth login. Do not replay the previous refresh token.' });
31
- if (session.expiresAt > Date.now() + 30000) return session;
76
+ if (session.refreshPending) {
77
+ fail('reauthentication_required', 'The previous renewal outcome is unknown.', {
78
+ remediation:
79
+ 'Run auth logout, then auth login. Do not replay the previous refresh token.',
80
+ });
81
+ }
82
+ if (session.expiresAt > Date.now() + 30000) {
83
+ return session;
84
+ }
32
85
  return this.renewLocked(session);
33
86
  }, this.transport.signal);
34
87
  }
@@ -39,36 +92,88 @@ export class AgentClient {
39
92
  await this.store.write({ ...session, refreshPending: true });
40
93
  let result;
41
94
  try {
42
- result = await this.transport.oauth('token', { grant_type: 'refresh_token', refresh_token: session.refresh_token,
43
- ...(selection ? { profile_id: selection.profileId, profile_revision: String(selection.expectedRevision), source_revision: String(selection.expectedSourceRevision) } : {}) });
95
+ result = await this.transport.oauth('token', {
96
+ grant_type: 'refresh_token',
97
+ refresh_token: session.refresh_token,
98
+ ...(selection
99
+ ? {
100
+ profile_id: selection.profileId,
101
+ profile_revision: String(selection.expectedRevision),
102
+ source_revision: String(selection.expectedSourceRevision),
103
+ }
104
+ : {}),
105
+ });
44
106
  } catch (error) {
45
107
  // These two server outcomes happen before token consumption/rotation.
46
108
  // Every other refusal or lost reply retains the uncertainty marker.
47
- if (selection && error?.status === 400 && ['profile_selection_conflict', 'profile_selection_denied'].includes(error.code)) {
109
+ if (
110
+ selection &&
111
+ error?.status === 400 &&
112
+ ['profile_selection_conflict', 'profile_selection_denied'].includes(error.code)
113
+ ) {
48
114
  await this.store.write(session);
49
- error.remediation = 'List profiles again. Only a human can approve broader access; a changed profile requires a fresh selection.';
115
+ error.remediation =
116
+ 'List profiles again. Only a human can approve broader access; a changed profile requires a fresh selection.';
50
117
  }
51
118
  throw error;
52
119
  }
53
120
  const next = credentials(result, this.transport.origin);
54
- if (next.connection_id !== session.connection_id || next.profile_id !== (selection?.profileId ?? session.profile_id) || next.session_id !== session.session_id)
121
+ if (
122
+ next.connection_id !== session.connection_id ||
123
+ next.profile_id !== (selection?.profileId ?? session.profile_id) ||
124
+ next.session_id !== session.session_id
125
+ ) {
55
126
  fail('invalid_response', 'Renewal returned an unexpected identity.');
127
+ }
56
128
  await this.store.write(next);
57
129
  return next;
58
130
  }
59
131
  async selectProfile(selection) {
60
132
  return this.store.locked(async () => {
61
133
  const session = await this.session();
62
- if (session.refreshPending) fail('reauthentication_required', 'The previous renewal outcome is unknown.', { remediation: 'Run auth logout, then auth login; do not replay the previous refresh token.' });
63
- if (selection.sourceProfileId !== session.profile_id || selection.connectionId !== session.connection_id)
64
- fail('profile_selection_conflict', 'Another process changed this context.', { status: 409, remediation: 'Read profiles again. Use separate --context names for independent terminals.' });
134
+ if (session.refreshPending) {
135
+ fail('reauthentication_required', 'The previous renewal outcome is unknown.', {
136
+ remediation:
137
+ 'Run auth logout, then auth login; do not replay the previous refresh token.',
138
+ });
139
+ }
140
+ if (
141
+ selection.sourceProfileId !== session.profile_id ||
142
+ selection.connectionId !== session.connection_id
143
+ ) {
144
+ fail('profile_selection_conflict', 'Another process changed this context.', {
145
+ status: 409,
146
+ remediation:
147
+ 'Read profiles again. Use separate --context names for independent terminals.',
148
+ });
149
+ }
65
150
  const next = await this.renewLocked(session, selection);
66
- return { connected: true, connectionId: next.connection_id, profileId: next.profile_id, sessionId: next.session_id };
151
+ return {
152
+ connected: true,
153
+ connectionId: next.connection_id,
154
+ profileId: next.profile_id,
155
+ sessionId: next.session_id,
156
+ };
67
157
  }, this.transport.signal);
68
158
  }
69
- async accessToken() { return (await this.authorizedSession()).access_token; }
159
+ async accessToken() {
160
+ return (await this.authorizedSession()).access_token;
161
+ }
70
162
  async get(resource, query) {
71
- if (!['me', 'capabilities', 'projects', 'memories', 'resources', 'profiles', 'answers', 'changes'].includes(resource)) fail('invalid_arguments', 'Unknown delegated resource.');
163
+ if (
164
+ ![
165
+ 'me',
166
+ 'capabilities',
167
+ 'projects',
168
+ 'memories',
169
+ 'resources',
170
+ 'profiles',
171
+ 'answers',
172
+ 'changes',
173
+ ].includes(resource)
174
+ ) {
175
+ fail('invalid_arguments', 'Unknown delegated resource.');
176
+ }
72
177
  const token = await this.accessToken();
73
178
  // No blanket retries, no automatic writes. Revocation and ACL errors remain
74
179
  // visible to the agent. A caller chooses how to recover using the envelope.
@@ -76,20 +181,32 @@ export class AgentClient {
76
181
  }
77
182
  async logout() {
78
183
  return this.store.locked(async () => {
79
- if (!await this.store.read()) return { connected: false, alreadyDisconnected: true };
184
+ if (!(await this.store.read())) {
185
+ return { connected: false, alreadyDisconnected: true };
186
+ }
80
187
  const session = await this.session();
81
188
  // Keep credentials on failure so revocation can be retried. A used refresh
82
189
  // token can revoke a family whose rotation response was lost.
83
- await this.transport.oauth('revoke', { token: session.refresh_token, token_type_hint: 'refresh_token' });
190
+ await this.transport.oauth('revoke', {
191
+ token: session.refresh_token,
192
+ token_type_hint: 'refresh_token',
193
+ });
84
194
  await this.store.remove();
85
195
  return { connected: false, revoked: true };
86
196
  }, this.transport.signal);
87
197
  }
88
198
  async doctor() {
89
199
  const session = await this.store.read();
90
- return { node: process.versions.node, origin: this.transport.origin, connectedLocally: Boolean(session),
200
+ return {
201
+ node: process.versions.node,
202
+ origin: this.transport.origin,
203
+ connectedLocally: Boolean(session),
91
204
  sameEnvironment: session ? session.origin === this.transport.origin : null,
92
- needsLogin: session ? session.refreshPending === true : true, storage: 'private OS-user file',
93
- publication: 'CLI installation does not grant service access; this beta requires an admitted account.', writes: 'Discover server capabilities before attempting changes.' };
205
+ needsLogin: session ? session.refreshPending === true : true,
206
+ storage: 'private OS-user file',
207
+ publication:
208
+ 'CLI installation does not grant service access; this beta requires an admitted account.',
209
+ writes: 'Discover server capabilities before attempting changes.',
210
+ };
94
211
  }
95
212
  }
package/src/commands.mjs CHANGED
@@ -7,102 +7,250 @@ import catalog from './command-catalog.mjs';
7
7
  import { CredentialStore } from './store.mjs';
8
8
  import { AgentError, fail, safeError } from './errors.mjs';
9
9
 
10
- export const operationIdSchema = z.string().regex(/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/);
11
- const identifier = z.string().min(1).max(128).regex(/^[^\u0000-\u001f]+$/u);
12
- export const commandSchema = z.object({
13
- operationId: operationIdSchema, issuedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
14
- scope: z.string().max(256).regex(/^(project|memory):[^\u0000-\u001f]+$/u),
15
- epoch: identifier, id: identifier, expectedRevision: z.string().regex(/^[1-9][0-9]{0,15}$/).nullable(),
16
- kind: z.enum(Object.keys(catalog.commands)), data: z.record(z.string(), z.json()),
17
- }).strict();
10
+ export const operationIdSchema = z
11
+ .string()
12
+ .regex(/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/);
13
+ const identifier = z
14
+ .string()
15
+ .min(1)
16
+ .max(128)
17
+ // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
18
+ .regex(/^[^\u0000-\u001f]+$/u);
19
+ export const commandSchema = z
20
+ .object({
21
+ operationId: operationIdSchema,
22
+ issuedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
23
+ scope: z
24
+ .string()
25
+ .max(256)
26
+ // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
27
+ .regex(/^(project|memory):[^\u0000-\u001f]+$/u),
28
+ epoch: identifier,
29
+ id: identifier,
30
+ expectedRevision: z
31
+ .string()
32
+ .regex(/^[1-9][0-9]{0,15}$/)
33
+ .nullable(),
34
+ kind: z.enum(Object.keys(catalog.commands)),
35
+ data: z.record(z.string(), z.json()),
36
+ })
37
+ .strict();
18
38
  export const draftSchema = commandSchema.omit({ operationId: true, issuedAt: true });
19
- const canonical = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
20
- ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
39
+ const canonical = (value) =>
40
+ JSON.stringify(value, (_, item) =>
41
+ item && typeof item === 'object' && !Array.isArray(item)
42
+ ? Object.fromEntries(
43
+ Object.keys(item)
44
+ .sort()
45
+ .map((key) => [key, item[key]]),
46
+ )
47
+ : item,
48
+ );
21
49
  const checked = (schema, value) => {
22
50
  const parsed = schema.safeParse(value);
23
- if (!parsed.success) fail('invalid_arguments', 'The command does not match its schema. Use commands describe; do not supply credentials or server-owned fields.');
51
+ if (!parsed.success) {
52
+ fail(
53
+ 'invalid_arguments',
54
+ 'The command does not match its schema. Use commands describe; do not supply credentials or server-owned fields.',
55
+ );
56
+ }
24
57
  return parsed.data;
25
58
  };
59
+ /** Validate the public envelope, generated field allowlist and resource scope. */
26
60
  export function validateCommand(value) {
27
- const command = checked(commandSchema, value), definition = catalog.commands[command.kind];
28
- if (Buffer.byteLength(JSON.stringify(command)) > catalog.maxCommandBytes ||
29
- Object.keys(command.data).some(key => !definition.fields.includes(key)) ||
30
- (definition.create ? command.expectedRevision !== null : command.expectedRevision === null))
31
- fail('invalid_arguments', 'Use the declared command fields and size limit. Creation requires a null revision; other commands require the current record revision.');
61
+ const command = checked(commandSchema, value),
62
+ definition = catalog.commands[command.kind];
63
+ if (
64
+ Buffer.byteLength(JSON.stringify(command)) > catalog.maxCommandBytes ||
65
+ Object.keys(command.data).some((key) => !definition.fields.includes(key)) ||
66
+ (definition.create ? command.expectedRevision !== null : command.expectedRevision === null)
67
+ ) {
68
+ fail(
69
+ 'invalid_arguments',
70
+ 'Use the declared command fields and size limit. Creation requires a null revision; other commands require the current record revision.',
71
+ );
72
+ }
32
73
  const prefix = definition.module.startsWith('Memory/') ? 'memory:' : 'project:';
33
- if (!command.scope.startsWith(prefix) || definition.module.endsWith('/$') && command.id !== command.scope.slice(prefix.length)) fail('invalid_arguments', 'The command must target the matching project or memory scope.');
34
- if (command.kind === 'ticket.plan' && command.id !== command.scope.slice(8))
35
- fail('invalid_arguments', 'ticket.plan targets the project ID and root revision; changes carry each task expectedVersion.');
74
+ if (
75
+ !command.scope.startsWith(prefix) ||
76
+ (definition.module.endsWith('/$') && command.id !== command.scope.slice(prefix.length))
77
+ ) {
78
+ fail('invalid_arguments', 'The command must target the matching project or memory scope.');
79
+ }
80
+ if (command.kind === 'ticket.plan' && command.id !== command.scope.slice(8)) {
81
+ fail(
82
+ 'invalid_arguments',
83
+ 'ticket.plan targets the project ID and root revision; changes carry each task expectedVersion.',
84
+ );
85
+ }
36
86
  return command;
37
87
  }
38
- export function describeCommands() { return { ...catalog, envelope: z.toJSONSchema(commandSchema), draft: z.toJSONSchema(draftSchema),
39
- recovery: 'Retain operationId, issuedAt and the complete body. Retry an uncertain write unchanged; reread before creating a new edit after a conflict.' }; }
88
+ export function describeCommands() {
89
+ return {
90
+ ...catalog,
91
+ envelope: z.toJSONSchema(commandSchema),
92
+ draft: z.toJSONSchema(draftSchema),
93
+ recovery:
94
+ 'Retain operationId, issuedAt and the complete body. Retry an uncertain write unchanged; reread before creating a new edit after a conflict.',
95
+ };
96
+ }
97
+ /** Read one bounded regular JSON file, rejecting symlinks and non-file inputs. */
40
98
  export async function readCommandFile(path) {
41
- if (typeof path !== 'string') fail('invalid_arguments', 'Supply --file with a JSON command or draft.');
99
+ if (typeof path !== 'string') {
100
+ fail('invalid_arguments', 'Supply --file with a JSON command or draft.');
101
+ }
42
102
  let handle;
43
103
  try {
44
104
  handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
45
105
  const stat = await handle.stat();
46
- if (!stat.isFile() || stat.size > catalog.maxCommandBytes) fail('invalid_arguments', 'Use a regular JSON file no larger than 32 KiB.');
47
- const bytes = Buffer.alloc(catalog.maxCommandBytes + 1), result = await handle.read(bytes, 0, bytes.length, 0);
48
- if (result.bytesRead > catalog.maxCommandBytes) fail('invalid_arguments', 'Command file exceeds 32 KiB.');
106
+ if (!stat.isFile() || stat.size > catalog.maxCommandBytes) {
107
+ fail('invalid_arguments', 'Use a regular JSON file no larger than 32 KiB.');
108
+ }
109
+ const bytes = Buffer.alloc(catalog.maxCommandBytes + 1),
110
+ result = await handle.read(bytes, 0, bytes.length, 0);
111
+ if (result.bytesRead > catalog.maxCommandBytes) {
112
+ fail('invalid_arguments', 'Command file exceeds 32 KiB.');
113
+ }
49
114
  return JSON.parse(bytes.subarray(0, result.bytesRead).toString('utf8'));
50
115
  } catch (error) {
51
- if (error instanceof AgentError) throw error;
116
+ if (error instanceof AgentError) {
117
+ throw error;
118
+ }
52
119
  fail('invalid_arguments', 'Could not read a regular, valid JSON command file.');
53
- } finally { await handle?.close(); }
120
+ } finally {
121
+ await handle?.close();
122
+ }
54
123
  }
55
- const binding = session => ({ origin: session.origin, connectionId: session.connection_id, profileId: session.profile_id, sessionId: session.session_id });
56
- const receiptSchema = z.object({ operationId: operationIdSchema, scope: z.string(), kind: z.string(), epoch: z.string(), cursor: z.string(),
57
- items: z.array(z.object({ id: z.string(), revision: z.string(), collection: z.string(), deleted: z.boolean() }).strict()),
58
- affectedCollections: z.array(z.string()),
59
- }).strict();
124
+ const binding = (session) => ({
125
+ origin: session.origin,
126
+ connectionId: session.connection_id,
127
+ profileId: session.profile_id,
128
+ sessionId: session.session_id,
129
+ });
130
+ const receiptSchema = z
131
+ .object({
132
+ operationId: operationIdSchema,
133
+ scope: z.string(),
134
+ kind: z.string(),
135
+ epoch: z.string(),
136
+ cursor: z.string(),
137
+ items: z.array(
138
+ z
139
+ .object({
140
+ id: z.string(),
141
+ revision: z.string(),
142
+ collection: z.string(),
143
+ deleted: z.boolean(),
144
+ })
145
+ .strict(),
146
+ ),
147
+ affectedCollections: z.array(z.string()),
148
+ })
149
+ .strict();
60
150
 
61
151
  /** A journal is bound to one authenticated terminal identity. Persist the full
62
152
  * request before the network call, never regenerate a UUID after a timeout.
63
153
  * It contains private project text but no access/refresh tokens. A process crash
64
154
  * leaves its operation lock for explicit recovery after all clients stop. */
65
155
  export class CommandJournal {
66
- constructor(client) { this.client = client; }
156
+ constructor(client) {
157
+ this.client = client;
158
+ }
67
159
  store(id) {
68
160
  checked(operationIdSchema, id);
69
- return new CredentialStore({ directory: join(this.client.store.directory, 'operations', this.client.store.context), context: id, maxBytes: 524288 });
161
+ return new CredentialStore({
162
+ directory: join(this.client.store.directory, 'operations', this.client.store.context),
163
+ context: id,
164
+ maxBytes: 524288,
165
+ });
70
166
  }
71
167
  async prepare(draft) {
72
- const input = validateCommand({ ...checked(draftSchema, draft), operationId: randomUUID(), issuedAt: Date.now() });
73
- const identity = binding(await this.client.session()), store = this.store(input.operationId);
74
- await store.locked(() => store.write({ formatVersion: 1, binding: identity, input, state: 'prepared' }), this.client.transport.signal);
168
+ const input = validateCommand({
169
+ ...checked(draftSchema, draft),
170
+ operationId: randomUUID(),
171
+ issuedAt: Date.now(),
172
+ });
173
+ const identity = binding(await this.client.session()),
174
+ store = this.store(input.operationId);
175
+ await store.locked(
176
+ () => store.write({ formatVersion: 1, binding: identity, input, state: 'prepared' }),
177
+ this.client.transport.signal,
178
+ );
75
179
  return { operationId: input.operationId, state: 'prepared', request: input };
76
180
  }
77
181
  async inspect(id) {
78
182
  const row = await this.store(id).read();
79
- if (!row || row.formatVersion !== 1 || row.input?.operationId !== id) fail('operation_not_found', 'This context has no valid local operation with that ID.');
80
- return { operationId: id, source: 'local_journal', state: row.state, request: row.input, ...(row.receipt ? { receipt: row.receipt } : {}), ...(row.error ? { error: row.error } : {}) };
183
+ if (!row || row.formatVersion !== 1 || row.input?.operationId !== id) {
184
+ fail('operation_not_found', 'This context has no valid local operation with that ID.');
185
+ }
186
+ return {
187
+ operationId: id,
188
+ source: 'local_journal',
189
+ state: row.state,
190
+ request: row.input,
191
+ ...(row.receipt ? { receipt: row.receipt } : {}),
192
+ ...(row.error ? { error: row.error } : {}),
193
+ };
81
194
  }
82
195
  async execute(value) {
83
- const input = validateCommand(value), store = this.store(input.operationId);
196
+ const input = validateCommand(value),
197
+ store = this.store(input.operationId);
84
198
  return store.locked(async () => {
85
199
  // Capture token and identity together under the refresh lock. A later
86
200
  // logout/login cannot turn this pending operation into another profile.
87
- const session = await this.client.authorizedSession(), identity = binding(session);
201
+ const session = await this.client.authorizedSession(),
202
+ identity = binding(session);
88
203
  let row = await store.read();
89
- if (row && (row.formatVersion !== 1 || canonical(row.binding) !== canonical(identity)))
90
- fail('operation_identity_mismatch', 'This operation belongs to another connection, profile, session or environment.', { remediation: 'Inspect it locally and reread current records. Do not replay it through a replacement login.' });
91
- if (row && canonical(row.input) !== canonical(input))
92
- fail('idempotency_mismatch', 'The operation ID already belongs to a different immutable request.', { status: 409, remediation: 'Use commands inspect, then retry the saved operation.' });
93
- if (row?.state === 'confirmed') return { source: 'local_receipt', receipt: row.receipt };
204
+ if (row && (row.formatVersion !== 1 || canonical(row.binding) !== canonical(identity))) {
205
+ fail(
206
+ 'operation_identity_mismatch',
207
+ 'This operation belongs to another connection, profile, session or environment.',
208
+ {
209
+ remediation:
210
+ 'Inspect it locally and reread current records. Do not replay it through a replacement login.',
211
+ },
212
+ );
213
+ }
214
+ if (row && canonical(row.input) !== canonical(input)) {
215
+ fail(
216
+ 'idempotency_mismatch',
217
+ 'The operation ID already belongs to a different immutable request.',
218
+ { status: 409, remediation: 'Use commands inspect, then retry the saved operation.' },
219
+ );
220
+ }
221
+ if (row?.state === 'confirmed') {
222
+ return { source: 'local_receipt', receipt: row.receipt };
223
+ }
94
224
  row = { formatVersion: 1, binding: identity, input, state: 'uncertain' };
95
225
  await store.write(row);
96
226
  try {
97
- const receipt = await this.client.transport.request('/api/agents/v1/commands', { method: 'POST', json: input, token: session.access_token });
98
- if (!receiptSchema.safeParse(receipt).success || ['operationId', 'scope', 'kind', 'epoch'].some(key => receipt[key] !== input[key]))
99
- fail('invalid_response', 'The command receipt was invalid. The server may have committed.', { execution: 'unknown', remediation: 'Retry the identical saved operation.' });
227
+ const receipt = await this.client.transport.request('/api/agents/v1/commands', {
228
+ method: 'POST',
229
+ json: input,
230
+ token: session.access_token,
231
+ });
232
+ if (
233
+ !receiptSchema.safeParse(receipt).success ||
234
+ ['operationId', 'scope', 'kind', 'epoch'].some((key) => receipt[key] !== input[key])
235
+ ) {
236
+ fail(
237
+ 'invalid_response',
238
+ 'The command receipt was invalid. The server may have committed.',
239
+ { execution: 'unknown', remediation: 'Retry the identical saved operation.' },
240
+ );
241
+ }
100
242
  await store.write({ ...row, state: 'confirmed', receipt });
101
243
  return { source: 'server', receipt };
102
244
  } catch (error) {
103
245
  const safe = safeError(error);
104
- const result = new AgentError(safe.code, safe.message, { status: safe.status, retryable: safe.retryable, remediation: safe.remediation,
105
- execution: 'unknown', ...safe.details, operationId: input.operationId });
246
+ const result = new AgentError(safe.code, safe.message, {
247
+ status: safe.status,
248
+ retryable: safe.retryable,
249
+ remediation: safe.remediation,
250
+ execution: 'unknown',
251
+ ...safe.details,
252
+ operationId: input.operationId,
253
+ });
106
254
  // The uncertainty marker is already durable. Preserve the useful original
107
255
  // error/operation ID even if disk pressure prevents saving diagnostics.
108
256
  await store.write({ ...row, error: result.toJSON() }).catch(() => {});
@@ -110,5 +258,7 @@ export class CommandJournal {
110
258
  }
111
259
  }, this.client.transport.signal);
112
260
  }
113
- async retry(id) { return this.execute((await this.inspect(id)).request); }
261
+ async retry(id) {
262
+ return this.execute((await this.inspect(id)).request);
263
+ }
114
264
  }
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
  }