@dev-mainsequence/codex-plugin 0.3.0 → 0.4.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.
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateExpectedAccount = validateExpectedAccount;
4
+ exports.bindSessionAccount = bindSessionAccount;
5
+ const models_1 = require("./models.js");
6
+ function validateExpectedAccount(value) {
7
+ if (typeof value !== 'string' || !value.trim() || value.length > 300 || /[\r\n\0]/.test(value)) {
8
+ throw new models_1.ToolingError('INVALID_REQUEST', 'Use the account returned by verified session status.');
9
+ }
10
+ }
11
+ /** Opt-in account scope for domain operations; never silently weaken a custom transport. */
12
+ async function bindSessionAccount(session, backend, expectedAccount) {
13
+ if (expectedAccount === undefined)
14
+ return session;
15
+ validateExpectedAccount(expectedAccount);
16
+ if (!session.withAccount)
17
+ throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'This session transport does not support account-bound operations. Upgrade its session implementation.');
18
+ return session.withAccount(backend, expectedAccount);
19
+ }
@@ -10,6 +10,7 @@ const repositories_1 = require("./repositories.js");
10
10
  const projects_1 = require("./projects.js");
11
11
  const organizationProjects_1 = require("./organizationProjects.js");
12
12
  const organizationProjectWorkspace_1 = require("./organizationProjectWorkspace.js");
13
+ const accountScope_1 = require("./accountScope.js");
13
14
  function createDevTools(options) {
14
15
  const { host, session: suppliedSession, allowMcpSkills, allowLegacyProjectCredentials, mcpConnectionsPath, ...sessionOptions } = options;
15
16
  const session = suppliedSession ?? new session_1.MachineSessionService(host, sessionOptions);
@@ -23,7 +24,8 @@ function createDevTools(options) {
23
24
  if (action.startsWith('organization-')) {
24
25
  const input = request;
25
26
  const backend = await (0, repositories_1.resolveBackend)(request.backend, undefined, env);
26
- const client = new organizationProjects_1.OrganizationProjectClient(session, backend, options.signal);
27
+ const scoped = await (0, accountScope_1.bindSessionAccount)(session, backend, input.expectedAccount);
28
+ const client = new organizationProjects_1.OrganizationProjectClient(scoped, backend, options.signal);
27
29
  let data;
28
30
  if (input.action === 'organization-environments')
29
31
  data = await client.environments();
@@ -33,14 +35,18 @@ function createDevTools(options) {
33
35
  data = await client.getProject(input.environmentUid, input.projectUid);
34
36
  else if (input.action === 'organization-project-link') {
35
37
  const url = await client.link(input);
38
+ if (input.expectedAccount !== undefined)
39
+ await scoped.status(backend, true);
36
40
  if (!await host.openBrowser(url))
37
41
  throw new models_1.ToolingError('BROWSER_FAILED', 'The resource link could not open.');
38
42
  data = { url };
39
43
  }
40
44
  else if (input.action === 'organization-project-setup')
41
- data = await (0, organizationProjectWorkspace_1.setupOrganizationProject)({ ...input, backend }, { host, session, env, signal: options.signal, state });
45
+ data = await (0, organizationProjectWorkspace_1.setupOrganizationProject)({ ...input, backend }, { host, session: scoped, env, signal: options.signal, state });
42
46
  else
43
47
  throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown organization project action.');
48
+ if (input.expectedAccount !== undefined)
49
+ await scoped.status(backend, true);
44
50
  return safeResult({ schemaVersion: 1, action, status: 'success', summary: input.action === 'organization-project-setup' ? 'Project checkouts are prepared on their assigned branches. Claude must attach them when its workspace session starts.' : 'Projects loaded.', data });
45
51
  }
46
52
  if ((0, agents_1.isAgentAction)(action)) {
@@ -49,7 +55,9 @@ function createDevTools(options) {
49
55
  }
50
56
  if (action.startsWith('mcp-')) {
51
57
  const backend = await (0, repositories_1.resolveBackend)(request.backend, process.cwd(), env);
52
- return safeResult(await new mcp_1.McpConnections(session, { connectionsPath: mcpConnectionsPath, env, fetch: options.fetch, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs }).dispatch(request, backend));
58
+ const input = request, scoped = await (0, accountScope_1.bindSessionAccount)(session, backend, input.expectedAccount);
59
+ return safeResult(await new mcp_1.McpConnections(scoped, { connectionsPath: mcpConnectionsPath, env, fetch: options.fetch, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs,
60
+ checkAccount: input.expectedAccount === undefined ? undefined : () => scoped.status(backend, true).then(() => { }) }).dispatch(input, backend));
53
61
  }
54
62
  let data;
55
63
  if (['login', 'logout', 'refresh-token', 'auth-status', 'repositories'].includes(action)) {
@@ -54,12 +54,16 @@ class AgentClient {
54
54
  if (['agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) {
55
55
  throw new models_1.ToolingError('INVALID_REQUEST', 'Agent conversations must be bound to the account returned by discovery.');
56
56
  }
57
+ // The built-in session binds every request/retry to the selected account. Legacy
58
+ // custom agent transports retain their existing account-check contract.
59
+ const session = input.expectedAccount !== undefined && this.session.withAccount
60
+ ? await this.session.withAccount(backend, input.expectedAccount) : this.session;
57
61
  const timeouts = new Map();
58
62
  const client = new mcpTransport_1.McpTransport(message => {
59
63
  const remaining = readinessDeadline === undefined ? undefined : Math.ceil(readinessDeadline - performance.now());
60
64
  if (remaining !== undefined && remaining <= 0)
61
65
  throw new models_1.ToolingError('PROCESS_TIMEOUT', 'The agent did not become ready in time. No message was sent. Try again.');
62
- return this.session.request(backend, 'POST', '/mcp', message, message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes(message.params?.name), remaining !== undefined ? { timeoutMs: Math.min(30000, remaining) }
66
+ return session.request(backend, 'POST', '/mcp', message, message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes(message.params?.name), remaining !== undefined ? { timeoutMs: Math.min(30000, remaining) }
63
67
  : message.method === 'tools/call' && timeouts.has(message.params?.name) ? { timeoutMs: timeouts.get(message.params.name) } : undefined);
64
68
  });
65
69
  await client.initialize();
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listEnvironments = listEnvironments;
4
+ const models_1 = require("./models.js");
5
+ const session_1 = require("./session.js");
6
+ /** One bounded directory reader for all hosts and domain clients. No settings writes. */
7
+ async function listEnvironments(session, inputBackend, signal) {
8
+ const backend = (0, session_1.requireBackend)(inputBackend), items = [], seen = new Set();
9
+ let expectedCount;
10
+ for (let offset = 0;;) {
11
+ signal?.throwIfAborted();
12
+ const page = await session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`);
13
+ signal?.throwIfAborted();
14
+ const rows = Array.isArray(page) ? page : page?.results;
15
+ const count = Array.isArray(page) ? page.length : page?.count;
16
+ if (!Array.isArray(rows) || !Number.isSafeInteger(count) || count < 0 || count > 10_000 || offset + rows.length > count
17
+ || (expectedCount !== undefined && expectedCount !== count) || (offset > 0 && Array.isArray(page))) {
18
+ throw new models_1.ToolingError('API_ERROR', 'The environment pagination is invalid or changed. Refresh the directory.');
19
+ }
20
+ expectedCount = count;
21
+ for (const row of rows) {
22
+ if (typeof row?.uid !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(row.uid)
23
+ || typeof row.name !== 'string' || !row.name.trim() || /[\r\n\0]/.test(row.name)) {
24
+ throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid organization environment.');
25
+ }
26
+ const uid = row.uid.toLowerCase();
27
+ if (seen.has(uid))
28
+ throw new models_1.ToolingError('API_ERROR', 'Environment pagination returned duplicate rows.');
29
+ seen.add(uid);
30
+ items.push({ uid, name: row.name.trim(), isProduction: row.is_production === true });
31
+ }
32
+ offset += rows.length;
33
+ if (offset === count)
34
+ return items;
35
+ if (!rows.length)
36
+ throw new models_1.ToolingError('API_ERROR', 'Environment pagination stopped before completion.');
37
+ }
38
+ }
@@ -41,12 +41,22 @@ const node_crypto_1 = require("node:crypto");
41
41
  const models_1 = require("./models.js");
42
42
  const session_1 = require("./session.js");
43
43
  const mcpTransport_1 = require("./mcpTransport.js");
44
+ const repositoryModels_1 = require("./repositoryModels.js");
45
+ const environments_1 = require("./environments.js");
46
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
47
  function uuid(value) {
45
- if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
48
+ if (typeof value !== 'string' || !UUID.test(value)) {
46
49
  throw new models_1.ToolingError('INVALID_REQUEST', 'Select an exact resource release UUID from MCP discovery or saved connections.');
47
50
  }
48
51
  return value.toLowerCase();
49
52
  }
53
+ /** Optional labels: an absent or malformed value is dropped, never an error. */
54
+ function labels(row) {
55
+ const text = (value) => typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value) ? value.trim() : undefined;
56
+ const name = text(row?.name), display = text(row?.display_name);
57
+ const branch = typeof row?.code_repository_branch_uid === 'string' && UUID.test(row.code_repository_branch_uid) ? row.code_repository_branch_uid.toLowerCase() : undefined;
58
+ return { ...(name ? { name } : {}), ...(display ? { display_name: display } : {}), ...(branch ? { code_repository_branch_uid: branch } : {}) };
59
+ }
50
60
  function endpoint(value, metadata = false) {
51
61
  if (typeof value !== 'string')
52
62
  throw new models_1.ToolingError('API_ERROR', 'The backend returned no MCP endpoint.');
@@ -76,8 +86,7 @@ function connection(backend, row) {
76
86
  if ([...metadataQuery.keys()].some(key => key !== 'resource_release_uid') || metadataQuery.getAll('resource_release_uid').length !== 1
77
87
  || metadataQuery.get('resource_release_uid') !== releaseUid)
78
88
  throw new models_1.ToolingError('API_ERROR', 'The MCP metadata targets another release.');
79
- return { backend: (0, session_1.requireBackend)(backend), resource_release_uid: releaseUid,
80
- ...(typeof row.name === 'string' && row.name.trim() ? { name: row.name } : {}),
89
+ return { backend: (0, session_1.requireBackend)(backend), resource_release_uid: releaseUid, ...labels(row),
81
90
  mcp_connection: { url, transport: 'streamable-http', resource_metadata_url: metadataUrl,
82
91
  organization_environment_uid: uuid(c.organization_environment_uid),
83
92
  ...(typeof c.organization_environment_name === 'string' && c.organization_environment_name.trim() ? { organization_environment_name: c.organization_environment_name.trim() } : {}), active_revision_uid: uuid(c.active_revision_uid) } };
@@ -128,6 +137,7 @@ class McpConnections {
128
137
  }
129
138
  async update(change) {
130
139
  this.options.signal?.throwIfAborted();
140
+ await this.options.checkAccount?.();
131
141
  await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
132
142
  const lock = this.file + '.lock', temporary = this.file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
133
143
  let handle;
@@ -174,15 +184,113 @@ class McpConnections {
174
184
  if (new URL(endpoint(grant.rpc_url)).origin !== new URL(value.mcp_connection.url).origin) {
175
185
  throw new models_1.ToolingError('API_ERROR', 'The release credential targets a different endpoint.');
176
186
  }
177
- const client = new mcpTransport_1.McpTransport(message => (0, mcpTransport_1.sendProjectMcp)(value.mcp_connection.url, grant.token, message, this.options));
187
+ const client = new mcpTransport_1.McpTransport(async (message) => {
188
+ await this.options.checkAccount?.();
189
+ const result = await (0, mcpTransport_1.sendProjectMcp)(value.mcp_connection.url, grant.token, message, this.options);
190
+ await this.options.checkAccount?.();
191
+ return result;
192
+ });
178
193
  await client.initialize();
179
194
  return client;
180
195
  }
196
+ /** The release's name and repository from the release directory and its branch. Whatever
197
+ * cannot be read is left out: labels never make a working MCP unavailable. */
198
+ async resolveLabels(value) {
199
+ const found = {};
200
+ const read = async (apiPath) => {
201
+ try {
202
+ return await this.session.request(value.backend, 'GET', apiPath, undefined, true);
203
+ }
204
+ catch {
205
+ this.options.signal?.throwIfAborted();
206
+ return undefined;
207
+ }
208
+ };
209
+ // The release detail carries no name or repository; the directory projection does.
210
+ const page = await read('/api/v1/resource-releases/?search=' + encodeURIComponent(value.resource_release_uid) + '&limit=100');
211
+ const row = Array.isArray(page?.results) ? page.results.find((item) => typeof item?.uid === 'string' && item.uid.toLowerCase() === value.resource_release_uid) : undefined;
212
+ if (row)
213
+ Object.assign(found, labels({ name: row.name, code_repository_branch_uid: row.code_repository_branch_uid }));
214
+ const branchUid = found.code_repository_branch_uid ?? value.code_repository_branch_uid;
215
+ if (branchUid) {
216
+ const branch = await read('/api/v1/code-repository-branches/' + encodeURIComponent(branchUid) + '/');
217
+ try {
218
+ const parsed = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(branch, 'CodeRepositoryBranch response');
219
+ if (parsed.uid.toLowerCase() === branchUid)
220
+ Object.assign(found, labels({ display_name: parsed.code_repository_name, code_repository_branch_uid: branchUid }));
221
+ }
222
+ catch { /* an unreadable branch leaves the repository name out */ }
223
+ }
224
+ return found;
225
+ }
226
+ labelled(value, found) {
227
+ return connection(value.backend, { ...value, ...found });
228
+ }
229
+ /** Optional discovery labels, deduplicated by branch. Never writes connection metadata. */
230
+ async discoveryNames(rows) {
231
+ const pending = [...new Set(rows.filter(row => !row.display_name).flatMap(row => row.code_repository_branch_uid ? [row.code_repository_branch_uid] : []))];
232
+ const names = new Map();
233
+ await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
234
+ while (pending.length) {
235
+ this.options.signal?.throwIfAborted();
236
+ const uid = pending.shift();
237
+ try {
238
+ const raw = await this.session.request(rows[0].backend, 'GET', '/api/v1/code-repository-branches/' + uid + '/');
239
+ const branch = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(raw, 'CodeRepositoryBranch response');
240
+ if (branch.uid.toLowerCase() === uid)
241
+ names.set(uid, branch.code_repository_name);
242
+ }
243
+ catch (error) {
244
+ this.options.signal?.throwIfAborted();
245
+ if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
246
+ throw error;
247
+ await this.options.checkAccount?.();
248
+ // Unavailable optional names must not hide usable endpoints.
249
+ }
250
+ }
251
+ }));
252
+ return rows.map(row => !row.display_name && row.code_repository_branch_uid && names.has(row.code_repository_branch_uid)
253
+ ? this.labelled(row, { display_name: names.get(row.code_repository_branch_uid) }) : row);
254
+ }
255
+ /** Records saved before labels were stored are completed once, when the backend answers. */
256
+ async backfill(rows) {
257
+ const pending = rows.filter(row => !row.display_name);
258
+ if (!pending.length)
259
+ return rows;
260
+ const found = new Map();
261
+ await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
262
+ while (pending.length) {
263
+ const row = pending.shift();
264
+ const resolved = await this.resolveLabels(row);
265
+ const added = Object.entries(resolved).filter(([key, item]) => item !== row[key]);
266
+ if (added.length)
267
+ found.set(row.backend + ' ' + row.resource_release_uid, Object.fromEntries(added));
268
+ }
269
+ }));
270
+ if (!found.size)
271
+ return rows;
272
+ const key = (row) => row.backend + ' ' + row.resource_release_uid;
273
+ const merge = (row) => found.has(key(row)) ? this.labelled(row, { ...found.get(key(row)) }) : row;
274
+ try {
275
+ await this.update(all => all.map(merge));
276
+ }
277
+ catch (error) {
278
+ // A held lock or a failed write keeps the listing itself available.
279
+ if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
280
+ throw error;
281
+ this.options.signal?.throwIfAborted();
282
+ }
283
+ return rows.map(merge);
284
+ }
181
285
  async dispatch(request, inputBackend) {
182
286
  let callStarted = false, resultData;
183
287
  try {
184
288
  const backend = (0, session_1.requireBackend)(inputBackend);
185
289
  this.options.signal?.throwIfAborted();
290
+ if (request.action === 'mcp-list' && request.readOnly !== undefined && typeof request.readOnly !== 'boolean'
291
+ || request.action === 'mcp-discover' && request.enrichNames !== undefined && typeof request.enrichNames !== 'boolean') {
292
+ throw new models_1.ToolingError('INVALID_REQUEST', 'Discovery and listing options must be booleans.');
293
+ }
186
294
  const environmentUid = request.environmentUid === undefined ? undefined : uuid(request.environmentUid);
187
295
  const belongs = (value) => !environmentUid || value.mcp_connection.organization_environment_uid === environmentUid;
188
296
  const requireEnvironment = (value) => {
@@ -191,31 +299,7 @@ class McpConnections {
191
299
  };
192
300
  let data, summary;
193
301
  if (request.action === 'mcp-environments') {
194
- const environments = new Map();
195
- for (let offset = 0;; offset += 100) {
196
- this.options.signal?.throwIfAborted();
197
- if (offset >= 10000)
198
- throw new models_1.ToolingError('API_ERROR', 'The environment list is too large.');
199
- const page = await this.session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`);
200
- const items = Array.isArray(page) ? page : page?.results;
201
- if (!Array.isArray(items))
202
- throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid environment list.');
203
- for (const row of items) {
204
- if (typeof row?.name !== 'string' || !row.name.trim())
205
- throw new models_1.ToolingError('API_ERROR', 'An organization environment has no name.');
206
- const uid = uuid(row.uid);
207
- environments.set(uid, { uid, name: row.name.trim(), isProduction: row.is_production === true });
208
- }
209
- if (Array.isArray(page))
210
- break;
211
- if (!Number.isInteger(page.count) || page.count < 0)
212
- throw new models_1.ToolingError('API_ERROR', 'The environment pagination is invalid.');
213
- if (offset + 100 >= page.count)
214
- break;
215
- if (!items.length)
216
- throw new models_1.ToolingError('API_ERROR', 'The environment directory changed; refresh it.');
217
- }
218
- data = [...environments.values()];
302
+ data = await (0, environments_1.listEnvironments)(this.session, backend, this.options.signal);
219
303
  summary = 'Organization environments available to your MainSequence account.';
220
304
  }
221
305
  else if (request.action === 'mcp-discover') {
@@ -255,10 +339,15 @@ class McpConnections {
255
339
  const values = [...matching.values()], offset = request.offset ?? 0;
256
340
  data = { count: values.length, results: values.slice(offset, offset + (request.limit ?? 100)) };
257
341
  }
342
+ if (request.enrichNames) {
343
+ const page = data;
344
+ data = { ...page, results: await this.discoveryNames(page.results) };
345
+ }
258
346
  summary = 'Available project MCP endpoints; discovery does not save a connection.';
259
347
  }
260
348
  else if (request.action === 'mcp-list') {
261
- data = (await this.read()).filter(row => row.backend === backend && belongs(row));
349
+ const saved = (await this.read()).filter(row => row.backend === backend && belongs(row));
350
+ data = request.readOnly ? saved : await this.backfill(saved);
262
351
  summary = 'Saved MCP connections for this backend. Availability is checked when used.';
263
352
  }
264
353
  else {
@@ -272,7 +361,7 @@ class McpConnections {
272
361
  summary = 'The saved connection was removed.';
273
362
  }
274
363
  else {
275
- const value = await this.current(backend, releaseUid);
364
+ let value = await this.current(backend, releaseUid);
276
365
  requireEnvironment(value);
277
366
  if (request.action !== 'mcp-add') {
278
367
  const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid);
@@ -280,10 +369,12 @@ class McpConnections {
280
369
  throw new models_1.ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.');
281
370
  if (saved.mcp_connection.url !== value.mcp_connection.url)
282
371
  throw new models_1.ToolingError('INVALID_REQUEST', 'The MCP endpoint changed. Add the release again to confirm its new endpoint.');
372
+ value = this.labelled(value, labels(saved));
283
373
  }
284
374
  const client = await this.open(value);
285
375
  const tools = await client.tools();
286
376
  if (request.action === 'mcp-add') {
377
+ value = this.labelled(value, await this.resolveLabels(value));
287
378
  await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]);
288
379
  data = { connection: value, tools };
289
380
  summary = 'MCP connection verified and saved. Its tools can now be used through the plugin.';
@@ -312,6 +403,7 @@ class McpConnections {
312
403
  throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown MCP action.');
313
404
  }
314
405
  }
406
+ await this.options.checkAccount?.();
315
407
  return this.sanitize({ schemaVersion: 1, action: request.action, status: 'success', summary, data });
316
408
  }
317
409
  catch (error) {
@@ -46,11 +46,24 @@ class McpTransport {
46
46
  if (!Array.isArray(page?.tools))
47
47
  throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog is invalid.');
48
48
  for (const tool of page.tools) {
49
- if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || names.has(tool.name)) {
49
+ if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema) || names.has(tool.name)) {
50
50
  throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog contains an invalid or duplicate tool.');
51
51
  }
52
+ const normalized = { ...tool };
53
+ if (typeof normalized.description !== 'string')
54
+ delete normalized.description;
55
+ for (const field of ['annotations', '_meta'])
56
+ if (!normalized[field] || typeof normalized[field] !== 'object' || Array.isArray(normalized[field]))
57
+ delete normalized[field];
58
+ if (normalized.annotations) {
59
+ normalized.annotations = { ...normalized.annotations };
60
+ for (const flag of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) {
61
+ if (typeof normalized.annotations[flag] !== 'boolean')
62
+ delete normalized.annotations[flag];
63
+ }
64
+ }
52
65
  names.add(tool.name);
53
- tools.push(tool);
66
+ tools.push(normalized);
54
67
  }
55
68
  cursor = page.nextCursor;
56
69
  if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursors.has(cursor))) {
@@ -6,6 +6,7 @@ const models_1 = require("./models.js");
6
6
  const session_1 = require("./session.js");
7
7
  const repositoryModels_1 = require("./repositoryModels.js");
8
8
  const repositoryClient_1 = require("./repositoryClient.js");
9
+ const environments_1 = require("./environments.js");
9
10
  function projectUid(value, label = 'UID') {
10
11
  if (typeof value !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value))
11
12
  throw new models_1.ToolingError('INVALID_REQUEST', `${label} must be a UUID.`);
@@ -58,19 +59,7 @@ class OrganizationProjectClient {
58
59
  }
59
60
  async get(apiPath) { this.signal?.throwIfAborted(); return this.session.request(this.backend, 'GET', apiPath); }
60
61
  async environments() {
61
- const items = [];
62
- for (let offset = 0;;) {
63
- const payload = pageValues(await this.get(`/api/v1/organization-environments/?limit=100&offset=${offset}`));
64
- const rows = payload.results.map(row => ({ uid: projectUid(object(row).uid), name: line(row.name, 'environment name') }));
65
- if (rows.some(row => items.some(item => item.uid === row.uid)))
66
- throw new models_1.ToolingError('API_ERROR', 'Environment pagination returned duplicate rows.');
67
- items.push(...rows);
68
- offset += rows.length;
69
- if (offset >= payload.count)
70
- return items;
71
- if (!rows.length)
72
- throw new models_1.ToolingError('API_ERROR', 'Environment pagination stopped before completion.');
73
- }
62
+ return (await (0, environments_1.listEnvironments)(this.session, this.backend, this.signal)).map(({ uid, name }) => ({ uid, name }));
74
63
  }
75
64
  async list(environment, options = {}) {
76
65
  const environmentUid = projectUid(environment, 'Environment UID'), limit = options.limit ?? 25, offset = options.offset ?? 0;
@@ -44,6 +44,10 @@ const http = __importStar(require("http"));
44
44
  const config_1 = require("./config.js");
45
45
  const machineSession_1 = require("./machineSession.js");
46
46
  const models_1 = require("./models.js");
47
+ const accountScope_1 = require("./accountScope.js");
48
+ class AccountChangedError extends models_1.ToolingError {
49
+ constructor() { super('AUTH_REJECTED', 'The MainSequence account or session changed. Refresh the current view before continuing.'); }
50
+ }
47
51
  function requireBackend(input) {
48
52
  const backend = (0, config_1.normalizeBackendUrl)(input);
49
53
  let url;
@@ -101,6 +105,7 @@ class MachineSessionService {
101
105
  env;
102
106
  renewals = new Map();
103
107
  secrets = new Set();
108
+ verifiedAccounts = new Map();
104
109
  constructor(host, options = {}) {
105
110
  this.host = host;
106
111
  this.options = options;
@@ -213,8 +218,9 @@ class MachineSessionService {
213
218
  this.options.signal?.removeEventListener('abort', abort);
214
219
  }
215
220
  }
216
- renew(backend) {
217
- const running = this.renewals.get(backend);
221
+ renew(backend, expectedAccount) {
222
+ const renewalKey = JSON.stringify([backend, expectedAccount]);
223
+ const running = this.renewals.get(renewalKey);
218
224
  if (running)
219
225
  return running;
220
226
  const task = (async () => {
@@ -222,6 +228,8 @@ class MachineSessionService {
222
228
  const previous = await this.saved(backend);
223
229
  if (!previous?.refresh)
224
230
  throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.');
231
+ if (expectedAccount !== undefined && previous.username && previous.username !== expectedAccount)
232
+ throw this.accountChanged();
225
233
  let data;
226
234
  try {
227
235
  data = await this.http(backend, 'POST', '/auth/jwt-token/token/refresh/', { refresh: previous.refresh });
@@ -232,11 +240,20 @@ class MachineSessionService {
232
240
  throw error;
233
241
  }
234
242
  const session = this.remember(tokenPair(data, previous));
243
+ if (expectedAccount !== undefined) {
244
+ const current = await this.saved(backend);
245
+ if (!current || current.access !== previous.access || current.refresh !== previous.refresh)
246
+ throw this.accountChanged();
247
+ }
248
+ // Refresh may rotate the old token. Keep the new pair even if the subsequent
249
+ // profile check is unavailable; no domain request runs before that check passes.
235
250
  await this.persist(backend, session);
251
+ if (expectedAccount !== undefined)
252
+ await this.verifyAccount(backend, session, expectedAccount);
236
253
  return session;
237
254
  })();
238
- this.renewals.set(backend, task);
239
- void task.finally(() => this.renewals.delete(backend)).catch(() => { });
255
+ this.renewals.set(renewalKey, task);
256
+ void task.finally(() => this.renewals.delete(renewalKey)).catch(() => { });
240
257
  return task;
241
258
  }
242
259
  async persist(backend, session) {
@@ -281,32 +298,103 @@ class MachineSessionService {
281
298
  };
282
299
  }
283
300
  async request(input, method, apiPath, body, retrySafe = method === 'GET', options) {
301
+ return this.accountRequest(input, method, apiPath, body, retrySafe, options);
302
+ }
303
+ accountChanged() {
304
+ return new AccountChangedError();
305
+ }
306
+ recordAccount(backend, session, profile, expectedAccount) {
307
+ const username = profile?.user?.username || profile?.username;
308
+ if (username !== expectedAccount) {
309
+ this.verifiedAccounts.delete(JSON.stringify([backend, session.access]));
310
+ throw this.accountChanged();
311
+ }
312
+ if (this.verifiedAccounts.size >= 32)
313
+ this.verifiedAccounts.delete(this.verifiedAccounts.keys().next().value);
314
+ this.verifiedAccounts.set(JSON.stringify([backend, session.access]), username);
315
+ }
316
+ async verifyAccount(backend, session, expectedAccount) {
317
+ if (this.verifiedAccounts.get(JSON.stringify([backend, session.access])) === expectedAccount)
318
+ return;
319
+ this.recordAccount(backend, session, await this.http(backend, 'GET', '/api/v1/users/me/', undefined, session.access), expectedAccount);
320
+ }
321
+ /** The same selected credential is verified and used. A 401 retry must verify its new credential too. */
322
+ async withAccount(input, expectedAccount) {
323
+ const backend = requireBackend(input);
324
+ (0, accountScope_1.validateExpectedAccount)(expectedAccount);
325
+ const target = (value) => { if (requireBackend(value) !== backend)
326
+ throw new models_1.ToolingError('BACKEND_MISMATCH', 'The request belongs to another backend.'); };
327
+ const unsupported = async () => { throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Account-bound operations cannot change authentication.'); };
328
+ const scoped = {
329
+ request: (value, method, apiPath, body, retrySafe = method === 'GET', options) => {
330
+ target(value);
331
+ return this.accountRequest(backend, method, apiPath, body, retrySafe, options, expectedAccount);
332
+ },
333
+ status: async (value) => {
334
+ target(value);
335
+ await this.accountRequest(backend, 'GET', '/api/v1/users/me/', undefined, true, undefined, expectedAccount);
336
+ const current = await this.selected(backend);
337
+ await this.verifyAccount(backend, current.session, expectedAccount);
338
+ const status = await this.status(backend);
339
+ if (!status.authenticated)
340
+ throw this.accountChanged();
341
+ return { ...status, username: expectedAccount, checked: true };
342
+ },
343
+ login: unsupported, logout: unsupported, refresh: unsupported,
344
+ sanitize: text => this.sanitize(text),
345
+ };
346
+ await scoped.status(backend, true);
347
+ return scoped;
348
+ }
349
+ async accountRequest(input, method, apiPath, body, retrySafe, options, expectedAccount) {
284
350
  if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000))
285
351
  throw new models_1.ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.');
286
352
  const backend = requireBackend(input);
287
353
  let { session, source } = await this.selected(backend);
288
354
  if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)) <= Date.now() + 30_000)) {
289
355
  if (source === 'machine')
290
- session = await this.renew(backend);
356
+ session = await this.renew(backend, expectedAccount);
291
357
  else if (!session.access)
292
358
  throw new models_1.ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.');
293
359
  }
360
+ const send = async (selected) => {
361
+ const checkSelected = async () => {
362
+ if (expectedAccount === undefined)
363
+ return;
364
+ const current = await this.selected(backend);
365
+ if (current.source !== source || current.session.access !== selected.access || current.session.refresh !== selected.refresh)
366
+ throw this.accountChanged();
367
+ };
368
+ if (expectedAccount !== undefined && apiPath !== '/api/v1/users/me/')
369
+ await this.verifyAccount(backend, selected, expectedAccount);
370
+ await checkSelected();
371
+ const value = await this.http(backend, method, apiPath, body, selected.access, options?.timeoutMs);
372
+ if (expectedAccount !== undefined && apiPath === '/api/v1/users/me/')
373
+ this.recordAccount(backend, selected, value, expectedAccount);
374
+ await checkSelected();
375
+ return value;
376
+ };
294
377
  try {
295
- return await this.http(backend, method, apiPath, body, session.access, options?.timeoutMs);
378
+ return await send(session);
296
379
  }
297
380
  catch (error) {
298
381
  if (!(error instanceof models_1.ToolingError) || error.code !== 'AUTH_REJECTED')
299
382
  throw error;
383
+ // Scope failures are not expired-token failures and must never trigger a replay.
384
+ if (error instanceof AccountChangedError)
385
+ throw error;
300
386
  if (source === 'environment')
301
387
  throw refusedCredentials('environment', backend, session.access);
302
388
  if (!retrySafe)
303
389
  throw error;
304
390
  const latest = await this.saved(backend);
305
- const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend);
391
+ const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend, expectedAccount);
306
392
  try {
307
- return await this.http(backend, method, apiPath, body, retry.access, options?.timeoutMs);
393
+ return await send(retry);
308
394
  }
309
395
  catch (retryError) {
396
+ if (retryError instanceof AccountChangedError)
397
+ throw retryError;
310
398
  if (retryError instanceof models_1.ToolingError && retryError.code === 'AUTH_REJECTED')
311
399
  throw refusedCredentials('machine', backend, retry.refresh || retry.access);
312
400
  throw retryError;