@phnx-labs/agents-cli 1.22.66 → 1.22.69

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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -1,10 +1,20 @@
1
1
  import { type ShareConfig } from './config.js';
2
2
  import { type ShareBackendKind } from './backend.js';
3
- export type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<{
3
+ import { type ShareVisibility } from '../storage/visibility.js';
4
+ /** The share upload result. `body`/`retryAfter` carry the server's error
5
+ * response so a failed publish can surface WHY (the Worker returns
6
+ * `{"error":"…"}` + a `Retry-After` on a 429); they are read only on `!ok`
7
+ * paths, and only ever fed through the bounded {@link extractShareHttpError}. */
8
+ export type PutResult = {
4
9
  ok: boolean;
5
10
  status: number;
6
11
  url?: string;
7
- }>;
12
+ /** Response body text, present on the `!ok` path so the error can be extracted. */
13
+ body?: string;
14
+ /** `Retry-After` header value, present on a 429. */
15
+ retryAfter?: string;
16
+ };
17
+ export type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<PutResult>;
8
18
  export interface PublishEndpoint {
9
19
  baseUrl: string;
10
20
  token: string;
@@ -24,9 +34,12 @@ export interface PublishOptions {
24
34
  /**
25
35
  * Server-enforced visibility (RUSH-3135). `public` (default) is listed in
26
36
  * the gallery; `unlisted` is a capability URL (GET still 200, X-Robots-Tag:
27
- * noindex, hidden from gallery/list). `me` requires a Phoenix session and is
28
- * visible only to the signed-in owner; `org` requires the same and is visible
29
- * to members of the same Phoenix organization.
37
+ * noindex, hidden from gallery/list — obscurity, NOT authentication);
38
+ * `private` is token-gated (the Worker serves it only to a request carrying
39
+ * the matching viewer key, else 404 — the real read-auth fix, PHNX-3654);
40
+ * `me` requires a Phoenix session and is visible only to the signed-in owner;
41
+ * `org` requires the same and is visible to members of the same Phoenix
42
+ * organization.
30
43
  */
31
44
  visibility?: ShareVisibility;
32
45
  /**
@@ -37,6 +50,14 @@ export interface PublishOptions {
37
50
  * not break.
38
51
  */
39
52
  unlisted?: boolean;
53
+ /**
54
+ * Token-gate this page (`--protected` → `visibility=private`, PHNX-3654). The
55
+ * CLI mints a random viewer token, the Worker stores only its SHA-256 hash,
56
+ * and the published URL carries `?k=<token>`; a request without a matching
57
+ * `?k=` / `Authorization: Bearer` gets a `404`. Unlike `unlisted` (obscurity),
58
+ * this is enforced read-auth. Alias of `--visibility private`.
59
+ */
60
+ protected?: boolean;
40
61
  /**
41
62
  * Bypass the pre-publish sensitive-content scan (emails / credential-shaped
42
63
  * strings). Required when the page intentionally carries those patterns.
@@ -91,9 +112,17 @@ export interface PublishOptions {
91
112
  /** DI seam for tests — override provenance auto-capture (agent/session/host/repo/date). */
92
113
  provenance?: ShareProvenance;
93
114
  }
94
- export type ShareVisibility = 'public' | 'unlisted' | 'me' | 'org';
95
- /** The visibility levels a share may carry — the Worker's own set, used to
96
- * validate `--visibility` and `share visibility <target> <level>`. */
115
+ export { type ShareVisibility } from '../storage/visibility.js';
116
+ /** The visibility levels a publish `--visibility` may select — the Worker's own
117
+ * set. `private` (token-gated, PHNX-3654) is settable only at publish time,
118
+ * since it mints a viewer token the metadata-edit route can't carry, so it is
119
+ * NOT in {@link SHARE_VISIBILITY_LEVELS} (the in-place `share visibility <level>`
120
+ * set). */
121
+ export declare const PUBLISH_VISIBILITY_LEVELS: readonly ShareVisibility[];
122
+ /** The visibility levels an ALREADY-published share may be re-scoped to in place
123
+ * — the set `share visibility <target> <level>` and the inline owner control
124
+ * accept. Excludes `private`: re-scoping to token-gated needs a fresh viewer
125
+ * token, which only the publish path mints. */
97
126
  export declare const SHARE_VISIBILITY_LEVELS: readonly ShareVisibility[];
98
127
  export interface PublishResult {
99
128
  url: string;
@@ -105,19 +134,46 @@ export interface PublishResult {
105
134
  visibility?: ShareVisibility;
106
135
  /** True when the page was published with `visibility=unlisted`. */
107
136
  unlisted?: boolean;
137
+ /** The raw viewer token minted for a `private` (token-gated) publish, or
138
+ * undefined for any other visibility. It rides in {@link url} as `?k=<token>`;
139
+ * only its hash is stored server-side. Treat as a secret. */
140
+ viewerToken?: string;
108
141
  /** The label stored with this share — explicit (`--label`) or derived. */
109
142
  label?: string;
110
143
  /** Whether `label` came from `--label` or was auto-derived. */
111
144
  labelSource?: 'explicit' | 'derived';
112
145
  }
113
146
  /**
114
- * `--unlisted` / `{ unlisted: true }` map to `unlisted`; `--visibility me|org`
115
- * passes through; otherwise `visibility` (default public).
147
+ * `--protected` / `{ protected: true }` map to `private` (token-gated — it wins
148
+ * over `--unlisted` when both are set, being the stronger control); `--unlisted`
149
+ * maps to `unlisted`; `--visibility private|unlisted|me|org` passes through;
150
+ * otherwise `visibility` (default public).
116
151
  */
117
152
  export declare function resolveShareVisibility(opts?: {
118
153
  visibility?: ShareVisibility;
119
154
  unlisted?: boolean;
155
+ protected?: boolean;
120
156
  }): ShareVisibility;
157
+ /** Mint a fresh random viewer token for a `private` (token-gated) publish. The
158
+ * raw token rides ONLY in the returned URL's `?k=`; the Worker stores just its
159
+ * SHA-256 hash, so the object metadata never carries the secret. */
160
+ export declare function generateViewerToken(): string;
161
+ /** SHA-256 hex of a viewer token — the form the Worker also computes and stores,
162
+ * so a CLI-side preview of the stored hash matches. Exported for tests. */
163
+ export declare function hashViewerToken(token: string): string;
164
+ /** A 64-bit random slug tail (16 lowercase hex chars) for a capability-URL
165
+ * publish, so the slug can never be derived/guessed from the title (PHNX-3654).
166
+ * `unlisted` leans on this for obscurity; `private` uses it as defense-in-depth
167
+ * behind the viewer token. */
168
+ export declare function randomSlugTail(): string;
169
+ /**
170
+ * The loud stderr warning printed on an `unlisted` / `--private` publish
171
+ * (PHNX-3654): `unlisted` is obscurity, NOT read-authentication — anyone with
172
+ * the URL can read it. Points the user at the real controls (`--protected`,
173
+ * `--expire`, `me`/`org`). Lives here beside the visibility logic; `share.ts`
174
+ * prints it so the lib layer stays free of `console.*`.
175
+ */
176
+ export declare function unlistedNotPrivateWarning(): string;
121
177
  export interface ShareProvenance {
122
178
  /** Harness/agent name (`AGENTS_AGENT_NAME`), when publishing from an agent run. */
123
179
  agent?: string;
@@ -136,7 +192,7 @@ export interface ShareProvenance {
136
192
  * provenance the CLI sets automatically, plus `expires-at` / `visibility` /
137
193
  * `owner` which the Worker stamps itself.
138
194
  */
139
- export declare const RESERVED_META_KEYS: readonly ["expires-at", "published-at", "visibility", "owner", "org_domain", "agent", "session", "host", "repo", "date", "avatar", "label", "label-source", "og-title", "og-description", "og-generated", "og-source-etag"];
195
+ export declare const RESERVED_META_KEYS: readonly ["expires-at", "published-at", "visibility", "viewer-token-hash", "owner", "org_domain", "agent", "session", "host", "repo", "date", "avatar", "label", "label-source", "og-title", "og-description", "og-generated", "og-source-etag"];
140
196
  /**
141
197
  * Auto-capture publish provenance from the exec env, git, and the local clock.
142
198
  * Every field is present only when the environment genuinely carries it — a
@@ -9,28 +9,78 @@ import { readFileSync } from 'node:fs';
9
9
  import { basename } from 'node:path';
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import { hostname as osHostname } from 'node:os';
12
- import { createHash } from 'node:crypto';
12
+ import { createHash, randomBytes } from 'node:crypto';
13
13
  import { readSession } from '../identity/client.js';
14
14
  import { readShareConfig } from './config.js';
15
15
  import { resolveGitHubUsername } from '../git.js';
16
16
  import { resolveShareBackend, sanitizeShareNamespace } from './backend.js';
17
17
  import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
18
18
  import { deriveMeta, injectOgMeta } from './og.js';
19
+ import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
20
+ import { PUBLISH_VISIBILITY_LEVELS as STORAGE_PUBLISH_VISIBILITY_LEVELS, EDITABLE_VISIBILITY_LEVELS, resolveVisibility, } from '../storage/visibility.js';
19
21
  import { injectAnalyticsBeacon } from './analytics.js';
20
22
  import { prepareShareHtml } from './html.js';
21
- /** The visibility levels a share may carry — the Worker's own set, used to
22
- * validate `--visibility` and `share visibility <target> <level>`. */
23
- export const SHARE_VISIBILITY_LEVELS = ['public', 'unlisted', 'me', 'org'];
23
+ /** The visibility levels a publish `--visibility` may select — the Worker's own
24
+ * set. `private` (token-gated, PHNX-3654) is settable only at publish time,
25
+ * since it mints a viewer token the metadata-edit route can't carry, so it is
26
+ * NOT in {@link SHARE_VISIBILITY_LEVELS} (the in-place `share visibility <level>`
27
+ * set). */
28
+ export const PUBLISH_VISIBILITY_LEVELS = STORAGE_PUBLISH_VISIBILITY_LEVELS;
29
+ /** The visibility levels an ALREADY-published share may be re-scoped to in place
30
+ * — the set `share visibility <target> <level>` and the inline owner control
31
+ * accept. Excludes `private`: re-scoping to token-gated needs a fresh viewer
32
+ * token, which only the publish path mints. */
33
+ export const SHARE_VISIBILITY_LEVELS = EDITABLE_VISIBILITY_LEVELS;
24
34
  /**
25
- * `--unlisted` / `{ unlisted: true }` map to `unlisted`; `--visibility me|org`
26
- * passes through; otherwise `visibility` (default public).
35
+ * `--protected` / `{ protected: true }` map to `private` (token-gated — it wins
36
+ * over `--unlisted` when both are set, being the stronger control); `--unlisted`
37
+ * maps to `unlisted`; `--visibility private|unlisted|me|org` passes through;
38
+ * otherwise `visibility` (default public).
27
39
  */
28
40
  export function resolveShareVisibility(opts = {}) {
29
- if (opts.unlisted === true)
30
- return 'unlisted';
31
- if (opts.visibility === 'unlisted' || opts.visibility === 'me' || opts.visibility === 'org')
32
- return opts.visibility;
33
- return 'public';
41
+ // Delegates to the shared visibility resolver. The library fallback stays
42
+ // `public` so a lib caller that hasn't opted into the managed `me` default
43
+ // (e.g. `sessions share --public`, which expresses "public" as the absence of
44
+ // --unlisted) is never silently flipped. The PRODUCT default (`me` on managed)
45
+ // is applied by the `agents artifacts share` command surface, which knows the
46
+ // resolved backend — see `commands/share.ts`.
47
+ return resolveVisibility(opts);
48
+ }
49
+ /** The bytes of viewer-token entropy the `private` mode mints (128-bit; the
50
+ * base64url form is ~22 URL-safe chars). Well past the 64-bit floor a guessable
51
+ * capability URL would fall to. */
52
+ const VIEWER_TOKEN_BYTES = 16;
53
+ /** Mint a fresh random viewer token for a `private` (token-gated) publish. The
54
+ * raw token rides ONLY in the returned URL's `?k=`; the Worker stores just its
55
+ * SHA-256 hash, so the object metadata never carries the secret. */
56
+ export function generateViewerToken() {
57
+ return randomBytes(VIEWER_TOKEN_BYTES).toString('base64url');
58
+ }
59
+ /** SHA-256 hex of a viewer token — the form the Worker also computes and stores,
60
+ * so a CLI-side preview of the stored hash matches. Exported for tests. */
61
+ export function hashViewerToken(token) {
62
+ return createHash('sha256').update(token).digest('hex');
63
+ }
64
+ /** A 64-bit random slug tail (16 lowercase hex chars) for a capability-URL
65
+ * publish, so the slug can never be derived/guessed from the title (PHNX-3654).
66
+ * `unlisted` leans on this for obscurity; `private` uses it as defense-in-depth
67
+ * behind the viewer token. */
68
+ export function randomSlugTail() {
69
+ return randomBytes(8).toString('hex');
70
+ }
71
+ /**
72
+ * The loud stderr warning printed on an `unlisted` / `--private` publish
73
+ * (PHNX-3654): `unlisted` is obscurity, NOT read-authentication — anyone with
74
+ * the URL can read it. Points the user at the real controls (`--protected`,
75
+ * `--expire`, `me`/`org`). Lives here beside the visibility logic; `share.ts`
76
+ * prints it so the lib layer stays free of `console.*`.
77
+ */
78
+ export function unlistedNotPrivateWarning() {
79
+ return ('unlisted is NOT private — anyone with the URL can read it (a capability link, ' +
80
+ 'hidden from the gallery and marked noindex, but not authenticated).\n' +
81
+ ' For sensitive content use --protected (a token-gated link that returns 404 ' +
82
+ 'without the key), and/or --expire to bound the window; --visibility me|org ' +
83
+ 'gates on your Phoenix login.');
34
84
  }
35
85
  /**
36
86
  * Reserved `customMetadata` keys a `--meta key=value` may not target (see
@@ -42,6 +92,7 @@ export const RESERVED_META_KEYS = [
42
92
  'expires-at',
43
93
  'published-at',
44
94
  'visibility',
95
+ 'viewer-token-hash',
45
96
  'owner',
46
97
  'org_domain',
47
98
  'agent',
@@ -516,11 +567,26 @@ export async function publishFile(filePath, opts = {}) {
516
567
  export async function publishToEndpoint(filePath, endpoint, opts = {}) {
517
568
  const username = await resolveShareUsername(opts);
518
569
  let body = readFileSync(filePath);
519
- const slugPart = (opts.slug ?? defaultSlug(filePath, body)).replace(/^\/+/, '');
520
- const key = buildShareKey(username, slugPart);
521
570
  const expiresAt = resolveExpire(opts.expire);
522
571
  const visibility = resolveShareVisibility(opts);
523
572
  const unlisted = visibility === 'unlisted';
573
+ // A capability-URL publish (unlisted / token-gated private) must not have a
574
+ // guessable slug — that was the PHNX-3654 hole. Without an explicit --slug the
575
+ // slug always carries a 64-bit random tail (a title-derived prefix may lead it,
576
+ // but the random suffix is what makes the whole URL unguessable); an explicit
577
+ // --slug is the caller's own choice (e.g. republishing to a known URL) and is
578
+ // honored verbatim.
579
+ const capabilityUrl = visibility === 'unlisted' || visibility === 'private';
580
+ const explicitSlug = typeof opts.slug === 'string' && opts.slug.trim() !== '';
581
+ const slugPart = (explicitSlug
582
+ ? opts.slug
583
+ : capabilityUrl
584
+ ? `${defaultSlug(filePath, body)}-${randomSlugTail()}`
585
+ : defaultSlug(filePath, body)).replace(/^\/+/, '');
586
+ const key = buildShareKey(username, slugPart);
587
+ // Token-gated read auth (PHNX-3654): mint a random viewer token for a private
588
+ // publish. Only its hash is sent to the Worker; the raw token rides in ?k=.
589
+ const viewerToken = visibility === 'private' ? generateViewerToken() : undefined;
524
590
  const pageUrl = `${endpoint.baseUrl.replace(/\/+$/, '')}/${key}`;
525
591
  const provenance = opts.provenance ?? resolveShareProvenance();
526
592
  const avatarUrl = opts.avatar ?? resolveShareAvatar({ session: opts.session });
@@ -528,7 +594,12 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
528
594
  const put = opts.uploader ??
529
595
  (async (u, b, h) => {
530
596
  const res = await fetch(u, { method: 'PUT', headers: h, body: new Uint8Array(b) });
531
- return { ok: res.ok, status: res.status, url: u };
597
+ // Read the error body only on failure, so the caller can surface the
598
+ // Worker's own `{"error":"…"}` + Retry-After (bounded by extractShareHttpError).
599
+ if (res.ok)
600
+ return { ok: true, status: res.status, url: u };
601
+ const body = await res.text().catch(() => undefined);
602
+ return { ok: false, status: res.status, url: u, body, retryAfter: res.headers.get('retry-after') ?? undefined };
532
603
  });
533
604
  let coverUrl;
534
605
  const isHtml = /\.html?$/i.test(filePath);
@@ -598,6 +669,11 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
598
669
  if (expiresAt)
599
670
  h['x-share-expires-at'] = expiresAt;
600
671
  h['x-share-visibility'] = visibility;
672
+ // Token-gated read auth (PHNX-3654): send the RAW viewer token. The Worker
673
+ // hashes it (SHA-256) and stores only the hash in customMetadata, so the
674
+ // secret never lands in object metadata. Only present for a private publish.
675
+ if (viewerToken)
676
+ h['x-share-viewer-token'] = viewerToken;
601
677
  // Two headers per free-text field, backward-compatible by construction
602
678
  // (PHNX-2786): `x-share-<field>` always carries the latin1-safe folded value
603
679
  // an already-deployed Worker reads verbatim, and — only when the fold is lossy
@@ -675,10 +751,15 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
675
751
  if (r.status === 409) {
676
752
  throw new Error(`Handle '${username}' is already claimed by another account. The public URL namespace is the email local-part; two Phoenix users cannot share it.`);
677
753
  }
678
- throw new Error(`Publish failed (${r.status}) for ${pageUrl}. Check the write token, or that 'agents artifacts setup' completed.`);
754
+ const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter }));
755
+ throw new Error(`Publish failed (${r.status}) for ${pageUrl}${detail}. Check the write token, or that 'agents artifacts setup' completed.`);
679
756
  }
757
+ // A token-gated page is only reachable WITH its key, so the URL we hand back
758
+ // (and store nowhere) carries it — https://<host>/<user>/<slug>?k=<token>.
759
+ const baseUrl = r.url ?? pageUrl;
760
+ const url = viewerToken ? `${baseUrl}?k=${encodeURIComponent(viewerToken)}` : baseUrl;
680
761
  return {
681
- url: r.url ?? pageUrl,
762
+ url,
682
763
  slug: key.slice(key.indexOf('/') + 1),
683
764
  expiresAt,
684
765
  coverUrl,
@@ -686,5 +767,6 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
686
767
  labelSource,
687
768
  visibility,
688
769
  ...(unlisted ? { unlisted: true } : {}),
770
+ ...(viewerToken ? { viewerToken } : {}),
689
771
  };
690
772
  }
@@ -137,6 +137,18 @@ export default {
137
137
  }
138
138
  }
139
139
  }
140
+ // Token-gated read auth (PHNX-3654). A 'private' page is served only to a
141
+ // request carrying the matching viewer key; we store just its SHA-256 hash
142
+ // so the secret never lands in R2 metadata. Any write principal (Phoenix or
143
+ // BYO WRITE_TOKEN) may publish 'private' — the gate is the token, not an
144
+ // identity, unlike me/org. The token is mandatory: a private page with no
145
+ // stored hash fails closed on read, so refuse the write instead.
146
+ let viewerTokenHash = '';
147
+ if (visibility === 'private') {
148
+ const rawToken = request.headers.get('x-share-viewer-token') || '';
149
+ if (!rawToken) return json({ error: 'visibility private requires a viewer token' }, 400);
150
+ viewerTokenHash = await sha256Hex(rawToken);
151
+ }
140
152
  const segments = path.split('/').filter(Boolean);
141
153
  if (auth.kind === 'phoenix') {
142
154
  const expected = phoenixHandle(auth);
@@ -225,6 +237,7 @@ export default {
225
237
  }
226
238
  if (expiresAt) customMetadata['expires-at'] = expiresAt;
227
239
  customMetadata['visibility'] = visibility;
240
+ if (viewerTokenHash) customMetadata['viewer-token-hash'] = viewerTokenHash;
228
241
  const owner =
229
242
  auth.kind === 'phoenix'
230
243
  ? auth.owner
@@ -364,6 +377,11 @@ export default {
364
377
  const vis = normalizeVisibility(edit.visibility);
365
378
  if (vis.error) return vis.error;
366
379
  const visibility = vis.value;
380
+ // 'private' can't be set via the metadata-edit route: token-gating needs a
381
+ // fresh viewer key, which only a full publish mints (PHNX-3654). Re-stamping
382
+ // visibility=private here alone would leave the page gated by a hash that
383
+ // never got stored — inaccessible to everyone. Fail loud.
384
+ if (visibility === 'private') return json({ error: 'visibility private must be set at publish time (share --protected)' }, 400);
367
385
  if (visibility === 'me' || visibility === 'org') {
368
386
  const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
369
387
  if (auth.kind !== 'phoenix' || !phoenixBase) return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
@@ -459,23 +477,33 @@ export default {
459
477
  // ?revisions=json must not leak that the page exists.
460
478
  if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
461
479
  const canonical = await env.BUCKET.get(path);
480
+ const canonicalVis = canonical ? ((canonical.customMetadata && canonical.customMetadata.visibility) || 'public') : 'public';
462
481
  // Only a me/org canonical needs the viewer resolved — and only there does
463
482
  // a phoenix_ticket failure gate the response, exactly as before this route
464
483
  // shared resolveViewer with the page GET. A public/unlisted revision list
465
484
  // never invoked ticket redemption, so a stale ticket must not 401 it.
466
- if (canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public')) {
485
+ if (canonical && isIdentityGated(canonicalVis)) {
467
486
  const viewer = await resolveViewer(request, env, url);
468
487
  if (viewer.redirect) return viewer.redirect;
469
488
  if (viewer.error) return viewer.error;
470
489
  const denied = gateVisibility(url, env, canonical, viewer.identity || null);
471
490
  if (denied) return denied;
472
491
  }
492
+ // A private canonical gates its revision list on the viewer key too
493
+ // (PHNX-3654) — listing session/host/repo of a token-gated page to an
494
+ // unauthenticated caller would leak the very metadata the gate protects.
495
+ if (canonical && canonicalVis === 'private') {
496
+ const viewer = await resolveViewer(request, env, url);
497
+ if (viewer.redirect) return viewer.redirect;
498
+ const tokenDenied = await gateTokenRead(request, url, canonical, viewer.identity || null);
499
+ if (tokenDenied) return tokenDenied;
500
+ }
473
501
  return renderRevisions(
474
502
  env.BUCKET,
475
503
  url.origin,
476
504
  path,
477
505
  request.method,
478
- canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public'),
506
+ !!canonical && (isIdentityGated(canonicalVis) || canonicalVis === 'private'),
479
507
  );
480
508
  }
481
509
 
@@ -498,6 +526,10 @@ export default {
498
526
  if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
499
527
  const denied = gateVisibility(url, env, page, viewer.identity || null);
500
528
  if (denied) return denied;
529
+ // A token-gated page's cover is token-gated too (PHNX-3654): a crawler
530
+ // fetching <slug>.png without the key gets 404, so no preview leaks.
531
+ const coverTokenDenied = await gateTokenRead(request, url, page, viewer.identity || null);
532
+ if (coverTokenDenied) return coverTokenDenied;
501
533
 
502
534
  if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
503
535
  const current = await env.BUCKET.get(pagePath);
@@ -589,6 +621,12 @@ export default {
589
621
  const identity = viewer.identity || null;
590
622
  const denied = gateVisibility(url, env, obj, identity);
591
623
  if (denied) return denied;
624
+ // Token-gated read auth (PHNX-3654): a 'private' page is served only to a
625
+ // request carrying the matching viewer key (?k= or Bearer), or to its owner.
626
+ // A miss returns 404 — never leaking that the page exists — exactly like a
627
+ // wrong me/org viewer.
628
+ const tokenDenied = await gateTokenRead(request, url, obj, identity);
629
+ if (tokenDenied) return tokenDenied;
592
630
  // The owner of the namespace (their handle === the first path segment) gets
593
631
  // an interactive visibility control; everyone else keeps the static cue.
594
632
  const isOwner = !!identity && handleFromEmail(identity.email) === firstSeg;
@@ -597,7 +635,9 @@ export default {
597
635
  obj.writeHttpMetadata(headers);
598
636
  headers.set('etag', obj.httpEtag);
599
637
  if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
600
- if (visibility === 'me' || visibility === 'org') {
638
+ if (visibility === 'me' || visibility === 'org' || visibility === 'private') {
639
+ // Token-gated + identity-gated reads must never be cached by a shared
640
+ // proxy, and never indexed.
601
641
  headers.set('cache-control', 'private, no-store');
602
642
  headers.set('X-Robots-Tag', 'noindex');
603
643
  } else {
@@ -647,7 +687,11 @@ export default {
647
687
  // without text/html) falls through to raw bytes so embedding is never broken.
648
688
  const wantsRaw = url.searchParams.get('raw') != null;
649
689
  const kind = viewableAssetKind(ctype);
650
- if (!wantsRaw && kind && acceptsHtml(request)) {
690
+ // A token-gated (private) asset is served as raw bytes, NOT wrapped in the
691
+ // viewer page (PHNX-3654): the viewer's inner <img src=…?raw> would drop the
692
+ // ?k= key and 404 (there's no cookie to carry it, unlike me/org), breaking the
693
+ // media. The gate already passed above, so the raw bytes are safe to serve.
694
+ if (!wantsRaw && kind && acceptsHtml(request) && visibility !== 'private') {
651
695
  const viewerPage = renderAssetViewer(url.pathname, kind, obj.customMetadata || {}, firstSeg, {
652
696
  isOwner,
653
697
  ownerDomain,
@@ -896,7 +940,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
896
940
  // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
897
941
  // One list, reused both to strip a same-named --meta collision on write and
898
942
  // to split arbitrary --meta entries back out on read.
899
- var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source', 'og-title', 'og-description', 'og-generated', 'og-source-etag'];
943
+ var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'viewer-token-hash', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source', 'og-title', 'og-description', 'og-generated', 'og-source-etag'];
900
944
  var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
901
945
  var SHARE_COOKIE = '__Host-phoenix_share';
902
946
  var SHARE_COOKIE_MAX_AGE = 604800;
@@ -1025,6 +1069,7 @@ var ASH_OWNER_JS = \`(function(){
1025
1069
  function visibilityChip(visibility, orgDomain) {
1026
1070
  if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
1027
1071
  if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
1072
+ if (visibility === 'private') return { icon: VIS_ICON.me, label: 'Protected (link + key)', color: '#f59e0b' };
1028
1073
  if (visibility === 'unlisted') return { icon: VIS_ICON.unlisted, label: 'Unlisted', color: '#9aa0a6' };
1029
1074
  return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
1030
1075
  }
@@ -1340,8 +1385,8 @@ async function claimHandle(bucket, handle, userId) {
1340
1385
  function normalizeVisibility(raw) {
1341
1386
  const v = (raw || '').trim().toLowerCase();
1342
1387
  if (!v || v === 'public') return { value: 'public' };
1343
- if (v === 'unlisted' || v === 'me' || v === 'org') return { value: v };
1344
- return { error: json({ error: 'visibility must be public, unlisted, me, or org' }, 400) };
1388
+ if (v === 'unlisted' || v === 'private' || v === 'me' || v === 'org') return { value: v };
1389
+ return { error: json({ error: 'visibility must be public, unlisted, private, me, or org' }, 400) };
1345
1390
  }
1346
1391
 
1347
1392
  function emailDomain(email) {
@@ -1352,7 +1397,7 @@ function emailDomain(email) {
1352
1397
  }
1353
1398
 
1354
1399
  function isHiddenFromGallery(visibility) {
1355
- return visibility === 'unlisted' || visibility === 'me' || visibility === 'org';
1400
+ return visibility === 'unlisted' || visibility === 'private' || visibility === 'me' || visibility === 'org';
1356
1401
  }
1357
1402
 
1358
1403
  function isIdentityGated(visibility) {
@@ -1361,7 +1406,11 @@ function isIdentityGated(visibility) {
1361
1406
 
1362
1407
  function managedCoverHeaders(visibility) {
1363
1408
  const headers = new Headers({ 'content-type': 'image/png' });
1364
- if (isIdentityGated(visibility)) {
1409
+ // Token-gated (private) + identity-gated (me/org) covers must never be
1410
+ // cached by a shared proxy, and never indexed. RFC 9111: public on an
1411
+ // Authorization response authorizes reuse for later unauthenticated
1412
+ // requests keyed on /user/slug.png (PHNX-3676).
1413
+ if (isIdentityGated(visibility) || visibility === 'private') {
1365
1414
  headers.set('cache-control', 'private, no-store');
1366
1415
  headers.set('X-Robots-Tag', 'noindex');
1367
1416
  } else {
@@ -1384,6 +1433,52 @@ function gateVisibility(url, env, obj, identity) {
1384
1433
  return null;
1385
1434
  }
1386
1435
 
1436
+ // SHA-256 hex of a string — the form a 'private' object's stored
1437
+ // 'viewer-token-hash' takes. Both the PUT (hash-on-store) and the read gate use
1438
+ // this, so a token minted by the CLI matches byte-for-byte (PHNX-3654).
1439
+ async function sha256Hex(text) {
1440
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(text)));
1441
+ return bufToHex(digest);
1442
+ }
1443
+
1444
+ // The viewer key a reader presents for a token-gated ('private') page: the ?k=
1445
+ // query param first (the shape the CLI emits — https://host/<u>/<s>?k=<token>),
1446
+ // then an Authorization: Bearer token as an alternative for scripted callers.
1447
+ function readViewerKey(request, url) {
1448
+ const q = url.searchParams.get('k');
1449
+ if (q) return q;
1450
+ const bearer = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
1451
+ return bearer || '';
1452
+ }
1453
+
1454
+ // Gate a 'private' (token-gated) read (PHNX-3654). Returns a 404 Response when
1455
+ // the request may NOT read, or null when it may. A miss ALWAYS 404s — never a
1456
+ // 401 and never a distinct "wrong key" — so a token-gated page never even leaks
1457
+ // that it exists, exactly like a wrong me/org viewer. The namespace owner
1458
+ // (resolved identity whose userId stamped the object) is let through without the
1459
+ // key so 'share open' and the owner's own browsing still work. Constant-time
1460
+ // compare (safeEqual) over the stored SHA-256 hash keeps the check timing-safe.
1461
+ async function gateTokenRead(request, url, obj, identity) {
1462
+ const meta = obj.customMetadata || {};
1463
+ const visibility = meta.visibility || 'public';
1464
+ if (visibility !== 'private') return null;
1465
+ const notFound = function () {
1466
+ return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
1467
+ };
1468
+ // Owner bypass: the signed-in owner of the page reads their own private page
1469
+ // without the key (mirrors 'me').
1470
+ if (identity && meta.owner && meta.owner === identity.userId) return null;
1471
+ const stored = meta['viewer-token-hash'] || '';
1472
+ // Fail closed: a private object with no stored hash can never be matched, so it
1473
+ // is unreadable rather than accidentally public.
1474
+ if (!stored) return notFound();
1475
+ const presented = readViewerKey(request, url);
1476
+ if (!presented) return notFound();
1477
+ const presentedHash = await sha256Hex(presented);
1478
+ if (!safeEqual(presentedHash, stored)) return notFound();
1479
+ return null;
1480
+ }
1481
+
1387
1482
  // Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
1388
1483
  // counting a view never rewrites the page object (which would reset its uploaded
1389
1484
  // timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
@@ -1805,6 +1900,7 @@ async function renderOgCard(input) {
1805
1900
  const visibilityLabels = {
1806
1901
  public: 'PUBLIC',
1807
1902
  unlisted: 'UNLISTED',
1903
+ private: 'PROTECTED',
1808
1904
  me: 'ONLY YOU',
1809
1905
  org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
1810
1906
  };
@@ -90,15 +90,29 @@ export function formatEmptyAutoPoolError() {
90
90
  'mark one with `agents devices role <name> worker`, or widen the pool with `agents config set auto.pool all`.');
91
91
  }
92
92
  export function formatNoHealthyDeviceError(pool, signals, agent) {
93
+ let timedOutCount = 0;
93
94
  const excluded = pool.map((key) => {
94
95
  const signal = signals.get(key);
95
- const reason = signal?.reachable !== true
96
- ? 'unreachable'
97
- : signal.headroom === 'loaded'
96
+ let reason;
97
+ if (signal?.reachable === true) {
98
+ reason = signal.headroom === 'loaded'
98
99
  ? 'overloaded'
99
100
  : signal.installed !== true || signal.signedIn !== true
100
101
  ? 'no ready harness account'
101
102
  : 'ineligible';
103
+ }
104
+ else if (signal?.timedOut) {
105
+ // A slow link is not an offline box. Saying "unreachable" here sent
106
+ // operators hunting a fleet outage that did not exist (PHNX-3682).
107
+ timedOutCount++;
108
+ reason = 'probe timed out';
109
+ }
110
+ else if (signal === undefined) {
111
+ reason = 'no probe signal';
112
+ }
113
+ else {
114
+ reason = 'unreachable';
115
+ }
102
116
  return `${key} (${reason})`;
103
117
  }).join(', ');
104
118
  const target = agent ? `can run ${agent}` : "for 'run auto'";
@@ -108,7 +122,16 @@ export function formatNoHealthyDeviceError(pool, signals, agent) {
108
122
  // in this error's own candidate set, not just the ones with a doc.
109
123
  const marked = describeAutoPool({ roster: pool });
110
124
  const poolNote = marked ? ` [pool: ${marked}]` : '';
111
- return `agents: no healthy device ${target}${poolNote} — excluded: ${excluded}; earliest window resets unknown`;
125
+ // Only talk about usage windows when a device was actually turned away for
126
+ // one. A pool that timed out needs a link/latency hint, not a reset time.
127
+ const scope = timedOutCount === pool.length
128
+ ? `every probe (${pool.length}) exceeded`
129
+ : `${timedOutCount} of ${pool.length} probes exceeded`;
130
+ const hint = timedOutCount > 0
131
+ ? `; ${scope} the probe budget — those devices are likely up but slow to answer`
132
+ + ' (relayed Tailscale paths). Retry, or check `tailscale status` for a direct path.'
133
+ : '; earliest window resets unknown';
134
+ return `agents: no healthy device ${target}${poolNote} — excluded: ${excluded}${hint}`;
112
135
  }
113
136
  /**
114
137
  * Pick the least-loaded healthy device that can run `agent` when the harness is
@@ -126,6 +126,8 @@ export interface SshExecResult {
126
126
  stderr: string;
127
127
  timedOut: boolean;
128
128
  }
129
+ /** Grace after a timed-out child receives SIGTERM before SIGKILL enforces the bound. */
130
+ export declare const SSH_TIMEOUT_KILL_GRACE_MS = 250;
129
131
  /**
130
132
  * Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
131
133
  *