@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.
- package/CHANGELOG.md +56 -0
- package/README.md +21 -8
- package/dist/bootstrap.js +6 -3
- 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 +50 -0
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +5 -2
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- 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/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +73 -20
- package/dist/commands/ssh.js +205 -2
- 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 +18 -5
- package/dist/lib/accounting/usage.js +165 -20
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- 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/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- 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/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +15 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- 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 +27 -2
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +28 -0
- package/dist/lib/run-trace-sync.js +99 -0
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- 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 +119 -101
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +5 -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 +16 -2
- package/dist/lib/session/db.js +73 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +187 -26
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +17 -0
- package/dist/lib/session/prompt.js +35 -0
- package/dist/lib/session/recovery.d.ts +43 -6
- package/dist/lib/session/recovery.js +80 -10
- 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 +188 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +67 -11
- package/dist/lib/share/publish.js +98 -16
- package/dist/lib/share/worker-template.js +105 -9
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- 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/backend.js +13 -2
- 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/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- package/package.json +2 -2
- package/dist/lib/account-state-service.d.ts +0 -21
- 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
|
-
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
95
|
-
/** The visibility levels a
|
|
96
|
-
*
|
|
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
|
-
* `--
|
|
115
|
-
*
|
|
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
|
|
22
|
-
*
|
|
23
|
-
|
|
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
|
-
* `--
|
|
26
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/lib/smart-launch.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
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
|
*
|