sliccy 6.112.1 → 6.112.3

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/node-server/bridge-security.d.ts +9 -0
  2. package/dist/node-server/bridge-security.js +11 -0
  3. package/dist/node-server/fetch-proxy-headers.d.ts +13 -0
  4. package/dist/node-server/fetch-proxy-headers.js +19 -0
  5. package/dist/node-server/hostfs.d.ts +57 -1
  6. package/dist/node-server/hostfs.js +197 -38
  7. package/dist/node-server/index.js +6 -11
  8. package/dist/ui/.vite/manifest.json +137 -137
  9. package/dist/ui/assets/{CLAUDE-mcg5-RUQ.js → CLAUDE-CgpX4Q5m.js} +1 -1
  10. package/dist/ui/assets/{account-store--nuF-RGJ.js → account-store-DKGiIfmq.js} +1 -1
  11. package/dist/ui/assets/{account-store-BeAa98D1.js → account-store-Drm8M57M.js} +1 -1
  12. package/dist/ui/assets/{adobe-CidVPXRa.js → adobe-CS7hK6MS.js} +1 -1
  13. package/dist/ui/assets/{adobe-B00trSo1.js → adobe-CsMz2rsC.js} +1 -1
  14. package/dist/ui/assets/{agent-bridge-BoLNvuOE.js → agent-bridge-Diq7l6-Z.js} +1 -1
  15. package/dist/ui/assets/{agent-panels-Tl57pRxc.js → agent-panels-B9c8k1kc.js} +1 -1
  16. package/dist/ui/assets/{apply-layout-DZt5RV9q.js → apply-layout-Cr_Qp7ts.js} +1 -1
  17. package/dist/ui/assets/{approver-agent-CYHU0chR.js → approver-agent-CoFD9luE.js} +3 -3
  18. package/dist/ui/assets/{apps-DhjdsdYb.js → apps-DQTe9Hdt.js} +1 -1
  19. package/dist/ui/assets/{auto-mount-table-B5XJxoBU.js → auto-mount-table-BRFqHPcB.js} +1 -1
  20. package/dist/ui/assets/{azure-openai-Bk8F_5Ct.js → azure-openai-BXn6Q7yb.js} +1 -1
  21. package/dist/ui/assets/{azure-openai-C7ikeAJM.js → azure-openai-CzFCMFS3.js} +1 -1
  22. package/dist/ui/assets/backend-hostfs-CwOm0hxZ.js +1 -0
  23. package/dist/ui/assets/backend-hostfs-DHZa_yjt.js +1 -0
  24. package/dist/ui/assets/{cerebras-C-b4420j.js → cerebras-B6ESuqhH.js} +1 -1
  25. package/dist/ui/assets/{cerebras-7NXHhdw9.js → cerebras-C-6yRmj5.js} +1 -1
  26. package/dist/ui/assets/{connect-surface-HN8UkePG.js → connect-surface-C6Z5eEC-.js} +1 -1
  27. package/dist/ui/assets/{directed-approval-BqGD0uli.js → directed-approval-LOfGNkxz.js} +1 -1
  28. package/dist/ui/assets/{fs-FMv2mTn9.js → fs-CH0Ljn4a.js} +1 -1
  29. package/dist/ui/assets/{fs-BMP_fMEC.js → fs-CjSSfDYh.js} +2 -2
  30. package/dist/ui/assets/{github-McCslPoz.js → github-C4S66iqT.js} +1 -1
  31. package/dist/ui/assets/{github-Dnmx9rG9.js → github-CGmhboTB.js} +2 -2
  32. package/dist/ui/assets/{github-copilot-DCIGII9u.js → github-copilot-Cys8f2bl.js} +1 -1
  33. package/dist/ui/assets/{github-copilot-CxdF4Fy6.js → github-copilot-DVPbVtKI.js} +1 -1
  34. package/dist/ui/assets/{github-install-B4wNs2LH.js → github-install-DL98HSJZ.js} +1 -1
  35. package/dist/ui/assets/{github-zip-CqGulfsL.js → github-zip-COa4Q2lq.js} +1 -1
  36. package/dist/ui/assets/{js-realm-worker-ITMN_2K6.js → js-realm-worker-DPFyND8C.js} +1 -1
  37. package/dist/ui/assets/{kernel-worker-Ds3v--4d.js → kernel-worker-C0uy1TbG.js} +610 -610
  38. package/dist/ui/assets/{local-llm-DSZm3QFD.js → local-llm-D-9YzYyO.js} +1 -1
  39. package/dist/ui/assets/{local-llm-DewB9FGZ.js → local-llm-DWgPIy_K.js} +1 -1
  40. package/dist/ui/assets/{main-fHoUORLi.js → main-B4SwrphU.js} +3 -3
  41. package/dist/ui/assets/{mount-BtKe9aDF.js → mount-C_4aO72D.js} +3 -3
  42. package/dist/ui/assets/{mount-DFmEuTiy.js → mount-D2bzwfnP.js} +1 -1
  43. package/dist/ui/assets/{mount-recovery-CMKD-bzt.js → mount-recovery-DX5ZMNPV.js} +1 -1
  44. package/dist/ui/assets/{new-session-DeVo5krf.js → new-session-CobplCqm.js} +2 -2
  45. package/dist/ui/assets/{oauth-bootstrap-CgDdETL4.js → oauth-bootstrap-Da8vG0Ce.js} +2 -2
  46. package/dist/ui/assets/{openai-codex-C1wHRHpH.js → openai-codex-BQy2GRvd.js} +1 -1
  47. package/dist/ui/assets/{openai-codex-D6RWtriP.js → openai-codex-BxXGZbZ2.js} +1 -1
  48. package/dist/ui/assets/{openrouter-R7ci1m2k.js → openrouter-BCGVjBV9.js} +1 -1
  49. package/dist/ui/assets/{openrouter-S6zAimHH.js → openrouter-CIP6PZdI.js} +1 -1
  50. package/dist/ui/assets/{openrouter-oauth-C9uW2DM8.js → openrouter-oauth-BSFzZC1g.js} +1 -1
  51. package/dist/ui/assets/{openrouter-oauth-B31J13WZ.js → openrouter-oauth-DnkEkrDp.js} +1 -1
  52. package/dist/ui/assets/{panel-rpc-handlers-Djq6ayLg.js → panel-rpc-handlers-9GTNZnKe.js} +2 -2
  53. package/dist/ui/assets/{panelize-shell-HCqDW0kS.js → panelize-shell-DfteHska.js} +1 -1
  54. package/dist/ui/assets/{provider-BdOLjeIN.js → provider-DNg8cWzc.js} +2 -2
  55. package/dist/ui/assets/{provider-DyZJkoqu.js → provider-kpBMt5XH.js} +1 -1
  56. package/dist/ui/assets/{provider-settings-c6LQDQyN.js → provider-settings-BFKSyu38.js} +1 -1
  57. package/dist/ui/assets/{provider-settings-BCGgAkb4.js → provider-settings-CXxoEx6k.js} +1 -1
  58. package/dist/ui/assets/provider-store-access-BTyiaWJG.js +1 -0
  59. package/dist/ui/assets/{provider-store-access-CZVIwB2B.js → provider-store-access-Dcy8pc8z.js} +2 -2
  60. package/dist/ui/assets/{providers-9Yhsk0CM.js → providers-90_avw00.js} +1 -1
  61. package/dist/ui/assets/{providers-BEaf1qJO.js → providers-C7JLZFjM.js} +2 -2
  62. package/dist/ui/assets/{quick-llm-BlrlCIMB.js → quick-llm-D59bKtv9.js} +1 -1
  63. package/dist/ui/assets/{recovery-screen-e4RP50qV.js → recovery-screen-C51ohvOQ.js} +1 -1
  64. package/dist/ui/assets/{run-B7GdGu08.js → run-CSH5VzF0.js} +2 -2
  65. package/dist/ui/assets/{run-BReo7uW0.js → run-Fu1DWOPZ.js} +1 -1
  66. package/dist/ui/assets/{session-freezer-mJRq9tDS.js → session-freezer-BUkrQCDA.js} +3 -3
  67. package/dist/ui/assets/{setup-feature-flags-remote-D0xfkklT.js → setup-feature-flags-remote-C_x2aqvQ.js} +1 -1
  68. package/dist/ui/assets/{setup-standalone-prelude-VfWdqiy6.js → setup-standalone-prelude-D1hf5svM.js} +1 -1
  69. package/dist/ui/assets/{setup-sudo-BKrQw1Rt.js → setup-sudo-BeQ_wWvx.js} +2 -2
  70. package/dist/ui/assets/{skills-ClAcOXt4.js → skills-c_2geZLF.js} +1 -1
  71. package/dist/ui/assets/slicc-version-Bmv6W3YW.js +1 -0
  72. package/dist/ui/assets/{state-CRJ8EjRP.js → state-CxYcgIEC.js} +1 -1
  73. package/dist/ui/assets/{store-BAvs4zf5.js → store-DDRMLUbL.js} +1 -1
  74. package/dist/ui/assets/{store-DFXW05cL.js → store-hz-L7qro.js} +1 -1
  75. package/dist/ui/assets/{sudo-DbzYnyAb.js → sudo-EuNE7Rcp.js} +1 -1
  76. package/dist/ui/assets/{upgrade-detection-DvObHCch.js → upgrade-detection-tKoUqNBL.js} +1 -1
  77. package/dist/ui/assets/{wc-attach-BKynrUhf.js → wc-attach-u_LmkqY-.js} +2 -2
  78. package/dist/ui/assets/{wc-extension-BGm_6rPO.js → wc-extension-DIm4QLFM.js} +2 -2
  79. package/dist/ui/assets/{wc-float-label-DBomNbxu.js → wc-float-label-CtNoKDwq.js} +1 -1
  80. package/dist/ui/assets/{wc-floatbar-online-B4EXNrH9.js → wc-floatbar-online-CPLQs77u.js} +1 -1
  81. package/dist/ui/assets/{wc-follower-XK-drJQa.js → wc-follower-D6gXrBmp.js} +3 -3
  82. package/dist/ui/assets/{wc-follower-oauth-vp1M17Dr.js → wc-follower-oauth-B9IGaduR.js} +1 -1
  83. package/dist/ui/assets/{wc-live-DceF_UzK.js → wc-live-CRYDgY8_.js} +4 -4
  84. package/dist/ui/assets/{wc-nav-CDkLZdES.js → wc-nav-BBBCCIWN.js} +2 -2
  85. package/dist/ui/assets/{wc-onboarding-BWy2dzK7.js → wc-onboarding-DQQ2cJsn.js} +2 -2
  86. package/dist/ui/assets/{wc-placeholder-BxAYxGjQ.js → wc-placeholder-_lRw8rmV.js} +2 -2
  87. package/dist/ui/assets/{wc-settings-DkB2E9BQ.js → wc-settings-CQAUL3mr.js} +2 -2
  88. package/dist/ui/assets/{wc-shell-DpHDz6l0.js → wc-shell-DCeYY9cZ.js} +2 -2
  89. package/dist/ui/assets/{wc-shortcut-config-zaGKgOg0.js → wc-shortcut-config-Tfio7pdk.js} +1 -1
  90. package/dist/ui/assets/{wc-sprinkles-BwHGBn0Y.js → wc-sprinkles-CnZmKYuh.js} +2 -2
  91. package/dist/ui/assets/{wc-tray-CDug6sCk.js → wc-tray-Q3ELQQvC.js} +4 -4
  92. package/dist/ui/assets/{xai-grok-C0MYOtlB.js → xai-grok-B0MEGouW.js} +1 -1
  93. package/dist/ui/assets/{xai-grok-Be30-EeE.js → xai-grok-fDgioGoH.js} +1 -1
  94. package/dist/ui/index.html +6 -6
  95. package/dist/ui/packages/webapp/index.html +6 -6
  96. package/package.json +1 -1
  97. package/dist/ui/assets/backend-hostfs-Com-zF5W.js +0 -1
  98. package/dist/ui/assets/backend-hostfs-bQQx-S0W.js +0 -1
  99. package/dist/ui/assets/provider-store-access-BVEsJXQR.js +0 -1
  100. package/dist/ui/assets/slicc-version-CW-yYQ5m.js +0 -1
@@ -161,3 +161,12 @@ export declare function buildCorsHeaders(origin: string | undefined | null, requ
161
161
  * — Chrome blocks public→private fetch / WS otherwise.
162
162
  */
163
163
  export declare function buildPnaPreflightHeaders(): Record<string, string>;
164
+ /**
165
+ * Preflight cache lifetime. `/api/hostfs/*` gets Chrome's cap (7200s) because
166
+ * the mounted-filesystem traffic is thousands of requests per command and
167
+ * every one of them is non-simple (bridge-token header) plus public→loopback
168
+ * (Chrome's Private Network Access preflight) — see #2715. The rest of /api
169
+ * keeps the conservative 10 minutes: those routes are low-volume, and a short
170
+ * window bounds how long a stale allowed-header set can linger.
171
+ */
172
+ export declare function preflightMaxAge(path: string): string;
@@ -332,3 +332,14 @@ export function buildCorsHeaders(origin, requestHeadersHeader) {
332
332
  export function buildPnaPreflightHeaders() {
333
333
  return { 'Access-Control-Allow-Private-Network': 'true' };
334
334
  }
335
+ /**
336
+ * Preflight cache lifetime. `/api/hostfs/*` gets Chrome's cap (7200s) because
337
+ * the mounted-filesystem traffic is thousands of requests per command and
338
+ * every one of them is non-simple (bridge-token header) plus public→loopback
339
+ * (Chrome's Private Network Access preflight) — see #2715. The rest of /api
340
+ * keeps the conservative 10 minutes: those routes are low-volume, and a short
341
+ * window bounds how long a stale allowed-header set can linger.
342
+ */
343
+ export function preflightMaxAge(path) {
344
+ return path === '/api/hostfs' || path.startsWith('/api/hostfs/') ? '7200' : '600';
345
+ }
@@ -1,3 +1,4 @@
1
+ import type { IncomingMessage } from 'http';
1
2
  /**
2
3
  * Headers the `/api/fetch-proxy` route does NOT forward upstream.
3
4
  *
@@ -66,3 +67,15 @@ export declare const FETCH_PROXY_BASE_EXPOSE_HEADERS: readonly string[];
66
67
  * Dedupes case-insensitively while preserving the first-seen spelling.
67
68
  */
68
69
  export declare function buildFetchProxyExposeHeaders(forwardedHeaderNames: Iterable<string>): string;
70
+ /**
71
+ * Which requests the global `express.json()` consumes. Two exclusions:
72
+ *
73
+ * - `X-Slicc-Raw-Body: 1` — SigV4-signed bodies must reach the
74
+ * /api/fetch-proxy handler byte-for-byte (the parser would re-serialize
75
+ * them via JSON.stringify and break the signature);
76
+ * - the stable hostfs dispatcher — it owns a bounded 1 MiB parser and an
77
+ * errno error adapter, and this parser is mounted ahead of
78
+ * `registerHostFsRoutes`, so without the exclusion it would consume the
79
+ * body first and answer a malformed one with code-less HTML.
80
+ */
81
+ export declare function shouldParseGlobalJson(req: IncomingMessage): boolean;
@@ -1,3 +1,4 @@
1
+ import { isHostFsStableBodyRequest } from './hostfs.js';
1
2
  /**
2
3
  * Headers the `/api/fetch-proxy` route does NOT forward upstream.
3
4
  *
@@ -111,3 +112,21 @@ export function buildFetchProxyExposeHeaders(forwardedHeaderNames) {
111
112
  }
112
113
  return out.join(', ');
113
114
  }
115
+ /**
116
+ * Which requests the global `express.json()` consumes. Two exclusions:
117
+ *
118
+ * - `X-Slicc-Raw-Body: 1` — SigV4-signed bodies must reach the
119
+ * /api/fetch-proxy handler byte-for-byte (the parser would re-serialize
120
+ * them via JSON.stringify and break the signature);
121
+ * - the stable hostfs dispatcher — it owns a bounded 1 MiB parser and an
122
+ * errno error adapter, and this parser is mounted ahead of
123
+ * `registerHostFsRoutes`, so without the exclusion it would consume the
124
+ * body first and answer a malformed one with code-less HTML.
125
+ */
126
+ export function shouldParseGlobalJson(req) {
127
+ if (req.headers['x-slicc-raw-body'] === '1')
128
+ return false;
129
+ if (isHostFsStableBodyRequest(req))
130
+ return false;
131
+ return (req.headers['content-type'] ?? '').includes('application/json');
132
+ }
@@ -14,6 +14,31 @@
14
14
  * Protected like every other /api route: same-origin / loopback callers
15
15
  * pass, cross-origin callers need the bridge token (see
16
16
  * `createThinBridgeCorsMiddleware` in index.ts).
17
+ *
18
+ * Two request shapes reach the same handlers:
19
+ *
20
+ * - the per-op routes (`GET /api/hostfs/list?mount=&path=` …), kept for
21
+ * compatibility and used for the body ops (`read`, `write`);
22
+ * - a single stable `POST /api/hostfs` whose JSON body carries
23
+ * `{ op, mount, path, … }`.
24
+ *
25
+ * The stable endpoint exists purely to make CORS preflights cacheable. The
26
+ * bridge token travels as a custom header, so every cross-origin hostfs call
27
+ * is a non-simple request, and Chrome's Private Network Access preflights
28
+ * public→loopback traffic regardless — but the preflight cache is keyed by
29
+ * URL, and the per-op URLs are unique per path, so `Access-Control-Max-Age`
30
+ * never got a chance to help (issue #2715: 246,893 `OPTIONS` for 385,033
31
+ * `GET`s in one session). One stable URL collapses that to one preflight per
32
+ * max-age. `read` deliberately stays a `GET` on a per-file URL so the
33
+ * browser HTTP cache can still revalidate large blobs with a 304.
34
+ *
35
+ * The hostfs surface owns its own body parsing end to end. `index.ts` skips
36
+ * the stable dispatcher in its global `express.json()` filter (via
37
+ * `isHostFsStableBodyRequest`) so the bounded 1 MiB parser here is the one
38
+ * that runs, and `hostFsBodyErrorHandler` maps body-parser failures to the
39
+ * same `{ code, message }` errno shape as every other error on this surface —
40
+ * otherwise express's default handler answers malformed JSON with code-less
41
+ * HTML and the webapp cannot rethrow a faithful `FsError`.
17
42
  */
18
43
  import type { Express } from 'express';
19
44
  import type { HostMountMapping } from './runtime-flags.js';
@@ -40,19 +65,50 @@ interface HostMountRoot {
40
65
  * webapp will surface the absent mount as "not mounted".
41
66
  */
42
67
  export declare function resolveHostMountRoots(mounts: readonly HostMountMapping[], warn?: (msg: string) => void): Promise<HostMountRoot[]>;
68
+ /**
69
+ * Bounded body cap for the stable dispatcher — it only ever carries a small
70
+ * JSON envelope, so it has no business inheriting the global 50 MiB limit.
71
+ */
72
+ export declare const HOSTFS_STABLE_MAX_BODY_BYTES: number;
73
+ /**
74
+ * True for a request `index.ts`'s global `express.json()` must NOT consume.
75
+ * The global parser is mounted before these routes with a 50 MiB limit, so
76
+ * without this exclusion it would swallow the stable dispatcher's body first:
77
+ * the route-local 1 MiB cap would never apply, and a malformed body would
78
+ * fail in the global parser and reach express's default (code-less HTML)
79
+ * handler instead of the errno shape every hostfs caller expects. Exported so
80
+ * `index.ts` and the middleware-order test filter identically.
81
+ */
82
+ export declare function isHostFsStableBodyRequest(req: {
83
+ method?: string;
84
+ url?: string;
85
+ }): boolean;
43
86
  /**
44
87
  * Register the /api/hostfs routes for the resolved mount roots. The route
45
88
  * surface mirrors `MountBackend` 1:1 (webapp `backend-hostfs.ts`):
46
89
  *
90
+ * POST /api/hostfs ← { op, mount, path, to?, recursive? }
47
91
  * GET /api/hostfs/list?mount=&path= → { entries: [...] }
48
92
  * GET /api/hostfs/stat?mount=&path= → { kind, size, mtime }
49
93
  * GET /api/hostfs/read?mount=&path= → octet-stream body
50
94
  * PUT /api/hostfs/write?mount=&path= ← octet-stream body
51
95
  * POST /api/hostfs/mkdir?mount=&path=
96
+ * POST /api/hostfs/rename?mount=&path=&to=
52
97
  * DELETE /api/hostfs/remove?mount=&path=[&recursive=1]
53
98
  *
99
+ * `POST /api/hostfs` is the preflight-cacheable form of `list`/`stat`/
100
+ * `mkdir`/`rename`/`remove` — one URL for all of them, so one CORS preflight
101
+ * covers the whole `Access-Control-Max-Age` window instead of one per path
102
+ * (#2715). It is purely a transport change: same handlers, same payloads,
103
+ * same errno JSON. The per-op routes stay for compatibility (older webapps,
104
+ * `curl`) and remain the only form for the two body ops.
105
+ *
54
106
  * Errors are `{ code, message }` JSON with an errno-derived status so the
55
- * webapp backend can rethrow faithful `FsError`s.
107
+ * webapp backend can rethrow faithful `FsError`s — including body-parser
108
+ * failures, via `hostFsBodyErrorHandler` registered at the end. A bridge that
109
+ * predates the stable endpoint answers `POST /api/hostfs` with a framework 404
110
+ * that has no `code`, which is how the webapp detects it and falls back to the
111
+ * per-op routes — so every error this route emits MUST carry a `code`.
56
112
  */
57
113
  export declare function registerHostFsRoutes(app: Express, roots: readonly HostMountRoot[]): void;
58
114
  export {};
@@ -14,6 +14,31 @@
14
14
  * Protected like every other /api route: same-origin / loopback callers
15
15
  * pass, cross-origin callers need the bridge token (see
16
16
  * `createThinBridgeCorsMiddleware` in index.ts).
17
+ *
18
+ * Two request shapes reach the same handlers:
19
+ *
20
+ * - the per-op routes (`GET /api/hostfs/list?mount=&path=` …), kept for
21
+ * compatibility and used for the body ops (`read`, `write`);
22
+ * - a single stable `POST /api/hostfs` whose JSON body carries
23
+ * `{ op, mount, path, … }`.
24
+ *
25
+ * The stable endpoint exists purely to make CORS preflights cacheable. The
26
+ * bridge token travels as a custom header, so every cross-origin hostfs call
27
+ * is a non-simple request, and Chrome's Private Network Access preflights
28
+ * public→loopback traffic regardless — but the preflight cache is keyed by
29
+ * URL, and the per-op URLs are unique per path, so `Access-Control-Max-Age`
30
+ * never got a chance to help (issue #2715: 246,893 `OPTIONS` for 385,033
31
+ * `GET`s in one session). One stable URL collapses that to one preflight per
32
+ * max-age. `read` deliberately stays a `GET` on a per-file URL so the
33
+ * browser HTTP cache can still revalidate large blobs with a 304.
34
+ *
35
+ * The hostfs surface owns its own body parsing end to end. `index.ts` skips
36
+ * the stable dispatcher in its global `express.json()` filter (via
37
+ * `isHostFsStableBodyRequest`) so the bounded 1 MiB parser here is the one
38
+ * that runs, and `hostFsBodyErrorHandler` maps body-parser failures to the
39
+ * same `{ code, message }` errno shape as every other error on this surface —
40
+ * otherwise express's default handler answers malformed JSON with code-less
41
+ * HTML and the webapp cannot rethrow a faithful `FsError`.
17
42
  */
18
43
  import express from 'express';
19
44
  import { lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'fs/promises';
@@ -124,19 +149,138 @@ function queryString(req, name) {
124
149
  const value = req.query[name];
125
150
  return typeof value === 'string' && value.length > 0 ? value : null;
126
151
  }
152
+ /** Path of the stable dispatcher. */
153
+ const HOSTFS_STABLE_PATH = '/api/hostfs';
154
+ /**
155
+ * Bounded body cap for the stable dispatcher — it only ever carries a small
156
+ * JSON envelope, so it has no business inheriting the global 50 MiB limit.
157
+ */
158
+ export const HOSTFS_STABLE_MAX_BODY_BYTES = 1024 * 1024;
159
+ function isHostFsPath(path) {
160
+ return path === HOSTFS_STABLE_PATH || path.startsWith(`${HOSTFS_STABLE_PATH}/`);
161
+ }
162
+ /**
163
+ * True for a request `index.ts`'s global `express.json()` must NOT consume.
164
+ * The global parser is mounted before these routes with a 50 MiB limit, so
165
+ * without this exclusion it would swallow the stable dispatcher's body first:
166
+ * the route-local 1 MiB cap would never apply, and a malformed body would
167
+ * fail in the global parser and reach express's default (code-less HTML)
168
+ * handler instead of the errno shape every hostfs caller expects. Exported so
169
+ * `index.ts` and the middleware-order test filter identically.
170
+ */
171
+ export function isHostFsStableBodyRequest(req) {
172
+ if (req.method !== 'POST')
173
+ return false;
174
+ const path = (req.url ?? '').split('?')[0];
175
+ return path === HOSTFS_STABLE_PATH || path === `${HOSTFS_STABLE_PATH}/`;
176
+ }
177
+ /**
178
+ * Map body-parser failures on the hostfs surface to `{ code, message }`.
179
+ * Registered after the routes (express only runs an error handler for
180
+ * middleware mounted before it), so it catches the stable dispatcher's JSON
181
+ * parser AND `PUT /write`'s raw parser. Mirrors swift-server, which answers
182
+ * 400 `EINVAL` for an unparseable body and 413 `EFBIG` for an oversized one.
183
+ * Anything that is not a body-parser error, and every non-hostfs path, is
184
+ * passed through untouched.
185
+ */
186
+ const hostFsBodyErrorHandler = (err, req, res, next) => {
187
+ const type = err?.type;
188
+ if (typeof type !== 'string' || res.headersSent || !isHostFsPath(req.path)) {
189
+ next(err);
190
+ return;
191
+ }
192
+ const message = err instanceof Error ? err.message : String(err);
193
+ if (type === 'entity.too.large') {
194
+ res.status(413).json({ code: 'EFBIG', message: `hostfs body too large: ${message}` });
195
+ return;
196
+ }
197
+ res.status(400).json({ code: 'EINVAL', message: `hostfs body rejected: ${message}` });
198
+ };
199
+ /** Ops reachable through the stable `POST /api/hostfs` endpoint. */
200
+ const STABLE_OPS = ['list', 'stat', 'mkdir', 'rename', 'remove'];
201
+ function isStableOp(value) {
202
+ return typeof value === 'string' && STABLE_OPS.includes(value);
203
+ }
204
+ function bodyString(body, name) {
205
+ const value = body[name];
206
+ return typeof value === 'string' && value.length > 0 ? value : null;
207
+ }
208
+ /** `recursive` accepts the query-string `'1'` and a JSON `true` alike. */
209
+ function isRecursive(value) {
210
+ return value === true || value === '1' || value === 1;
211
+ }
212
+ /** `readdir` + a stat per entry, so consumers get size/mtime without an N+1. */
213
+ async function listOp(target) {
214
+ const dirents = await readdir(target, { withFileTypes: true });
215
+ const entries = await Promise.all(dirents.map(async (d) => {
216
+ if (d.isDirectory())
217
+ return { name: d.name, kind: 'directory' };
218
+ try {
219
+ // stat() follows symlinks, so a link to a directory classifies as
220
+ // a directory — matching what every subsequent access sees (and
221
+ // the swift server, whose fileExists follows links). Escaping
222
+ // links are still refused at access time by resolveWithinRoot.
223
+ const s = await stat(resolve(target, d.name));
224
+ if (s.isDirectory())
225
+ return { name: d.name, kind: 'directory' };
226
+ return {
227
+ name: d.name,
228
+ kind: 'file',
229
+ size: s.size,
230
+ lastModified: Math.round(s.mtimeMs),
231
+ };
232
+ }
233
+ catch {
234
+ // Raced deletion / dangling link / unreadable: name only.
235
+ return { name: d.name, kind: 'file' };
236
+ }
237
+ }));
238
+ return { entries };
239
+ }
240
+ async function statOp(target) {
241
+ return statPayload(await stat(target));
242
+ }
243
+ async function mkdirOp(target) {
244
+ await mkdir(target, { recursive: true });
245
+ return { ok: true };
246
+ }
247
+ async function renameOp(from, to) {
248
+ await rename(from, to);
249
+ return { ok: true };
250
+ }
251
+ async function removeOp(target, recursive, roots) {
252
+ if (roots.some((r) => r.root === target)) {
253
+ throw Object.assign(new Error('refusing to remove a mount root'), { code: 'EACCES' });
254
+ }
255
+ await rm(target, { recursive, force: false });
256
+ return { ok: true };
257
+ }
127
258
  /**
128
259
  * Register the /api/hostfs routes for the resolved mount roots. The route
129
260
  * surface mirrors `MountBackend` 1:1 (webapp `backend-hostfs.ts`):
130
261
  *
262
+ * POST /api/hostfs ← { op, mount, path, to?, recursive? }
131
263
  * GET /api/hostfs/list?mount=&path= → { entries: [...] }
132
264
  * GET /api/hostfs/stat?mount=&path= → { kind, size, mtime }
133
265
  * GET /api/hostfs/read?mount=&path= → octet-stream body
134
266
  * PUT /api/hostfs/write?mount=&path= ← octet-stream body
135
267
  * POST /api/hostfs/mkdir?mount=&path=
268
+ * POST /api/hostfs/rename?mount=&path=&to=
136
269
  * DELETE /api/hostfs/remove?mount=&path=[&recursive=1]
137
270
  *
271
+ * `POST /api/hostfs` is the preflight-cacheable form of `list`/`stat`/
272
+ * `mkdir`/`rename`/`remove` — one URL for all of them, so one CORS preflight
273
+ * covers the whole `Access-Control-Max-Age` window instead of one per path
274
+ * (#2715). It is purely a transport change: same handlers, same payloads,
275
+ * same errno JSON. The per-op routes stay for compatibility (older webapps,
276
+ * `curl`) and remain the only form for the two body ops.
277
+ *
138
278
  * Errors are `{ code, message }` JSON with an errno-derived status so the
139
- * webapp backend can rethrow faithful `FsError`s.
279
+ * webapp backend can rethrow faithful `FsError`s — including body-parser
280
+ * failures, via `hostFsBodyErrorHandler` registered at the end. A bridge that
281
+ * predates the stable endpoint answers `POST /api/hostfs` with a framework 404
282
+ * that has no `code`, which is how the webapp detects it and falls back to the
283
+ * per-op routes — so every error this route emits MUST carry a `code`.
140
284
  */
141
285
  export function registerHostFsRoutes(app, roots) {
142
286
  const byPath = new Map(roots.map((r) => [r.path, r]));
@@ -160,35 +304,58 @@ export function registerHostFsRoutes(app, roots) {
160
304
  })();
161
305
  };
162
306
  };
163
- app.get('/api/hostfs/list', withTarget(async (target, _req, res) => {
164
- const dirents = await readdir(target, { withFileTypes: true });
165
- const entries = await Promise.all(dirents.map(async (d) => {
166
- if (d.isDirectory())
167
- return { name: d.name, kind: 'directory' };
307
+ // Stable-URL dispatcher. Mounted before the per-op routes so `/api/hostfs`
308
+ // is matched exactly; express keeps `/api/hostfs/mkdir` distinct.
309
+ app.post(HOSTFS_STABLE_PATH, express.json({ limit: HOSTFS_STABLE_MAX_BODY_BYTES }), (req, res) => {
310
+ void (async () => {
311
+ const body = (req.body ?? {});
312
+ const op = body.op;
313
+ if (!isStableOp(op)) {
314
+ res.status(400).json({ code: 'EINVAL', message: `unsupported hostfs op: ${String(op)}` });
315
+ return;
316
+ }
317
+ const mount = bodyString(body, 'mount');
318
+ const entry = mount ? byPath.get(mount) : undefined;
319
+ if (!entry) {
320
+ res.status(404).json({ code: 'ENOENT', message: `no such mount: ${mount ?? ''}` });
321
+ return;
322
+ }
168
323
  try {
169
- // stat() follows symlinks, so a link to a directory classifies as
170
- // a directory — matching what every subsequent access sees (and
171
- // the swift server, whose fileExists follows links). Escaping
172
- // links are still refused at access time by resolveWithinRoot.
173
- const s = await stat(resolve(target, d.name));
174
- if (s.isDirectory())
175
- return { name: d.name, kind: 'directory' };
176
- return {
177
- name: d.name,
178
- kind: 'file',
179
- size: s.size,
180
- lastModified: Math.round(s.mtimeMs),
181
- };
324
+ const target = await resolveWithinRoot(entry.root, bodyString(body, 'path') ?? '');
325
+ switch (op) {
326
+ case 'list':
327
+ res.json(await listOp(target));
328
+ return;
329
+ case 'stat':
330
+ res.json(await statOp(target));
331
+ return;
332
+ case 'mkdir':
333
+ res.json(await mkdirOp(target));
334
+ return;
335
+ case 'rename': {
336
+ const toRel = bodyString(body, 'to');
337
+ if (!toRel) {
338
+ res.status(400).json({ code: 'EINVAL', message: 'rename requires to' });
339
+ return;
340
+ }
341
+ res.json(await renameOp(target, await resolveWithinRoot(entry.root, toRel)));
342
+ return;
343
+ }
344
+ case 'remove':
345
+ res.json(await removeOp(target, isRecursive(body.recursive), roots));
346
+ return;
347
+ }
182
348
  }
183
- catch {
184
- // Raced deletion / dangling link / unreadable: name only.
185
- return { name: d.name, kind: 'file' };
349
+ catch (err) {
350
+ sendFsError(res, err);
186
351
  }
187
- }));
188
- res.json({ entries });
352
+ })();
353
+ });
354
+ app.get('/api/hostfs/list', withTarget(async (target, _req, res) => {
355
+ res.json(await listOp(target));
189
356
  }));
190
357
  app.get('/api/hostfs/stat', withTarget(async (target, _req, res) => {
191
- res.json(statPayload(await stat(target)));
358
+ res.json(await statOp(target));
192
359
  }));
193
360
  app.get('/api/hostfs/read', withTarget(async (target, _req, res) => {
194
361
  // lstat first so a dangling symlink is ENOENT, a directory is EISDIR.
@@ -229,8 +396,7 @@ export function registerHostFsRoutes(app, roots) {
229
396
  res.json({ ok: true });
230
397
  }));
231
398
  app.post('/api/hostfs/mkdir', withTarget(async (target, _req, res) => {
232
- await mkdir(target, { recursive: true });
233
- res.json({ ok: true });
399
+ res.json(await mkdirOp(target));
234
400
  }));
235
401
  app.post('/api/hostfs/rename', withTarget(async (target, req, res) => {
236
402
  const mount = queryString(req, 'mount');
@@ -240,18 +406,11 @@ export function registerHostFsRoutes(app, roots) {
240
406
  res.status(400).json({ code: 'EINVAL', message: 'rename requires mount and to' });
241
407
  return;
242
408
  }
243
- const to = await resolveWithinRoot(entry.root, toRel);
244
- await rename(target, to);
245
- res.json({ ok: true });
409
+ res.json(await renameOp(target, await resolveWithinRoot(entry.root, toRel)));
246
410
  }));
247
411
  app.delete('/api/hostfs/remove', withTarget(async (target, req, res) => {
248
- const isRoot = roots.some((r) => r.root === target);
249
- if (isRoot) {
250
- res.status(403).json({ code: 'EACCES', message: 'refusing to remove a mount root' });
251
- return;
252
- }
253
- const recursive = queryString(req, 'recursive') === '1';
254
- await rm(target, { recursive, force: false });
255
- res.json({ ok: true });
412
+ res.json(await removeOp(target, queryString(req, 'recursive') === '1', roots));
256
413
  }));
414
+ // Last: only reachable for errors raised by middleware mounted above it.
415
+ app.use(hostFsBodyErrorHandler);
257
416
  }
@@ -11,7 +11,7 @@ import { homedir } from 'os';
11
11
  import { basename, dirname, join, resolve } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { WebSocket, WebSocketServer } from 'ws';
14
- import { BRIDGE_TOKEN_HEADER, buildCorsHeaders, buildPnaPreflightHeaders, isLoopbackBridgeOrigin, resolveServerBridgeToken, selectBridgeSubprotocol, shouldMountThinBridgeCors, validateBridgeToken, validateBridgeUpgrade, } from './bridge-security.js';
14
+ import { BRIDGE_TOKEN_HEADER, buildCorsHeaders, buildPnaPreflightHeaders, isLoopbackBridgeOrigin, preflightMaxAge, resolveServerBridgeToken, selectBridgeSubprotocol, shouldMountThinBridgeCors, validateBridgeToken, validateBridgeUpgrade, } from './bridge-security.js';
15
15
  import { closeLaunchedBrowserGracefully } from './browser-shutdown.js';
16
16
  import { applyCdpUnmask } from './cdp-proxy/cdp-unmask.js';
17
17
  import { createCdpSessionUrlTracker } from './cdp-proxy/session-url-tracker.js';
@@ -28,6 +28,7 @@ import { registerCloudStatusEndpoint } from './cloud-status.js';
28
28
  import { ElectronAppAlreadyRunningError, ElectronOverlayInjector, launchElectronApp, resolveOverlayThinBridge, } from './electron-controller.js';
29
29
  import { getElectronAppPorts } from './electron-runtime.js';
30
30
  import { ElectronTrayFollower } from './electron-tray-follower.js';
31
+ import { shouldParseGlobalJson } from './fetch-proxy-headers.js';
31
32
  import { FileLogger } from './file-logger.js';
32
33
  import { registerHostedBootstrapEndpoint } from './hosted-bootstrap.js';
33
34
  import { registerHostFsRoutes, resolveHostMountRoots } from './hostfs.js';
@@ -1018,7 +1019,7 @@ function createThinBridgeCorsMiddleware(bridgeToken) {
1018
1019
  if (req.method === 'OPTIONS' && cors) {
1019
1020
  for (const [k, v] of Object.entries(buildPnaPreflightHeaders()))
1020
1021
  res.setHeader(k, v);
1021
- res.setHeader('Access-Control-Max-Age', '600');
1022
+ res.setHeader('Access-Control-Max-Age', preflightMaxAge(req.path));
1022
1023
  res.status(204).end();
1023
1024
  return;
1024
1025
  }
@@ -1084,15 +1085,9 @@ async function main() {
1084
1085
  const { lickWss, broadcastLickEvent } = lickBridge;
1085
1086
  // OAuth callback — generic redirect target for OAuth providers (implicit + PKCE)
1086
1087
  registerOAuthCallbackRoutes(app);
1087
- // Global JSON body parser. Skipped when the request carries
1088
- // `X-Slicc-Raw-Body: 1`, so SigV4-signed bodies survive into the
1089
- // /api/fetch-proxy handler byte-for-byte (the parser would otherwise
1090
- // re-serialize them via JSON.stringify, breaking the signature).
1091
- app.use(express.json({
1092
- limit: '50mb',
1093
- type: (req) => req.headers['x-slicc-raw-body'] !== '1' &&
1094
- (req.headers['content-type'] ?? '').includes('application/json'),
1095
- }));
1088
+ // Global JSON body parser; `shouldParseGlobalJson` owns the exclusions
1089
+ // (SigV4 raw bodies, the stable hostfs dispatcher).
1090
+ app.use(express.json({ limit: '50mb', type: shouldParseGlobalJson }));
1096
1091
  app.get('/api/runtime-config', (_req, res) => {
1097
1092
  res.json({
1098
1093
  trayWorkerBaseUrl: