@pulsarinteractive/geniustasker 0.0.0-stage → 0.1.0-beta.2
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 +16 -0
- package/CONTRIBUTING.md +17 -0
- package/README.md +75 -2
- package/SECURITY.md +14 -0
- package/bin/geniustasker.mjs +6 -0
- package/docs/CLI.md +400 -0
- package/docs/INTEGRATIONS.md +62 -0
- package/docs/OPENAI.md +38 -0
- package/docs/RELEASING.md +15 -0
- package/examples/codex.toml +3 -0
- package/examples/mcp.json +14 -0
- package/mcp.json +16 -0
- package/package.json +60 -4
- package/plugin.json +33 -0
- package/skills/geniustasker/SKILL.md +108 -0
- package/skills/geniustasker/agents/openai.yaml +4 -0
- package/skills/geniustasker/references/collaboration.md +72 -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 +63 -0
- package/src/cli.mjs +135 -0
- package/src/client.mjs +95 -0
- package/src/command-catalog.mjs +287 -0
- package/src/commands.mjs +114 -0
- package/src/errors.mjs +22 -0
- package/src/login.mjs +115 -0
- package/src/mcp.mjs +60 -0
- package/src/profiles.mjs +63 -0
- package/src/store.mjs +66 -0
- package/src/transport.mjs +68 -0
package/src/client.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { fail } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
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 };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AgentClient {
|
|
16
|
+
constructor(transport, store) { this.transport = transport; this.store = store; }
|
|
17
|
+
async session() {
|
|
18
|
+
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.' });
|
|
22
|
+
// Validate private storage before ever sending any token to the network.
|
|
23
|
+
credentials(session, session.origin);
|
|
24
|
+
if (!Number.isSafeInteger(session.expiresAt)) fail('invalid_credential_store', 'The session expiry is invalid.');
|
|
25
|
+
return session;
|
|
26
|
+
}
|
|
27
|
+
async authorizedSession() {
|
|
28
|
+
return this.store.locked(async () => {
|
|
29
|
+
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;
|
|
32
|
+
return this.renewLocked(session);
|
|
33
|
+
}, this.transport.signal);
|
|
34
|
+
}
|
|
35
|
+
/** Only called while holding the credential store lock. Selection rotates
|
|
36
|
+
* access and refresh together so an in-flight old command cannot acquire the
|
|
37
|
+
* new profile's identity. It never extends the server's absolute lifetime. */
|
|
38
|
+
async renewLocked(session, selection) {
|
|
39
|
+
await this.store.write({ ...session, refreshPending: true });
|
|
40
|
+
let result;
|
|
41
|
+
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) } : {}) });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// These two server outcomes happen before token consumption/rotation.
|
|
46
|
+
// 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)) {
|
|
48
|
+
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.';
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
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)
|
|
55
|
+
fail('invalid_response', 'Renewal returned an unexpected identity.');
|
|
56
|
+
await this.store.write(next);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
async selectProfile(selection) {
|
|
60
|
+
return this.store.locked(async () => {
|
|
61
|
+
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.' });
|
|
65
|
+
const next = await this.renewLocked(session, selection);
|
|
66
|
+
return { connected: true, connectionId: next.connection_id, profileId: next.profile_id, sessionId: next.session_id };
|
|
67
|
+
}, this.transport.signal);
|
|
68
|
+
}
|
|
69
|
+
async accessToken() { return (await this.authorizedSession()).access_token; }
|
|
70
|
+
async get(resource, query) {
|
|
71
|
+
if (!['me', 'capabilities', 'projects', 'memories', 'resources', 'profiles', 'answers', 'changes'].includes(resource)) fail('invalid_arguments', 'Unknown delegated resource.');
|
|
72
|
+
const token = await this.accessToken();
|
|
73
|
+
// No blanket retries, no automatic writes. Revocation and ACL errors remain
|
|
74
|
+
// visible to the agent. A caller chooses how to recover using the envelope.
|
|
75
|
+
return this.transport.request(`/api/agents/v1/${resource}`, { token, query });
|
|
76
|
+
}
|
|
77
|
+
async logout() {
|
|
78
|
+
return this.store.locked(async () => {
|
|
79
|
+
if (!await this.store.read()) return { connected: false, alreadyDisconnected: true };
|
|
80
|
+
const session = await this.session();
|
|
81
|
+
// Keep credentials on failure so revocation can be retried. A used refresh
|
|
82
|
+
// 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' });
|
|
84
|
+
await this.store.remove();
|
|
85
|
+
return { connected: false, revoked: true };
|
|
86
|
+
}, this.transport.signal);
|
|
87
|
+
}
|
|
88
|
+
async doctor() {
|
|
89
|
+
const session = await this.store.read();
|
|
90
|
+
return { node: process.versions.node, origin: this.transport.origin, connectedLocally: Boolean(session),
|
|
91
|
+
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.' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Generated from contracts/agents.tsp. Do not edit by hand.
|
|
2
|
+
export default {
|
|
3
|
+
"maxCommandBytes": 32768,
|
|
4
|
+
"commands": {
|
|
5
|
+
"approval.cancel": {
|
|
6
|
+
"fields": [
|
|
7
|
+
"requestVersion"
|
|
8
|
+
],
|
|
9
|
+
"module": "Project/ProjectTaskComment",
|
|
10
|
+
"permission": "approvals:write"
|
|
11
|
+
},
|
|
12
|
+
"approval.consume": {
|
|
13
|
+
"fields": [
|
|
14
|
+
"requestVersion",
|
|
15
|
+
"actionJson"
|
|
16
|
+
],
|
|
17
|
+
"module": "Project/ProjectTaskComment",
|
|
18
|
+
"permission": "approvals:write"
|
|
19
|
+
},
|
|
20
|
+
"approval.request": {
|
|
21
|
+
"create": true,
|
|
22
|
+
"fields": [
|
|
23
|
+
"task",
|
|
24
|
+
"taskRevision",
|
|
25
|
+
"title",
|
|
26
|
+
"summary",
|
|
27
|
+
"actionJson",
|
|
28
|
+
"approver",
|
|
29
|
+
"expiresAt"
|
|
30
|
+
],
|
|
31
|
+
"module": "Project/ProjectTaskComment",
|
|
32
|
+
"permission": "approvals:write"
|
|
33
|
+
},
|
|
34
|
+
"comment.create": {
|
|
35
|
+
"create": true,
|
|
36
|
+
"fields": [
|
|
37
|
+
"task",
|
|
38
|
+
"text",
|
|
39
|
+
"mentions"
|
|
40
|
+
],
|
|
41
|
+
"module": "Project/ProjectTaskComment",
|
|
42
|
+
"permission": "comments:write"
|
|
43
|
+
},
|
|
44
|
+
"comment.delete": {
|
|
45
|
+
"delete": true,
|
|
46
|
+
"fields": [],
|
|
47
|
+
"module": "Project/ProjectTaskComment",
|
|
48
|
+
"permission": "comments:write"
|
|
49
|
+
},
|
|
50
|
+
"comment.update": {
|
|
51
|
+
"fields": [
|
|
52
|
+
"text",
|
|
53
|
+
"mentions"
|
|
54
|
+
],
|
|
55
|
+
"module": "Project/ProjectTaskComment",
|
|
56
|
+
"permission": "comments:write"
|
|
57
|
+
},
|
|
58
|
+
"goal.create": {
|
|
59
|
+
"create": true,
|
|
60
|
+
"fields": [
|
|
61
|
+
"title",
|
|
62
|
+
"owner",
|
|
63
|
+
"isTeamOwner",
|
|
64
|
+
"period",
|
|
65
|
+
"interval",
|
|
66
|
+
"startTime",
|
|
67
|
+
"taskNumber",
|
|
68
|
+
"scheduleTaskOnly",
|
|
69
|
+
"notification",
|
|
70
|
+
"alertNotification"
|
|
71
|
+
],
|
|
72
|
+
"module": "Project/ProjectMemberGoals",
|
|
73
|
+
"permission": "goals:write"
|
|
74
|
+
},
|
|
75
|
+
"goal.delete": {
|
|
76
|
+
"delete": true,
|
|
77
|
+
"fields": [],
|
|
78
|
+
"module": "Project/ProjectMemberGoals",
|
|
79
|
+
"permission": "goals:write"
|
|
80
|
+
},
|
|
81
|
+
"goal.update": {
|
|
82
|
+
"fields": [
|
|
83
|
+
"title",
|
|
84
|
+
"owner",
|
|
85
|
+
"isTeamOwner",
|
|
86
|
+
"period",
|
|
87
|
+
"interval",
|
|
88
|
+
"startTime",
|
|
89
|
+
"taskNumber",
|
|
90
|
+
"scheduleTaskOnly",
|
|
91
|
+
"notification",
|
|
92
|
+
"alertNotification"
|
|
93
|
+
],
|
|
94
|
+
"module": "Project/ProjectMemberGoals",
|
|
95
|
+
"permission": "goals:write"
|
|
96
|
+
},
|
|
97
|
+
"memory.create": {
|
|
98
|
+
"create": true,
|
|
99
|
+
"fields": [
|
|
100
|
+
"title",
|
|
101
|
+
"productKey",
|
|
102
|
+
"summary",
|
|
103
|
+
"instructions",
|
|
104
|
+
"color"
|
|
105
|
+
],
|
|
106
|
+
"module": "Memory/$",
|
|
107
|
+
"permission": "memory:write"
|
|
108
|
+
},
|
|
109
|
+
"memory.delete": {
|
|
110
|
+
"delete": true,
|
|
111
|
+
"fields": [],
|
|
112
|
+
"module": "Memory/$",
|
|
113
|
+
"permission": "memory:write"
|
|
114
|
+
},
|
|
115
|
+
"memory.page.create": {
|
|
116
|
+
"create": true,
|
|
117
|
+
"fields": [
|
|
118
|
+
"title",
|
|
119
|
+
"path",
|
|
120
|
+
"summary",
|
|
121
|
+
"blocks",
|
|
122
|
+
"tags",
|
|
123
|
+
"provenance"
|
|
124
|
+
],
|
|
125
|
+
"module": "Memory/MemoryPage",
|
|
126
|
+
"permission": "memory:write"
|
|
127
|
+
},
|
|
128
|
+
"memory.page.delete": {
|
|
129
|
+
"delete": true,
|
|
130
|
+
"fields": [],
|
|
131
|
+
"module": "Memory/MemoryPage",
|
|
132
|
+
"permission": "memory:write"
|
|
133
|
+
},
|
|
134
|
+
"memory.page.update": {
|
|
135
|
+
"fields": [
|
|
136
|
+
"title",
|
|
137
|
+
"path",
|
|
138
|
+
"summary",
|
|
139
|
+
"blocks",
|
|
140
|
+
"tags",
|
|
141
|
+
"provenance"
|
|
142
|
+
],
|
|
143
|
+
"module": "Memory/MemoryPage",
|
|
144
|
+
"permission": "memory:write"
|
|
145
|
+
},
|
|
146
|
+
"memory.update": {
|
|
147
|
+
"fields": [
|
|
148
|
+
"title",
|
|
149
|
+
"productKey",
|
|
150
|
+
"summary",
|
|
151
|
+
"instructions",
|
|
152
|
+
"color"
|
|
153
|
+
],
|
|
154
|
+
"module": "Memory/$",
|
|
155
|
+
"permission": "memory:write"
|
|
156
|
+
},
|
|
157
|
+
"question.close": {
|
|
158
|
+
"fields": [],
|
|
159
|
+
"module": "Project/ProjectVote",
|
|
160
|
+
"permission": "questions:write"
|
|
161
|
+
},
|
|
162
|
+
"question.create": {
|
|
163
|
+
"create": true,
|
|
164
|
+
"fields": [
|
|
165
|
+
"task",
|
|
166
|
+
"question",
|
|
167
|
+
"description",
|
|
168
|
+
"answerKind",
|
|
169
|
+
"options",
|
|
170
|
+
"selection",
|
|
171
|
+
"anonymous",
|
|
172
|
+
"eligibleUsers",
|
|
173
|
+
"closesAt"
|
|
174
|
+
],
|
|
175
|
+
"module": "$private/VoteDraft",
|
|
176
|
+
"permission": "questions:write"
|
|
177
|
+
},
|
|
178
|
+
"task.archive": {
|
|
179
|
+
"fields": [],
|
|
180
|
+
"module": "Project/ProjectTask",
|
|
181
|
+
"permission": "tasks:write"
|
|
182
|
+
},
|
|
183
|
+
"task.create": {
|
|
184
|
+
"create": true,
|
|
185
|
+
"fields": [
|
|
186
|
+
"title",
|
|
187
|
+
"description",
|
|
188
|
+
"type",
|
|
189
|
+
"owner",
|
|
190
|
+
"status",
|
|
191
|
+
"enableDueDate",
|
|
192
|
+
"dueDate",
|
|
193
|
+
"estimateDays",
|
|
194
|
+
"quantity",
|
|
195
|
+
"quantityUnit",
|
|
196
|
+
"autoArchive",
|
|
197
|
+
"autoArchiveDays",
|
|
198
|
+
"autoArchiveHours",
|
|
199
|
+
"dependencies",
|
|
200
|
+
"tags"
|
|
201
|
+
],
|
|
202
|
+
"module": "Project/ProjectTask",
|
|
203
|
+
"permission": "tasks:write"
|
|
204
|
+
},
|
|
205
|
+
"task.report": {
|
|
206
|
+
"fields": [
|
|
207
|
+
"phase",
|
|
208
|
+
"summary",
|
|
209
|
+
"nextSteps",
|
|
210
|
+
"evidence",
|
|
211
|
+
"mentions"
|
|
212
|
+
],
|
|
213
|
+
"module": "Project/ProjectTask",
|
|
214
|
+
"permission": "tasks:write"
|
|
215
|
+
},
|
|
216
|
+
"task.restore": {
|
|
217
|
+
"fields": [],
|
|
218
|
+
"module": "Project/ProjectTask",
|
|
219
|
+
"permission": "tasks:write"
|
|
220
|
+
},
|
|
221
|
+
"task.update": {
|
|
222
|
+
"fields": [
|
|
223
|
+
"title",
|
|
224
|
+
"description",
|
|
225
|
+
"type",
|
|
226
|
+
"owner",
|
|
227
|
+
"status",
|
|
228
|
+
"enableDueDate",
|
|
229
|
+
"dueDate",
|
|
230
|
+
"estimateDays",
|
|
231
|
+
"quantity",
|
|
232
|
+
"quantityUnit",
|
|
233
|
+
"autoArchive",
|
|
234
|
+
"autoArchiveDays",
|
|
235
|
+
"autoArchiveHours",
|
|
236
|
+
"dependencies",
|
|
237
|
+
"tags"
|
|
238
|
+
],
|
|
239
|
+
"module": "Project/ProjectTask",
|
|
240
|
+
"permission": "tasks:write"
|
|
241
|
+
},
|
|
242
|
+
"ticket.create": {
|
|
243
|
+
"create": true,
|
|
244
|
+
"fields": [
|
|
245
|
+
"title",
|
|
246
|
+
"description",
|
|
247
|
+
"kind",
|
|
248
|
+
"placement",
|
|
249
|
+
"sprint",
|
|
250
|
+
"parent",
|
|
251
|
+
"details",
|
|
252
|
+
"planning"
|
|
253
|
+
],
|
|
254
|
+
"module": "Project/ProjectTask",
|
|
255
|
+
"permission": "tasks:write"
|
|
256
|
+
},
|
|
257
|
+
"ticket.plan": {
|
|
258
|
+
"fields": [
|
|
259
|
+
"changes"
|
|
260
|
+
],
|
|
261
|
+
"module": "Project/$",
|
|
262
|
+
"permission": "tasks:write"
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
"changes": {
|
|
266
|
+
"pollIntervalSeconds": 5,
|
|
267
|
+
"maxWatchSeconds": 60
|
|
268
|
+
},
|
|
269
|
+
"grants": {
|
|
270
|
+
"permissions": [
|
|
271
|
+
"projects:read",
|
|
272
|
+
"tasks:read",
|
|
273
|
+
"tasks:write",
|
|
274
|
+
"comments:write",
|
|
275
|
+
"questions:write",
|
|
276
|
+
"approvals:write",
|
|
277
|
+
"goals:read",
|
|
278
|
+
"goals:write",
|
|
279
|
+
"memory:read",
|
|
280
|
+
"memory:write",
|
|
281
|
+
"media:read",
|
|
282
|
+
"media:write"
|
|
283
|
+
],
|
|
284
|
+
"maxResources": 200,
|
|
285
|
+
"maxProfilesPerConnection": 20
|
|
286
|
+
}
|
|
287
|
+
};
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import * as z from 'zod/v4';
|
|
6
|
+
import catalog from './command-catalog.mjs';
|
|
7
|
+
import { CredentialStore } from './store.mjs';
|
|
8
|
+
import { AgentError, fail, safeError } from './errors.mjs';
|
|
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();
|
|
18
|
+
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);
|
|
21
|
+
const checked = (schema, value) => {
|
|
22
|
+
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.');
|
|
24
|
+
return parsed.data;
|
|
25
|
+
};
|
|
26
|
+
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.');
|
|
32
|
+
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.');
|
|
36
|
+
return command;
|
|
37
|
+
}
|
|
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.' }; }
|
|
40
|
+
export async function readCommandFile(path) {
|
|
41
|
+
if (typeof path !== 'string') fail('invalid_arguments', 'Supply --file with a JSON command or draft.');
|
|
42
|
+
let handle;
|
|
43
|
+
try {
|
|
44
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
45
|
+
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.');
|
|
49
|
+
return JSON.parse(bytes.subarray(0, result.bytesRead).toString('utf8'));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof AgentError) throw error;
|
|
52
|
+
fail('invalid_arguments', 'Could not read a regular, valid JSON command file.');
|
|
53
|
+
} finally { await handle?.close(); }
|
|
54
|
+
}
|
|
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();
|
|
60
|
+
|
|
61
|
+
/** A journal is bound to one authenticated terminal identity. Persist the full
|
|
62
|
+
* request before the network call, never regenerate a UUID after a timeout.
|
|
63
|
+
* It contains private project text but no access/refresh tokens. A process crash
|
|
64
|
+
* leaves its operation lock for explicit recovery after all clients stop. */
|
|
65
|
+
export class CommandJournal {
|
|
66
|
+
constructor(client) { this.client = client; }
|
|
67
|
+
store(id) {
|
|
68
|
+
checked(operationIdSchema, id);
|
|
69
|
+
return new CredentialStore({ directory: join(this.client.store.directory, 'operations', this.client.store.context), context: id, maxBytes: 524288 });
|
|
70
|
+
}
|
|
71
|
+
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);
|
|
75
|
+
return { operationId: input.operationId, state: 'prepared', request: input };
|
|
76
|
+
}
|
|
77
|
+
async inspect(id) {
|
|
78
|
+
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 } : {}) };
|
|
81
|
+
}
|
|
82
|
+
async execute(value) {
|
|
83
|
+
const input = validateCommand(value), store = this.store(input.operationId);
|
|
84
|
+
return store.locked(async () => {
|
|
85
|
+
// Capture token and identity together under the refresh lock. A later
|
|
86
|
+
// logout/login cannot turn this pending operation into another profile.
|
|
87
|
+
const session = await this.client.authorizedSession(), identity = binding(session);
|
|
88
|
+
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 };
|
|
94
|
+
row = { formatVersion: 1, binding: identity, input, state: 'uncertain' };
|
|
95
|
+
await store.write(row);
|
|
96
|
+
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.' });
|
|
100
|
+
await store.write({ ...row, state: 'confirmed', receipt });
|
|
101
|
+
return { source: 'server', receipt };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
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 });
|
|
106
|
+
// The uncertainty marker is already durable. Preserve the useful original
|
|
107
|
+
// error/operation ID even if disk pressure prevents saving diagnostics.
|
|
108
|
+
await store.write({ ...row, error: result.toJSON() }).catch(() => {});
|
|
109
|
+
throw result;
|
|
110
|
+
}
|
|
111
|
+
}, this.client.transport.signal);
|
|
112
|
+
}
|
|
113
|
+
async retry(id) { return this.execute((await this.inspect(id)).request); }
|
|
114
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Never forward raw network exceptions: URLs, HTML and provider diagnostics
|
|
2
|
+
* can contain credentials. API messages are data, never terminal control. */
|
|
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;
|
|
7
|
+
}
|
|
8
|
+
toJSON() { return { code: this.code, message: this.message, retryable: this.retryable, remediation: this.remediation, ...this.details }; }
|
|
9
|
+
}
|
|
10
|
+
export const fail = (code, message, options) => { throw new AgentError(code, message, options); };
|
|
11
|
+
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.' });
|
|
13
|
+
}
|
|
14
|
+
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;
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { credentials } from './client.mjs';
|
|
5
|
+
import { fail, AgentError } from './errors.mjs';
|
|
6
|
+
import { CLIENT_ID, ORIGINS } from './transport.mjs';
|
|
7
|
+
|
|
8
|
+
// Fixed copy only: callback codes, state and private account data never enter HTML.
|
|
9
|
+
function callbackPage(response, approved) {
|
|
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>`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loginResult(session) { return { connected: true, origin: session.origin, connectionId: session.connection_id, profileId: session.profile_id, sessionId: session.session_id }; }
|
|
16
|
+
async function saveLogin(client, exchange) {
|
|
17
|
+
// Hold the context lock throughout consent so two terminals cannot overwrite
|
|
18
|
+
// independent connections and leave an inaccessible active session behind.
|
|
19
|
+
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.' }); }
|
|
26
|
+
fail('credential_save_failed', 'Local storage failed. The new server session was revoked.');
|
|
27
|
+
}
|
|
28
|
+
return loginResult(session);
|
|
29
|
+
}, client.transport.signal);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Consent URLs are server data. Restrict automatic navigation to the product
|
|
33
|
+
* app paired with this API (or the explicitly opted-in local development app). */
|
|
34
|
+
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)))
|
|
40
|
+
fail('invalid_response', 'The consent URL is not a trusted GeniusTasker application.');
|
|
41
|
+
return url.href;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deviceLogin(client, { scope, notify, openBrowser, wait = delay, now = Date.now } = {}) {
|
|
45
|
+
return saveLogin(client, async () => {
|
|
46
|
+
const startedAt = now();
|
|
47
|
+
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)
|
|
52
|
+
fail('invalid_response', 'The device authorization response was invalid.');
|
|
53
|
+
const url = consentUrl(device.verification_uri_complete, client.transport.origin);
|
|
54
|
+
notify?.({ event: 'consent_required', url, userCode: device.user_code, expiresIn: device.expires_in });
|
|
55
|
+
await openBrowser?.(url);
|
|
56
|
+
const deadline = startedAt + device.expires_in * 1000; let interval = device.interval;
|
|
57
|
+
while (now() + interval * 1000 < deadline) {
|
|
58
|
+
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; }
|
|
64
|
+
// Rate limits are explicit; no endless polling or accidental login loop.
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
fail('expired_token', 'The authorization request expired.', { remediation: 'Start auth login again and confirm it in the application.' });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function browserLogin(client, { scope, notify, openBrowser, lifetimeMs = 600000 } = {}) {
|
|
73
|
+
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; });
|
|
77
|
+
// Attach immediately so abort during navigation cannot become unhandled.
|
|
78
|
+
codePromise.catch(() => {});
|
|
79
|
+
const server = createServer((request, response) => {
|
|
80
|
+
response.setHeader('Cache-Control', 'no-store'); response.setHeader('Referrer-Policy', 'no-referrer');
|
|
81
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
|
|
82
|
+
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; }
|
|
84
|
+
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;
|
|
89
|
+
}
|
|
90
|
+
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);
|
|
94
|
+
});
|
|
95
|
+
const abort = () => rejectCode(new AgentError('login_cancelled', 'Login was cancelled.'));
|
|
96
|
+
let timer;
|
|
97
|
+
try {
|
|
98
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
99
|
+
redirect = `http://127.0.0.1:${server.address().port}/callback`;
|
|
100
|
+
timer = setTimeout(() => rejectCode(new AgentError('expired_token', 'The authorization request expired.')), lifetimeMs);
|
|
101
|
+
client.transport.signal?.addEventListener('abort', abort, { once: true });
|
|
102
|
+
if (client.transport.signal?.aborted) abort();
|
|
103
|
+
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();
|
|
106
|
+
notify?.({ event: 'consent_required', url: url.href, expiresIn: lifetimeMs / 1000 });
|
|
107
|
+
await openBrowser?.(url.href);
|
|
108
|
+
const code = await codePromise;
|
|
109
|
+
return await client.transport.oauth('token', { grant_type: 'authorization_code', code, redirect_uri: redirect, code_verifier: verifier });
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timer); client.transport.signal?.removeEventListener('abort', abort);
|
|
112
|
+
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|