@pulsarinteractive/geniustasker 0.1.0-beta.1 → 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/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +52 -371
- package/SECURITY.md +14 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/CLI.md +395 -0
- package/docs/INTEGRATIONS.md +62 -0
- package/docs/OPENAI.md +37 -0
- package/docs/RELEASING.md +16 -0
- package/docs/TESTING.md +52 -0
- package/examples/codex.toml +3 -0
- package/examples/mcp.json +8 -0
- package/mcp.json +10 -0
- package/package.json +42 -7
- package/plugin.json +24 -0
- package/skills/geniustasker/SKILL.md +6 -115
- package/skills/geniustasker/agents/openai.yaml +4 -0
- package/skills/geniustasker/references/collaboration.md +70 -0
- package/skills/geniustasker/references/incremental-reads.md +16 -0
- package/skills/geniustasker/references/profiles.md +22 -0
- package/skills/geniustasker-memory/SKILL.md +52 -0
- package/skills/geniustasker-memory/agents/openai.yaml +4 -0
- package/src/changes.mjs +104 -28
- package/src/cli.mjs +298 -77
- package/src/client.mjs +149 -32
- package/src/commands.mjs +202 -52
- package/src/errors.mjs +55 -12
- package/src/login.mjs +190 -47
- package/src/mcp.mjs +183 -43
- package/src/profiles.mjs +123 -32
- package/src/store.mjs +101 -26
- package/src/transport.mjs +141 -31
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) =>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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) {
|
|
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)
|
|
20
|
-
|
|
21
|
-
|
|
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))
|
|
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)
|
|
31
|
-
|
|
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', {
|
|
43
|
-
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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)
|
|
63
|
-
|
|
64
|
-
|
|
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 {
|
|
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() {
|
|
159
|
+
async accessToken() {
|
|
160
|
+
return (await this.authorizedSession()).access_token;
|
|
161
|
+
}
|
|
70
162
|
async get(resource, query) {
|
|
71
|
-
if (
|
|
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())
|
|
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', {
|
|
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 {
|
|
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,
|
|
93
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
20
|
-
|
|
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)
|
|
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),
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 (
|
|
34
|
-
|
|
35
|
-
|
|
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() {
|
|
39
|
-
|
|
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')
|
|
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)
|
|
47
|
-
|
|
48
|
-
|
|
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)
|
|
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 {
|
|
120
|
+
} finally {
|
|
121
|
+
await handle?.close();
|
|
122
|
+
}
|
|
54
123
|
}
|
|
55
|
-
const binding = session => ({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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) {
|
|
156
|
+
constructor(client) {
|
|
157
|
+
this.client = client;
|
|
158
|
+
}
|
|
67
159
|
store(id) {
|
|
68
160
|
checked(operationIdSchema, id);
|
|
69
|
-
return new CredentialStore({
|
|
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({
|
|
73
|
-
|
|
74
|
-
|
|
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)
|
|
80
|
-
|
|
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),
|
|
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(),
|
|
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(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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', {
|
|
98
|
-
|
|
99
|
-
|
|
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, {
|
|
105
|
-
|
|
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) {
|
|
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(
|
|
5
|
-
|
|
6
|
-
|
|
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) => {
|
|
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
|
|
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))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|