@noodleseed/one 0.139.4 → 0.141.0

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.
Files changed (100) hide show
  1. package/dist/metrics-render.d.ts +8 -0
  2. package/dist/metrics-render.d.ts.map +1 -1
  3. package/dist/metrics-render.js +7 -1
  4. package/dist/metrics-render.js.map +1 -1
  5. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
  6. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +9 -1
  7. package/node_modules/@noodle-borg/app-package/dist/index.d.ts +1 -0
  8. package/node_modules/@noodle-borg/app-package/dist/index.js +1 -0
  9. package/node_modules/@noodle-borg/{compiler → app-package}/dist/schema-validation.d.ts +2 -1
  10. package/node_modules/@noodle-borg/app-package/package.json +2 -0
  11. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-app-sandbox.d.ts +13 -0
  12. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-app-sandbox.js +204 -0
  13. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +22 -0
  14. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +10 -7
  15. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.d.ts +18 -0
  16. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.js +6 -0
  17. package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.d.ts +7 -0
  18. package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.js +1 -0
  19. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +2 -0
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +2 -0
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +21 -0
  22. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +39 -0
  23. package/node_modules/@noodle-borg/assistant-gateway/dist/session-resume.d.ts +7 -0
  24. package/node_modules/@noodle-borg/assistant-gateway/dist/session-resume.js +12 -0
  25. package/node_modules/@noodle-borg/auth/dist/control-plane-token-exchange.d.ts +110 -0
  26. package/node_modules/@noodle-borg/auth/dist/control-plane-token-exchange.js +217 -0
  27. package/node_modules/@noodle-borg/auth/dist/index.d.ts +1 -0
  28. package/node_modules/@noodle-borg/auth/dist/index.js +1 -0
  29. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +5 -0
  30. package/node_modules/@noodle-borg/compiler/dist/index.d.ts +1 -2
  31. package/node_modules/@noodle-borg/compiler/dist/index.js +1 -2
  32. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +16 -0
  33. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +19 -2
  34. package/node_modules/@noodle-borg/compiler/package.json +0 -2
  35. package/node_modules/@noodle-borg/compute/dist/code-connector.js +44 -1
  36. package/node_modules/@noodle-borg/compute/dist/quickjs-engine.js +82 -266
  37. package/node_modules/@noodle-borg/compute/dist/sandbox-eval.js +276 -0
  38. package/node_modules/@noodle-borg/compute/dist/worker-pool.js +262 -0
  39. package/node_modules/@noodle-borg/{service/dist/auth/deploy-gate.d.ts → control-plane/dist/deploy-gates.d.ts} +10 -6
  40. package/node_modules/@noodle-borg/control-plane/dist/deploy-gates.js +266 -0
  41. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +1 -0
  42. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  43. package/node_modules/@noodle-borg/developer-mcp/dist/capabilities.js +11 -1
  44. package/node_modules/@noodle-borg/developer-mcp/dist/index.js +1 -1
  45. package/node_modules/@noodle-borg/module/dist/contract.d.ts +0 -64
  46. package/node_modules/@noodle-borg/module/dist/contract.js +3 -2
  47. package/node_modules/@noodle-borg/module/dist/index.d.ts +1 -0
  48. package/node_modules/@noodle-borg/module/dist/index.js +1 -0
  49. package/node_modules/@noodle-borg/module/dist/intent.d.ts +1 -1
  50. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +80 -0
  51. package/node_modules/@noodle-borg/module/dist/request-analytics.js +10 -0
  52. package/node_modules/@noodle-borg/observability/dist/intent-capture.js +4 -0
  53. package/node_modules/@noodle-borg/observability/dist/request-event-buffer.js +4 -0
  54. package/node_modules/@noodle-borg/observability/dist/request-event-metrics.js +15 -0
  55. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +19 -3
  56. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  57. package/node_modules/@noodle-borg/observability/dist/runtime.js +61 -1
  58. package/node_modules/@noodle-borg/protocol/dist/execution-error-mapping.js +13 -1
  59. package/node_modules/@noodle-borg/protocol/dist/handlers/prompts.js +2 -2
  60. package/node_modules/@noodle-borg/protocol/dist/handlers/resources.js +2 -2
  61. package/node_modules/@noodle-borg/protocol/dist/handlers/tools.js +3 -3
  62. package/node_modules/@noodle-borg/protocol/dist/observation.d.ts +18 -0
  63. package/node_modules/@noodle-borg/protocol/dist/observation.js +15 -0
  64. package/node_modules/@noodle-borg/protocol/dist/v2/server.js +5 -5
  65. package/node_modules/@noodle-borg/runtime/dist/connector/failure-details.d.ts +1 -1
  66. package/node_modules/@noodle-borg/runtime/dist/connector/failure-details.js +5 -0
  67. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +15 -1
  68. package/node_modules/@noodle-borg/runtime/dist/connector/types.js +6 -0
  69. package/node_modules/@noodle-borg/runtime/dist/index.d.ts +1 -1
  70. package/node_modules/@noodle-borg/runtime/dist/operation-execution.d.ts +1 -1
  71. package/node_modules/@noodle-borg/runtime/dist/operation-execution.js +35 -6
  72. package/node_modules/@noodle-borg/runtime/dist/result.d.ts +18 -0
  73. package/node_modules/@noodle-borg/service/dist/auth/developer-grant-guard.js +2 -1
  74. package/node_modules/@noodle-borg/service/dist/modules/route-dispatch.js +1 -1
  75. package/node_modules/@noodle-borg/service/dist/registry-types.d.ts +1 -1
  76. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +15 -3
  77. package/node_modules/@noodle-borg/service/dist/routes/assistant-clients.js +116 -0
  78. package/node_modules/@noodle-borg/service/dist/routes/assistant-dispatch.js +12 -3
  79. package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +1 -0
  80. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +4 -1
  81. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +8 -1
  82. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +3 -9
  83. package/node_modules/@noodle-borg/service/dist/routes/assistant-route-http.js +11 -0
  84. package/node_modules/@noodle-borg/service/dist/routes/assistant-sandbox.js +12 -47
  85. package/node_modules/@noodle-borg/service/dist/routes/assistant-session-target.js +34 -8
  86. package/node_modules/@noodle-borg/service/dist/routes/assistant-transcript.js +27 -0
  87. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +28 -130
  88. package/node_modules/@noodle-borg/service/dist/routes/config-values.js +1 -1
  89. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +8 -2
  90. package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +1 -6
  91. package/node_modules/@noodle-borg/service/dist/service.js +15 -1
  92. package/node_modules/@noodle-borg/transport-http/dist/client-identity.js +124 -0
  93. package/node_modules/@noodle-borg/transport-http/dist/identity-authorization.js +8 -8
  94. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +97 -10
  95. package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +40 -10
  96. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +17 -0
  97. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +25 -0
  98. package/node_modules/@noodleseed/assistant/README.md +4 -9
  99. package/package.json +1 -1
  100. /package/node_modules/@noodle-borg/{compiler → app-package}/dist/schema-validation.js +0 -0
@@ -1,23 +1,49 @@
1
- import { projectArtifactForSurface, publicSurfaceOf, } from '@noodle-borg/assistant-gateway/portable';
1
+ import { authenticatedSurfaceOf, projectArtifactForSurface, publicSurfaceOf, surfaceBindingForOrigin, } from '@noodle-borg/assistant-gateway/portable';
2
2
  export async function sessionScopedTarget(registry, session) {
3
3
  const target = await registry.get(session.deploymentId);
4
4
  if (!target)
5
5
  return undefined;
6
- // Keyed on the embed id, not `identityKind`: after elevation a signed-in visitor on a mixed surface
7
- // keeps the surface that admitted them, and should keep its projection with it.
8
- if (session.publicEmbedId === undefined)
6
+ const assistant = target.served.artifact.server.assistant;
7
+ const bound = session.boundSurface ?? deriveLegacyBinding(assistant, session);
8
+ if (bound === 'pre-surfaces')
9
9
  return target;
10
- const surface = publicSurfaceOf(target.served.artifact.server.assistant);
11
- // Fail closed. A rollback to an artifact with no public surface must end the session's reach, never
12
- // hand it the unprojected server.
10
+ // Fail closed: an origin no surface owns must never widen to the whole server.
11
+ if (bound === 'unowned')
12
+ return undefined;
13
+ // Fail closed on a vanished surface. A rollback to an artifact without the bound surface must end the
14
+ // session's reach, never hand it the unprojected server.
15
+ if (bound === 'authenticated') {
16
+ const surface = authenticatedSurfaceOf(assistant);
17
+ if (surface === undefined)
18
+ return undefined;
19
+ // An omitted allowlist on an authenticated surface is the authored whole-server intent, so the
20
+ // exact binding still holds (instructions, budgets, attribution) without narrowing capabilities.
21
+ if (surface.capabilities === undefined)
22
+ return target;
23
+ return projected(target, surface.capabilities);
24
+ }
25
+ const surface = publicSurfaceOf(assistant);
13
26
  if (surface === undefined)
14
27
  return undefined;
28
+ return projected(target, surface.capabilities);
29
+ }
30
+ function projected(target, capabilities) {
15
31
  return {
16
32
  ...target,
17
33
  served: {
18
34
  ...target.served,
19
- artifact: projectArtifactForSurface(target.served.artifact, surface.capabilities),
35
+ artifact: projectArtifactForSurface(target.served.artifact, capabilities),
20
36
  },
21
37
  };
22
38
  }
39
+ /**
40
+ * A record minted before `boundSurface` existed: the embed id marks the public surface; otherwise the
41
+ * session's origin selects the owning surface on the pinned deployment. `pre-surfaces` means the whole
42
+ * server on that released artifact shape; `unowned` fails closed above.
43
+ */
44
+ function deriveLegacyBinding(assistant, session) {
45
+ if (session.publicEmbedId !== undefined)
46
+ return 'public';
47
+ return surfaceBindingForOrigin(assistant, session.origin).kind;
48
+ }
23
49
  //# sourceMappingURL=assistant-session-target.js.map
@@ -0,0 +1,27 @@
1
+ import { visibleTranscript } from '@noodle-borg/assistant-gateway/portable';
2
+ import { assistantTranscriptResponseSchema } from '@noodle-borg/wire-contracts';
3
+ import { sendJson } from '../http-util.js';
4
+ import { applyBrowserCors, authenticateSession } from './assistant-route-http.js';
5
+ /**
6
+ * The bounded visible-transcript read (ADR 0141/0201 amendments 2026-08-26), in its own module for
7
+ * the same reason `assistant-elevation.ts` is: `assistant.ts` is at its size limit, and this is a
8
+ * different responsibility from running a turn.
9
+ *
10
+ * POST, deliberately: every other session route is a POST, so the existing preflight's
11
+ * `POST, OPTIONS` shape covers it without widening allow-methods. Auth is exactly the shared
12
+ * session gate — current token, pinned origin — so a stale pre-elevation token cannot read, by
13
+ * construction. A read spends no turn budget: admission binds metered work (ADR 0201 decision 8),
14
+ * and this is a projection over state that already exists and already expires with the session.
15
+ */
16
+ export async function handleAssistantTranscript(req, res, deps) {
17
+ const session = await authenticateSession(req, res, deps);
18
+ if (!session)
19
+ return;
20
+ applyBrowserCors(req, res, session.origin);
21
+ const body = { entries: visibleTranscript(session.history) };
22
+ // Parse before sending, like the session exchange: a contract break fails loudly here rather than
23
+ // stranding deployed widgets (ADR 0151).
24
+ assistantTranscriptResponseSchema.parse(body);
25
+ return sendJson(res, 200, body);
26
+ }
27
+ //# sourceMappingURL=assistant-transcript.js.map
@@ -1,5 +1,5 @@
1
1
  import { clientAddressBucket, } from '@noodle-borg/admission-limits/portable';
2
- import { ASSISTANT_SESSION_IDLE_MS, dispatchAssistantTool, parseAssistantContextPreferences, parseAssistantCustomerRouting, resumeTurnMessage, shouldAutoResume, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
2
+ import { ASSISTANT_SESSION_IDLE_MS, dispatchAssistantTool, parseAssistantContextPreferences, parseAssistantCustomerRouting, resumeTurnMessage, resumeUnavailableMessage, shouldAutoResume, surfaceBindingForOrigin, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
3
  import { canonicalizeAuthorizationClaimValues } from '@noodle-borg/auth';
4
4
  import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
5
5
  import { tenantMcpUrl } from '@noodle-borg/module';
@@ -14,7 +14,7 @@ import { assistantBrowserConfiguration } from './assistant-configuration.js';
14
14
  import { elevateAssistantSession } from './assistant-elevation.js';
15
15
  import { executeAssistantKnowledgeSearch, findAssistantKnowledgeComponent, resolveAssistantKnowledge, } from './assistant-knowledge.js';
16
16
  import { refusePublicTurn } from './assistant-public-turn.js';
17
- import { applyBrowserCors, authenticateSession, handleAssistantPreflight, now, } from './assistant-route-http.js';
17
+ import { applyBrowserCors, assistantSessionEndpoints, authenticateSession, handleAssistantPreflight, now, } from './assistant-route-http.js';
18
18
  import { sessionScopedTarget } from './assistant-session-target.js';
19
19
  import { authorizeControlPlane } from './control-plane.js';
20
20
  export { applyBrowserCors, authenticateSession, handleAssistantPreflight, now };
@@ -42,108 +42,6 @@ export async function handleConsoleApprovalNonce(req, res, deps) {
42
42
  const consumed = await deps.store.consumeConsoleApprovalNonce(nonce, identity.subject, expiry, current);
43
43
  return sendJson(res, consumed ? 201 : 409, consumed ? { ok: true } : { ok: false, error: 'already_used_or_expired' });
44
44
  }
45
- export async function handleAssistantClients(req, res, tenant, id, action, deps) {
46
- const identity = await authorizeControlPlane(req, res, deps.gate, { requireIdentity: false });
47
- if (identity === false)
48
- return;
49
- if (identity && !identity.superAdmin) {
50
- const member = await deps.controlPlane.isOrgMember({
51
- org: tenant.org,
52
- subject: identity.subject,
53
- });
54
- if (!member)
55
- return sendForbidden(res, 'forbidden');
56
- }
57
- if (action === 'collection' && req.method === 'GET') {
58
- const clients = await deps.store.listClients(tenant);
59
- return sendJson(res, 200, {
60
- ok: true,
61
- clients: clients.map(publicClient),
62
- });
63
- }
64
- if (action === 'collection' && req.method === 'POST') {
65
- const body = await readJsonBody(req, deps.maxBody);
66
- if (!body.ok)
67
- return sendJson(res, body.status, { error: body.error });
68
- const name = body.value.name;
69
- if (typeof name !== 'string' || name.trim().length < 1 || name.length > 80) {
70
- return sendJson(res, 400, { error: '"name" must be a non-empty string' });
71
- }
72
- const target = await deps.registry.getActiveByTenant(tenant);
73
- const assistant = target?.served.artifact.server.assistant;
74
- if (!target?.deploymentId || !assistant)
75
- return sendJson(res, 409, { error: 'deployment has no embedded assistant' });
76
- const created = await deps.store.createClient({
77
- name: name.trim(),
78
- tenant,
79
- deploymentId: target.deploymentId,
80
- allowedOrigins: assistant.allowedOrigins,
81
- now: now(deps),
82
- });
83
- await deps.audit.emit({
84
- eventType: 'assistant.client.created',
85
- org: tenant.org,
86
- app: tenant.app,
87
- env: tenant.env,
88
- deploymentId: target.deploymentId,
89
- decision: 'allow',
90
- status: 201,
91
- ...(identity ? { actorSubject: identity.subject } : {}),
92
- ...(identity?.email ? { actorEmail: identity.email } : {}),
93
- details: { clientId: created.client.id, name: created.client.name },
94
- });
95
- return sendJson(res, 201, {
96
- ok: true,
97
- ...publicClient(created.client),
98
- clientSecret: created.secret,
99
- });
100
- }
101
- if (!id)
102
- return sendJson(res, 404, { error: 'not found' });
103
- const owned = (await deps.store.listClients(tenant)).some((client) => client.id === id);
104
- if (!owned)
105
- return sendJson(res, 404, { error: 'not found' });
106
- if (action === 'rotate' && req.method === 'POST') {
107
- const rotated = await deps.store.rotateClient(id, now(deps));
108
- if (!rotated)
109
- return sendJson(res, 404, { error: 'not found' });
110
- await deps.audit.emit({
111
- eventType: 'assistant.client.rotated',
112
- org: tenant.org,
113
- app: tenant.app,
114
- env: tenant.env,
115
- deploymentId: rotated.client.deploymentId,
116
- decision: 'allow',
117
- status: 200,
118
- ...(identity ? { actorSubject: identity.subject } : {}),
119
- ...(identity?.email ? { actorEmail: identity.email } : {}),
120
- details: { clientId: rotated.client.id },
121
- });
122
- return sendJson(res, 200, {
123
- ok: true,
124
- ...publicClient(rotated.client),
125
- clientSecret: rotated.secret,
126
- });
127
- }
128
- if (action === 'item' && req.method === 'DELETE') {
129
- await deps.store.revokeClient(id, now(deps));
130
- await deps.audit.emit({
131
- eventType: 'assistant.client.revoked',
132
- org: tenant.org,
133
- app: tenant.app,
134
- env: tenant.env,
135
- decision: 'allow',
136
- status: 204,
137
- ...(identity ? { actorSubject: identity.subject } : {}),
138
- ...(identity?.email ? { actorEmail: identity.email } : {}),
139
- details: { clientId: id },
140
- });
141
- res.statusCode = 204;
142
- res.end();
143
- return;
144
- }
145
- sendJson(res, 405, { error: 'method not allowed' });
146
- }
147
45
  export async function handleAssistantSession(req, res, deps) {
148
46
  const usageStartedAt = performance.now();
149
47
  const credentials = basicCredentials(req);
@@ -173,6 +71,13 @@ export async function handleAssistantSession(req, res, deps) {
173
71
  if (!assistant.allowedOrigins.includes(parsed.origin)) {
174
72
  return sendForbidden(res, 'origin is not allowed');
175
73
  }
74
+ // Every session binds one exact authored surface (ADR 0201): the origin selects it, and an origin no
75
+ // surface owns is refused rather than admitted against the deployment-wide union. A pre-surfaces
76
+ // artifact has no surfaces to select from; its union remains its whole released contract.
77
+ const surfaceBinding = surfaceBindingForOrigin(assistant, parsed.origin);
78
+ if (surfaceBinding.kind === 'unowned') {
79
+ return sendForbidden(res, 'origin is not allowed');
80
+ }
176
81
  const customerRouting = parseAssistantCustomerRouting(target.served.artifact.customerEndpoints, parsed.routing);
177
82
  if (!customerRouting.ok) {
178
83
  return sendJson(res, 400, {
@@ -229,18 +134,15 @@ export async function handleAssistantSession(req, res, deps) {
229
134
  client,
230
135
  resume: shouldAutoResume(parsed.resume),
231
136
  origin: parsed.origin,
137
+ // The conversation lands on the surface owning the (validated) landing origin: signing in on
138
+ // the app surface continues under its projection (ADR 0201 amendment 2026-08-26).
139
+ ...(surfaceBinding.kind === 'pre-surfaces' ? {} : { boundSurface: surfaceBinding.kind }),
232
140
  ...(customerRouting.customerRouting
233
141
  ? { customerRouting: customerRouting.customerRouting }
234
142
  : {}),
235
143
  caller,
236
144
  configuration,
237
- endpoints: {
238
- turns: `${deps.serviceBase(req)}/v1/assistant/turns`,
239
- toolConfirmations: `${deps.serviceBase(req)}/v1/assistant/tool-confirmations`,
240
- interactions: `${deps.serviceBase(req)}/v1/assistant/interactions`,
241
- apps: `${deps.serviceBase(req)}/v1/assistant/apps`,
242
- sandbox: `${deps.serviceBase(req)}/v1/assistant/sandbox`,
243
- },
145
+ endpoints: assistantSessionEndpoints(deps.serviceBase(req), deps.sandboxBase?.(req)),
244
146
  });
245
147
  }
246
148
  const session = await deps.store.createSession({
@@ -249,6 +151,7 @@ export async function handleAssistantSession(req, res, deps) {
249
151
  deploymentId: target.deploymentId,
250
152
  modelSource: assistant.model.kind === 'noodle-managed' ? 'noodle-managed' : 'operator',
251
153
  origin: parsed.origin,
154
+ ...(surfaceBinding.kind === 'pre-surfaces' ? {} : { boundSurface: surfaceBinding.kind }),
252
155
  ...(customerRouting.customerRouting
253
156
  ? { customerRouting: customerRouting.customerRouting }
254
157
  : {}),
@@ -263,13 +166,7 @@ export async function handleAssistantSession(req, res, deps) {
263
166
  const sessionBody = {
264
167
  token: session.token,
265
168
  expiresAt: session.session.expiresAt,
266
- endpoints: {
267
- turns: `${deps.serviceBase(req)}/v1/assistant/turns`,
268
- toolConfirmations: `${deps.serviceBase(req)}/v1/assistant/tool-confirmations`,
269
- interactions: `${deps.serviceBase(req)}/v1/assistant/interactions`,
270
- apps: `${deps.serviceBase(req)}/v1/assistant/apps`,
271
- sandbox: `${deps.serviceBase(req)}/v1/assistant/sandbox`,
272
- },
169
+ endpoints: assistantSessionEndpoints(deps.serviceBase(req), deps.sandboxBase?.(req)),
273
170
  ...(configuration ? { configuration } : {}),
274
171
  };
275
172
  // Every published @noodleseed/assistant widget consumes this shape; parse (don't just test)
@@ -293,6 +190,7 @@ export async function handleAssistantTurn(req, res, deps) {
293
190
  const addressBucket = clientAddressBucket(typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'] : undefined);
294
191
  let turn;
295
192
  let message;
193
+ let resumedTool;
296
194
  if (isRecord(body.value) && 'resume' in body.value) {
297
195
  // The one-shot post-sign-in resume (issue #1177): no user text, the pending intent is server
298
196
  // state. Admission runs FIRST so a refused resume stays armed for a retry; the consume is a
@@ -313,6 +211,7 @@ export async function handleAssistantTurn(req, res, deps) {
313
211
  });
314
212
  }
315
213
  message = resumeTurnMessage(pending.tool);
214
+ resumedTool = pending.tool;
316
215
  await deps.audit.emit({
317
216
  eventType: 'assistant.session.resumed',
318
217
  org: session.tenant.org,
@@ -361,6 +260,12 @@ export async function handleAssistantTurn(req, res, deps) {
361
260
  const target = await sessionScopedTarget(deps.registry, session);
362
261
  if (!target)
363
262
  return sendJson(res, 409, { error: 'assistant deployment is unavailable' });
263
+ if (resumedTool !== undefined &&
264
+ !target.served.artifact.tools.some((tool) => tool.name === resumedTool)) {
265
+ // The elevation landed on a surface whose projection does not offer the intercepted tool: fail
266
+ // closed to an honest explanation instead of asking the model to call a tool it cannot see.
267
+ message = resumeUnavailableMessage(resumedTool);
268
+ }
364
269
  const clientContext = turn?.clientContext === undefined
365
270
  ? undefined
366
271
  : parseAssistantContextPreferences(turn.clientContext);
@@ -396,8 +301,11 @@ export async function handleAssistantTurn(req, res, deps) {
396
301
  .map((event) => String(event.data.delta ?? ''))
397
302
  .join('');
398
303
  await deps.store.appendHistory(session.id, [
399
- { role: 'user', content: message.trim() },
400
- ...(assistantContent ? [{ role: 'assistant', content: assistantContent }] : []),
304
+ // A resume turn's user row is the [platform] prompt the visitor never typed or saw.
305
+ { role: 'user', content: message.trim(), kind: turn ? 'visible' : 'narration' },
306
+ ...(assistantContent
307
+ ? [{ role: 'assistant', content: assistantContent, kind: 'visible' }]
308
+ : []),
401
309
  ]);
402
310
  }
403
311
  catch {
@@ -559,14 +467,4 @@ function isContext(value) {
559
467
  typeof item === 'boolean' ||
560
468
  (typeof item === 'string' && item.length <= 2_000))));
561
469
  }
562
- function publicClient(client) {
563
- return {
564
- id: client.id,
565
- name: client.name,
566
- createdAt: client.createdAt,
567
- // Audit data only: sessions follow the tenant's active deployment, not this snapshot.
568
- createdAgainstDeploymentId: client.deploymentId,
569
- ...(client.revokedAt ? { revokedAt: client.revokedAt } : {}),
570
- };
571
- }
572
470
  //# sourceMappingURL=assistant.js.map
@@ -94,7 +94,7 @@ function developerConfigAccess(grants, controlPlane, ref, method) {
94
94
  controlPlane,
95
95
  org: ref.scope.org,
96
96
  capability: method === 'GET' ? 'cloud:read' : 'config:write',
97
- resourcePath: DEVELOPER_CLI_PATH,
97
+ resourcePaths: new Set([DEVELOPER_CLI_PATH]),
98
98
  },
99
99
  };
100
100
  }
@@ -1,6 +1,6 @@
1
1
  import { cspFaultsInManifest } from '@noodle-borg/compiler';
2
2
  import { ensurePersonalWorkspace } from '@noodle-borg/control-plane/portable';
3
- import { DEVELOPER_CLI_PATH } from '@noodle-borg/developer-mcp';
3
+ import { DEVELOPER_ASSISTANT_PATH, DEVELOPER_CLI_PATH, } from '@noodle-borg/developer-mcp';
4
4
  import { normalizeServerVersion } from '@noodle-borg/module';
5
5
  import { noopLogger } from '@noodle-borg/transport-http';
6
6
  import { deployRequestSchema, formatWireError, } from '@noodle-borg/wire-contracts';
@@ -504,9 +504,15 @@ export async function handleDeploymentStatus(req, res, registry, gate, controlPl
504
504
  }
505
505
  }
506
506
  export function developerGrantRouteAccess(grants, capability) {
507
+ // Control-plane routes serve the CLI resource and the assistant resource (ADR 0218); the grant's
508
+ // own capability ceiling — never this set — decides what each may actually do.
507
509
  return grants === undefined
508
510
  ? undefined
509
- : { grants, capability, resourcePath: DEVELOPER_CLI_PATH };
511
+ : {
512
+ grants,
513
+ capability,
514
+ resourcePaths: new Set([DEVELOPER_CLI_PATH, DEVELOPER_ASSISTANT_PATH]),
515
+ };
510
516
  }
511
517
  async function authorizeHumanDeploy(req, res, options, gate, controlPlane, org) {
512
518
  const developerAccess = developerGrantAccess(options.developerGrantStore, controlPlane, org, 'deployments:write');
@@ -1,9 +1,5 @@
1
1
  /** Close every boot-owned resource, preserving all failures without skipping later cleanup. */
2
2
  export async function closeServiceResources(input) {
3
- if (input.retentionTimer !== undefined)
4
- clearInterval(input.retentionTimer);
5
- if (input.intentRetentionTimer !== undefined)
6
- clearInterval(input.intentRetentionTimer);
7
3
  if (input.welcomeEmailTimer !== undefined)
8
4
  clearInterval(input.welcomeEmailTimer);
9
5
  if (input.feedbackOperationsTimer !== undefined)
@@ -14,8 +10,7 @@ export async function closeServiceResources(input) {
14
10
  if (moduleHost !== undefined) {
15
11
  await captureCleanupError(errors, () => moduleHost.dispose());
16
12
  }
17
- await captureCleanupError(errors, () => input.requestEventBuffer.close());
18
- await captureCleanupError(errors, () => input.intentEventBuffer.close());
13
+ await captureCleanupError(errors, () => input.telemetry.dispose());
19
14
  const postgresPool = input.postgresPool;
20
15
  if (postgresPool !== undefined) {
21
16
  await captureCleanupError(errors, () => postgresPool.close());
@@ -1,4 +1,4 @@
1
- import { InMemoryAssistantStore } from '@noodle-borg/assistant-gateway/portable';
1
+ import { InMemoryAssistantStore, isAssistantSandboxHost, validateAssistantSandboxBaseUrl, } from '@noodle-borg/assistant-gateway/portable';
2
2
  import { allowAllGate, InMemoryControlPlaneStore } from '@noodle-borg/control-plane/portable';
3
3
  import { dispatchKnowledgeRequest } from '@noodle-borg/knowledge-operations/portable';
4
4
  import { OPENAI_APPS_CHALLENGE_PATH } from '@noodle-borg/module';
@@ -50,6 +50,9 @@ const DEFAULT_MAX_BODY = 1 << 20;
50
50
  const DEFAULT_MAX_DEPLOY_BODY = 32 * 1024 * 1024;
51
51
  /** The combined service listener: tenant-scoped deploy management plus the multi-tenant MCP router. */
52
52
  export function createServiceHandler(registry, options = {}) {
53
+ const assistantSandboxBaseUrl = options.assistantSandboxBaseUrl === undefined
54
+ ? undefined
55
+ : validateAssistantSandboxBaseUrl(options.assistantSandboxBaseUrl, options.publicBaseUrl);
53
56
  const logger = options.logger ?? noopLogger;
54
57
  const tls = options.tls ?? {};
55
58
  const buildInfo = options.buildInfo ?? resolveBuildInfo();
@@ -155,6 +158,14 @@ export function createServiceHandler(registry, options = {}) {
155
158
  };
156
159
  return (req, res) => {
157
160
  const url = new URL(req.url ?? '/', 'http://localhost');
161
+ const onAssistantSandboxHost = isAssistantSandboxHost(req.headers.host, assistantSandboxBaseUrl);
162
+ if (onAssistantSandboxHost && url.pathname !== '/v1/assistant/sandbox') {
163
+ applySecurityHeaders(res, tls);
164
+ if (enforceHttps(req, res, tls))
165
+ return;
166
+ sendJson(res, 404, { error: 'not found' });
167
+ return;
168
+ }
158
169
  // Liveness/readiness probes: un-gated and **not** HTTPS-enforced — Cloud Run's internal probe is plain
159
170
  // HTTP, so enforcing HTTPS here would `426` the probe and the revision would never go healthy (ADR 0034).
160
171
  // `/healthz` is a pure liveness 200 (no store touch); `/readyz` reflects the injected readiness probe.
@@ -250,6 +261,9 @@ export function createServiceHandler(registry, options = {}) {
250
261
  audit: activeAudit,
251
262
  maxBody,
252
263
  serviceBase: (request) => options.publicBaseUrl ?? baseFromRequest(request, tls),
264
+ ...(assistantSandboxBaseUrl === undefined
265
+ ? {}
266
+ : { sandboxBase: () => assistantSandboxBaseUrl }),
253
267
  ...(options.assistantModelFetch !== undefined
254
268
  ? { modelFetch: options.assistantModelFetch }
255
269
  : {}),
@@ -0,0 +1,124 @@
1
+ import { CLIENT_FAMILY_OTHER, CLIENT_FAMILY_UNKNOWN } from '@noodle-borg/module';
2
+ /**
3
+ * Safe client attribution for request analytics (issue #1309): bounded projections of MCP
4
+ * `clientInfo` and of the transport user agent, plus the best-effort session→clientInfo memo that
5
+ * lets legacy-era requests after `initialize` keep their client identity. Nothing here ever retains
6
+ * a raw user-agent string, and every retained value is length-bounded and control-character free.
7
+ */
8
+ const MAX_CLIENT_NAME = 128;
9
+ const MAX_CLIENT_VERSION = 64;
10
+ const MAX_FAMILY = 32;
11
+ /**
12
+ * Known HTTP client user agents, matched case-insensitively against the user-agent prefix tokens.
13
+ * A closed table keeps the retained cardinality bounded: anything else becomes `other`, an absent
14
+ * user agent becomes `unknown`. Order matters — first match wins.
15
+ */
16
+ const KNOWN_UA_FAMILIES = [
17
+ [/claude/i, 'claude'],
18
+ [/anthropic/i, 'anthropic-sdk'],
19
+ [/openai/i, 'openai'],
20
+ [/mcp-inspector|inspector/i, 'mcp-inspector'],
21
+ [/undici/i, 'undici'],
22
+ [/node-fetch/i, 'node-fetch'],
23
+ [/\bnode\b|nodejs/i, 'node'],
24
+ [/\bbun\b/i, 'bun'],
25
+ [/\bdeno\b/i, 'deno'],
26
+ [/python-httpx|\bhttpx\b/i, 'python-httpx'],
27
+ [/python-requests/i, 'python-requests'],
28
+ [/python-urllib|python/i, 'python'],
29
+ [/axios/i, 'axios'],
30
+ [/\bgot\b/i, 'got'],
31
+ [/curl/i, 'curl'],
32
+ [/wget/i, 'wget'],
33
+ [/postman/i, 'postman'],
34
+ [/insomnia/i, 'insomnia'],
35
+ [/go-http-client/i, 'go-http-client'],
36
+ [/okhttp/i, 'okhttp'],
37
+ [/java/i, 'java'],
38
+ [/mozilla|chrome|safari|firefox|edg/i, 'browser'],
39
+ ];
40
+ /** Bound a wire-supplied client identity scalar: printable, trimmed, and length-capped. */
41
+ export function boundedClientScalar(value, max) {
42
+ if (value === undefined)
43
+ return undefined;
44
+ let cleaned = '';
45
+ for (const char of value) {
46
+ const code = char.codePointAt(0) ?? 0;
47
+ if (code >= 0x20 && code !== 0x7f)
48
+ cleaned += char; // strip C0 controls and DEL
49
+ }
50
+ cleaned = cleaned.trim();
51
+ if (cleaned.length === 0)
52
+ return undefined;
53
+ return cleaned.length > max ? cleaned.slice(0, max) : cleaned;
54
+ }
55
+ export function boundedClientName(value) {
56
+ return boundedClientScalar(value, MAX_CLIENT_NAME);
57
+ }
58
+ export function boundedClientVersion(value) {
59
+ return boundedClientScalar(value, MAX_CLIENT_VERSION);
60
+ }
61
+ /**
62
+ * The bounded client family for one request. `clientInfo.name` wins (its sanitized token — the name
63
+ * itself is already retained, so the family adds no cardinality); otherwise the user agent maps
64
+ * through the closed table above; `other` for an unmatched agent, `unknown` for none at all.
65
+ */
66
+ export function clientFamily(clientName, userAgent) {
67
+ if (clientName !== undefined) {
68
+ const token = clientName
69
+ .toLowerCase()
70
+ .replace(/[^a-z0-9._-]+/g, '-')
71
+ .replace(/^-+|-+$/g, '')
72
+ .slice(0, MAX_FAMILY);
73
+ if (token.length > 0)
74
+ return token;
75
+ }
76
+ if (userAgent === undefined || userAgent.trim().length === 0)
77
+ return CLIENT_FAMILY_UNKNOWN;
78
+ for (const [pattern, family] of KNOWN_UA_FAMILIES) {
79
+ if (pattern.test(userAgent))
80
+ return family;
81
+ }
82
+ return CLIENT_FAMILY_OTHER;
83
+ }
84
+ /**
85
+ * Bounded, best-effort LRU memo from an analytics session id to the `initialize` client identity,
86
+ * keyed per tenant so one tenant's session ids can never resolve another's client names. Purely an
87
+ * attribution aid: entries are non-authoritative, evicted oldest-first, and never required.
88
+ */
89
+ export class SessionClientMemo {
90
+ #entries = new Map();
91
+ #cap;
92
+ constructor(cap = 2048) {
93
+ this.#cap = cap;
94
+ }
95
+ remember(key, identity) {
96
+ if (identity.clientName === undefined && identity.clientVersion === undefined)
97
+ return;
98
+ if (this.#entries.has(key))
99
+ this.#entries.delete(key);
100
+ this.#entries.set(key, identity);
101
+ if (this.#entries.size > this.#cap) {
102
+ const oldest = this.#entries.keys().next().value;
103
+ if (oldest !== undefined)
104
+ this.#entries.delete(oldest);
105
+ }
106
+ }
107
+ recall(key) {
108
+ const found = this.#entries.get(key);
109
+ if (found !== undefined) {
110
+ // Refresh recency so active sessions survive eviction pressure.
111
+ this.#entries.delete(key);
112
+ this.#entries.set(key, found);
113
+ }
114
+ return found;
115
+ }
116
+ get size() {
117
+ return this.#entries.size;
118
+ }
119
+ }
120
+ /** Tenant-scoped memo key; the org always participates so keys cannot collide across tenants. */
121
+ export function sessionClientKey(org, app, env, sessionId) {
122
+ return `${org}/${app ?? ''}/${env ?? ''}#${sessionId}`;
123
+ }
124
+ //# sourceMappingURL=client-identity.js.map
@@ -11,12 +11,12 @@ export async function authorizeIdentityMode(req, res, auth) {
11
11
  const token = bearerToken(req);
12
12
  if (token === null || auth.verifyOwnerToken === undefined) {
13
13
  sendUnauthorized(res, challenge);
14
- return { allow: false };
14
+ return { allow: false, reason: token === null ? 'missing_token' : 'verifier_unavailable' };
15
15
  }
16
16
  const verification = await auth.verifyOwnerToken(token, canonicalResourceUrl(req, auth));
17
17
  if (verification === null) {
18
18
  sendUnauthorized(res, challenge);
19
- return { allow: false };
19
+ return { allow: false, reason: 'token_rejected' };
20
20
  }
21
21
  const { caller: identity } = verification;
22
22
  if (identity.identityKind === 'service') {
@@ -36,18 +36,18 @@ export async function authorizeIdentityMode(req, res, auth) {
36
36
  if (auth.accessMode === 'customers') {
37
37
  if (identity.identityKind !== 'customer') {
38
38
  sendUnauthorized(res, challenge);
39
- return { allow: false };
39
+ return { allow: false, reason: 'identity_not_customer' };
40
40
  }
41
41
  return { allow: true, caller: identity, ...privateCustomerContext };
42
42
  }
43
43
  if (auth.accessMode === 'owner-only') {
44
44
  if (auth.ownerSubject === undefined) {
45
45
  sendUnauthorized(res, challenge);
46
- return { allow: false };
46
+ return { allow: false, reason: 'owner_unconfigured' };
47
47
  }
48
48
  if (identity.subject !== auth.ownerSubject) {
49
49
  sendForbidden(res);
50
- return { allow: false };
50
+ return { allow: false, reason: 'owner_mismatch' };
51
51
  }
52
52
  return { allow: true, caller: identity, ...privateCustomerContext };
53
53
  }
@@ -65,7 +65,7 @@ export async function authorizeIdentityMode(req, res, auth) {
65
65
  });
66
66
  if (membership?.allowed !== true) {
67
67
  sendForbidden(res);
68
- return { allow: false };
68
+ return { allow: false, reason: 'org_membership_denied' };
69
69
  }
70
70
  }
71
71
  return { allow: true, caller: identity, ...privateCustomerContext };
@@ -90,12 +90,12 @@ export async function authorizeMixedMode(req, res, auth) {
90
90
  const challenge = protectedResourceMetadataUrl(req, auth);
91
91
  if (auth.verifyOwnerToken === undefined) {
92
92
  sendUnauthorized(res, challenge);
93
- return { allow: false };
93
+ return { allow: false, reason: 'verifier_unavailable' };
94
94
  }
95
95
  const verification = await auth.verifyOwnerToken(token, canonicalResourceUrl(req, auth));
96
96
  if (verification === null) {
97
97
  sendUnauthorized(res, challenge);
98
- return { allow: false };
98
+ return { allow: false, reason: 'token_rejected' };
99
99
  }
100
100
  return {
101
101
  allow: true,