sliccy 3.39.2 → 3.40.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 (158) hide show
  1. package/dist/node-server/_shared/cdp-frame-unmask.d.ts +30 -0
  2. package/dist/node-server/_shared/cdp-frame-unmask.d.ts.map +1 -0
  3. package/dist/node-server/_shared/cdp-frame-unmask.js +82 -0
  4. package/dist/node-server/_shared/cdp-frame-unmask.js.map +1 -0
  5. package/dist/node-server/_shared/index.d.ts +2 -0
  6. package/dist/node-server/_shared/index.d.ts.map +1 -1
  7. package/dist/node-server/_shared/index.js +2 -0
  8. package/dist/node-server/_shared/index.js.map +1 -1
  9. package/dist/node-server/_shared/secrets-pipeline.d.ts +9 -0
  10. package/dist/node-server/_shared/secrets-pipeline.d.ts.map +1 -1
  11. package/dist/node-server/_shared/secrets-pipeline.js +8 -1
  12. package/dist/node-server/_shared/secrets-pipeline.js.map +1 -1
  13. package/dist/node-server/_shared/session-secret-store.d.ts +48 -0
  14. package/dist/node-server/_shared/session-secret-store.d.ts.map +1 -0
  15. package/dist/node-server/_shared/session-secret-store.js +78 -0
  16. package/dist/node-server/_shared/session-secret-store.js.map +1 -0
  17. package/dist/node-server/cdp-proxy/cdp-unmask.d.ts +48 -0
  18. package/dist/node-server/cdp-proxy/cdp-unmask.js +59 -0
  19. package/dist/node-server/cdp-proxy/session-url-tracker.d.ts +27 -0
  20. package/dist/node-server/cdp-proxy/session-url-tracker.js +153 -0
  21. package/dist/node-server/index.js +143 -4
  22. package/dist/node-server/secrets/proxy-manager.d.ts +11 -1
  23. package/dist/node-server/secrets/proxy-manager.js +15 -2
  24. package/dist/node-server/sudo/dialog-backends.d.ts +29 -0
  25. package/dist/node-server/sudo/dialog-backends.js +181 -0
  26. package/dist/node-server/sudo/electron-backend.d.ts +36 -0
  27. package/dist/node-server/sudo/electron-backend.js +76 -0
  28. package/dist/node-server/sudo/endpoint.d.ts +25 -0
  29. package/dist/node-server/sudo/endpoint.js +52 -0
  30. package/dist/node-server/sudo/select.d.ts +29 -0
  31. package/dist/node-server/sudo/select.js +59 -0
  32. package/dist/node-server/sudo/tty-backend.d.ts +17 -0
  33. package/dist/node-server/sudo/tty-backend.js +49 -0
  34. package/dist/node-server/sudo/types.d.ts +33 -0
  35. package/dist/node-server/sudo/types.js +11 -0
  36. package/dist/ui/assets/{adobe-BkKbqjN2.js → adobe-D7Bu_6Xq.js} +1 -1
  37. package/dist/ui/assets/{adobe-EVDv-oJe.js → adobe-PH-jjYjF.js} +2 -2
  38. package/dist/ui/assets/{agent-bridge-C_uLyvTI.js → agent-bridge-DDNenR2p.js} +1 -1
  39. package/dist/ui/assets/{agent-message-to-chat-CeS0fVHu.js → agent-message-to-chat-Cs1a90IF.js} +1 -1
  40. package/dist/ui/assets/anthropic-CTkdrlJN.js +39 -0
  41. package/dist/ui/assets/{anthropic-CU0B0Pm9.js → anthropic-DXRkmxDU.js} +2 -2
  42. package/dist/ui/assets/{apps-Cr7y2pou.js → apps-B2AntWKe.js} +2 -2
  43. package/dist/ui/assets/{azure-openai-BbQrZgL2.js → azure-openai-Cmc98FIq.js} +1 -1
  44. package/dist/ui/assets/{azure-openai-BkZHl52u.js → azure-openai-CwjjFN-O.js} +1 -1
  45. package/dist/ui/assets/azure-openai-responses-BLIW1xU7.js +1 -0
  46. package/dist/ui/assets/{azure-openai-responses-IwnQ3IBU.js → azure-openai-responses-CF6XLlVB.js} +1 -1
  47. package/dist/ui/assets/{biome_wasm-C3ncT0Zp.js → biome_wasm-C0WbzefQ.js} +1 -1
  48. package/dist/ui/assets/{bsh-watchdog-vMuNcim4.js → bsh-watchdog-BxX5IGxY.js} +1 -1
  49. package/dist/ui/assets/{cdp-zqWeM9Fx.js → cdp-Bd7aCDAK.js} +3 -3
  50. package/dist/ui/assets/{cdp-ws-page-bridge-BccgQ_MP.js → cdp-ws-page-bridge-DSeh5Jiw.js} +1 -1
  51. package/dist/ui/assets/{common-DZkhMfZN.js → common-DQ_jkQux.js} +1 -1
  52. package/dist/ui/assets/{connect-surface-DEau8hB2.js → connect-surface-DDDI1Dgm.js} +1 -1
  53. package/dist/ui/assets/cost-command-aIrlbZJo.js +1 -0
  54. package/dist/ui/assets/{dist-DuRiHK77.js → dist-BfqAr0aD.js} +2 -2
  55. package/dist/ui/assets/{dist-BEjMwuAO.js → dist-KZ5qYFb-.js} +1 -1
  56. package/dist/ui/assets/{dist-F3PYJWK5.js → dist-LM9XR3Ck.js} +4 -4
  57. package/dist/ui/assets/{dist-BAWk6emk.js → dist-dMDCOAqo.js} +1 -1
  58. package/dist/ui/assets/{es-C7IOSTrK.js → es-B-rb0sPs.js} +1 -1
  59. package/dist/ui/assets/{esp32-l4qNjLi8.js → esp32-CljpQAJY.js} +1 -1
  60. package/dist/ui/assets/{esp32-Bgz6pdUM.js → esp32-DFF7rglw.js} +1 -1
  61. package/dist/ui/assets/{esp32c2-BvojiH-8.js → esp32c2-BLeN-vix.js} +1 -1
  62. package/dist/ui/assets/{esp32c2-Cw-tOYX0.js → esp32c2-BmdiKjVW.js} +1 -1
  63. package/dist/ui/assets/{esp32c3-B_2bJHNk.js → esp32c3-8cCC0ir4.js} +1 -1
  64. package/dist/ui/assets/{esp32c3-DY9pxlPN.js → esp32c3-CTu-f3Xa.js} +1 -1
  65. package/dist/ui/assets/{esp32c5-D7qf04lj.js → esp32c5-DfM0P74N.js} +1 -1
  66. package/dist/ui/assets/{esp32c5-FIUNaCuV.js → esp32c5-Dmf9q2Kt.js} +1 -1
  67. package/dist/ui/assets/{esp32c6-BdBb7y38.js → esp32c6-Dalsz3Ct.js} +1 -1
  68. package/dist/ui/assets/{esp32c6-DUgdbovF.js → esp32c6-IeKAg0Kz.js} +1 -1
  69. package/dist/ui/assets/{esp32c61-DLeQhFdd.js → esp32c61-57FHL1Xw.js} +1 -1
  70. package/dist/ui/assets/{esp32c61-UDRWrpzb.js → esp32c61-BWqD6AVB.js} +1 -1
  71. package/dist/ui/assets/{esp32h2-DB3fwjOh.js → esp32h2-D7eWIDCE.js} +1 -1
  72. package/dist/ui/assets/{esp32h2-JwDRtz-6.js → esp32h2-bzsrRjL9.js} +1 -1
  73. package/dist/ui/assets/{esp32p4-BREZajQ_.js → esp32p4-CDKjbwAm.js} +1 -1
  74. package/dist/ui/assets/{esp32p4-DImm9rZq.js → esp32p4-WaZKy8n8.js} +1 -1
  75. package/dist/ui/assets/{esp32s2-BzUqmNUY.js → esp32s2-CJWWe00j.js} +1 -1
  76. package/dist/ui/assets/{esp32s2-Dl_HOpu8.js → esp32s2-g795J8Us.js} +1 -1
  77. package/dist/ui/assets/{esp32s3-CVtlgvOB.js → esp32s3-BnK1LBEK.js} +1 -1
  78. package/dist/ui/assets/{esp32s3-dQC1sCq-.js → esp32s3-Cul2n__O.js} +1 -1
  79. package/dist/ui/assets/esp8266-CERpRtkt.js +1 -0
  80. package/dist/ui/assets/esp8266-CbDmUO0X.js +1 -0
  81. package/dist/ui/assets/{esptool-operations-C_StTYge.js → esptool-operations-D1aen6ZL.js} +3 -3
  82. package/dist/ui/assets/{esptool-operations-Bqunmv8c.js → esptool-operations-DltyURXu.js} +2 -2
  83. package/dist/ui/assets/{follower-sprinkle-bridge-BAwETPVw.js → follower-sprinkle-bridge-CnPTZaZh.js} +1 -1
  84. package/dist/ui/assets/{github-D__yuYPE.js → github-C3i8pVHA.js} +1 -1
  85. package/dist/ui/assets/{github-BRvRXw7P.js → github-CMrw5zty.js} +2 -2
  86. package/dist/ui/assets/{github-copilot-CciTQvjp.js → github-copilot-BvDkdFYE.js} +1 -1
  87. package/dist/ui/assets/{github-copilot-f_yTYAGH.js → github-copilot-DQLbeHXc.js} +2 -2
  88. package/dist/ui/assets/{google-z7G7cUTg.js → google-B7W0uHWB.js} +1 -1
  89. package/dist/ui/assets/{google-DlsDPGgv.js → google-UbHq3P8Y.js} +1 -1
  90. package/dist/ui/assets/{google-shared-CHBsSinA.js → google-shared-BnvN4efk.js} +1 -1
  91. package/dist/ui/assets/{google-shared-BgAGdoyv.js → google-shared-DS-_SSGb.js} +1 -1
  92. package/dist/ui/assets/{google-vertex-BkQMSZWx.js → google-vertex-B05qyweB.js} +1 -1
  93. package/dist/ui/assets/{google-vertex-DB0-2x4F.js → google-vertex-B68biZKe.js} +1 -1
  94. package/dist/ui/assets/{hosted-config-apply-C0EMBy-k.js → hosted-config-apply-CFYBj6qv.js} +1 -1
  95. package/dist/ui/assets/{intercepted-oauth-98jE6MdJ.js → intercepted-oauth-Bq-TCYrk.js} +1 -1
  96. package/dist/ui/assets/{kernel-worker-C_bNzCH6.js → kernel-worker-CIvIwZQg.js} +1185 -1096
  97. package/dist/ui/assets/lick-ws-bridge-BHZTexU3.js +1 -0
  98. package/dist/ui/assets/{local-llm-BWbSsi3a.js → local-llm-D5crtA-T.js} +2 -2
  99. package/dist/ui/assets/{magick-wasm-Dtq-eXDu.js → magick-wasm-gsjLyST3.js} +1 -1
  100. package/dist/ui/assets/{main-D7lo-Iw7.js → main-KLdStgNy.js} +21 -21
  101. package/dist/ui/assets/{main-cherry-DA2Vwatj.js → main-cherry-B9S3KoTZ.js} +1 -1
  102. package/dist/ui/assets/{migration-run-B828fX-Z.js → migration-run--MyxIthe.js} +1 -1
  103. package/dist/ui/assets/{mistral-CDP7d9Q7.js → mistral-6QPBN4nW.js} +1 -1
  104. package/dist/ui/assets/{mistral-roBDzZtU.js → mistral-D_t9egZV.js} +2 -2
  105. package/dist/ui/assets/{nodejs-Bwix3RAJ.js → nodejs-DSUq4B29.js} +1 -1
  106. package/dist/ui/assets/{nuke-command-vh4Y9o7P.js → nuke-command-Zw-leLT5.js} +1 -1
  107. package/dist/ui/assets/{oauth-B6PLhGUg.js → oauth-MqZ4M6m-.js} +1 -1
  108. package/dist/ui/assets/{oauth-bootstrap-Cr4B6L_c.js → oauth-bootstrap-C59I_3tx.js} +2 -2
  109. package/dist/ui/assets/{oauth-service-BNaPQI8J.js → oauth-service-DA4rM4JC.js} +1 -1
  110. package/dist/ui/assets/{oauth-service-D7FIq6-6.js → oauth-service-zJNIoo1V.js} +1 -1
  111. package/dist/ui/assets/{offscreen-client-Bfe0KuPj.js → offscreen-client-GWuo9BNt.js} +1 -1
  112. package/dist/ui/assets/{onboarding-orchestrator-BYaZ0be3.js → onboarding-orchestrator-BeANj7Ul.js} +1 -1
  113. package/dist/ui/assets/{openai-codex-D1hjYfz1.js → openai-codex-Bv4yjwxV.js} +1 -1
  114. package/dist/ui/assets/{openai-codex-DHgM74Sl.js → openai-codex-k8eWFcZV.js} +1 -1
  115. package/dist/ui/assets/{openai-codex-responses-BimSBWEw.js → openai-codex-responses-C7JqE0YG.js} +1 -1
  116. package/dist/ui/assets/{openai-codex-responses-DayS7aDG.js → openai-codex-responses-Co5XyulR.js} +5 -5
  117. package/dist/ui/assets/{openai-completions-VKddpnU6.js → openai-completions-Di-lTps6.js} +3 -3
  118. package/dist/ui/assets/{openai-responses-D9HA45yW.js → openai-responses-DcNwTkEg.js} +1 -1
  119. package/dist/ui/assets/{openai-responses-aNm5t_ca.js → openai-responses-SO5bUHHa.js} +1 -1
  120. package/dist/ui/assets/{openai-responses-shared-BZFLXrgF.js → openai-responses-shared-5JtIgjw9.js} +3 -3
  121. package/dist/ui/assets/{openai-responses-shared-DzI8rQQS.js → openai-responses-shared-Bn2RwoxX.js} +1 -1
  122. package/dist/ui/assets/{openrouter-OoruFe8v.js → openrouter-DLKnm2fn.js} +1 -1
  123. package/dist/ui/assets/panel-rpc-handlers-BcjV-5BH.js +2 -0
  124. package/dist/ui/assets/{provider-BMMzCthA.js → provider-BCqaH59Q.js} +2 -2
  125. package/dist/ui/assets/{provider-Ba1IsiDR.js → provider-CjSymjn8.js} +1 -1
  126. package/dist/ui/assets/provider-store-access-DLqGKyKa.js +1 -0
  127. package/dist/ui/assets/provider-store-access-ihLsqbjK.js +1 -0
  128. package/dist/ui/assets/{proxied-fetch-Cn_CFESw.js → proxied-fetch-DM7Dn6UW.js} +1 -1
  129. package/dist/ui/assets/{pyodide-Cpi0sKef.js → pyodide-DPVxTLey.js} +2 -2
  130. package/dist/ui/assets/{remote-terminal-view-BYQH-0XJ.js → remote-terminal-view-BgzYQ6yQ.js} +3 -3
  131. package/dist/ui/assets/{remote-vfs-client-B4-lP0Zo.js → remote-vfs-client-DwVNXJtn.js} +1 -1
  132. package/dist/ui/assets/{secret-env-D39KVeV6.js → secret-env-B97hdahj.js} +1 -1
  133. package/dist/ui/assets/{slicc-editor-BWgR4Xj3.js → slicc-editor-B8iqXLRa.js} +1 -1
  134. package/dist/ui/assets/{spawn-MJ8-EjZE.js → spawn-DZ-kooaL.js} +1 -1
  135. package/dist/ui/assets/{sql-wasm-Ulc3s55p.js → sql-wasm-KvD5hCYg.js} +1 -1
  136. package/dist/ui/assets/{src-6t6EkdjL.js → src-DoxSh3Qb.js} +1 -1
  137. package/dist/ui/assets/{store-nrzYvE_Y.js → store-B65s0_qo.js} +1 -1
  138. package/dist/ui/assets/sudo-b4cnGRfs.js +4 -0
  139. package/dist/ui/assets/{tray-leave-runtime-mn_AtTiW.js → tray-leave-runtime-Cj8YRVeh.js} +1 -1
  140. package/dist/ui/assets/{typescript-6K4Vreer.js → typescript-Bjb8Ed-M.js} +1 -1
  141. package/dist/ui/assets/{upgrade-detection-rDZlWMcK.js → upgrade-detection-PZDmKvvH.js} +1 -1
  142. package/dist/ui/assets/{web-C3ecTZIA.js → web-Ck9rK35p.js} +1 -1
  143. package/dist/ui/assets/{writable-vfs-client-NlEFPGwe.js → writable-vfs-client-BaCzCT1_.js} +1 -1
  144. package/dist/ui/assets/{ws-subscribers-DAPNffxG.js → ws-subscribers-CYoDKDxH.js} +1 -1
  145. package/dist/ui/assets/{xai-grok-B9fBrwRF.js → xai-grok-CgZiPzSS.js} +1 -1
  146. package/dist/ui/assets/{xai-grok-BDnhgMfk.js → xai-grok-DvO83Y1t.js} +1 -1
  147. package/dist/ui/index.html +1 -1
  148. package/dist/ui/packages/webapp/index.html +1 -1
  149. package/package.json +1 -1
  150. package/dist/ui/assets/anthropic-BGuKxqSF.js +0 -39
  151. package/dist/ui/assets/azure-openai-responses-BLuAUdUm.js +0 -1
  152. package/dist/ui/assets/cost-command-DLq7GqKn.js +0 -1
  153. package/dist/ui/assets/esp8266-BEpoAOn2.js +0 -1
  154. package/dist/ui/assets/esp8266-CqYRyoC9.js +0 -1
  155. package/dist/ui/assets/lick-ws-bridge-BBMzHt7o.js +0 -1
  156. package/dist/ui/assets/panel-rpc-handlers-CGWZd1To.js +0 -2
  157. package/dist/ui/assets/provider-store-access-CKu7KBid.js +0 -1
  158. package/dist/ui/assets/provider-store-access-CeJAwMVA.js +0 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * CdpSessionUrlTracker — sniffs the Chrome→Client leg of the CDP WebSocket
3
+ * proxy and maintains a `sessionId -> currentUrl` map keyed the same way
4
+ * navigation-watcher.ts keys its session state. The map feeds the Client→
5
+ * Chrome unmask gate (cdp-unmask.ts) so per-frame unmasking is scoped to
6
+ * the target tab's actual current URL.
7
+ *
8
+ * Three CDP events feed the tracker:
9
+ * - `Target.attachedToTarget` → seed via `params.sessionId` + `params.targetInfo.url`
10
+ * - `Target.targetInfoChanged` → keyed by `params.targetInfo.targetId`; updates
11
+ * every session pointing at that target
12
+ * - `Page.frameNavigated` (root) → `sessionId` lives at the wire-frame top level;
13
+ * only the root frame (no parentId) updates the session url
14
+ *
15
+ * `Target.detachedFromTarget` clears the entry. Everything else is a no-op.
16
+ *
17
+ * Hostname resolution is done lazily via `getHostname(sessionId)`. Empty,
18
+ * un-parseable, or `about:blank`-style URLs fail closed (return null).
19
+ */
20
+ function asObject(v) {
21
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
22
+ }
23
+ function parseHostname(url) {
24
+ if (!url || typeof url !== 'string')
25
+ return null;
26
+ try {
27
+ const u = new URL(url);
28
+ if (!u.hostname)
29
+ return null;
30
+ return u.hostname;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function createCdpSessionUrlTracker() {
37
+ // sessionId -> targetId
38
+ const sessionToTarget = new Map();
39
+ // sessionId -> url
40
+ const sessionToUrl = new Map();
41
+ // targetId -> url (last-seen, for hop-back resolution on targetInfoChanged)
42
+ const targetToUrl = new Map();
43
+ function setSessionUrl(sessionId, url) {
44
+ if (!url || typeof url !== 'string')
45
+ return;
46
+ sessionToUrl.set(sessionId, url);
47
+ const targetId = sessionToTarget.get(sessionId);
48
+ if (targetId)
49
+ targetToUrl.set(targetId, url);
50
+ }
51
+ function handleAttached(frame) {
52
+ const params = frame.params;
53
+ if (!params)
54
+ return;
55
+ const sessionId = params['sessionId'];
56
+ if (typeof sessionId !== 'string')
57
+ return;
58
+ const info = asObject(params['targetInfo']);
59
+ if (!info)
60
+ return;
61
+ const targetId = info['targetId'];
62
+ const url = info['url'];
63
+ if (typeof targetId === 'string') {
64
+ sessionToTarget.set(sessionId, targetId);
65
+ if (typeof url === 'string')
66
+ targetToUrl.set(targetId, url);
67
+ }
68
+ if (typeof url === 'string')
69
+ sessionToUrl.set(sessionId, url);
70
+ }
71
+ function handleDetached(frame) {
72
+ const sessionId = frame.params?.['sessionId'];
73
+ if (typeof sessionId !== 'string')
74
+ return;
75
+ sessionToUrl.delete(sessionId);
76
+ sessionToTarget.delete(sessionId);
77
+ }
78
+ function handleTargetInfoChanged(frame) {
79
+ const info = asObject(frame.params?.['targetInfo']);
80
+ if (!info)
81
+ return;
82
+ const targetId = info['targetId'];
83
+ const url = info['url'];
84
+ if (typeof targetId !== 'string' || typeof url !== 'string')
85
+ return;
86
+ targetToUrl.set(targetId, url);
87
+ for (const [sid, tid] of sessionToTarget.entries()) {
88
+ if (tid === targetId)
89
+ sessionToUrl.set(sid, url);
90
+ }
91
+ }
92
+ function handleFrameNavigated(frame) {
93
+ // sessionId is at the wire-frame top level for routed events.
94
+ const sessionId = frame.sessionId;
95
+ if (typeof sessionId !== 'string')
96
+ return;
97
+ const inner = asObject(frame.params?.['frame']);
98
+ if (!inner)
99
+ return;
100
+ // Only the root frame (no parentId) drives the per-tab URL.
101
+ if (typeof inner['parentId'] === 'string')
102
+ return;
103
+ const url = inner['url'];
104
+ if (typeof url === 'string')
105
+ setSessionUrl(sessionId, url);
106
+ }
107
+ function observe(raw) {
108
+ let frame;
109
+ if (typeof raw === 'string') {
110
+ try {
111
+ frame = JSON.parse(raw);
112
+ }
113
+ catch {
114
+ return;
115
+ }
116
+ }
117
+ else if (raw && typeof raw === 'object') {
118
+ frame = raw;
119
+ }
120
+ else {
121
+ return;
122
+ }
123
+ if (!frame || typeof frame.method !== 'string')
124
+ return;
125
+ switch (frame.method) {
126
+ case 'Target.attachedToTarget':
127
+ handleAttached(frame);
128
+ return;
129
+ case 'Target.detachedFromTarget':
130
+ handleDetached(frame);
131
+ return;
132
+ case 'Target.targetInfoChanged':
133
+ handleTargetInfoChanged(frame);
134
+ return;
135
+ case 'Page.frameNavigated':
136
+ handleFrameNavigated(frame);
137
+ return;
138
+ default:
139
+ return;
140
+ }
141
+ }
142
+ return {
143
+ observeChromeToClient: observe,
144
+ getUrl: (sessionId) => (sessionId ? (sessionToUrl.get(sessionId) ?? null) : null),
145
+ getHostname: (sessionId) => parseHostname(sessionId ? sessionToUrl.get(sessionId) : undefined),
146
+ size: () => sessionToUrl.size,
147
+ clear: () => {
148
+ sessionToUrl.clear();
149
+ sessionToTarget.clear();
150
+ targetToUrl.clear();
151
+ },
152
+ };
153
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { promises as fsPromises } from 'node:fs';
3
3
  import { createSubstrate } from './_cloud_core/src/index.js';
4
+ import { previewSecret } from './_shared/index.js';
4
5
  import { spawn } from 'child_process';
5
6
  import express from 'express';
6
7
  import { existsSync, readFileSync } from 'fs';
@@ -12,6 +13,8 @@ import { Readable, Transform } from 'stream';
12
13
  import { StringDecoder } from 'string_decoder';
13
14
  import { fileURLToPath } from 'url';
14
15
  import { WebSocket, WebSocketServer } from 'ws';
16
+ import { applyCdpUnmask } from './cdp-proxy/cdp-unmask.js';
17
+ import { createCdpSessionUrlTracker } from './cdp-proxy/session-url-tracker.js';
15
18
  import { buildChromeLaunchArgs, clearStaleDevToolsActivePort, ensureQaProfileScaffold, findChromeExecutable, legacyChromeCandidates, migrateLegacyDefaultChromeProfile, planChromeSpawn, resolveChromeLaunchProfile, waitForCdpPort, } from './chrome-launch.js';
16
19
  import { CliLogDedup } from './cli-log-dedup.js';
17
20
  import { parseCloudArgs } from './cloud/dispatch.js';
@@ -37,6 +40,7 @@ import { SecretProxyManager } from './secrets/proxy-manager.js';
37
40
  import { readOrCreateSessionId } from './secrets/session-id-file.js';
38
41
  import { handleDaSignAndForward, handleS3SignAndForward } from './secrets/sign-and-forward.js';
39
42
  import { registerSecretsReloadEndpoint } from './secrets-reload-endpoint.js';
43
+ import { registerSudoApproveEndpoint } from './sudo/endpoint.js';
40
44
  const Dirname = fileURLToPath(new URL('.', import.meta.url));
41
45
  const PROJECT_ROOT = resolve(Dirname, '..', '..');
42
46
  // ---------------------------------------------------------------------------
@@ -937,6 +941,90 @@ async function main() {
937
941
  .json({ error: err instanceof Error ? err.message : 'Failed to list secrets' });
938
942
  }
939
943
  });
944
+ // Persisted-set — write a secret to ~/.slicc/secrets.env. Gated by the agent's
945
+ // intrinsic sudo prompt before the request is ever sent.
946
+ app.post('/api/secrets', express.json(), async (req, res) => {
947
+ const { name, value, domains } = req.body ?? {};
948
+ if (typeof name !== 'string' ||
949
+ typeof value !== 'string' ||
950
+ !Array.isArray(domains) ||
951
+ domains.some((d) => typeof d !== 'string')) {
952
+ return res.status(400).json({ error: 'bad-request' });
953
+ }
954
+ try {
955
+ secretStore.set(name, value, domains);
956
+ await secretProxy.reload();
957
+ res.json({ ok: true });
958
+ }
959
+ catch (err) {
960
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to set secret' });
961
+ }
962
+ });
963
+ // Scope edit — update the allowed domains of an existing secret (persisted or
964
+ // session), preserving the value. Gated by the agent before sending.
965
+ app.post('/api/secrets/scope', express.json(), async (req, res) => {
966
+ const { name, domains } = req.body ?? {};
967
+ if (typeof name !== 'string' ||
968
+ !Array.isArray(domains) ||
969
+ domains.some((d) => typeof d !== 'string')) {
970
+ return res.status(400).json({ error: 'bad-request' });
971
+ }
972
+ try {
973
+ if (secretProxy.sessionStore.has(name)) {
974
+ secretProxy.sessionStore.setDomains(name, domains);
975
+ }
976
+ else {
977
+ const existing = secretStore.get(name);
978
+ if (!existing)
979
+ return res.status(404).json({ error: `no secret named "${name}"` });
980
+ secretStore.set(name, existing.value, domains);
981
+ }
982
+ await secretProxy.reload();
983
+ res.json({ ok: true });
984
+ }
985
+ catch (err) {
986
+ res
987
+ .status(500)
988
+ .json({ error: err instanceof Error ? err.message : 'Failed to update scope' });
989
+ }
990
+ });
991
+ // Session secrets — in-memory only, never written to disk. Free for the agent
992
+ // to create; the masking pipeline picks them up on the reload below.
993
+ app.get('/api/secrets/session', (_req, res) => {
994
+ res.json(secretProxy.sessionStore.list());
995
+ });
996
+ app.post('/api/secrets/session', express.json(), async (req, res) => {
997
+ const { name, value, domains } = req.body ?? {};
998
+ if (typeof name !== 'string' ||
999
+ typeof value !== 'string' ||
1000
+ (domains !== undefined &&
1001
+ (!Array.isArray(domains) || domains.some((d) => typeof d !== 'string')))) {
1002
+ return res.status(400).json({ error: 'bad-request' });
1003
+ }
1004
+ secretProxy.sessionStore.set(name, value, Array.isArray(domains) ? domains : []);
1005
+ await secretProxy.reload();
1006
+ res.json({ ok: true });
1007
+ });
1008
+ // Peek — elided preview of the unmasked value (session or persisted). The
1009
+ // full value never leaves the server.
1010
+ app.get('/api/secrets/peek', (req, res) => {
1011
+ const name = typeof req.query.name === 'string' ? req.query.name : '';
1012
+ if (!name)
1013
+ return res.status(400).json({ error: 'bad-request' });
1014
+ const session = secretProxy.sessionStore.getRecord(name);
1015
+ if (session) {
1016
+ return res.json({ name, preview: previewSecret(session.value), domains: session.domains });
1017
+ }
1018
+ const persisted = secretStore.get(name);
1019
+ if (persisted) {
1020
+ return res.json({
1021
+ name,
1022
+ preview: previewSecret(persisted.value),
1023
+ domains: persisted.domains,
1024
+ });
1025
+ }
1026
+ return res.status(404).json({ error: `no secret named "${name}"` });
1027
+ });
940
1028
  // S3 sign-and-forward — browser-side mount backend posts envelopes here;
941
1029
  // server resolves the s3.<profile>.* secrets, signs SigV4 v4, forwards to
942
1030
  // the upstream, returns the response as a JSON envelope. The browser
@@ -991,6 +1079,27 @@ async function main() {
991
1079
  }
992
1080
  }
993
1081
  });
1082
+ // Tool-output real→masked scrub. The browser-side agent realm
1083
+ // never holds real secret values, so the defense-in-depth scrub of
1084
+ // bash / read_file / other tool results runs here against the
1085
+ // node-server-owned `SecretProxyManager`. Direction is real→masked
1086
+ // ONLY (`scrubResponse`), so it is always safe and is idempotent
1087
+ // for already-masked tokens and secret-free output. The caller
1088
+ // (`packages/webapp/src/core/secret-scrub.ts`) treats any non-2xx
1089
+ // or malformed response as "return input unchanged" — the scrub is
1090
+ // defense-in-depth, not the primary defense.
1091
+ app.post('/api/secrets/scrub', express.json({ limit: '32mb' }), (req, res) => {
1092
+ const text = req.body?.text;
1093
+ if (typeof text !== 'string') {
1094
+ return res.status(400).json({ error: 'bad-request' });
1095
+ }
1096
+ try {
1097
+ res.json({ text: secretProxy.scrubResponse(text) });
1098
+ }
1099
+ catch (err) {
1100
+ res.status(500).json({ error: err instanceof Error ? err.message : 'scrub failed', text });
1101
+ }
1102
+ });
994
1103
  // Masked secrets endpoint — returns name + maskedValue pairs for shell env population.
995
1104
  // The browser fetches this at shell init to populate env vars with masked values.
996
1105
  // Real values are never exposed; only deterministic session-scoped masks.
@@ -1039,6 +1148,10 @@ async function main() {
1039
1148
  registerHostedBootstrapEndpoint(app, { secretStore });
1040
1149
  registerSecretsReloadEndpoint(app, { secretProxy });
1041
1150
  }
1151
+ // Sudo approval endpoint — raises a native OS dialog / TTY prompt from this
1152
+ // trusted process so the in-browser agent can request, but never fabricate,
1153
+ // an approval. Loopback-only; selects a backend by environment at call time.
1154
+ registerSudoApproveEndpoint(app);
1042
1155
  // Fetch proxy — forwards cross-origin requests from the browser to bypass CORS.
1043
1156
  // Used by just-bash's curl which calls the browser's fetch() API.
1044
1157
  // Note: express.json() may have already parsed the body, so we check req.body first.
@@ -1498,6 +1611,10 @@ async function main() {
1498
1611
  let activeClientWs = null;
1499
1612
  let messageBuffer = null;
1500
1613
  const cdpDedup = new CliLogDedup();
1614
+ // Tracks per-session current URL by sniffing Chrome→Client events; feeds
1615
+ // the Client→Chrome unmask gate so per-frame unmasking is scoped to
1616
+ // the target tab's actual hostname (fail-closed when unknown).
1617
+ const cdpSessionUrls = createCdpSessionUrlTracker();
1501
1618
  // Ensure everything is cleaned up when CLI exits
1502
1619
  const gracefulShutdown = async () => {
1503
1620
  if (shuttingDown)
@@ -1585,13 +1702,25 @@ async function main() {
1585
1702
  }
1586
1703
  }
1587
1704
  });
1705
+ // Apply the Client→Chrome unmask gate to a buffered frame on flush.
1706
+ // Defined here so both buffer-drain sites (ensureChromeConnection
1707
+ // already-open path + chromeWs 'open' handler) share one
1708
+ // implementation; falls back to the original bytes on any error.
1709
+ const flushClientFrame = (target, raw) => {
1710
+ const original = String(raw);
1711
+ const { output } = applyCdpUnmask(original, {
1712
+ tracker: cdpSessionUrls,
1713
+ pipeline: secretProxy.rawPipeline,
1714
+ });
1715
+ target.send(output);
1716
+ };
1588
1717
  function ensureChromeConnection(url) {
1589
1718
  return new Promise((resolve, reject) => {
1590
1719
  if (chromeWs && chromeWs.readyState === WebSocket.OPEN) {
1591
1720
  // Already connected — flush any buffered messages and go direct
1592
1721
  if (messageBuffer) {
1593
1722
  for (const msg of messageBuffer) {
1594
- chromeWs.send(String(msg));
1723
+ flushClientFrame(chromeWs, msg);
1595
1724
  }
1596
1725
  messageBuffer = null;
1597
1726
  }
@@ -1624,7 +1753,7 @@ async function main() {
1624
1753
  // Flush buffered messages
1625
1754
  if (messageBuffer) {
1626
1755
  for (const msg of messageBuffer) {
1627
- chromeWs.send(String(msg));
1756
+ flushClientFrame(chromeWs, msg);
1628
1757
  }
1629
1758
  messageBuffer = null;
1630
1759
  }
@@ -1697,6 +1826,9 @@ async function main() {
1697
1826
  const msg = `[cdp-proxy] Chrome→Client: ${preview}`;
1698
1827
  if (cdpDedup.shouldLog(msg))
1699
1828
  console.debug(msg);
1829
+ // Sniff Target.attachedToTarget / targetInfoChanged / Page.frameNavigated
1830
+ // so the Client→Chrome unmask gate can resolve per-session hostnames.
1831
+ cdpSessionUrls.observeChromeToClient(str);
1700
1832
  if (activeClientWs && activeClientWs.readyState === WebSocket.OPEN) {
1701
1833
  activeClientWs.send(str);
1702
1834
  }
@@ -1728,14 +1860,21 @@ async function main() {
1728
1860
  }
1729
1861
  // Register ALL handlers BEFORE any async work so no messages are lost
1730
1862
  clientWs.on('message', (data) => {
1731
- const preview = String(data).slice(0, 200);
1863
+ const original = String(data);
1864
+ const preview = original.slice(0, 200);
1732
1865
  if (chromeWs && chromeWs.readyState === WebSocket.OPEN && messageBuffer === null) {
1733
1866
  const msg = `[cdp-proxy] Client→Chrome: ${preview}`;
1734
1867
  if (cdpDedup.shouldLog(msg))
1735
1868
  console.debug(msg);
1736
- chromeWs.send(String(data));
1869
+ const { output } = applyCdpUnmask(original, {
1870
+ tracker: cdpSessionUrls,
1871
+ pipeline: secretProxy.rawPipeline,
1872
+ });
1873
+ chromeWs.send(output);
1737
1874
  }
1738
1875
  else if (messageBuffer !== null) {
1876
+ // Buffer the ORIGINAL bytes; unmask runs on flush so the
1877
+ // hostname tracker reflects the state at send time.
1739
1878
  messageBuffer.push(data);
1740
1879
  const msg = `[cdp-proxy] Client→Chrome (buffered): ${preview}`;
1741
1880
  if (cdpDedup.shouldLog(msg))
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * As of Task 1.4, this is a thin wrapper around SecretsPipeline from @slicc/shared-ts.
9
9
  */
10
+ import { SecretsPipeline, SessionSecretStore } from '../_shared/index.js';
10
11
  import type { EnvSecretStore } from './env-secret-store.js';
11
12
  import type { OauthSecretStore } from './oauth-secret-store.js';
12
13
  export declare class SecretProxyManager {
@@ -14,10 +15,19 @@ export declare class SecretProxyManager {
14
15
  private readonly _sessionId;
15
16
  private _envStore?;
16
17
  private _oauthStore?;
17
- constructor(store?: EnvSecretStore, sessionId?: string, oauthStore?: OauthSecretStore);
18
+ /** In-memory session secrets, layered into the pipeline; never persisted. */
19
+ readonly sessionStore: SessionSecretStore;
20
+ constructor(store?: EnvSecretStore, sessionId?: string, oauthStore?: OauthSecretStore, sessionStore?: SessionSecretStore);
18
21
  private buildSource;
19
22
  setOauthStore(store: OauthSecretStore): void;
20
23
  get sessionId(): string;
24
+ /**
25
+ * Underlying pipeline. Exposed for CDP proxy unmasking, which needs to
26
+ * call `unmaskCdpFrame(frame, hostname, pipeline)` (whole-token, gated
27
+ * on the target tab's current hostname). Do not use for new fetch-proxy
28
+ * paths — prefer the typed `unmask*` methods above.
29
+ */
30
+ get rawPipeline(): SecretsPipeline;
21
31
  reload(): Promise<void>;
22
32
  hasSecrets(): boolean;
23
33
  getMaskedEntries(): Array<{
@@ -8,19 +8,23 @@
8
8
  * As of Task 1.4, this is a thin wrapper around SecretsPipeline from @slicc/shared-ts.
9
9
  */
10
10
  import { randomUUID } from 'node:crypto';
11
- import { SecretsPipeline } from '../_shared/index.js';
11
+ import { SecretsPipeline, SessionSecretStore } from '../_shared/index.js';
12
12
  export class SecretProxyManager {
13
13
  pipeline;
14
14
  _sessionId;
15
15
  _envStore;
16
16
  _oauthStore;
17
- constructor(store, sessionId, oauthStore) {
17
+ /** In-memory session secrets, layered into the pipeline; never persisted. */
18
+ sessionStore;
19
+ constructor(store, sessionId, oauthStore, sessionStore) {
18
20
  this._sessionId = sessionId ?? randomUUID();
19
21
  this._envStore = store;
20
22
  this._oauthStore = oauthStore;
23
+ this.sessionStore = sessionStore ?? new SessionSecretStore();
21
24
  this.pipeline = new SecretsPipeline({
22
25
  sessionId: this._sessionId,
23
26
  source: this.buildSource(),
27
+ sessionStore: this.sessionStore,
24
28
  });
25
29
  }
26
30
  buildSource() {
@@ -59,6 +63,15 @@ export class SecretProxyManager {
59
63
  get sessionId() {
60
64
  return this._sessionId;
61
65
  }
66
+ /**
67
+ * Underlying pipeline. Exposed for CDP proxy unmasking, which needs to
68
+ * call `unmaskCdpFrame(frame, hostname, pipeline)` (whole-token, gated
69
+ * on the target tab's current hostname). Do not use for new fetch-proxy
70
+ * paths — prefer the typed `unmask*` methods above.
71
+ */
72
+ get rawPipeline() {
73
+ return this.pipeline;
74
+ }
62
75
  async reload() {
63
76
  await this.pipeline.reload();
64
77
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * GUI + fail-closed native sudo backends for the node-server float.
3
+ *
4
+ * Each backend turns a {@link SudoApproveRequest} into a real OS gesture
5
+ * (osascript / PowerShell / zenity / kdialog) and parses the result into a
6
+ * {@link SudoDecision}. They are deliberately injectable via {@link ExecFn} so
7
+ * tests can assert the argv and parsing without spawning a real dialog.
8
+ *
9
+ * Fail-closed contract: a dismissed dialog, a non-zero exit, an unparsable
10
+ * result, or a thrown error all resolve to `deny`. An `always` result with an
11
+ * empty pattern falls back to the suggested default.
12
+ */
13
+ import type { SudoApproveRequest, SudoBackend } from './types.js';
14
+ /** Minimal exec seam: run a binary with args, resolve its stdout. */
15
+ export type ExecFn = (cmd: string, args: string[]) => Promise<{
16
+ stdout: string;
17
+ }>;
18
+ /** Human-readable one-liner describing the gated action. */
19
+ export declare function describeRequest(req: SudoApproveRequest): string;
20
+ /** macOS: single `osascript display dialog` with 3 buttons + a text field. */
21
+ export declare function createOsascriptBackend(exec?: ExecFn): SudoBackend;
22
+ /** Windows: MessageBox (Yes/No/Cancel) + InputBox for the "Always" pattern. */
23
+ export declare function createPowerShellBackend(exec?: ExecFn): SudoBackend;
24
+ /** Linux GUI: `zenity --question` (+ `--entry` for the pattern). */
25
+ export declare function createZenityBackend(exec?: ExecFn): SudoBackend;
26
+ /** Linux GUI: `kdialog --warningyesnocancel` (+ `--inputbox`). */
27
+ export declare function createKdialogBackend(exec?: ExecFn): SudoBackend;
28
+ /** A backend that always denies — used when no native channel exists. */
29
+ export declare function createDenyBackend(name?: string): SudoBackend;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * GUI + fail-closed native sudo backends for the node-server float.
3
+ *
4
+ * Each backend turns a {@link SudoApproveRequest} into a real OS gesture
5
+ * (osascript / PowerShell / zenity / kdialog) and parses the result into a
6
+ * {@link SudoDecision}. They are deliberately injectable via {@link ExecFn} so
7
+ * tests can assert the argv and parsing without spawning a real dialog.
8
+ *
9
+ * Fail-closed contract: a dismissed dialog, a non-zero exit, an unparsable
10
+ * result, or a thrown error all resolve to `deny`. An `always` result with an
11
+ * empty pattern falls back to the suggested default.
12
+ */
13
+ import { execFile as nodeExecFile } from 'child_process';
14
+ import { promisify } from 'util';
15
+ const defaultExec = promisify(nodeExecFile);
16
+ /** Human-readable one-liner describing the gated action. */
17
+ export function describeRequest(req) {
18
+ return `${req.kind}: ${req.detail}`;
19
+ }
20
+ function fallbackPattern(req) {
21
+ return req.suggestedPattern?.trim() || req.detail.trim();
22
+ }
23
+ /** macOS: single `osascript display dialog` with 3 buttons + a text field. */
24
+ export function createOsascriptBackend(exec = defaultExec) {
25
+ return {
26
+ name: 'osascript',
27
+ async prompt(req) {
28
+ const suggested = fallbackPattern(req);
29
+ const message = `SLICC sudo — approve ${describeRequest(req)}\n\nEdit pattern for "Always":`;
30
+ const script = `display dialog ${q(message)} default answer ${q(suggested)} ` +
31
+ `buttons {"Deny", "Allow Once", "Always"} default button "Allow Once" ` +
32
+ `with title "SLICC sudo" with icon caution`;
33
+ try {
34
+ const { stdout } = await exec('osascript', ['-e', script]);
35
+ const button = /button returned:([^,\n]*)/.exec(stdout)?.[1]?.trim() ?? '';
36
+ const text = /text returned:([\s\S]*)$/.exec(stdout)?.[1]?.trim() ?? '';
37
+ if (button === 'Allow Once')
38
+ return { decision: 'allow' };
39
+ if (button === 'Always') {
40
+ return { decision: 'always', pattern: text.length > 0 ? text : suggested };
41
+ }
42
+ return { decision: 'deny' };
43
+ }
44
+ catch {
45
+ return { decision: 'deny' };
46
+ }
47
+ },
48
+ };
49
+ }
50
+ /** Windows: MessageBox (Yes/No/Cancel) + InputBox for the "Always" pattern. */
51
+ export function createPowerShellBackend(exec = defaultExec) {
52
+ return {
53
+ name: 'powershell',
54
+ async prompt(req) {
55
+ const suggested = fallbackPattern(req);
56
+ const msg = `SLICC sudo — approve ${describeRequest(req)}\n\nYes = Allow once No = Deny Cancel = Always`;
57
+ const script = [
58
+ 'Add-Type -AssemblyName System.Windows.Forms,Microsoft.VisualBasic;',
59
+ `$r=[System.Windows.Forms.MessageBox]::Show(${ps(msg)},'SLICC sudo','YesNoCancel','Warning');`,
60
+ "if($r -eq 'Yes'){Write-Output 'ALLOW'}",
61
+ `elseif($r -eq 'Cancel'){Write-Output 'ALWAYS'; Write-Output ([Microsoft.VisualBasic.Interaction]::InputBox('Edit the Always pattern','SLICC sudo',${ps(suggested)}))}`,
62
+ "else{Write-Output 'DENY'}",
63
+ ].join('\n');
64
+ try {
65
+ const { stdout } = await exec('powershell', ['-NoProfile', '-Command', script]);
66
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim());
67
+ const verb = lines.find((l) => l.length > 0) ?? '';
68
+ if (verb === 'ALLOW')
69
+ return { decision: 'allow' };
70
+ if (verb === 'ALWAYS') {
71
+ const pattern = lines[lines.indexOf('ALWAYS') + 1]?.trim();
72
+ return {
73
+ decision: 'always',
74
+ pattern: pattern && pattern.length > 0 ? pattern : suggested,
75
+ };
76
+ }
77
+ return { decision: 'deny' };
78
+ }
79
+ catch {
80
+ return { decision: 'deny' };
81
+ }
82
+ },
83
+ };
84
+ }
85
+ /** Linux GUI: `zenity --question` (+ `--entry` for the pattern). */
86
+ export function createZenityBackend(exec = defaultExec) {
87
+ return {
88
+ name: 'zenity',
89
+ async prompt(req) {
90
+ const suggested = fallbackPattern(req);
91
+ const text = `SLICC sudo — approve ${describeRequest(req)}`;
92
+ let allowed = false;
93
+ let always = false;
94
+ try {
95
+ const { stdout } = await exec('zenity', [
96
+ '--question',
97
+ '--title=SLICC sudo',
98
+ `--text=${text}`,
99
+ '--ok-label=Allow once',
100
+ '--cancel-label=Deny',
101
+ '--extra-button=Always',
102
+ ]);
103
+ always = stdout.trim() === 'Always';
104
+ allowed = true;
105
+ }
106
+ catch (err) {
107
+ // Non-zero exit: either Deny (no stdout) or the extra "Always" button.
108
+ always = stdoutOf(err).trim() === 'Always';
109
+ allowed = always;
110
+ }
111
+ if (!allowed)
112
+ return { decision: 'deny' };
113
+ if (!always)
114
+ return { decision: 'allow' };
115
+ return entryDecision(exec, 'zenity', [
116
+ '--entry',
117
+ '--title=SLICC sudo',
118
+ '--text=Edit the Always pattern',
119
+ `--entry-text=${suggested}`,
120
+ ], suggested);
121
+ },
122
+ };
123
+ }
124
+ /** Linux GUI: `kdialog --warningyesnocancel` (+ `--inputbox`). */
125
+ export function createKdialogBackend(exec = defaultExec) {
126
+ return {
127
+ name: 'kdialog',
128
+ async prompt(req) {
129
+ const suggested = fallbackPattern(req);
130
+ const text = `SLICC sudo — approve ${describeRequest(req)}\n\nYes = Allow once No = Deny Cancel = Always`;
131
+ let code;
132
+ try {
133
+ await exec('kdialog', ['--warningyesnocancel', text, '--title', 'SLICC sudo']);
134
+ code = 0; // Yes
135
+ }
136
+ catch (err) {
137
+ code = exitCodeOf(err);
138
+ }
139
+ if (code === 0)
140
+ return { decision: 'allow' };
141
+ if (code !== 2)
142
+ return { decision: 'deny' }; // 1 = No / dismissed
143
+ return entryDecision(exec, 'kdialog', ['--inputbox', 'Edit the Always pattern', suggested, '--title', 'SLICC sudo'], suggested);
144
+ },
145
+ };
146
+ }
147
+ /** A backend that always denies — used when no native channel exists. */
148
+ export function createDenyBackend(name = 'none') {
149
+ return {
150
+ name,
151
+ async prompt() {
152
+ return { decision: 'deny' };
153
+ },
154
+ };
155
+ }
156
+ async function entryDecision(exec, cmd, args, suggested) {
157
+ try {
158
+ const { stdout } = await exec(cmd, args);
159
+ const pattern = stdout.trim();
160
+ return { decision: 'always', pattern: pattern.length > 0 ? pattern : suggested };
161
+ }
162
+ catch {
163
+ return { decision: 'always', pattern: suggested };
164
+ }
165
+ }
166
+ function stdoutOf(err) {
167
+ const s = err?.stdout;
168
+ return typeof s === 'string' ? s : '';
169
+ }
170
+ function exitCodeOf(err) {
171
+ const c = err?.code;
172
+ return typeof c === 'number' ? c : 1;
173
+ }
174
+ /** Quote a string for AppleScript. */
175
+ function q(s) {
176
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
177
+ }
178
+ /** Quote a string as a PowerShell single-quoted literal. */
179
+ function ps(s) {
180
+ return `'${s.replace(/'/g, "''")}'`;
181
+ }