@phnx-labs/agents-cli 1.22.47 → 1.22.50

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 (272) hide show
  1. package/CHANGELOG.md +503 -17
  2. package/README.md +76 -32
  3. package/dist/bootstrap.js +4 -2
  4. package/dist/cli/command-registry.d.ts +1 -0
  5. package/dist/cli/command-registry.js +2 -0
  6. package/dist/commands/accounts.d.ts +58 -0
  7. package/dist/commands/accounts.js +201 -39
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/attach.js +1 -1
  10. package/dist/commands/browser.js +501 -103
  11. package/dist/commands/config.d.ts +0 -1
  12. package/dist/commands/config.js +0 -20
  13. package/dist/commands/daemon-test-harness.d.ts +42 -0
  14. package/dist/commands/daemon-test-harness.js +90 -0
  15. package/dist/commands/daemon.d.ts +21 -0
  16. package/dist/commands/daemon.js +147 -12
  17. package/dist/commands/device-config-test-harness.d.ts +11 -0
  18. package/dist/commands/device-config-test-harness.js +59 -0
  19. package/dist/commands/exec.js +76 -16
  20. package/dist/commands/focus.d.ts +14 -0
  21. package/dist/commands/focus.js +48 -5
  22. package/dist/commands/go.d.ts +3 -0
  23. package/dist/commands/go.js +16 -3
  24. package/dist/commands/inspect.js +2 -2
  25. package/dist/commands/lease.d.ts +1 -1
  26. package/dist/commands/lease.js +4 -3
  27. package/dist/commands/mailboxes.js +4 -3
  28. package/dist/commands/menubar.js +11 -4
  29. package/dist/commands/projects.d.ts +16 -1
  30. package/dist/commands/projects.js +58 -64
  31. package/dist/commands/resume.js +1 -1
  32. package/dist/commands/route.js +4 -4
  33. package/dist/commands/routines.js +3 -2
  34. package/dist/commands/run-broadcast.d.ts +1 -1
  35. package/dist/commands/run-broadcast.js +1 -1
  36. package/dist/commands/sessions-picker.d.ts +22 -0
  37. package/dist/commands/sessions-picker.js +48 -9
  38. package/dist/commands/sessions-trace.js +1 -1
  39. package/dist/commands/sessions.js +1 -0
  40. package/dist/commands/setup-secrets.js +23 -0
  41. package/dist/commands/share.d.ts +20 -5
  42. package/dist/commands/share.js +158 -53
  43. package/dist/commands/ssh.js +54 -7
  44. package/dist/commands/teams.d.ts +12 -0
  45. package/dist/commands/teams.js +44 -67
  46. package/dist/commands/traces.d.ts +2 -0
  47. package/dist/commands/traces.js +188 -0
  48. package/dist/commands/view.d.ts +1 -1
  49. package/dist/commands/view.js +17 -34
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +5 -0
  52. package/dist/lib/account-provider-registry.d.ts +9 -0
  53. package/dist/lib/account-provider-registry.js +20 -0
  54. package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
  55. package/dist/lib/accounting/account-pool-collect.js +71 -0
  56. package/dist/lib/accounting/account-pool.d.ts +46 -0
  57. package/dist/lib/accounting/account-pool.js +33 -0
  58. package/dist/lib/accounting/capacity.d.ts +24 -0
  59. package/dist/lib/accounting/capacity.js +30 -0
  60. package/dist/lib/accounting/rotate.d.ts +11 -18
  61. package/dist/lib/accounting/rotate.js +6 -24
  62. package/dist/lib/accounting/usage.d.ts +14 -3
  63. package/dist/lib/accounting/usage.js +64 -18
  64. package/dist/lib/agent-spec/agents.js +11 -9
  65. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  66. package/dist/lib/browser/drivers/ssh.js +36 -9
  67. package/dist/lib/browser/ipc.js +8 -3
  68. package/dist/lib/browser/profiles.d.ts +35 -101
  69. package/dist/lib/browser/profiles.js +130 -301
  70. package/dist/lib/browser/registry.d.ts +39 -0
  71. package/dist/lib/browser/registry.js +149 -0
  72. package/dist/lib/browser/remote-control.d.ts +8 -6
  73. package/dist/lib/browser/remote-control.js +8 -6
  74. package/dist/lib/browser/resolve-target.d.ts +68 -0
  75. package/dist/lib/browser/resolve-target.js +250 -0
  76. package/dist/lib/browser/runtime-state.d.ts +40 -16
  77. package/dist/lib/browser/runtime-state.js +92 -25
  78. package/dist/lib/browser/service.d.ts +28 -9
  79. package/dist/lib/browser/service.js +139 -62
  80. package/dist/lib/browser/task-index.d.ts +70 -0
  81. package/dist/lib/browser/task-index.js +180 -0
  82. package/dist/lib/byok-usage.js +2 -2
  83. package/dist/lib/claude-statusline.d.ts +29 -0
  84. package/dist/lib/claude-statusline.js +139 -0
  85. package/dist/lib/cloud/host.d.ts +1 -1
  86. package/dist/lib/cloud/host.js +1 -1
  87. package/dist/lib/cloud/rush.js +1 -1
  88. package/dist/lib/computer/computer-rpc.js +2 -2
  89. package/dist/lib/computer/download.d.ts +15 -15
  90. package/dist/lib/computer/download.js +35 -98
  91. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  92. package/dist/lib/computer/ssh-tunnel.js +12 -22
  93. package/dist/lib/config-keys.d.ts +2 -8
  94. package/dist/lib/config-keys.js +2 -13
  95. package/dist/lib/crabbox/cli.d.ts +1 -1
  96. package/dist/lib/crabbox/cli.js +2 -2
  97. package/dist/lib/crabbox/lease.d.ts +9 -0
  98. package/dist/lib/crabbox/lease.js +70 -15
  99. package/dist/lib/crabbox/runtimes.js +1 -1
  100. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  101. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  102. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  103. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  104. package/dist/lib/daemon/daemon.d.ts +3 -0
  105. package/dist/lib/daemon/daemon.js +212 -444
  106. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  107. package/dist/lib/daemon/device-probe-service.js +47 -0
  108. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  109. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  110. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  111. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  112. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  113. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  114. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  115. package/dist/lib/daemon/self-heal-service.js +48 -0
  116. package/dist/lib/daemon/service.d.ts +102 -0
  117. package/dist/lib/daemon/service.js +98 -0
  118. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  119. package/dist/lib/daemon/session-index-service.js +40 -0
  120. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  121. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  122. package/dist/lib/daemon/supervisor.d.ts +87 -0
  123. package/dist/lib/daemon/supervisor.js +298 -0
  124. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  125. package/dist/lib/daemon/watchdog-service.js +40 -0
  126. package/dist/lib/daemon-health.d.ts +15 -0
  127. package/dist/lib/daemon-health.js +29 -5
  128. package/dist/lib/daemon-services.d.ts +8 -1
  129. package/dist/lib/daemon-services.js +55 -0
  130. package/dist/lib/daemon-ticks.d.ts +13 -2
  131. package/dist/lib/daemon-ticks.js +20 -18
  132. package/dist/lib/device-config.d.ts +0 -2
  133. package/dist/lib/device-config.js +1 -23
  134. package/dist/lib/devices/registry.d.ts +1 -1
  135. package/dist/lib/devices/registry.js +1 -1
  136. package/dist/lib/devices/stats-cache.d.ts +20 -0
  137. package/dist/lib/devices/stats-cache.js +49 -5
  138. package/dist/lib/devices/worker-pick.d.ts +48 -0
  139. package/dist/lib/devices/worker-pick.js +113 -0
  140. package/dist/lib/exec.d.ts +67 -13
  141. package/dist/lib/exec.js +85 -29
  142. package/dist/lib/harness/adapters/cursor.js +8 -11
  143. package/dist/lib/helper-download.d.ts +108 -0
  144. package/dist/lib/helper-download.js +194 -0
  145. package/dist/lib/helper-versions.d.ts +52 -0
  146. package/dist/lib/helper-versions.js +58 -0
  147. package/dist/lib/hooks/cache.js +11 -0
  148. package/dist/lib/hooks/install.js +2 -2
  149. package/dist/lib/hosts/credentials.d.ts +1 -1
  150. package/dist/lib/hosts/credentials.js +1 -1
  151. package/dist/lib/hosts/dispatch.d.ts +7 -1
  152. package/dist/lib/hosts/dispatch.js +24 -5
  153. package/dist/lib/hosts/passthrough.d.ts +8 -15
  154. package/dist/lib/hosts/passthrough.js +19 -16
  155. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  156. package/dist/lib/hosts/providers/devices.js +1 -1
  157. package/dist/lib/hosts/reconnect.d.ts +179 -21
  158. package/dist/lib/hosts/reconnect.js +266 -45
  159. package/dist/lib/installations/migrate.d.ts +4 -5
  160. package/dist/lib/installations/migrate.js +6 -7
  161. package/dist/lib/installations/shims.d.ts +1 -1
  162. package/dist/lib/installations/shims.js +23 -13
  163. package/dist/lib/installations/store.js +6 -3
  164. package/dist/lib/installations/versions.js +73 -48
  165. package/dist/lib/linear-autoclose.d.ts +1 -1
  166. package/dist/lib/linear-autoclose.js +1 -1
  167. package/dist/lib/loop.d.ts +2 -2
  168. package/dist/lib/loop.js +1 -1
  169. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  170. package/dist/lib/menubar/download-menubar.js +60 -0
  171. package/dist/lib/menubar/install-menubar.d.ts +31 -7
  172. package/dist/lib/menubar/install-menubar.js +79 -21
  173. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  174. package/dist/lib/menubar/notify-desktop.js +6 -6
  175. package/dist/lib/model-tiers.d.ts +1 -1
  176. package/dist/lib/project-probe.d.ts +1 -1
  177. package/dist/lib/project-probe.js +1 -1
  178. package/dist/lib/project-pull.d.ts +1 -1
  179. package/dist/lib/project-pull.js +2 -2
  180. package/dist/lib/projects.d.ts +1 -1
  181. package/dist/lib/projects.js +1 -1
  182. package/dist/lib/sandbox.js +6 -7
  183. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  184. package/dist/lib/secrets/download-keychain.js +70 -0
  185. package/dist/lib/secrets/install-helper.d.ts +20 -1
  186. package/dist/lib/secrets/install-helper.js +44 -3
  187. package/dist/lib/session/active.js +17 -1
  188. package/dist/lib/session/db.d.ts +11 -1
  189. package/dist/lib/session/db.js +138 -6
  190. package/dist/lib/session/discover.d.ts +21 -1
  191. package/dist/lib/session/discover.js +33 -5
  192. package/dist/lib/session/highlights.d.ts +49 -1
  193. package/dist/lib/session/highlights.js +95 -0
  194. package/dist/lib/session/host-link.d.ts +16 -2
  195. package/dist/lib/session/host-link.js +7 -1
  196. package/dist/lib/session/parse.js +33 -1
  197. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  198. package/dist/lib/session/remote/remote-list.js +19 -2
  199. package/dist/lib/session/remote/watch.js +11 -2
  200. package/dist/lib/session/session-cache.d.ts +53 -0
  201. package/dist/lib/session/session-cache.js +88 -0
  202. package/dist/lib/session/state.js +1 -1
  203. package/dist/lib/session/tool-calls.d.ts +2 -0
  204. package/dist/lib/session/tool-calls.js +1 -1
  205. package/dist/lib/session/trajectory-html.d.ts +5 -1
  206. package/dist/lib/session/trajectory-html.js +42 -22
  207. package/dist/lib/session/trajectory-text.js +9 -2
  208. package/dist/lib/session/trajectory.js +53 -4
  209. package/dist/lib/session/types.d.ts +15 -0
  210. package/dist/lib/sha256-asset.d.ts +26 -0
  211. package/dist/lib/sha256-asset.js +41 -0
  212. package/dist/lib/share/backend.d.ts +95 -0
  213. package/dist/lib/share/backend.js +139 -0
  214. package/dist/lib/share/delete.d.ts +6 -2
  215. package/dist/lib/share/delete.js +8 -9
  216. package/dist/lib/share/html.d.ts +22 -0
  217. package/dist/lib/share/html.js +88 -0
  218. package/dist/lib/share/provision.d.ts +18 -4
  219. package/dist/lib/share/provision.js +38 -4
  220. package/dist/lib/share/publish.d.ts +44 -15
  221. package/dist/lib/share/publish.js +58 -26
  222. package/dist/lib/share/worker-template.js +402 -25
  223. package/dist/lib/sqlite.js +16 -3
  224. package/dist/lib/ssh-exec.d.ts +65 -0
  225. package/dist/lib/ssh-exec.js +120 -4
  226. package/dist/lib/startup/command-registry.js +1 -1
  227. package/dist/lib/startup/dev-build.js +3 -3
  228. package/dist/lib/state.d.ts +1 -1
  229. package/dist/lib/state.js +7 -3
  230. package/dist/lib/subagents-registry.js +1 -1
  231. package/dist/lib/teams/agents.d.ts +14 -12
  232. package/dist/lib/teams/agents.js +188 -73
  233. package/dist/lib/teams/api.d.ts +6 -1
  234. package/dist/lib/teams/api.js +2 -0
  235. package/dist/lib/teams/scheduler.d.ts +7 -0
  236. package/dist/lib/teams/scheduler.js +26 -8
  237. package/dist/lib/terminal/index.d.ts +1 -1
  238. package/dist/lib/terminal/types.d.ts +1 -1
  239. package/dist/lib/terminal/types.js +1 -1
  240. package/dist/lib/tmux/orphan-reap.js +1 -1
  241. package/dist/lib/tmux/session.js +31 -0
  242. package/dist/lib/traces/backend.d.ts +29 -0
  243. package/dist/lib/traces/backend.js +46 -0
  244. package/dist/lib/traces/classify.d.ts +52 -0
  245. package/dist/lib/traces/classify.js +72 -0
  246. package/dist/lib/traces/config.d.ts +10 -0
  247. package/dist/lib/traces/config.js +2 -0
  248. package/dist/lib/traces/provision.d.ts +19 -0
  249. package/dist/lib/traces/provision.js +30 -0
  250. package/dist/lib/traces/sync.d.ts +150 -0
  251. package/dist/lib/traces/sync.js +459 -0
  252. package/dist/lib/traces/worker-template.d.ts +4 -0
  253. package/dist/lib/traces/worker-template.js +159 -0
  254. package/dist/lib/types.d.ts +18 -19
  255. package/dist/lib/types.js +14 -0
  256. package/dist/lib/usage-refresh.d.ts +1 -1
  257. package/dist/lib/usage-refresh.js +11 -8
  258. package/dist/lib/workflows.d.ts +1 -1
  259. package/package.json +4 -6
  260. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  261. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  262. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  263. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  264. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  265. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  266. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
  267. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  268. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  269. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
  270. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  271. package/dist/lib/usage-fleet.d.ts +0 -32
  272. package/dist/lib/usage-fleet.js +0 -125
@@ -1,13 +1,31 @@
1
1
  // The Cloudflare Worker that fronts the R2 share bucket.
2
2
  //
3
3
  // One tiny Worker does both sides:
4
- // - PUT /<username>/<slug> — bearer-gated (WRITE_TOKEN secret); writes the body
5
- // to R2 via the BUCKET binding, storing an optional expires-at value plus
6
- // provenance (agent/session/host/repo/date), a label, and any `--meta`
7
- // entries in object metadata. Overwriting an existing slug first copies the
8
- // current object to <slug>/rev-<ts>-<rand> (revision history) unless
4
+ // - PUT /<username>/<slug> — bearer-gated by TWO principals (not a fallback):
5
+ // 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
6
+ // bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
7
+ // path's first segment.
8
+ // 2. Phoenix bearer — otherwise GET ${env.PHOENIX_ID_BASE}/api/v1/auth/me
9
+ // with that bearer → {userId,email}; 401 if absent/invalid.
10
+ // customMetadata.owner = userId (stable). The path's first segment MUST
11
+ // equal handleFromEmail(email) (the public handle, e.g. muqsitnawaz),
12
+ // so one user cannot write another's prefix (403 namespace mismatch).
13
+ // A __handles/<handle> claim binds the handle to the first userId that
14
+ // writes it; a different userId gets 409 handle taken.
15
+ // A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
16
+ // platform endpoint may set both. Fail loud (401) when neither authenticates.
17
+ // Writes the body to R2 via the BUCKET binding, storing visibility
18
+ // (public|unlisted|me|org), owner, org_domain (org only), an optional
19
+ // expires-at, plus provenance (agent/session/host/repo/date), a label, and
20
+ // any `--meta` entries in object metadata. me/org require a Phoenix
21
+ // identity (BYO WRITE_TOKEN cannot publish them). org from a public inbox
22
+ // domain is 400. Overwriting an existing slug first copies the current
23
+ // object to <slug>/rev-<ts>-<rand> (revision history) unless
9
24
  // x-share-no-revision is set.
10
- // - GET /<username>/<slug> — public; streams the object from R2, 410s (and lazily
25
+ // - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
26
+ // Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
27
+ // HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
28
+ // Phoenix login (or 401 JSON if PHOENIX_ID_BASE is unset). 410s (and lazily
11
29
  // deletes) once its expiry has passed. A bucket lifecycle rule is the durable
12
30
  // sweeper; this is the immediate gate.
13
31
  // - GET /<username>/<slug>?revisions=json — machine-readable history of the
@@ -39,6 +57,10 @@
39
57
  export function renderWorkerScript() {
40
58
  return `// GENERATED by agents-cli agents share setup — do not edit here; edit
41
59
  // src/lib/share/worker-template.ts and re-run setup.
60
+ export const hooks = {
61
+ verifyPhoenixToken: defaultVerifyPhoenixToken,
62
+ };
63
+
42
64
  export default {
43
65
  async fetch(request, env) {
44
66
  const url = new URL(request.url);
@@ -50,15 +72,44 @@ export default {
50
72
  });
51
73
  }
52
74
 
75
+ const firstSeg = path.split('/').filter(Boolean)[0] || '';
76
+ if ((request.method === 'GET' || request.method === 'HEAD') && firstSeg.startsWith('__')) {
77
+ return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
78
+ }
79
+
53
80
  if (request.method === 'PUT') {
54
- const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
55
- if (!env.WRITE_TOKEN || !safeEqual(presented, env.WRITE_TOKEN)) {
56
- return json({ error: 'unauthorized' }, 401);
81
+ const auth = await authorizeWrite(request, env);
82
+ if (auth.error) return auth.error;
83
+ const vis = normalizeVisibility(request.headers.get('x-share-visibility'));
84
+ if (vis.error) return vis.error;
85
+ const visibility = vis.value;
86
+ if (visibility === 'me' || visibility === 'org') {
87
+ const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
88
+ if (auth.kind !== 'phoenix' || !phoenixBase) {
89
+ return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
90
+ }
91
+ if (visibility === 'org') {
92
+ const domain = emailDomain(auth.email);
93
+ if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
94
+ if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) {
95
+ return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
96
+ }
97
+ }
98
+ }
99
+ const segments = path.split('/').filter(Boolean);
100
+ if (auth.kind === 'phoenix') {
101
+ const expected = phoenixHandle(auth);
102
+ if (!expected || segments[0] !== expected) {
103
+ return json({ error: 'namespace mismatch', owner: expected }, 403);
104
+ }
105
+ const claimed = await claimHandle(env.BUCKET, expected, auth.owner);
106
+ if (claimed.error) return claimed.error;
57
107
  }
58
108
  const expiresAt = request.headers.get('x-share-expires-at') || '';
59
109
  // 'unlisted' hides the page from the public gallery + JSON listing while
60
110
  // keeping the direct URL world-readable (capability URL, not secret).
61
- const visibility = (request.headers.get('x-share-visibility') || '').toLowerCase();
111
+ // me/org are also hidden from gallery/listing; GET is identity-gated
112
+ // and always sends X-Robots-Tag: noindex (same as unlisted).
62
113
  const contentType = request.headers.get('content-type') || 'text/html; charset=utf-8';
63
114
  // Provenance (RUSH-2683): captured client-side from the exec env/git/clock,
64
115
  // never invented here — a header is simply absent when the CLI had nothing
@@ -93,7 +144,13 @@ export default {
93
144
  delete customMetadata[reservedKey];
94
145
  }
95
146
  if (expiresAt) customMetadata['expires-at'] = expiresAt;
96
- if (visibility === 'unlisted') customMetadata['visibility'] = 'unlisted';
147
+ customMetadata['visibility'] = visibility;
148
+ const owner =
149
+ auth.kind === 'phoenix'
150
+ ? auth.owner
151
+ : (env.SHARE_NAMESPACE || segments[0] || 'byo');
152
+ if (owner) customMetadata['owner'] = owner;
153
+ if (visibility === 'org') customMetadata['org_domain'] = emailDomain(auth.email);
97
154
  if (agent) customMetadata['agent'] = agent;
98
155
  if (session) customMetadata['session'] = session;
99
156
  if (host) customMetadata['host'] = host;
@@ -129,7 +186,7 @@ export default {
129
186
  httpMetadata: { contentType },
130
187
  customMetadata,
131
188
  });
132
- return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted' }, 200);
189
+ return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
133
190
  }
134
191
 
135
192
  if (request.method === 'GET' || request.method === 'HEAD') {
@@ -157,9 +214,22 @@ export default {
157
214
 
158
215
  // Revision history for one canonical <user>/<slug> key (RUSH-2683). Checked
159
216
  // before the plain object GET below so the query param routes even though
160
- // the canonical key itself resolves to a real object.
217
+ // the canonical key itself resolves to a real object. me/org use the same
218
+ // identity gate as the page GET — listing session/host/repo on an unauthed
219
+ // ?revisions=json must not leak that the page exists.
161
220
  if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
162
- return renderRevisions(env.BUCKET, url.origin, path, request.method);
221
+ const canonical = await env.BUCKET.get(path);
222
+ if (canonical) {
223
+ const denied = await gateRestrictedGet(request, env, url, canonical);
224
+ if (denied) return denied;
225
+ }
226
+ return renderRevisions(
227
+ env.BUCKET,
228
+ url.origin,
229
+ path,
230
+ request.method,
231
+ canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public'),
232
+ );
163
233
  }
164
234
 
165
235
  const obj = await env.BUCKET.get(path);
@@ -169,17 +239,43 @@ export default {
169
239
  await env.BUCKET.delete(path);
170
240
  return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
171
241
  }
242
+ const denied = await gateRestrictedGet(request, env, url, obj);
243
+ if (denied) return denied;
244
+ const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
172
245
  const headers = new Headers();
173
246
  obj.writeHttpMetadata(headers);
174
247
  headers.set('etag', obj.httpEtag);
175
248
  if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
176
- headers.set('cache-control', 'public, max-age=60');
249
+ if (visibility === 'me' || visibility === 'org') {
250
+ headers.set('cache-control', 'private, no-store');
251
+ headers.set('X-Robots-Tag', 'noindex');
252
+ } else {
253
+ headers.set('cache-control', 'public, max-age=60');
254
+ if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
255
+ }
177
256
  return new Response(request.method === 'HEAD' ? null : obj.body, { status: 200, headers });
178
257
  }
179
258
 
180
259
  if (request.method === 'DELETE') {
181
- const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
182
- if (!env.WRITE_TOKEN || !safeEqual(presented, env.WRITE_TOKEN)) return json({ error: 'unauthorized' }, 401);
260
+ const auth = await authorizeWrite(request, env);
261
+ if (auth.error) return auth.error;
262
+ if (auth.kind === 'phoenix') {
263
+ const handle = phoenixHandle(auth);
264
+ const uid = sanitizeNamespace(auth.owner);
265
+ const delSegments = path.split('/').filter(Boolean);
266
+ // Handle is the public namespace; userId prefix is still deletable so
267
+ // P1 UUID-namespaced objects the same account published can be taken down.
268
+ // A colliding local-part (same handle, different userId) must not be
269
+ // able to DELETE the claimant's pages — consult the claim the same way PUT does.
270
+ if (delSegments[0] && delSegments[0] === uid) {
271
+ // own leftover UUID prefix
272
+ } else if (delSegments[0] && delSegments[0] === handle) {
273
+ const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner);
274
+ if (owned.error) return owned.error;
275
+ } else {
276
+ return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
277
+ }
278
+ }
183
279
  await env.BUCKET.delete(path);
184
280
  return json({ ok: true, deleted: path }, 200);
185
281
  }
@@ -210,8 +306,8 @@ async function renderGallery(bucket, origin, user, method) {
210
306
  const activeObjects = objects.filter(o => {
211
307
  if (isRevisionKey(o.key)) return false;
212
308
  if (o.key.endsWith('.png')) return false;
213
- // Unlisted pages are reachable by direct URL only — never on the gallery.
214
- if (o.customMetadata && o.customMetadata.visibility === 'unlisted') return false;
309
+ // Unlisted / me / org pages are reachable by direct URL only — never on the gallery.
310
+ if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
215
311
  const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
216
312
  return !(expiresAt && Date.now() > Date.parse(expiresAt));
217
313
  });
@@ -273,10 +369,10 @@ async function renderListing(bucket, origin, user, method) {
273
369
  const items = objects
274
370
  .filter(o => {
275
371
  // Mirror the gallery: hide revisions, sibling .png OG covers, expired
276
- // pages, and unlisted pages — the listing is the machine-readable public surface.
372
+ // pages, and unlisted/me/org pages — the listing is the machine-readable public surface.
277
373
  if (isRevisionKey(o.key)) return false;
278
374
  if (o.key.endsWith('.png')) return false;
279
- if (o.customMetadata && o.customMetadata.visibility === 'unlisted') return false;
375
+ if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
280
376
  const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
281
377
  return !(expiresAt && now > Date.parse(expiresAt));
282
378
  })
@@ -307,7 +403,7 @@ async function renderListing(bucket, origin, user, method) {
307
403
  });
308
404
  }
309
405
 
310
- async function renderRevisions(bucket, origin, key, method) {
406
+ async function renderRevisions(bucket, origin, key, method, identityGated) {
311
407
  const objects = [];
312
408
  let cursor;
313
409
  do {
@@ -333,12 +429,15 @@ async function renderRevisions(bucket, origin, key, method) {
333
429
  // Newest first — the most recently replaced version leads.
334
430
  items.sort((a, b) => (a.uploadedAt < b.uploadedAt ? 1 : a.uploadedAt > b.uploadedAt ? -1 : 0));
335
431
 
432
+ const cacheHeaders = identityGated
433
+ ? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
434
+ : { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
336
435
  if (method === 'HEAD') {
337
- return new Response(null, { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' } });
436
+ return new Response(null, { status: 200, headers: cacheHeaders });
338
437
  }
339
438
  return new Response(JSON.stringify({ key, count: items.length, revisions: items }), {
340
439
  status: 200,
341
- headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' },
440
+ headers: cacheHeaders,
342
441
  });
343
442
  }
344
443
 
@@ -347,7 +446,10 @@ async function renderRevisions(bucket, origin, key, method) {
347
446
  // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
348
447
  // One list, reused both to strip a same-named --meta collision on write and
349
448
  // to split arbitrary --meta entries back out on read.
350
- var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
449
+ var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
450
+ var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
451
+ var SHARE_COOKIE = '__Host-phoenix_share';
452
+ var SHARE_COOKIE_MAX_AGE = 604800;
351
453
 
352
454
  // Everything in customMetadata that ISN'T one of the reserved provenance/label
353
455
  // keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
@@ -383,5 +485,280 @@ function safeEqual(a, b) {
383
485
  for (let i = 0; i < la; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i % lb || 0);
384
486
  return out === 0 && la === lb;
385
487
  }
488
+
489
+ // URL-safety, not collision-resistance. Two values that differ only in
490
+ // case/punctuation share a prefix.
491
+ function sanitizeNamespace(s) {
492
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
493
+ }
494
+
495
+ // Public handle: email local-part, plus-tag stripped. Must match CLI handleFromEmail.
496
+ function handleFromEmail(email) {
497
+ if (!email) return '';
498
+ const local = String(email).split('@')[0] || '';
499
+ const beforePlus = local.split('+')[0] || local;
500
+ return sanitizeNamespace(beforePlus);
501
+ }
502
+
503
+ function phoenixHandle(auth) {
504
+ return handleFromEmail(auth.email) || sanitizeNamespace(auth.owner);
505
+ }
506
+
507
+ // First writer of a handle owns it. Later PUTs from the same userId are fine;
508
+ // a different userId whose email local-part collides gets 409, not a silent overwrite.
509
+ async function assertHandleOwner(bucket, handle, userId) {
510
+ // Pages under the handle already stamp customMetadata.owner = userId.
511
+ // Treat that as the source of truth so a colliding local-part cannot
512
+ // PUT/DELETE another account's objects even if the claim object is missing.
513
+ const list = await bucket.list({ prefix: handle + '/', include: ['customMetadata'] });
514
+ for (const o of list.objects || []) {
515
+ const owner = o.customMetadata && o.customMetadata.owner;
516
+ if (owner && owner !== userId) {
517
+ return { error: json({ error: 'handle taken', handle: handle }, 409) };
518
+ }
519
+ }
520
+ const key = '__handles/' + handle;
521
+ const existing = await bucket.get(key);
522
+ if (!existing) return {};
523
+ const claimed = existing.customMetadata && existing.customMetadata.userId;
524
+ if (claimed && claimed !== userId) {
525
+ return { error: json({ error: 'handle taken', handle: handle }, 409) };
526
+ }
527
+ return {};
528
+ }
529
+
530
+ async function claimHandle(bucket, handle, userId) {
531
+ const owned = await assertHandleOwner(bucket, handle, userId);
532
+ if (owned.error) return owned;
533
+ const key = '__handles/' + handle;
534
+ // Write (or rewrite) so a same-user republish resets object Age against the
535
+ // bucket's 366-day lifecycle — otherwise the claim can expire while pages stay live.
536
+ await bucket.put(key, JSON.stringify({ userId: userId }), {
537
+ httpMetadata: { contentType: 'application/json' },
538
+ customMetadata: { userId: userId, visibility: 'unlisted' },
539
+ });
540
+ return {};
541
+ }
542
+
543
+ function normalizeVisibility(raw) {
544
+ const v = (raw || '').trim().toLowerCase();
545
+ if (!v || v === 'public') return { value: 'public' };
546
+ if (v === 'unlisted' || v === 'me' || v === 'org') return { value: v };
547
+ return { error: json({ error: 'visibility must be public, unlisted, me, or org' }, 400) };
548
+ }
549
+
550
+ function emailDomain(email) {
551
+ if (!email) return '';
552
+ const at = String(email).lastIndexOf('@');
553
+ if (at < 0) return '';
554
+ return String(email).slice(at + 1).toLowerCase();
555
+ }
556
+
557
+ function isHiddenFromGallery(visibility) {
558
+ return visibility === 'unlisted' || visibility === 'me' || visibility === 'org';
559
+ }
560
+
561
+ function isIdentityGated(visibility) {
562
+ return visibility === 'me' || visibility === 'org';
563
+ }
564
+
565
+ async function gateRestrictedGet(request, env, url, obj) {
566
+ const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
567
+ if (!isIdentityGated(visibility)) return null;
568
+ const viewer = await resolveViewer(request, env, url);
569
+ if (viewer.redirect) return viewer.redirect;
570
+ if (viewer.error) return viewer.error;
571
+ if (!viewer.identity) return bounceToLogin(url, env);
572
+ if (!viewerMayRead(visibility, obj.customMetadata, viewer.identity)) {
573
+ return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
574
+ }
575
+ return null;
576
+ }
577
+
578
+ function viewerMayRead(visibility, meta, identity) {
579
+ if (visibility === 'me') {
580
+ const owner = meta && meta.owner;
581
+ return !!owner && owner === identity.userId;
582
+ }
583
+ if (visibility === 'org') {
584
+ const stamped = meta && meta.org_domain;
585
+ if (!stamped) return false;
586
+ return emailDomain(identity.email) === String(stamped).toLowerCase();
587
+ }
588
+ return true;
589
+ }
590
+
591
+ function bounceToLogin(url, env) {
592
+ const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
593
+ if (!base) return json({ error: 'phoenix login is not configured' }, 401);
594
+ return new Response(null, {
595
+ status: 302,
596
+ headers: { Location: base + '/login?return=' + encodeURIComponent(url.toString()) },
597
+ });
598
+ }
599
+
600
+ function readCookie(request, name) {
601
+ const header = request.headers.get('Cookie') || '';
602
+ const parts = header.split(';');
603
+ for (let i = 0; i < parts.length; i++) {
604
+ const p = parts[i];
605
+ const eq = p.indexOf('=');
606
+ if (eq < 0) continue;
607
+ if (p.slice(0, eq).trim() === name) return p.slice(eq + 1).trim();
608
+ }
609
+ return '';
610
+ }
611
+
612
+ function toB64Url(s) {
613
+ const bytes = new TextEncoder().encode(s);
614
+ let bin = '';
615
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
616
+ return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
617
+ }
618
+
619
+ function fromB64Url(s) {
620
+ const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
621
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
622
+ const bin = atob(b64);
623
+ const bytes = new Uint8Array(bin.length);
624
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
625
+ return new TextDecoder().decode(bytes);
626
+ }
627
+
628
+ function bufToHex(buf) {
629
+ return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
630
+ }
631
+
632
+ async function hmacHex(secret, data) {
633
+ const key = await crypto.subtle.importKey(
634
+ 'raw',
635
+ new TextEncoder().encode(secret),
636
+ { name: 'HMAC', hash: 'SHA-256' },
637
+ false,
638
+ ['sign'],
639
+ );
640
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
641
+ return bufToHex(sig);
642
+ }
643
+
644
+ async function signShareCookie(identity, env) {
645
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
646
+ if (!secret) return '';
647
+ const exp = Math.floor(Date.now() / 1000) + SHARE_COOKIE_MAX_AGE;
648
+ const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
649
+ const sig = await hmacHex(secret, payload);
650
+ const value = toB64Url(payload) + '.' + sig;
651
+ return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
652
+ }
653
+
654
+ async function identityFromCookie(request, env) {
655
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
656
+ if (!secret) return null;
657
+ const raw = readCookie(request, SHARE_COOKIE);
658
+ if (!raw) return null;
659
+ const dot = raw.lastIndexOf('.');
660
+ if (dot < 1) return null;
661
+ let payload;
662
+ try {
663
+ payload = fromB64Url(raw.slice(0, dot));
664
+ } catch {
665
+ return null;
666
+ }
667
+ const sig = raw.slice(dot + 1);
668
+ const expected = await hmacHex(secret, payload);
669
+ if (!safeEqual(sig, expected)) return null;
670
+ const first = payload.indexOf('|');
671
+ const last = payload.lastIndexOf('|');
672
+ if (first < 0 || last <= first) return null;
673
+ const userId = payload.slice(0, first);
674
+ const email = payload.slice(first + 1, last);
675
+ const cookieExp = Number(payload.slice(last + 1));
676
+ if (!userId || !Number.isFinite(cookieExp) || Math.floor(Date.now() / 1000) > cookieExp) return null;
677
+ return { userId: userId, email: email };
678
+ }
679
+
680
+ async function redeemTicket(ticket, env) {
681
+ const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
682
+ if (!base || !ticket) return null;
683
+ let res;
684
+ try {
685
+ res = await fetch(base + '/api/v1/auth/ticket', {
686
+ method: 'POST',
687
+ headers: { 'content-type': 'application/json' },
688
+ body: JSON.stringify({ ticket: ticket }),
689
+ });
690
+ } catch {
691
+ return null;
692
+ }
693
+ if (!res.ok) return null;
694
+ let body;
695
+ try {
696
+ body = await res.json();
697
+ } catch {
698
+ return null;
699
+ }
700
+ if (!body || typeof body.userId !== 'string' || !body.userId) return null;
701
+ return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
702
+ }
703
+
704
+ async function resolveViewer(request, env, url) {
705
+ const claims = await hooks.verifyPhoenixToken(request, env);
706
+ if (claims && typeof claims.userId === 'string' && claims.userId) {
707
+ return { identity: { userId: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' } };
708
+ }
709
+ const cookieId = await identityFromCookie(request, env);
710
+ if (cookieId) return { identity: cookieId };
711
+ const ticket = url.searchParams.get('phoenix_ticket');
712
+ if (!ticket) return {};
713
+ const redeemed = await redeemTicket(ticket, env);
714
+ if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
715
+ const cookie = await signShareCookie(redeemed, env);
716
+ if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
717
+ url.searchParams.delete('phoenix_ticket');
718
+ const headers = new Headers();
719
+ headers.set('Location', url.toString());
720
+ headers.append('Set-Cookie', cookie);
721
+ return { redirect: new Response(null, { status: 302, headers: headers }) };
722
+ }
723
+
724
+ async function defaultVerifyPhoenixToken(request, env) {
725
+ const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
726
+ if (!presented) return null;
727
+ const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
728
+ if (!base) return null;
729
+ let res;
730
+ try {
731
+ res = await fetch(base + '/api/v1/auth/me', {
732
+ method: 'GET',
733
+ headers: { Authorization: 'Bearer ' + presented },
734
+ });
735
+ } catch {
736
+ return null;
737
+ }
738
+ if (!res.ok) return null;
739
+ let body;
740
+ try {
741
+ body = await res.json();
742
+ } catch {
743
+ return null;
744
+ }
745
+ if (!body || typeof body.userId !== 'string' || !body.userId) return null;
746
+ return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
747
+ }
748
+
749
+ // Two legitimate principals: WRITE_TOKEN equality first (admin/BYO), otherwise
750
+ // the bearer is a Phoenix token and we verify it. 401 when neither authenticates.
751
+ async function authorizeWrite(request, env) {
752
+ const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
753
+ if (!presented) return { error: json({ error: 'unauthorized' }, 401) };
754
+ if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
755
+ return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
756
+ }
757
+ const claims = await hooks.verifyPhoenixToken(request, env);
758
+ if (claims && typeof claims.userId === 'string' && claims.userId) {
759
+ return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
760
+ }
761
+ return { error: json({ error: 'unauthorized' }, 401) };
762
+ }
386
763
  `;
387
764
  }
@@ -41,10 +41,23 @@ function loadNodeSqlite() {
41
41
  process.emitWarning = original;
42
42
  }
43
43
  }
44
- // Keep Node on createRequire() so Vitest doesn't try to prebundle the built-in
45
- // sqlite module as a userland package during test collection.
44
+ // Keep BOTH runtimes on createRequire() so Vitest doesn't try to prebundle the
45
+ // built-in sqlite module as a userland package during test collection.
46
+ //
47
+ // The Bun arm used to be `await import('bun:sqlite' as string)`, which made this
48
+ // a TOP-LEVEL AWAIT. esbuild cannot lower top-level await to CJS, so every test
49
+ // that spawns a subprocess through `tsx` (CJS mode) died at transform time with
50
+ // `Top-level await is currently not supported with the "cjs" output format` --
51
+ // 49 failures across 7 files, none of which touch sqlite. `require` is
52
+ // synchronous, so the await disappears and with it the whole failure class.
53
+ //
54
+ // The specifier is held in a variable, not written inline: a literal
55
+ // `require('bun:sqlite')` is statically analyzable, so bundlers and Vitest's
56
+ // collector try to resolve a module that does not exist off-Bun. That
57
+ // indirection is what the old `as string` cast was doing for the dynamic import.
58
+ const BUN_SQLITE = 'bun:sqlite';
46
59
  const sqliteMod = isBun
47
- ? await import('bun:sqlite')
60
+ ? require(BUN_SQLITE)
48
61
  : loadNodeSqlite();
49
62
  // bun:sqlite exports `Database`; node:sqlite exports `DatabaseSync`.
50
63
  const NativeDatabase = sqliteMod.Database
@@ -13,6 +13,14 @@
13
13
  * smuggled in as part of a remote command, and `sshExec` additionally rejects a
14
14
  * leading `-` so it can never be parsed as an ssh argv flag.
15
15
  */
16
+ /**
17
+ * ssh's own connection-layer failure code — the transport dropped or never came
18
+ * up, as opposed to the remote command choosing an exit status. Defined here,
19
+ * in the layer that owns the ssh invocation, and re-exported by
20
+ * `lib/hosts/reconnect.ts` as `SSH_CONN_FAILURE` so there is one constant for
21
+ * the concept rather than two that can drift apart.
22
+ */
23
+ export declare const SSH_CONN_FAILURE_CODE = 255;
16
24
  export declare const SSH_TARGET_RE: RegExp;
17
25
  export declare function assertValidSshTarget(host: string): void;
18
26
  /** POSIX single-quote a string for safe interpolation into a remote shell command. */
@@ -156,11 +164,68 @@ export interface SshStreamOptions {
156
164
  /** Additional OpenSSH argv placed before the target (for example `-i <path>`). */
157
165
  extraSshArgs?: string[];
158
166
  }
167
+ /**
168
+ * The DEC private modes a full-screen remote app turns ON and, when it exits
169
+ * cleanly, turns back off itself. A remote agent TUI killed by a dropped link
170
+ * never gets to, so they stay enabled on the LOCAL terminal after ssh is gone:
171
+ * focus reporting (1004) and the mode/colour-scheme reports (996/997) then make
172
+ * the terminal emit answerback sequences at a shell that is not expecting them,
173
+ * which is the `^[[?997;1n ^[[I ^[[O` litter seen after a drop (RUSH-3125). Also
174
+ * resets bracketed paste (2004), the alternate screen (1049), mouse tracking
175
+ * (1000/1002/1003/1006), and re-shows the cursor (25) — every one of which a TUI
176
+ * leaves armed the same way.
177
+ */
178
+ export declare const TERMINAL_MODE_RESET: string;
179
+ /**
180
+ * Put the local terminal back the way ssh found it.
181
+ *
182
+ * Two independent kinds of state survive a hard ssh death, and BOTH have to be
183
+ * undone or the next thing the user sees is corrupted:
184
+ *
185
+ * - **termios** (kernel side): `-tt` puts the tty in raw mode, so a killed ssh
186
+ * leaves it with echo and line-editing off. `saved` is the `stty -g` snapshot
187
+ * taken before the spawn; restoring it is exact, unlike guessing at `sane`.
188
+ * - **DEC private modes** (terminal side): see {@link TERMINAL_MODE_RESET}.
189
+ *
190
+ * Then drain whatever the terminal already answered back into the input buffer.
191
+ * Without the drain those bytes are not merely cosmetic — they are still queued
192
+ * on the tty, so the NEXT attach hands them straight to the agent as if the user
193
+ * had typed them.
194
+ *
195
+ * Best-effort by construction: this runs while recovering from a failure, so a
196
+ * missing `stty`, a non-TTY stdin, or a closed stream must never turn a
197
+ * recoverable drop into a thrown error.
198
+ */
199
+ export declare function restoreLocalTerminal(saved: string | undefined, opts: {
200
+ drainStdin: boolean;
201
+ }): void;
202
+ /** `stty -g` snapshot of the local tty, or undefined when there is nothing to snapshot. */
203
+ export declare function saveLocalTerminal(): string | undefined;
159
204
  /**
160
205
  * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
161
206
  * local stdio wired straight through (`stdio: 'inherit'`), so output streams as
162
207
  * it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
163
208
  * until the remote command exits and returns its exit code (255 is ssh's own
164
209
  * connection-layer failure; any other non-zero is the remote command's code).
210
+ *
211
+ * On a `tty` stream the local terminal state is snapshotted before the spawn and
212
+ * restored after it. ssh restores termios itself when it exits cleanly, so this
213
+ * is for the case that matters here: the link dying under a full-screen agent
214
+ * TUI, which leaves the tty raw and the TUI's DEC modes armed. Doing it HERE
215
+ * rather than in the reconnect loop means every caller that opens an interactive
216
+ * remote stream is covered, not just the one that noticed (RUSH-3125).
217
+ *
218
+ * **The stdin drain is gated on an ABNORMAL exit, and the distinction is not
219
+ * cosmetic.** Resetting termios and the DEC modes is idempotent — on a clean
220
+ * exit ssh and the remote TUI have already done it, so re-doing it changes
221
+ * nothing. Draining stdin is destructive: it discards whatever the user has
222
+ * typed ahead. On a clean exit those bytes are legitimate type-ahead the
223
+ * resuming shell should receive, and eating them would be a new bug in every
224
+ * caller of this function (`runInteractiveOnHost`, the remote-tmux attach in
225
+ * commands/go.ts, the remote secrets browse) rather than a fix. Only an
226
+ * abnormal exit — ssh killed by a signal (`status === null`) or its own
227
+ * connection-layer failure (255) — produces the answerback storm the drain
228
+ * exists to clear, because only then did the TUI die without sending its own
229
+ * mode resets.
165
230
  */
166
231
  export declare function sshStream(target: string, remoteCmd: string, opts?: SshStreamOptions): number;