@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.
- package/CHANGELOG.md +39 -0
- package/README.md +7 -2
- package/dist/bootstrap.js +3 -3
- package/dist/commands/apply.js +9 -2
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +40 -0
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +33 -9
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +11 -3
- package/dist/lib/accounting/usage.js +23 -10
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/known-hosts.d.ts +15 -0
- package/dist/lib/devices/known-hosts.js +18 -0
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/exec.js +11 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +9 -2
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +13 -0
- package/dist/lib/run-trace-sync.js +77 -0
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +120 -102
- package/dist/lib/session/active.d.ts +3 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +5 -2
- package/dist/lib/session/db.js +54 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +106 -25
- package/dist/lib/session/prompt.d.ts +11 -0
- package/dist/lib/session/prompt.js +25 -0
- package/dist/lib/session/recovery.d.ts +30 -2
- package/dist/lib/session/recovery.js +56 -4
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +185 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/publish.d.ts +55 -9
- package/dist/lib/share/publish.js +87 -9
- package/dist/lib/share/worker-template.js +100 -8
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/package.json +1 -1
- package/dist/lib/account-state-service.d.ts +0 -21
- 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
|
|
22
|
-
*
|
|
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
|
-
* `--
|
|
26
|
-
*
|
|
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' ||
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
};
|
package/dist/lib/ssh-exec.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/lib/ssh-exec.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/lib/traces/sync.js
CHANGED
|
@@ -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
|
-
/**
|
|
154
|
-
|
|
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.
|
|
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
|
-
}
|