@noodleseed/one 0.141.1 → 0.141.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/dist/bin.js CHANGED
File without changes
@@ -1,6 +1,5 @@
1
1
  /** Assistant gateway contracts and in-memory adapters safe for the published CLI runtime. */
2
2
  export * from './artifact-projection.js';
3
- export * from './assistant-app-sandbox.js';
4
3
  export * from './assistant-context.js';
5
4
  export * from './assistant-customer-issuer.js';
6
5
  export * from './assistant-customer-routing.js';
@@ -1,6 +1,5 @@
1
1
  /** Assistant gateway contracts and in-memory adapters safe for the published CLI runtime. */
2
2
  export * from './artifact-projection.js';
3
- export * from './assistant-app-sandbox.js';
4
3
  export * from './assistant-context.js';
5
4
  export * from './assistant-customer-issuer.js';
6
5
  export * from './assistant-customer-routing.js';
@@ -1,4 +1,3 @@
1
- import { isAssistantSandboxHost, } from '@noodle-borg/assistant-gateway/portable';
2
1
  import { handleAssistantAppRequest, handleAssistantPreflight, handleAssistantSession, handleAssistantTurn, handleConsoleApprovalNonce, } from './assistant.js';
3
2
  import { handleAssistantClients } from './assistant-clients.js';
4
3
  import { handleAssistantDoctor } from './assistant-doctor.js';
@@ -36,9 +35,7 @@ export function dispatchAssistantRoutes(req, res, url, deps) {
36
35
  deps.applySecurityHeaders(res, deps.tls);
37
36
  if (deps.enforceHttps(req, res, deps.tls))
38
37
  return true;
39
- const sandboxBase = deps.sandboxBase?.(req);
40
- const isolatedOrigin = isAssistantSandboxHost(req.headers.host, sandboxBase);
41
- handleAssistantSandbox(req, res, { isolatedOrigin });
38
+ handleAssistantSandbox(req, res);
42
39
  return true;
43
40
  }
44
41
  if (url.pathname === '/v1/assistant/public-sessions' && req.method === 'POST') {
@@ -98,7 +98,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
98
98
  const sessionBody = {
99
99
  token: result.token,
100
100
  expiresAt: result.expiresAt,
101
- endpoints: assistantSessionEndpoints(deps.serviceBase(req), deps.sandboxBase?.(req)),
101
+ endpoints: assistantSessionEndpoints(deps.serviceBase(req)),
102
102
  ...(configuration ? { configuration } : {}),
103
103
  };
104
104
  // Parse rather than trust: every published @noodleseed/assistant widget consumes this shape, and the
@@ -48,13 +48,13 @@ export function now(deps) {
48
48
  return deps.clock?.() ?? new Date();
49
49
  }
50
50
  /** The one endpoint map every session mint returns; three mints, one source of truth. */
51
- export function assistantSessionEndpoints(base, sandboxBase = base) {
51
+ export function assistantSessionEndpoints(base) {
52
52
  return {
53
53
  turns: `${base}/v1/assistant/turns`,
54
54
  toolConfirmations: `${base}/v1/assistant/tool-confirmations`,
55
55
  interactions: `${base}/v1/assistant/interactions`,
56
56
  apps: `${base}/v1/assistant/apps`,
57
- sandbox: `${sandboxBase}/v1/assistant/sandbox`,
57
+ sandbox: `${base}/v1/assistant/sandbox`,
58
58
  transcript: `${base}/v1/assistant/transcript`,
59
59
  };
60
60
  }
@@ -1,23 +1,58 @@
1
- import { ASSISTANT_SANDBOX_DOCUMENT, resolveAssistantSandboxResponsePolicy, } from '@noodle-borg/assistant-gateway/portable';
2
- /** Serve the static, secret-free sandbox relay and apply its validated CSP before writing the body. */
3
- export function handleAssistantSandbox(req, res, options = {}) {
1
+ /**
2
+ * Hosted widget sandbox document (ADR 0151 addendum). The embedded assistant renders MCP App
3
+ * widgets in a double iframe; when the outer frame is an `about:srcdoc` document it inherits the
4
+ * customer page's CSP, so a strict `script-src` silently blanks every widget. Serving this relay
5
+ * document from the service origin gives it — and the inner `srcdoc` widget document that inherits
6
+ * from it — a CSP the platform controls. The body is pinned byte-for-byte to
7
+ * `contract/v1/assistant-sandbox-document.html` and to the published client's srcdoc fallback; any
8
+ * relay-protocol change is a coordinated contract event, never a local edit.
9
+ */
10
+ export const ASSISTANT_SANDBOX_DOCUMENT = `<!doctype html><meta charset="utf-8"><style>html,body,iframe{border:0;margin:0;width:100%;height:100%;overflow:hidden}body{background:transparent}</style><script>
11
+ let inner;
12
+ const ready={jsonrpc:'2.0',method:'ui/notifications/sandbox-proxy-ready',params:{}};
13
+ // Re-announce until the host acknowledges with the resource: when this document is hosted
14
+ // cross-origin, the first announcement can arrive before the host's load handler is listening.
15
+ const announce=setInterval(()=>parent.postMessage(ready,'*'),120);
16
+ setTimeout(()=>clearInterval(announce),15000);
17
+ addEventListener('message',(event)=>{
18
+ if(event.source===parent){
19
+ const message=event.data;
20
+ if(message?.method==='ui/notifications/sandbox-resource-ready'){
21
+ clearInterval(announce);
22
+ if(inner)return;
23
+ inner=document.createElement('iframe');
24
+ inner.setAttribute('sandbox',message.params?.sandbox||'allow-scripts');
25
+ inner.setAttribute('referrerpolicy','no-referrer');
26
+ inner.srcdoc=String(message.params?.html||'');
27
+ document.body.replaceChildren(inner);
28
+ return;
29
+ }
30
+ inner?.contentWindow?.postMessage(message,'*');
31
+ } else if(inner && event.source===inner.contentWindow) {
32
+ parent.postMessage(event.data,'*');
33
+ }
34
+ });
35
+ parent.postMessage(ready,'*');
36
+ </script>`;
37
+ /**
38
+ * The sandbox document's own CSP. `sandbox allow-scripts` forces an opaque origin server-side even
39
+ * if a future embedder frames the URL without a sandbox attribute (the document can never read
40
+ * service cookies or storage); inline script/style must be allowed because the relay script and the
41
+ * inner widget document's injected bridge are inline by design; everything else stays closed —
42
+ * widget data flows only over the postMessage bridge, never direct fetch.
43
+ */
44
+ const SANDBOX_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src https: data: blob:; font-src https: data:; frame-src about:; form-action 'none'; base-uri 'none'; frame-ancestors *";
45
+ /** Serve the static, secret-free sandbox document. GET-only; cacheable despite the no-store baseline. */
46
+ export function handleAssistantSandbox(req, res) {
4
47
  if (req.method !== 'GET') {
5
48
  res.statusCode = 405;
6
49
  res.setHeader('Allow', 'GET');
7
50
  res.end();
8
51
  return;
9
52
  }
10
- const policy = resolveAssistantSandboxResponsePolicy(req.url ?? '/', options.isolatedOrigin === true);
11
- res.setHeader('Content-Security-Policy', policy.contentSecurityPolicy);
12
- if (!policy.ok) {
13
- res.statusCode = 400;
14
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
15
- res.setHeader('Cache-Control', 'no-store');
16
- res.end('Invalid sandbox policy');
17
- return;
18
- }
19
53
  res.statusCode = 200;
20
54
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
55
+ res.setHeader('Content-Security-Policy', SANDBOX_CSP);
21
56
  res.setHeader('Cache-Control', 'public, max-age=300');
22
57
  res.end(ASSISTANT_SANDBOX_DOCUMENT);
23
58
  }
@@ -142,7 +142,7 @@ export async function handleAssistantSession(req, res, deps) {
142
142
  : {}),
143
143
  caller,
144
144
  configuration,
145
- endpoints: assistantSessionEndpoints(deps.serviceBase(req), deps.sandboxBase?.(req)),
145
+ endpoints: assistantSessionEndpoints(deps.serviceBase(req)),
146
146
  });
147
147
  }
148
148
  const session = await deps.store.createSession({
@@ -166,7 +166,7 @@ export async function handleAssistantSession(req, res, deps) {
166
166
  const sessionBody = {
167
167
  token: session.token,
168
168
  expiresAt: session.session.expiresAt,
169
- endpoints: assistantSessionEndpoints(deps.serviceBase(req), deps.sandboxBase?.(req)),
169
+ endpoints: assistantSessionEndpoints(deps.serviceBase(req)),
170
170
  ...(configuration ? { configuration } : {}),
171
171
  };
172
172
  // Every published @noodleseed/assistant widget consumes this shape; parse (don't just test)
@@ -1,4 +1,4 @@
1
- import { InMemoryAssistantStore, isAssistantSandboxHost, validateAssistantSandboxBaseUrl, } from '@noodle-borg/assistant-gateway/portable';
1
+ import { InMemoryAssistantStore } 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,9 +50,6 @@ 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);
56
53
  const logger = options.logger ?? noopLogger;
57
54
  const tls = options.tls ?? {};
58
55
  const buildInfo = options.buildInfo ?? resolveBuildInfo();
@@ -158,14 +155,6 @@ export function createServiceHandler(registry, options = {}) {
158
155
  };
159
156
  return (req, res) => {
160
157
  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
- }
169
158
  // Liveness/readiness probes: un-gated and **not** HTTPS-enforced — Cloud Run's internal probe is plain
170
159
  // HTTP, so enforcing HTTPS here would `426` the probe and the revision would never go healthy (ADR 0034).
171
160
  // `/healthz` is a pure liveness 200 (no store touch); `/readyz` reflects the injected readiness probe.
@@ -261,9 +250,6 @@ export function createServiceHandler(registry, options = {}) {
261
250
  audit: activeAudit,
262
251
  maxBody,
263
252
  serviceBase: (request) => options.publicBaseUrl ?? baseFromRequest(request, tls),
264
- ...(assistantSandboxBaseUrl === undefined
265
- ? {}
266
- : { sandboxBase: () => assistantSandboxBaseUrl }),
267
253
  ...(options.assistantModelFetch !== undefined
268
254
  ? { modelFetch: options.assistantModelFetch }
269
255
  : {}),
@@ -35,8 +35,8 @@ troubleshooting) lives at <https://docs.noodleseed.dev/guides/embedded-assistant
35
35
  the server itself is `@noodleseed/one` (`npm install -g @noodleseed/one`).
36
36
 
37
37
  If your page sends a `Content-Security-Policy`, allow the Noodle service origin in `connect-src` (turns and
38
- event streams) and the exact session-advertised `endpoints.sandbox` origin in `frame-src` (it may be a
39
- dedicated sandbox origin, so your `script-src` can stay strict). Details are in the guide's
38
+ event streams) **and** `frame-src` (app widgets render inside a hosted sandbox document served from the
39
+ service origin, `endpoints.sandbox`, so your `script-src` can stay strict). Details are in the guide's
40
40
  "Content-Security-Policy on your page" section.
41
41
 
42
42
  ## Quick start
@@ -410,8 +410,13 @@ import "@noodleseed/assistant/app-view";
410
410
  </template>
411
411
  ```
412
412
 
413
- Framework setup, lifecycle ownership, sandbox/CSP behavior, and staged diagnostics are owned by the
414
- [Bring your own UI guide](https://docs.noodleseed.dev/docs/guides/bring-your-own-ui).
413
+ Configure Vue's `isCustomElement` for `noodle-app-view`. Angular uses the same element with `[client]`,
414
+ `[view]`, and `[theme]` property bindings. Do not serialize `client` or `view` into attributes.
415
+
416
+ `<noodle-app-view>` is the canonical host; `NoodleAppView` delegates to it. Both use the
417
+ service-advertised sandbox URL, route App calls through the supplied client, publish theme changes without
418
+ replacing the iframe, and request standard teardown on semantic replacement, disconnect, or an App teardown
419
+ request.
415
420
 
416
421
  The App document owns its action intent: it calls standard `tools/call` after connecting and never relies on
417
422
  native form navigation. If that call needs input or confirmation, the same `client` publishes the normal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/one",
3
- "version": "0.141.1",
3
+ "version": "0.141.2",
4
4
  "private": false,
5
5
  "description": "Noodle CLI by Noodle Seed — author, run, and deploy declarative MCP servers. Embedding the assistant in your own web app is @noodleseed/assistant.",
6
6
  "license": "Apache-2.0",
@@ -1,13 +0,0 @@
1
- /** Relay body pinned to `contract/v1/assistant-sandbox-document.html` and the browser client. */
2
- export declare const ASSISTANT_SANDBOX_DOCUMENT = "<!doctype html><meta charset=\"utf-8\"><style>html,body,iframe{border:0;margin:0;width:100%;height:100%;overflow:hidden}body{background:transparent}</style><body><script>\nlet inner,loaded=false;\nif(self===top)throw new Error('The assistant sandbox relay must be embedded');\nconst standardSandbox='allow-scripts allow-same-origin allow-forms';\nconst rawParentOrigin=new URLSearchParams(location.hash.slice(1)).get('parentOrigin');\nlet expectedParentOrigin;\ntry{const parsed=new URL(rawParentOrigin||'');if(parsed.origin===rawParentOrigin&&/^https?:$/.test(parsed.protocol))expectedParentOrigin=parsed.origin}catch{}\nconst parentTarget=expectedParentOrigin||'*';\nconst ownOrigin=self.origin;\nconst ready={jsonrpc:'2.0',method:'ui/notifications/sandbox-proxy-ready',params:{}};\n// Re-announce until the host acknowledges with the resource: when this document is hosted\n// cross-origin, the first announcement can arrive before the host's load handler is listening.\nconst announce=setInterval(()=>parent.postMessage(ready,parentTarget),120);\nsetTimeout(()=>clearInterval(announce),15000);\naddEventListener('message',(event)=>{\n if(event.source===parent){\n if(expectedParentOrigin&&event.origin!==expectedParentOrigin)return;\n const message=event.data;\n if(message?.method==='ui/notifications/sandbox-resource-ready'){\n clearInterval(announce);\n if(loaded)return;\n loaded=true;\n const requestedSandbox=message.params?.sandbox;\n inner=document.createElement('iframe');\n inner.setAttribute('sandbox',requestedSandbox===standardSandbox?standardSandbox:'allow-scripts');\n inner.setAttribute('referrerpolicy','no-referrer');\n const permissions=message.params?.permissions;\n const allow=[];\n if(permissions?.camera)allow.push('camera');\n if(permissions?.microphone)allow.push('microphone');\n if(permissions?.geolocation)allow.push('geolocation');\n if(permissions?.clipboardWrite)allow.push('clipboard-write');\n if(allow.length)inner.setAttribute('allow',allow.join('; '));\n document.body.append(inner);\n let html=String(message.params?.html||'');\n const csp=message.params?.csp;\n if(csp&&typeof csp==='object'){\n const list=(value,fallback)=>Array.isArray(value)&&value.length?value.join(' '):fallback;\n const resources=Array.isArray(csp.resourceDomains)?csp.resourceDomains:[];\n const suffix=resources.length?' '+resources.join(' '):'';\n const policy=[\"default-src 'none'\",\"script-src 'unsafe-inline'\"+suffix,\"style-src 'unsafe-inline'\"+suffix,'img-src data: blob:'+suffix,'font-src data: blob:'+suffix,'media-src data: blob:'+suffix,'worker-src blob:'+suffix,'connect-src '+list(csp.connectDomains,\"'none'\"),'frame-src '+list(csp.frameDomains,\"'none'\"),\"object-src 'none'\",\"form-action 'none'\",'base-uri '+list(csp.baseUriDomains,\"'none'\")].join('; ');\n const meta='<meta http-equiv=\"Content-Security-Policy\" content=\"'+policy.replace(/&/g,'&amp;').replace(/\"/g,'&quot;')+'\">';\n const doctype=html.match(/^\\s*<!doctype[^>]*>/i);\n html=doctype?html.slice(0,doctype[0].length)+meta+html.slice(doctype[0].length):meta+html;\n }\n try{\n const doc=inner.contentDocument||inner.contentWindow?.document;\n if(!doc)throw new Error('document unavailable');\n doc.open();doc.write(html);doc.close();\n }catch{inner.srcdoc=html}\n return;\n }\n inner?.contentWindow?.postMessage(message,'*');\n } else if(inner && event.source===inner.contentWindow) {\n if(event.origin!==ownOrigin)return;\n parent.postMessage(event.data,parentTarget);\n }\n});\nparent.postMessage(ready,parentTarget);\n</script></body>";
3
- export interface AssistantSandboxResponsePolicy {
4
- readonly ok: boolean;
5
- readonly contentSecurityPolicy: string;
6
- }
7
- /** Match a request authority to the configured sandbox origin, normalizing default ports. */
8
- export declare function isAssistantSandboxHost(requestHost: string | undefined, sandboxBaseUrl: string | undefined): boolean;
9
- /** Validate an untrusted query and return the CSP response header to apply before navigation. */
10
- export declare function resolveAssistantSandboxResponsePolicy(requestUrl: string, isolatedOrigin?: boolean): AssistantSandboxResponsePolicy;
11
- /** Normalize the operator-provided dedicated sandbox origin and keep it off the service origin. */
12
- export declare function validateAssistantSandboxBaseUrl(value: string, serviceBase: string | undefined): string;
13
- //# sourceMappingURL=assistant-app-sandbox.d.ts.map
@@ -1,204 +0,0 @@
1
- /** Relay body pinned to `contract/v1/assistant-sandbox-document.html` and the browser client. */
2
- export const ASSISTANT_SANDBOX_DOCUMENT = `<!doctype html><meta charset="utf-8"><style>html,body,iframe{border:0;margin:0;width:100%;height:100%;overflow:hidden}body{background:transparent}</style><body><script>
3
- let inner,loaded=false;
4
- if(self===top)throw new Error('The assistant sandbox relay must be embedded');
5
- const standardSandbox='allow-scripts allow-same-origin allow-forms';
6
- const rawParentOrigin=new URLSearchParams(location.hash.slice(1)).get('parentOrigin');
7
- let expectedParentOrigin;
8
- try{const parsed=new URL(rawParentOrigin||'');if(parsed.origin===rawParentOrigin&&/^https?:$/.test(parsed.protocol))expectedParentOrigin=parsed.origin}catch{}
9
- const parentTarget=expectedParentOrigin||'*';
10
- const ownOrigin=self.origin;
11
- const ready={jsonrpc:'2.0',method:'ui/notifications/sandbox-proxy-ready',params:{}};
12
- // Re-announce until the host acknowledges with the resource: when this document is hosted
13
- // cross-origin, the first announcement can arrive before the host's load handler is listening.
14
- const announce=setInterval(()=>parent.postMessage(ready,parentTarget),120);
15
- setTimeout(()=>clearInterval(announce),15000);
16
- addEventListener('message',(event)=>{
17
- if(event.source===parent){
18
- if(expectedParentOrigin&&event.origin!==expectedParentOrigin)return;
19
- const message=event.data;
20
- if(message?.method==='ui/notifications/sandbox-resource-ready'){
21
- clearInterval(announce);
22
- if(loaded)return;
23
- loaded=true;
24
- const requestedSandbox=message.params?.sandbox;
25
- inner=document.createElement('iframe');
26
- inner.setAttribute('sandbox',requestedSandbox===standardSandbox?standardSandbox:'allow-scripts');
27
- inner.setAttribute('referrerpolicy','no-referrer');
28
- const permissions=message.params?.permissions;
29
- const allow=[];
30
- if(permissions?.camera)allow.push('camera');
31
- if(permissions?.microphone)allow.push('microphone');
32
- if(permissions?.geolocation)allow.push('geolocation');
33
- if(permissions?.clipboardWrite)allow.push('clipboard-write');
34
- if(allow.length)inner.setAttribute('allow',allow.join('; '));
35
- document.body.append(inner);
36
- let html=String(message.params?.html||'');
37
- const csp=message.params?.csp;
38
- if(csp&&typeof csp==='object'){
39
- const list=(value,fallback)=>Array.isArray(value)&&value.length?value.join(' '):fallback;
40
- const resources=Array.isArray(csp.resourceDomains)?csp.resourceDomains:[];
41
- const suffix=resources.length?' '+resources.join(' '):'';
42
- const policy=["default-src 'none'","script-src 'unsafe-inline'"+suffix,"style-src 'unsafe-inline'"+suffix,'img-src data: blob:'+suffix,'font-src data: blob:'+suffix,'media-src data: blob:'+suffix,'worker-src blob:'+suffix,'connect-src '+list(csp.connectDomains,"'none'"),'frame-src '+list(csp.frameDomains,"'none'"),"object-src 'none'","form-action 'none'",'base-uri '+list(csp.baseUriDomains,"'none'")].join('; ');
43
- const meta='<meta http-equiv="Content-Security-Policy" content="'+policy.replace(/&/g,'&amp;').replace(/"/g,'&quot;')+'">';
44
- const doctype=html.match(/^\\s*<!doctype[^>]*>/i);
45
- html=doctype?html.slice(0,doctype[0].length)+meta+html.slice(doctype[0].length):meta+html;
46
- }
47
- try{
48
- const doc=inner.contentDocument||inner.contentWindow?.document;
49
- if(!doc)throw new Error('document unavailable');
50
- doc.open();doc.write(html);doc.close();
51
- }catch{inner.srcdoc=html}
52
- return;
53
- }
54
- inner?.contentWindow?.postMessage(message,'*');
55
- } else if(inner && event.source===inner.contentWindow) {
56
- if(event.origin!==ownOrigin)return;
57
- parent.postMessage(event.data,parentTarget);
58
- }
59
- });
60
- parent.postMessage(ready,parentTarget);
61
- </script></body>`;
62
- const COMPATIBILITY_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src https: data: blob:; font-src https: data:; frame-src about:; form-action 'none'; base-uri 'none'; frame-ancestors *";
63
- const MAX_CSP_QUERY_LENGTH = 8_192;
64
- const MAX_DOMAINS_PER_DIRECTIVE = 32;
65
- const CSP_KEYS = ['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'];
66
- const CSP_KEY_SET = new Set(CSP_KEYS);
67
- /** Match a request authority to the configured sandbox origin, normalizing default ports. */
68
- export function isAssistantSandboxHost(requestHost, sandboxBaseUrl) {
69
- if (requestHost === undefined || sandboxBaseUrl === undefined)
70
- return false;
71
- try {
72
- const sandboxUrl = new URL(sandboxBaseUrl);
73
- const requestUrl = new URL(`${sandboxUrl.protocol}//${requestHost}`);
74
- return requestUrl.href === `${sandboxUrl.origin}/`;
75
- }
76
- catch {
77
- return false;
78
- }
79
- }
80
- function isRecord(value) {
81
- return typeof value === 'object' && value !== null && !Array.isArray(value);
82
- }
83
- function isLoopback(hostname) {
84
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
85
- }
86
- function normalizeCspDomain(value, connect) {
87
- if (value.length > 512 || /[;\r\n'"\s]/u.test(value))
88
- return undefined;
89
- try {
90
- const parsed = new URL(value);
91
- const secure = parsed.protocol === 'https:' || (connect && parsed.protocol === 'wss:');
92
- const local = isLoopback(parsed.hostname) &&
93
- (parsed.protocol === 'http:' || (connect && parsed.protocol === 'ws:'));
94
- if (!secure && !local)
95
- return undefined;
96
- if (parsed.username ||
97
- parsed.password ||
98
- parsed.pathname !== '/' ||
99
- parsed.search ||
100
- parsed.hash) {
101
- return undefined;
102
- }
103
- return parsed.origin;
104
- }
105
- catch {
106
- return undefined;
107
- }
108
- }
109
- function parseSandboxCsp(requestUrl) {
110
- const url = new URL(requestUrl, 'http://sandbox.invalid');
111
- const values = url.searchParams.getAll('csp');
112
- if (values.length === 0)
113
- return { ok: true };
114
- const serialized = values[0];
115
- if (values.length !== 1 || serialized === undefined || serialized.length > MAX_CSP_QUERY_LENGTH) {
116
- return { ok: false };
117
- }
118
- let parsed;
119
- try {
120
- parsed = JSON.parse(serialized);
121
- }
122
- catch {
123
- return { ok: false };
124
- }
125
- if (!isRecord(parsed) || Object.keys(parsed).some((key) => !CSP_KEY_SET.has(key))) {
126
- return { ok: false };
127
- }
128
- const csp = {};
129
- for (const key of CSP_KEYS) {
130
- const raw = parsed[key];
131
- if (raw === undefined)
132
- continue;
133
- if (!Array.isArray(raw) || raw.length > MAX_DOMAINS_PER_DIRECTIVE)
134
- return { ok: false };
135
- const normalized = [];
136
- for (const entry of raw) {
137
- if (typeof entry !== 'string')
138
- return { ok: false };
139
- const domain = normalizeCspDomain(entry, key === 'connectDomains');
140
- if (!domain)
141
- return { ok: false };
142
- normalized.push(domain);
143
- }
144
- csp[key] = [...new Set(normalized)];
145
- }
146
- return { ok: true, value: csp };
147
- }
148
- function sources(values, fallback) {
149
- return values && values.length > 0 ? values.join(' ') : fallback;
150
- }
151
- function sandboxCsp(csp, isolatedOrigin) {
152
- const resources = csp.resourceDomains ?? [];
153
- const resourceSuffix = resources.length > 0 ? ` ${resources.join(' ')}` : '';
154
- return [
155
- isolatedOrigin
156
- ? 'sandbox allow-scripts allow-same-origin allow-forms'
157
- : 'sandbox allow-scripts',
158
- "default-src 'none'",
159
- `script-src 'self' 'unsafe-inline'${resourceSuffix}`,
160
- `style-src 'self' 'unsafe-inline'${resourceSuffix}`,
161
- `img-src 'self' data: blob:${resourceSuffix}`,
162
- `font-src 'self' data: blob:${resourceSuffix}`,
163
- `media-src 'self' data: blob:${resourceSuffix}`,
164
- `worker-src 'self' blob:${resourceSuffix}`,
165
- `connect-src 'self'${csp.connectDomains && csp.connectDomains.length > 0 ? ` ${csp.connectDomains.join(' ')}` : ''}`,
166
- `frame-src ${sources(csp.frameDomains, "'none'")}`,
167
- "object-src 'none'",
168
- "form-action 'none'",
169
- `base-uri ${sources(csp.baseUriDomains, "'none'")}`,
170
- 'frame-ancestors *',
171
- ].join('; ');
172
- }
173
- /** Validate an untrusted query and return the CSP response header to apply before navigation. */
174
- export function resolveAssistantSandboxResponsePolicy(requestUrl, isolatedOrigin = false) {
175
- const parsed = parseSandboxCsp(requestUrl);
176
- if (!parsed.ok)
177
- return { ok: false, contentSecurityPolicy: COMPATIBILITY_CSP };
178
- return {
179
- ok: true,
180
- contentSecurityPolicy: parsed.value !== undefined || isolatedOrigin
181
- ? sandboxCsp(parsed.value ?? {}, isolatedOrigin)
182
- : COMPATIBILITY_CSP,
183
- };
184
- }
185
- /** Normalize the operator-provided dedicated sandbox origin and keep it off the service origin. */
186
- export function validateAssistantSandboxBaseUrl(value, serviceBase) {
187
- let parsed;
188
- try {
189
- parsed = new URL(value);
190
- }
191
- catch {
192
- throw new Error('assistantSandboxBaseUrl must be an exact HTTPS or HTTP loopback origin');
193
- }
194
- const loopback = isLoopback(parsed.hostname);
195
- if (parsed.origin !== value ||
196
- (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback))) {
197
- throw new Error('assistantSandboxBaseUrl must be an exact HTTPS or HTTP loopback origin');
198
- }
199
- if (serviceBase !== undefined && new URL(serviceBase).origin === parsed.origin) {
200
- throw new Error('assistantSandboxBaseUrl must use an origin separate from publicBaseUrl');
201
- }
202
- return parsed.origin;
203
- }
204
- //# sourceMappingURL=assistant-app-sandbox.js.map