@phnx-labs/agents-cli 1.22.65 → 1.22.68

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 (112) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/apply.js +9 -2
  5. package/dist/commands/browser.js +46 -21
  6. package/dist/commands/daemon-test-harness.d.ts +1 -1
  7. package/dist/commands/daemon-test-harness.js +2 -2
  8. package/dist/commands/daemon.js +21 -22
  9. package/dist/commands/exec.js +40 -0
  10. package/dist/commands/monitors.js +2 -2
  11. package/dist/commands/repo.js +57 -19
  12. package/dist/commands/resume.d.ts +16 -0
  13. package/dist/commands/resume.js +41 -8
  14. package/dist/commands/routines.js +42 -21
  15. package/dist/commands/sessions-optimize.js +1 -1
  16. package/dist/commands/sessions.js +4 -11
  17. package/dist/commands/share.d.ts +5 -3
  18. package/dist/commands/share.js +33 -9
  19. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  20. package/dist/lib/accounting/account-pool-collect.js +6 -4
  21. package/dist/lib/accounting/usage-ingest.js +4 -2
  22. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  23. package/dist/lib/accounting/usage-sync.js +66 -210
  24. package/dist/lib/accounting/usage.d.ts +11 -3
  25. package/dist/lib/accounting/usage.js +23 -10
  26. package/dist/lib/auth-health.d.ts +8 -0
  27. package/dist/lib/auth-health.js +4 -4
  28. package/dist/lib/browser/caller-identity.d.ts +12 -0
  29. package/dist/lib/browser/caller-identity.js +19 -0
  30. package/dist/lib/browser/ipc.d.ts +37 -32
  31. package/dist/lib/browser/ipc.js +146 -94
  32. package/dist/lib/browser/task-index.d.ts +10 -2
  33. package/dist/lib/browser/task-index.js +22 -3
  34. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  35. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  36. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  37. package/dist/lib/daemon/auth-sync-service.js +17 -6
  38. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  39. package/dist/lib/daemon/catchup-service.js +51 -0
  40. package/dist/lib/daemon/daemon.d.ts +12 -22
  41. package/dist/lib/daemon/daemon.js +463 -176
  42. package/dist/lib/daemon/runner.js +2 -0
  43. package/dist/lib/daemon/service.d.ts +22 -4
  44. package/dist/lib/daemon/service.js +2 -2
  45. package/dist/lib/daemon/supervisor.d.ts +55 -15
  46. package/dist/lib/daemon/supervisor.js +119 -29
  47. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  48. package/dist/lib/daemon/usage-sync-service.js +22 -18
  49. package/dist/lib/daemon-health.js +36 -31
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +12 -2
  52. package/dist/lib/daemon-ticks.d.ts +9 -6
  53. package/dist/lib/daemon-ticks.js +14 -8
  54. package/dist/lib/devices/known-hosts.d.ts +15 -0
  55. package/dist/lib/devices/known-hosts.js +18 -0
  56. package/dist/lib/devices/registry.js +2 -0
  57. package/dist/lib/exec.js +11 -0
  58. package/dist/lib/feed/watch.d.ts +3 -0
  59. package/dist/lib/feed/watch.js +13 -3
  60. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  61. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  62. package/dist/lib/fleet-shared-state.d.ts +38 -0
  63. package/dist/lib/fleet-shared-state.js +105 -0
  64. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  65. package/dist/lib/hosts/remote-cmd.js +12 -3
  66. package/dist/lib/lock-compromise.d.ts +8 -0
  67. package/dist/lib/lock-compromise.js +12 -0
  68. package/dist/lib/monitors/engine.d.ts +2 -1
  69. package/dist/lib/monitors/engine.js +9 -2
  70. package/dist/lib/refresh-coordinator.js +2 -0
  71. package/dist/lib/run-trace-sync.d.ts +13 -0
  72. package/dist/lib/run-trace-sync.js +77 -0
  73. package/dist/lib/secrets/push.d.ts +10 -0
  74. package/dist/lib/secrets/push.js +86 -7
  75. package/dist/lib/secrets/remote.d.ts +18 -6
  76. package/dist/lib/secrets/remote.js +29 -4
  77. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  78. package/dist/lib/secrets/reserved-sync.js +120 -102
  79. package/dist/lib/session/active.d.ts +3 -1
  80. package/dist/lib/session/active.js +2 -0
  81. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  82. package/dist/lib/session/actor-sidecar.js +2 -0
  83. package/dist/lib/session/db.d.ts +5 -2
  84. package/dist/lib/session/db.js +54 -22
  85. package/dist/lib/session/discover.d.ts +12 -3
  86. package/dist/lib/session/discover.js +106 -25
  87. package/dist/lib/session/prompt.d.ts +11 -0
  88. package/dist/lib/session/prompt.js +25 -0
  89. package/dist/lib/session/recovery.d.ts +30 -2
  90. package/dist/lib/session/recovery.js +56 -4
  91. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  92. package/dist/lib/session/remote/remote-list.js +29 -4
  93. package/dist/lib/session/remote/watch.d.ts +25 -2
  94. package/dist/lib/session/remote/watch.js +185 -11
  95. package/dist/lib/session/session-cache.d.ts +2 -1
  96. package/dist/lib/session/session-cache.js +1 -0
  97. package/dist/lib/session/types.d.ts +2 -0
  98. package/dist/lib/share/publish.d.ts +55 -9
  99. package/dist/lib/share/publish.js +87 -9
  100. package/dist/lib/share/worker-template.js +100 -8
  101. package/dist/lib/ssh-exec.d.ts +2 -0
  102. package/dist/lib/ssh-exec.js +20 -4
  103. package/dist/lib/teams/registry.js +2 -0
  104. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  105. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  106. package/dist/lib/traces/sync.d.ts +7 -0
  107. package/dist/lib/traces/sync.js +9 -0
  108. package/dist/lib/usage-refresh.d.ts +8 -2
  109. package/dist/lib/usage-refresh.js +3 -3
  110. package/package.json +1 -1
  111. package/dist/lib/account-state-service.d.ts +0 -21
  112. package/dist/lib/account-state-service.js +0 -60
@@ -9,7 +9,7 @@ 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';
@@ -18,20 +18,72 @@ import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
18
18
  import { deriveMeta, injectOgMeta } from './og.js';
19
19
  import { injectAnalyticsBeacon } from './analytics.js';
20
20
  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>`. */
21
+ /** The visibility levels a publish `--visibility` may select — the Worker's own
22
+ * set. `private` (token-gated, PHNX-3654) is settable only at publish time,
23
+ * since it mints a viewer token the metadata-edit route can't carry, so it is
24
+ * NOT in {@link SHARE_VISIBILITY_LEVELS} (the in-place `share visibility <level>`
25
+ * set). */
26
+ export const PUBLISH_VISIBILITY_LEVELS = ['public', 'unlisted', 'private', 'me', 'org'];
27
+ /** The visibility levels an ALREADY-published share may be re-scoped to in place
28
+ * — the set `share visibility <target> <level>` and the inline owner control
29
+ * accept. Excludes `private`: re-scoping to token-gated needs a fresh viewer
30
+ * token, which only the publish path mints. */
23
31
  export const SHARE_VISIBILITY_LEVELS = ['public', 'unlisted', 'me', 'org'];
24
32
  /**
25
- * `--unlisted` / `{ unlisted: true }` map to `unlisted`; `--visibility me|org`
26
- * passes through; otherwise `visibility` (default public).
33
+ * `--protected` / `{ protected: true }` map to `private` (token-gated — it wins
34
+ * over `--unlisted` when both are set, being the stronger control); `--unlisted`
35
+ * maps to `unlisted`; `--visibility private|unlisted|me|org` passes through;
36
+ * otherwise `visibility` (default public).
27
37
  */
28
38
  export function resolveShareVisibility(opts = {}) {
39
+ if (opts.protected === true)
40
+ return 'private';
29
41
  if (opts.unlisted === true)
30
42
  return 'unlisted';
31
- if (opts.visibility === 'unlisted' || opts.visibility === 'me' || opts.visibility === 'org')
43
+ if (opts.visibility === 'unlisted' ||
44
+ opts.visibility === 'private' ||
45
+ opts.visibility === 'me' ||
46
+ opts.visibility === 'org') {
32
47
  return opts.visibility;
48
+ }
33
49
  return 'public';
34
50
  }
51
+ /** The bytes of viewer-token entropy the `private` mode mints (128-bit; the
52
+ * base64url form is ~22 URL-safe chars). Well past the 64-bit floor a guessable
53
+ * capability URL would fall to. */
54
+ const VIEWER_TOKEN_BYTES = 16;
55
+ /** Mint a fresh random viewer token for a `private` (token-gated) publish. The
56
+ * raw token rides ONLY in the returned URL's `?k=`; the Worker stores just its
57
+ * SHA-256 hash, so the object metadata never carries the secret. */
58
+ export function generateViewerToken() {
59
+ return randomBytes(VIEWER_TOKEN_BYTES).toString('base64url');
60
+ }
61
+ /** SHA-256 hex of a viewer token — the form the Worker also computes and stores,
62
+ * so a CLI-side preview of the stored hash matches. Exported for tests. */
63
+ export function hashViewerToken(token) {
64
+ return createHash('sha256').update(token).digest('hex');
65
+ }
66
+ /** A 64-bit random slug tail (16 lowercase hex chars) for a capability-URL
67
+ * publish, so the slug can never be derived/guessed from the title (PHNX-3654).
68
+ * `unlisted` leans on this for obscurity; `private` uses it as defense-in-depth
69
+ * behind the viewer token. */
70
+ export function randomSlugTail() {
71
+ return randomBytes(8).toString('hex');
72
+ }
73
+ /**
74
+ * The loud stderr warning printed on an `unlisted` / `--private` publish
75
+ * (PHNX-3654): `unlisted` is obscurity, NOT read-authentication — anyone with
76
+ * the URL can read it. Points the user at the real controls (`--protected`,
77
+ * `--expire`, `me`/`org`). Lives here beside the visibility logic; `share.ts`
78
+ * prints it so the lib layer stays free of `console.*`.
79
+ */
80
+ export function unlistedNotPrivateWarning() {
81
+ return ('unlisted is NOT private — anyone with the URL can read it (a capability link, ' +
82
+ 'hidden from the gallery and marked noindex, but not authenticated).\n' +
83
+ ' For sensitive content use --protected (a token-gated link that returns 404 ' +
84
+ 'without the key), and/or --expire to bound the window; --visibility me|org ' +
85
+ 'gates on your Phoenix login.');
86
+ }
35
87
  /**
36
88
  * Reserved `customMetadata` keys a `--meta key=value` may not target (see
37
89
  * {@link parseMetaEntries}). Matches the Worker's `RESERVED_METADATA_KEYS`:
@@ -42,6 +94,7 @@ export const RESERVED_META_KEYS = [
42
94
  'expires-at',
43
95
  'published-at',
44
96
  'visibility',
97
+ 'viewer-token-hash',
45
98
  'owner',
46
99
  'org_domain',
47
100
  'agent',
@@ -516,11 +569,26 @@ export async function publishFile(filePath, opts = {}) {
516
569
  export async function publishToEndpoint(filePath, endpoint, opts = {}) {
517
570
  const username = await resolveShareUsername(opts);
518
571
  let body = readFileSync(filePath);
519
- const slugPart = (opts.slug ?? defaultSlug(filePath, body)).replace(/^\/+/, '');
520
- const key = buildShareKey(username, slugPart);
521
572
  const expiresAt = resolveExpire(opts.expire);
522
573
  const visibility = resolveShareVisibility(opts);
523
574
  const unlisted = visibility === 'unlisted';
575
+ // A capability-URL publish (unlisted / token-gated private) must not have a
576
+ // guessable slug — that was the PHNX-3654 hole. Without an explicit --slug the
577
+ // slug always carries a 64-bit random tail (a title-derived prefix may lead it,
578
+ // but the random suffix is what makes the whole URL unguessable); an explicit
579
+ // --slug is the caller's own choice (e.g. republishing to a known URL) and is
580
+ // honored verbatim.
581
+ const capabilityUrl = visibility === 'unlisted' || visibility === 'private';
582
+ const explicitSlug = typeof opts.slug === 'string' && opts.slug.trim() !== '';
583
+ const slugPart = (explicitSlug
584
+ ? opts.slug
585
+ : capabilityUrl
586
+ ? `${defaultSlug(filePath, body)}-${randomSlugTail()}`
587
+ : defaultSlug(filePath, body)).replace(/^\/+/, '');
588
+ const key = buildShareKey(username, slugPart);
589
+ // Token-gated read auth (PHNX-3654): mint a random viewer token for a private
590
+ // publish. Only its hash is sent to the Worker; the raw token rides in ?k=.
591
+ const viewerToken = visibility === 'private' ? generateViewerToken() : undefined;
524
592
  const pageUrl = `${endpoint.baseUrl.replace(/\/+$/, '')}/${key}`;
525
593
  const provenance = opts.provenance ?? resolveShareProvenance();
526
594
  const avatarUrl = opts.avatar ?? resolveShareAvatar({ session: opts.session });
@@ -598,6 +666,11 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
598
666
  if (expiresAt)
599
667
  h['x-share-expires-at'] = expiresAt;
600
668
  h['x-share-visibility'] = visibility;
669
+ // Token-gated read auth (PHNX-3654): send the RAW viewer token. The Worker
670
+ // hashes it (SHA-256) and stores only the hash in customMetadata, so the
671
+ // secret never lands in object metadata. Only present for a private publish.
672
+ if (viewerToken)
673
+ h['x-share-viewer-token'] = viewerToken;
601
674
  // Two headers per free-text field, backward-compatible by construction
602
675
  // (PHNX-2786): `x-share-<field>` always carries the latin1-safe folded value
603
676
  // an already-deployed Worker reads verbatim, and — only when the fold is lossy
@@ -677,8 +750,12 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
677
750
  }
678
751
  throw new Error(`Publish failed (${r.status}) for ${pageUrl}. Check the write token, or that 'agents artifacts setup' completed.`);
679
752
  }
753
+ // A token-gated page is only reachable WITH its key, so the URL we hand back
754
+ // (and store nowhere) carries it — https://<host>/<user>/<slug>?k=<token>.
755
+ const baseUrl = r.url ?? pageUrl;
756
+ const url = viewerToken ? `${baseUrl}?k=${encodeURIComponent(viewerToken)}` : baseUrl;
680
757
  return {
681
- url: r.url ?? pageUrl,
758
+ url,
682
759
  slug: key.slice(key.indexOf('/') + 1),
683
760
  expiresAt,
684
761
  coverUrl,
@@ -686,5 +763,6 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
686
763
  labelSource,
687
764
  visibility,
688
765
  ...(unlisted ? { unlisted: true } : {}),
766
+ ...(viewerToken ? { viewerToken } : {}),
689
767
  };
690
768
  }
@@ -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) {
@@ -1384,6 +1429,52 @@ function gateVisibility(url, env, obj, identity) {
1384
1429
  return null;
1385
1430
  }
1386
1431
 
1432
+ // SHA-256 hex of a string — the form a 'private' object's stored
1433
+ // 'viewer-token-hash' takes. Both the PUT (hash-on-store) and the read gate use
1434
+ // this, so a token minted by the CLI matches byte-for-byte (PHNX-3654).
1435
+ async function sha256Hex(text) {
1436
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(text)));
1437
+ return bufToHex(digest);
1438
+ }
1439
+
1440
+ // The viewer key a reader presents for a token-gated ('private') page: the ?k=
1441
+ // query param first (the shape the CLI emits — https://host/<u>/<s>?k=<token>),
1442
+ // then an Authorization: Bearer token as an alternative for scripted callers.
1443
+ function readViewerKey(request, url) {
1444
+ const q = url.searchParams.get('k');
1445
+ if (q) return q;
1446
+ const bearer = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
1447
+ return bearer || '';
1448
+ }
1449
+
1450
+ // Gate a 'private' (token-gated) read (PHNX-3654). Returns a 404 Response when
1451
+ // the request may NOT read, or null when it may. A miss ALWAYS 404s — never a
1452
+ // 401 and never a distinct "wrong key" — so a token-gated page never even leaks
1453
+ // that it exists, exactly like a wrong me/org viewer. The namespace owner
1454
+ // (resolved identity whose userId stamped the object) is let through without the
1455
+ // key so 'share open' and the owner's own browsing still work. Constant-time
1456
+ // compare (safeEqual) over the stored SHA-256 hash keeps the check timing-safe.
1457
+ async function gateTokenRead(request, url, obj, identity) {
1458
+ const meta = obj.customMetadata || {};
1459
+ const visibility = meta.visibility || 'public';
1460
+ if (visibility !== 'private') return null;
1461
+ const notFound = function () {
1462
+ return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
1463
+ };
1464
+ // Owner bypass: the signed-in owner of the page reads their own private page
1465
+ // without the key (mirrors 'me').
1466
+ if (identity && meta.owner && meta.owner === identity.userId) return null;
1467
+ const stored = meta['viewer-token-hash'] || '';
1468
+ // Fail closed: a private object with no stored hash can never be matched, so it
1469
+ // is unreadable rather than accidentally public.
1470
+ if (!stored) return notFound();
1471
+ const presented = readViewerKey(request, url);
1472
+ if (!presented) return notFound();
1473
+ const presentedHash = await sha256Hex(presented);
1474
+ if (!safeEqual(presentedHash, stored)) return notFound();
1475
+ return null;
1476
+ }
1477
+
1387
1478
  // Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
1388
1479
  // counting a view never rewrites the page object (which would reset its uploaded
1389
1480
  // timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
@@ -1805,6 +1896,7 @@ async function renderOgCard(input) {
1805
1896
  const visibilityLabels = {
1806
1897
  public: 'PUBLIC',
1807
1898
  unlisted: 'UNLISTED',
1899
+ private: 'PROTECTED',
1808
1900
  me: 'ONLY YOU',
1809
1901
  org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
1810
1902
  };
@@ -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
  *
@@ -173,6 +173,8 @@ export function controlOpts() {
173
173
  export function sshConnectOpts(mux, hostKeyOpts) {
174
174
  return [...(hostKeyOpts ?? []), ...SSH_OPTS, ...mux];
175
175
  }
176
+ /** Grace after a timed-out child receives SIGTERM before SIGKILL enforces the bound. */
177
+ export const SSH_TIMEOUT_KILL_GRACE_MS = 250;
176
178
  /**
177
179
  * Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
178
180
  *
@@ -228,12 +230,28 @@ export function sshExecAsync(target, remoteCmd, opts = {}) {
228
230
  let stderr = '';
229
231
  let settled = false;
230
232
  let timedOut = false;
233
+ let killTimer = null;
231
234
  const timer = opts.timeoutMs
232
235
  ? setTimeout(() => {
233
236
  timedOut = true;
234
237
  child.kill('SIGTERM');
238
+ // SIGTERM is advisory. A wedged ssh process can ignore it and keep the
239
+ // Promise (and daemon tick) open forever, so enforce a short hard-kill
240
+ // bound. Timeout calls always disable ControlMaster above; killing this
241
+ // direct client therefore tears down the remote connection too.
242
+ killTimer = setTimeout(() => {
243
+ if (!settled)
244
+ child.kill('SIGKILL');
245
+ }, SSH_TIMEOUT_KILL_GRACE_MS);
246
+ killTimer.unref?.();
235
247
  }, opts.timeoutMs)
236
248
  : null;
249
+ const clearTimers = () => {
250
+ if (timer)
251
+ clearTimeout(timer);
252
+ if (killTimer)
253
+ clearTimeout(killTimer);
254
+ };
237
255
  child.stdout.setEncoding('utf-8');
238
256
  child.stderr.setEncoding('utf-8');
239
257
  child.stdout.on('data', (chunk) => { stdout += chunk; });
@@ -251,16 +269,14 @@ export function sshExecAsync(target, remoteCmd, opts = {}) {
251
269
  if (settled)
252
270
  return;
253
271
  settled = true;
254
- if (timer)
255
- clearTimeout(timer);
272
+ clearTimers();
256
273
  resolve({ code: null, stdout, stderr: stderr + err.message, timedOut });
257
274
  });
258
275
  child.on('close', (code) => {
259
276
  if (settled)
260
277
  return;
261
278
  settled = true;
262
- if (timer)
263
- clearTimeout(timer);
279
+ clearTimers();
264
280
  resolve({ code, stdout, stderr, timedOut });
265
281
  });
266
282
  });
@@ -14,6 +14,7 @@ import lockfile from 'proper-lockfile';
14
14
  import { getTeamsRegistryPath } from '../state.js';
15
15
  import { emit } from '../feed/events.js';
16
16
  import { atomicWriteJsonSync } from '../fs-atomic.js';
17
+ import { logAndContinueOnLockCompromised } from '../lock-compromise.js';
17
18
  async function registryPath() {
18
19
  return getTeamsRegistryPath();
19
20
  }
@@ -38,6 +39,7 @@ async function withRegistryLock(p, fn) {
38
39
  const release = await lockfile.lock(p, {
39
40
  retries: { retries: 60, minTimeout: 25, maxTimeout: 250, factor: 1.5 },
40
41
  stale: 10_000,
42
+ onCompromised: logAndContinueOnLockCompromised('teams registry'),
41
43
  });
42
44
  try {
43
45
  return await fn();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { recordSubsystemError } from '../daemon-health.js';
2
+ const [subsystem, countRaw] = process.argv.slice(2);
3
+ const count = Number(countRaw);
4
+ if (!subsystem || !Number.isInteger(count) || count < 1)
5
+ process.exit(2);
6
+ for (let i = 0; i < count; i++) {
7
+ recordSubsystemError(subsystem, `${process.pid}:${i}`);
8
+ }
@@ -390,6 +390,13 @@ interface SyncLedger {
390
390
  }
391
391
  export declare function readSyncLedger(): SyncLedger;
392
392
  export declare function writeSyncLedger(ledger: SyncLedger): void;
393
+ /**
394
+ * True once the user has run `agents traces sync` at least once (the ledger
395
+ * file exists). This is the opt-in signal the run-exit auto-sync gates on
396
+ * (PHNX-3628): a user who has never synced has not opted into the traces store,
397
+ * so an `agents run` never uploads on their behalf.
398
+ */
399
+ export declare function hasSyncedBefore(): boolean;
393
400
  export interface SyncRow {
394
401
  id: string;
395
402
  short_id: string;
@@ -816,6 +816,15 @@ export function writeSyncLedger(ledger) {
816
816
  fs.mkdirSync(path.dirname(p), { recursive: true });
817
817
  fs.writeFileSync(p, JSON.stringify(ledger, null, 2) + '\n', 'utf8');
818
818
  }
819
+ /**
820
+ * True once the user has run `agents traces sync` at least once (the ledger
821
+ * file exists). This is the opt-in signal the run-exit auto-sync gates on
822
+ * (PHNX-3628): a user who has never synced has not opted into the traces store,
823
+ * so an `agents run` never uploads on their behalf.
824
+ */
825
+ export function hasSyncedBefore() {
826
+ return fs.existsSync(ledgerPath());
827
+ }
819
828
  // ---------------------------------------------------------------------------
820
829
  // Local device name
821
830
  // ---------------------------------------------------------------------------
@@ -150,8 +150,12 @@ export declare function providerSpacingTokens(lastCallMs: number, now: number):
150
150
  export interface LocalUsageAccount {
151
151
  usageKey: string;
152
152
  agentId: AgentId;
153
- /** Live-fetch this account's usage; the daemon passes the real network fetch. */
154
- fetch: () => Promise<UsageInfo>;
153
+ /**
154
+ * Live-fetch this account's usage; the daemon passes the real network fetch.
155
+ * `signal` (the daemon tick's deadline AbortSignal) bounds the provider fetch
156
+ * so a hung refresh is aborted at deadlineMs, not just its own 5s timeout.
157
+ */
158
+ fetch: (signal?: AbortSignal) => Promise<UsageInfo>;
155
159
  }
156
160
  /**
157
161
  * Order a pass STALEST-FIRST so a scarce per-provider budget
@@ -206,6 +210,8 @@ export interface UsageRefreshDeps {
206
210
  * spending scarce provider budget re-fetching an already-current account.
207
211
  */
208
212
  readCachedSnapshot?: (usageKey: string) => UsageSnapshot | null;
213
+ /** Daemon tick deadline signal, forwarded to each account's provider fetch (PHNX-3608). */
214
+ signal?: AbortSignal;
209
215
  }
210
216
  export interface UsageRefreshResult {
211
217
  refreshed: number;
@@ -392,14 +392,14 @@ export async function buildLocalUsageAccounts() {
392
392
  // only, never the interactive login (see loadClaudeOauth); no setup-token
393
393
  // reads as "usage unavailable (no usage credential)" — "usage pending"
394
394
  // is now the cold-cache state only (#2987).
395
- fetch: async () => {
395
+ fetch: async (signal) => {
396
396
  const { getUsageInfoForIdentity } = await import('./accounting/usage.js');
397
397
  return getUsageInfoForIdentity({
398
398
  agentId,
399
399
  home: fetchInput.home,
400
400
  cliVersion: fetchInput.cliVersion,
401
401
  info: canonical,
402
- }, { forceRefresh: true, fileOnly: true });
402
+ }, { forceRefresh: true, fileOnly: true, signal });
403
403
  },
404
404
  });
405
405
  }
@@ -487,7 +487,7 @@ export async function runUsageRefresh(deps) {
487
487
  spacingUsed.set(account.agentId, (spacingUsed.get(account.agentId) ?? 0) + 1);
488
488
  }
489
489
  try {
490
- const usage = await account.fetch();
490
+ const usage = await account.fetch(deps.signal);
491
491
  if (usage.snapshot) {
492
492
  // `source` is provenance, not freshness. A forced collection that just
493
493
  // reread a local harness event returns `last_seen`; that is still a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.65",
3
+ "version": "1.22.68",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,21 +0,0 @@
1
- /** Device-local owner for usage snapshots and authentication health. */
2
- export declare const USAGE_STATE_TICK_MS = 60000;
3
- export declare const AUTH_STATE_TICK_MS: number;
4
- export interface AccountStateService {
5
- stop(): void;
6
- }
7
- export interface AccountStateServiceDeps {
8
- refreshUsage: () => Promise<void>;
9
- refreshAuth: () => Promise<void>;
10
- onError?: (area: 'usage' | 'auth', error: unknown) => void;
11
- setInterval?: typeof globalThis.setInterval;
12
- clearInterval?: typeof globalThis.clearInterval;
13
- }
14
- /**
15
- * Start the daemon-owned account-state loops.
16
- *
17
- * Each loop is overlap-safe within the daemon. Provider-level work is also
18
- * guarded by the cross-process refresh lease, so an explicit CLI refresh and
19
- * an older routine process converge on the same published result.
20
- */
21
- export declare function startAccountStateService(deps: AccountStateServiceDeps): AccountStateService;
@@ -1,60 +0,0 @@
1
- /** Device-local owner for usage snapshots and authentication health. */
2
- export const USAGE_STATE_TICK_MS = 60_000;
3
- export const AUTH_STATE_TICK_MS = 3 * 60_000;
4
- /**
5
- * Start the daemon-owned account-state loops.
6
- *
7
- * Each loop is overlap-safe within the daemon. Provider-level work is also
8
- * guarded by the cross-process refresh lease, so an explicit CLI refresh and
9
- * an older routine process converge on the same published result.
10
- */
11
- export function startAccountStateService(deps) {
12
- const setTimer = deps.setInterval ?? globalThis.setInterval;
13
- const clearTimer = deps.clearInterval ?? globalThis.clearInterval;
14
- let usageRunning = false;
15
- let authRunning = false;
16
- let stopped = false;
17
- const runUsage = async () => {
18
- if (stopped || usageRunning)
19
- return;
20
- usageRunning = true;
21
- try {
22
- await deps.refreshUsage();
23
- }
24
- catch (error) {
25
- deps.onError?.('usage', error);
26
- }
27
- finally {
28
- usageRunning = false;
29
- }
30
- };
31
- const runAuth = async () => {
32
- if (stopped || authRunning)
33
- return;
34
- authRunning = true;
35
- try {
36
- await deps.refreshAuth();
37
- }
38
- catch (error) {
39
- deps.onError?.('auth', error);
40
- }
41
- finally {
42
- authRunning = false;
43
- }
44
- };
45
- const usageTimer = setTimer(() => { void runUsage(); }, USAGE_STATE_TICK_MS);
46
- const authTimer = setTimer(() => { void runAuth(); }, AUTH_STATE_TICK_MS);
47
- usageTimer.unref?.();
48
- authTimer.unref?.();
49
- void runUsage();
50
- void runAuth();
51
- return {
52
- stop() {
53
- if (stopped)
54
- return;
55
- stopped = true;
56
- clearTimer(usageTimer);
57
- clearTimer(authTimer);
58
- },
59
- };
60
- }