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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.mjs CHANGED
@@ -1,34 +1,94 @@
1
1
  import { fail } from './errors.mjs';
2
+ import { createHash } from 'node:crypto';
3
+ import { ConditionalReadCache } from './read-cache.mjs';
2
4
 
5
+ /** Validate a token response before it can replace an origin-bound local session. */
3
6
  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 };
7
+ const token = (value, kind) =>
8
+ typeof value === 'string' &&
9
+ new RegExp(`^gta1\\.[a-f0-9]{64}\\.wd${kind}_[A-Za-z0-9_-]{43}$`).test(value);
10
+ if (
11
+ !token(result?.access_token, 'a') ||
12
+ !token(result?.refresh_token, 'r') ||
13
+ result.token_type !== 'Bearer' ||
14
+ !Number.isSafeInteger(result.expires_in) ||
15
+ result.expires_in < 1 ||
16
+ result.expires_in > 3600 ||
17
+ ['connection_id', 'profile_id', 'session_id'].some(
18
+ (key) => typeof result[key] !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(result[key]),
19
+ )
20
+ ) {
21
+ fail('invalid_response', 'The authorization response was invalid.', {
22
+ remediation: 'Revoke the new session in Account → Agents and retry login.',
23
+ });
24
+ }
25
+ return {
26
+ formatVersion: 1,
27
+ origin,
28
+ clientId: 'geniustasker-cli',
29
+ access_token: result.access_token,
30
+ refresh_token: result.refresh_token,
31
+ token_type: 'Bearer',
32
+ expires_in: result.expires_in,
33
+ connection_id: result.connection_id,
34
+ profile_id: result.profile_id,
35
+ session_id: result.session_id,
36
+ expiresAt: now + result.expires_in * 1000,
37
+ refreshPending: false,
38
+ };
13
39
  }
14
40
 
41
+ /**
42
+ * Shared delegated client for CLI and MCP operations.
43
+ * Renewal and profile selection serialize on the context lock; domain writes
44
+ * use CommandJournal so their identity and immutable request survive failures.
45
+ */
15
46
  export class AgentClient {
16
- constructor(transport, store) { this.transport = transport; this.store = store; }
47
+ constructor(transport, store) {
48
+ this.transport = transport;
49
+ this.store = store;
50
+ this.readCache = new ConditionalReadCache();
51
+ this.readRealm = null;
52
+ }
53
+ invalidateReads() {
54
+ this.readCache.clear();
55
+ }
17
56
  async session() {
18
57
  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.' });
58
+ if (!session) {
59
+ fail('not_authenticated', 'This context is not connected.', {
60
+ remediation: 'Run auth login with this context and environment.',
61
+ });
62
+ }
63
+ if (
64
+ session.formatVersion !== 1 ||
65
+ session.origin !== this.transport.origin ||
66
+ session.clientId !== 'geniustasker-cli'
67
+ ) {
68
+ fail('credential_origin_mismatch', 'This context belongs to another API environment.', {
69
+ remediation:
70
+ 'Select its original environment, or use a different --context for the new environment.',
71
+ });
72
+ }
22
73
  // Validate private storage before ever sending any token to the network.
23
74
  credentials(session, session.origin);
24
- if (!Number.isSafeInteger(session.expiresAt)) fail('invalid_credential_store', 'The session expiry is invalid.');
75
+ if (!Number.isSafeInteger(session.expiresAt)) {
76
+ fail('invalid_credential_store', 'The session expiry is invalid.');
77
+ }
25
78
  return session;
26
79
  }
27
80
  async authorizedSession() {
28
81
  return this.store.locked(async () => {
29
82
  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;
83
+ if (session.refreshPending) {
84
+ fail('reauthentication_required', 'The previous renewal outcome is unknown.', {
85
+ remediation:
86
+ 'Run auth logout, then auth login. Do not replay the previous refresh token.',
87
+ });
88
+ }
89
+ if (session.expiresAt > Date.now() + 30000) {
90
+ return session;
91
+ }
32
92
  return this.renewLocked(session);
33
93
  }, this.transport.signal);
34
94
  }
@@ -36,60 +96,167 @@ export class AgentClient {
36
96
  * access and refresh together so an in-flight old command cannot acquire the
37
97
  * new profile's identity. It never extends the server's absolute lifetime. */
38
98
  async renewLocked(session, selection) {
99
+ this.invalidateReads();
39
100
  await this.store.write({ ...session, refreshPending: true });
40
101
  let result;
41
102
  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) } : {}) });
103
+ result = await this.transport.oauth('token', {
104
+ grant_type: 'refresh_token',
105
+ refresh_token: session.refresh_token,
106
+ ...(selection
107
+ ? {
108
+ profile_id: selection.profileId,
109
+ profile_revision: String(selection.expectedRevision),
110
+ source_revision: String(selection.expectedSourceRevision),
111
+ }
112
+ : {}),
113
+ });
44
114
  } catch (error) {
45
115
  // These two server outcomes happen before token consumption/rotation.
46
116
  // 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)) {
117
+ if (
118
+ selection &&
119
+ error?.status === 400 &&
120
+ ['profile_selection_conflict', 'profile_selection_denied'].includes(error.code)
121
+ ) {
48
122
  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.';
123
+ error.remediation =
124
+ 'List profiles again. Only a human can approve broader access; a changed profile requires a fresh selection.';
50
125
  }
51
126
  throw error;
52
127
  }
53
128
  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)
129
+ if (
130
+ next.connection_id !== session.connection_id ||
131
+ next.profile_id !== (selection?.profileId ?? session.profile_id) ||
132
+ next.session_id !== session.session_id
133
+ ) {
55
134
  fail('invalid_response', 'Renewal returned an unexpected identity.');
135
+ }
56
136
  await this.store.write(next);
57
137
  return next;
58
138
  }
59
139
  async selectProfile(selection) {
60
140
  return this.store.locked(async () => {
61
141
  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.' });
142
+ if (session.refreshPending) {
143
+ fail('reauthentication_required', 'The previous renewal outcome is unknown.', {
144
+ remediation:
145
+ 'Run auth logout, then auth login; do not replay the previous refresh token.',
146
+ });
147
+ }
148
+ if (
149
+ selection.sourceProfileId !== session.profile_id ||
150
+ selection.connectionId !== session.connection_id
151
+ ) {
152
+ fail('profile_selection_conflict', 'Another process changed this context.', {
153
+ status: 409,
154
+ remediation:
155
+ 'Read profiles again. Use separate --context names for independent terminals.',
156
+ });
157
+ }
65
158
  const next = await this.renewLocked(session, selection);
66
- return { connected: true, connectionId: next.connection_id, profileId: next.profile_id, sessionId: next.session_id };
159
+ return {
160
+ connected: true,
161
+ connectionId: next.connection_id,
162
+ profileId: next.profile_id,
163
+ sessionId: next.session_id,
164
+ };
67
165
  }, this.transport.signal);
68
166
  }
69
- async accessToken() { return (await this.authorizedSession()).access_token; }
167
+ async accessToken() {
168
+ return (await this.authorizedSession()).access_token;
169
+ }
70
170
  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();
171
+ if (
172
+ ![
173
+ 'me',
174
+ 'capabilities',
175
+ 'projects',
176
+ 'memories',
177
+ 'resources',
178
+ 'profiles',
179
+ 'answers',
180
+ 'changes',
181
+ 'knowledge',
182
+ 'reference',
183
+ ].includes(resource)
184
+ ) {
185
+ fail('invalid_arguments', 'Unknown delegated resource.');
186
+ }
187
+ let token;
188
+ try {
189
+ token = await this.accessToken();
190
+ } catch (error) {
191
+ this.invalidateReads();
192
+ throw error;
193
+ }
194
+ const realm = createHash('sha256').update(`${this.transport.origin}\n${token}`).digest('hex');
195
+ if (realm !== this.readRealm) {
196
+ this.invalidateReads();
197
+ this.readRealm = realm;
198
+ }
199
+ if (resource === 'resources') {
200
+ if (query && Object.hasOwn(query, 'ifNoneMatch')) {
201
+ fail('invalid_arguments', 'Resource validators are managed by the client.');
202
+ }
203
+ const normalized = Object.entries(query ?? {})
204
+ .filter(([, value]) => value !== undefined)
205
+ .sort(([a], [b]) => a.localeCompare(b));
206
+ const key = JSON.stringify([realm, resource, normalized]);
207
+ return this.readCache.read(key, async (etag) => {
208
+ const result = await this.transport.request('/api/agents/v1/resources', {
209
+ token,
210
+ query: { ...query, ...(etag ? { ifNoneMatch: etag } : {}) },
211
+ });
212
+ if (
213
+ result?.scope !== query?.scope ||
214
+ result?.collection !== query?.collection ||
215
+ !Array.isArray(result?.items)
216
+ ) {
217
+ fail('invalid_response', 'The resource response did not match the requested collection.');
218
+ }
219
+ return result;
220
+ });
221
+ }
73
222
  // No blanket retries, no automatic writes. Revocation and ACL errors remain
74
223
  // visible to the agent. A caller chooses how to recover using the envelope.
75
- return this.transport.request(`/api/agents/v1/${resource}`, { token, query });
224
+ try {
225
+ return await this.transport.request(`/api/agents/v1/${resource}`, { token, query });
226
+ } catch (error) {
227
+ this.invalidateReads();
228
+ throw error;
229
+ }
76
230
  }
77
231
  async logout() {
232
+ this.invalidateReads();
78
233
  return this.store.locked(async () => {
79
- if (!await this.store.read()) return { connected: false, alreadyDisconnected: true };
234
+ if (!(await this.store.read())) {
235
+ return { connected: false, alreadyDisconnected: true };
236
+ }
80
237
  const session = await this.session();
81
238
  // Keep credentials on failure so revocation can be retried. A used refresh
82
239
  // 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' });
240
+ await this.transport.oauth('revoke', {
241
+ token: session.refresh_token,
242
+ token_type_hint: 'refresh_token',
243
+ });
84
244
  await this.store.remove();
85
245
  return { connected: false, revoked: true };
86
246
  }, this.transport.signal);
87
247
  }
88
248
  async doctor() {
89
249
  const session = await this.store.read();
90
- return { node: process.versions.node, origin: this.transport.origin, connectedLocally: Boolean(session),
250
+ return {
251
+ node: process.versions.node,
252
+ origin: this.transport.origin,
253
+ connectedLocally: Boolean(session),
91
254
  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.' };
255
+ needsLogin: session ? session.refreshPending === true : true,
256
+ storage: 'private OS-user file',
257
+ publication:
258
+ 'CLI installation does not grant service access; this beta requires an admitted account.',
259
+ writes: 'Discover server capabilities before attempting changes.',
260
+ };
94
261
  }
95
262
  }
@@ -1,6 +1,6 @@
1
1
  // Generated from contracts/agents.tsp. Do not edit by hand.
2
2
  export default {
3
- "maxCommandBytes": 32768,
3
+ "maxCommandBytes": 131072,
4
4
  "commands": {
5
5
  "approval.cancel": {
6
6
  "fields": [
@@ -97,11 +97,13 @@ export default {
97
97
  "memory.create": {
98
98
  "create": true,
99
99
  "fields": [
100
+ "contentRoles",
100
101
  "title",
101
102
  "productKey",
102
103
  "summary",
103
104
  "instructions",
104
- "color"
105
+ "color",
106
+ "kind"
105
107
  ],
106
108
  "module": "Memory/$",
107
109
  "permission": "memory:write"
@@ -112,15 +114,104 @@ export default {
112
114
  "module": "Memory/$",
113
115
  "permission": "memory:write"
114
116
  },
117
+ "memory.edge.create": {
118
+ "create": true,
119
+ "fields": [
120
+ "source",
121
+ "target",
122
+ "relation",
123
+ "label",
124
+ "note",
125
+ "certainty",
126
+ "references",
127
+ "provenance"
128
+ ],
129
+ "module": "Memory/MemoryEdge",
130
+ "permission": "memory:write"
131
+ },
132
+ "memory.edge.delete": {
133
+ "delete": true,
134
+ "fields": [],
135
+ "module": "Memory/MemoryEdge",
136
+ "permission": "memory:write"
137
+ },
138
+ "memory.edge.update": {
139
+ "fields": [
140
+ "source",
141
+ "target",
142
+ "relation",
143
+ "label",
144
+ "note",
145
+ "certainty",
146
+ "references",
147
+ "provenance"
148
+ ],
149
+ "module": "Memory/MemoryEdge",
150
+ "permission": "memory:write"
151
+ },
152
+ "memory.node.create": {
153
+ "create": true,
154
+ "fields": [
155
+ "imageAssetId",
156
+ "detailLevel",
157
+ "title",
158
+ "summary",
159
+ "text",
160
+ "kind",
161
+ "authority",
162
+ "certainty",
163
+ "tags",
164
+ "references",
165
+ "sourceId",
166
+ "provenance"
167
+ ],
168
+ "module": "Memory/MemoryNode",
169
+ "permission": "memory:write"
170
+ },
171
+ "memory.node.delete": {
172
+ "delete": true,
173
+ "fields": [],
174
+ "module": "Memory/MemoryNode",
175
+ "permission": "memory:write"
176
+ },
177
+ "memory.node.update": {
178
+ "fields": [
179
+ "imageAssetId",
180
+ "detailLevel",
181
+ "title",
182
+ "summary",
183
+ "text",
184
+ "kind",
185
+ "authority",
186
+ "certainty",
187
+ "tags",
188
+ "references",
189
+ "sourceId",
190
+ "provenance"
191
+ ],
192
+ "module": "Memory/MemoryNode",
193
+ "permission": "memory:write"
194
+ },
115
195
  "memory.page.create": {
116
196
  "create": true,
117
197
  "fields": [
198
+ "visibility",
199
+ "readRoles",
118
200
  "title",
119
201
  "path",
120
202
  "summary",
121
203
  "blocks",
122
204
  "tags",
123
- "provenance"
205
+ "provenance",
206
+ "sourceId",
207
+ "parentId",
208
+ "directory",
209
+ "order",
210
+ "kind",
211
+ "state",
212
+ "authority",
213
+ "section",
214
+ "metadataJson"
124
215
  ],
125
216
  "module": "Memory/MemoryPage",
126
217
  "permission": "memory:write"
@@ -133,23 +224,36 @@ export default {
133
224
  },
134
225
  "memory.page.update": {
135
226
  "fields": [
227
+ "visibility",
228
+ "readRoles",
136
229
  "title",
137
230
  "path",
138
231
  "summary",
139
232
  "blocks",
140
233
  "tags",
141
- "provenance"
234
+ "provenance",
235
+ "sourceId",
236
+ "parentId",
237
+ "directory",
238
+ "order",
239
+ "kind",
240
+ "state",
241
+ "authority",
242
+ "section",
243
+ "metadataJson"
142
244
  ],
143
245
  "module": "Memory/MemoryPage",
144
246
  "permission": "memory:write"
145
247
  },
146
248
  "memory.update": {
147
249
  "fields": [
250
+ "contentRoles",
148
251
  "title",
149
252
  "productKey",
150
253
  "summary",
151
254
  "instructions",
152
- "color"
255
+ "color",
256
+ "kind"
153
257
  ],
154
258
  "module": "Memory/$",
155
259
  "permission": "memory:write"