@phnx-labs/agents-cli 1.22.54 → 1.22.55
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 +18 -0
- package/README.md +19 -2
- package/dist/commands/routines.js +31 -2
- package/dist/commands/share.d.ts +27 -0
- package/dist/commands/share.js +85 -0
- package/dist/commands/view.d.ts +8 -0
- package/dist/commands/view.js +31 -4
- package/dist/lib/accounting/usage.d.ts +31 -0
- package/dist/lib/accounting/usage.js +45 -4
- package/dist/lib/browser/cdp.d.ts +1 -1
- package/dist/lib/browser/cdp.js +1 -1
- package/dist/lib/browser/ffmpeg.d.ts +12 -0
- package/dist/lib/browser/ffmpeg.js +184 -0
- package/dist/lib/browser/service.js +119 -25
- package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
- package/dist/lib/daemon/browser-task-reap-service.js +26 -0
- package/dist/lib/daemon/daemon.js +57 -172
- package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
- package/dist/lib/daemon/heartbeat-service.js +26 -0
- package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
- package/dist/lib/daemon/monitor-engine-service.js +15 -7
- package/dist/lib/daemon/runner.d.ts +15 -0
- package/dist/lib/daemon/runner.js +23 -0
- package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
- package/dist/lib/daemon/secrets-broker-service.js +17 -32
- package/dist/lib/daemon/service.d.ts +2 -2
- package/dist/lib/daemon/service.js +1 -1
- package/dist/lib/daemon/session-state-service.d.ts +21 -0
- package/dist/lib/daemon/session-state-service.js +34 -0
- package/dist/lib/daemon/supervisor.d.ts +17 -7
- package/dist/lib/daemon/supervisor.js +87 -14
- package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
- package/dist/lib/daemon/tmux-reap-service.js +28 -0
- package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
- package/dist/lib/daemon/webhook-receiver-service.js +17 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +20 -0
- package/dist/lib/devices/harness-inventory.js +5 -2
- package/dist/lib/monitors/engine.d.ts +5 -1
- package/dist/lib/monitors/engine.js +5 -3
- package/dist/lib/scheduling/routines.d.ts +47 -0
- package/dist/lib/scheduling/routines.js +70 -1
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +2 -1
- package/dist/lib/share/capture.js +11 -2
- package/dist/lib/share/publish.d.ts +4 -1
- package/dist/lib/share/publish.js +27 -1
- package/dist/lib/share/worker-template.d.ts +3 -12
- package/dist/lib/share/worker-template.js +279 -3
- package/dist/lib/view-types.d.ts +6 -1
- package/package.json +8 -2
package/dist/lib/session/db.js
CHANGED
|
@@ -435,7 +435,8 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
|
|
|
435
435
|
*/
|
|
436
436
|
/** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
|
|
437
437
|
export const INSIGHTS_EXTRACTOR_VERSION = 7;
|
|
438
|
-
|
|
438
|
+
/** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
|
|
439
|
+
export const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
|
|
439
440
|
const PREVIEW_EXTRACTOR_VERSION = 1;
|
|
440
441
|
let dbInstance = null;
|
|
441
442
|
/**
|
|
@@ -36,8 +36,17 @@ export function candidateBrowsers() {
|
|
|
36
36
|
if (p && fs.existsSync(p) && !out.includes(p))
|
|
37
37
|
out.push(p);
|
|
38
38
|
};
|
|
39
|
-
// 1)
|
|
40
|
-
|
|
39
|
+
// 1) Explicit overrides are contracts, not hints. A typo must fail at the
|
|
40
|
+
// boundary instead of silently falling through to an unrelated browser.
|
|
41
|
+
for (const name of ['PUPPETEER_EXECUTABLE_PATH', 'AGENTS_SHARE_BROWSER']) {
|
|
42
|
+
const value = process.env[name]?.trim();
|
|
43
|
+
if (!value)
|
|
44
|
+
continue;
|
|
45
|
+
if (!fs.existsSync(value)) {
|
|
46
|
+
throw new Error(`${name} points to a browser that does not exist: ${value}`);
|
|
47
|
+
}
|
|
48
|
+
push(value);
|
|
49
|
+
}
|
|
41
50
|
// 2) Managed Chromium in the Playwright / Puppeteer caches — purpose-built for
|
|
42
51
|
// headless, so it's the most reliable capture host when present.
|
|
43
52
|
for (const bin of scanCaches())
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ShareConfig } from './config.js';
|
|
2
|
+
import { type ShareBackendKind } from './backend.js';
|
|
2
3
|
export type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<{
|
|
3
4
|
ok: boolean;
|
|
4
5
|
status: number;
|
|
@@ -9,6 +10,8 @@ export interface PublishEndpoint {
|
|
|
9
10
|
token: string;
|
|
10
11
|
}
|
|
11
12
|
export interface PublishOptions {
|
|
13
|
+
/** Internal resolved backend; publishFile sets this after authentication. */
|
|
14
|
+
backendKind?: ShareBackendKind;
|
|
12
15
|
slug?: string;
|
|
13
16
|
/**
|
|
14
17
|
* Auto-expire window. Relative (`30d`, `12h`), absolute (`2026-08-01`), or
|
|
@@ -133,7 +136,7 @@ export interface ShareProvenance {
|
|
|
133
136
|
* provenance the CLI sets automatically, plus `expires-at` / `visibility` /
|
|
134
137
|
* `owner` which the Worker stamps itself.
|
|
135
138
|
*/
|
|
136
|
-
export declare const RESERVED_META_KEYS: readonly ["expires-at", "published-at", "visibility", "owner", "org_domain", "agent", "session", "host", "repo", "date", "avatar", "label", "label-source"];
|
|
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"];
|
|
137
140
|
/**
|
|
138
141
|
* Auto-capture publish provenance from the exec env, git, and the local clock.
|
|
139
142
|
* Every field is present only when the environment genuinely carries it — a
|
|
@@ -52,6 +52,10 @@ export const RESERVED_META_KEYS = [
|
|
|
52
52
|
'avatar',
|
|
53
53
|
'label',
|
|
54
54
|
'label-source',
|
|
55
|
+
'og-title',
|
|
56
|
+
'og-description',
|
|
57
|
+
'og-generated',
|
|
58
|
+
'og-source-etag',
|
|
55
59
|
];
|
|
56
60
|
const META_KEY_RE = /^[a-z0-9-]{1,64}$/;
|
|
57
61
|
/**
|
|
@@ -506,6 +510,7 @@ export async function publishFile(filePath, opts = {}) {
|
|
|
506
510
|
...opts,
|
|
507
511
|
githubUser: username,
|
|
508
512
|
analyticsToken,
|
|
513
|
+
backendKind: backend.kind,
|
|
509
514
|
});
|
|
510
515
|
}
|
|
511
516
|
export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
@@ -536,6 +541,19 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
536
541
|
// is only reached when --label is omitted.
|
|
537
542
|
const label = explicitLabel ? sanitizeLabel(explicitLabel) : deriveLabel(filePath, body);
|
|
538
543
|
const labelSource = explicitLabel ? 'explicit' : 'derived';
|
|
544
|
+
const ogMeta = isHtml ? deriveMeta(body.toString('utf8')) : undefined;
|
|
545
|
+
// The managed Worker owns deterministic OG generation. Point crawlers at the
|
|
546
|
+
// lazy sibling route without invoking a browser on the publishing machine.
|
|
547
|
+
if (isHtml && opts.cover !== false && opts.backendKind === 'managed' && ogMeta) {
|
|
548
|
+
coverUrl = `${pageUrl}.png`;
|
|
549
|
+
body = Buffer.from(injectOgMeta(body.toString('utf8'), {
|
|
550
|
+
...ogMeta,
|
|
551
|
+
imageUrl: coverUrl,
|
|
552
|
+
pageUrl,
|
|
553
|
+
imageWidth: OG_WIDTH,
|
|
554
|
+
imageHeight: OG_HEIGHT,
|
|
555
|
+
}), 'utf8');
|
|
556
|
+
}
|
|
539
557
|
// Pre-publish scan (RUSH-2443/RUSH-2683): refuse emails / credential-shaped
|
|
540
558
|
// strings unless --force. Runs on the raw file body AND on every piece of
|
|
541
559
|
// free-text metadata that lands in public customMetadata — --label (explicit
|
|
@@ -558,6 +576,10 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
558
576
|
// Validate the FULL customMetadata payload before any network call — fail
|
|
559
577
|
// fast, not mid-upload.
|
|
560
578
|
const metadataPreview = { ...meta, label, 'label-source': labelSource };
|
|
579
|
+
if (ogMeta && opts.backendKind === 'managed') {
|
|
580
|
+
metadataPreview['og-title'] = ogMeta.title;
|
|
581
|
+
metadataPreview['og-description'] = ogMeta.description;
|
|
582
|
+
}
|
|
561
583
|
if (provenance.agent)
|
|
562
584
|
metadataPreview.agent = provenance.agent;
|
|
563
585
|
if (provenance.session)
|
|
@@ -606,6 +628,10 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
606
628
|
setText('x-share-avatar', avatarUrl);
|
|
607
629
|
setText('x-share-label', label);
|
|
608
630
|
h['x-share-label-source'] = labelSource;
|
|
631
|
+
if (ogMeta && opts.backendKind === 'managed') {
|
|
632
|
+
setText('x-share-og-title', ogMeta.title);
|
|
633
|
+
setText('x-share-og-description', ogMeta.description);
|
|
634
|
+
}
|
|
609
635
|
// Per VALUE, before JSON.stringify — folding the serialized form would rewrite
|
|
610
636
|
// a curly quote inside a value into a bare `"`, which is structural in JSON and
|
|
611
637
|
// makes the Worker's JSON.parse throw. It swallows that error, so every --meta
|
|
@@ -633,7 +659,7 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
633
659
|
// Cover: screenshot the page's hero → upload <slug>.png → inject og:image meta.
|
|
634
660
|
// Unlisted pages still get a cover (the direct URL is the capability), but the
|
|
635
661
|
// cover inherits visibility=unlisted so it is also omitted from the gallery.
|
|
636
|
-
if (isHtml && opts.cover !== false) {
|
|
662
|
+
if (isHtml && opts.cover !== false && opts.backendKind !== 'managed') {
|
|
637
663
|
const res = await attachOgCover(filePath, body, {
|
|
638
664
|
pngUrl: `${pageUrl}.png`,
|
|
639
665
|
pageUrl,
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
|
|
3
|
-
*
|
|
4
|
-
* The literal below still spells the CLI `agents share` in its provenance comment,
|
|
5
|
-
* its root response, and its gallery title, even though the command is now
|
|
6
|
-
* `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
|
|
7
|
-
* this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
|
|
8
|
-
* recorded `templateHash` against, so editing ANY byte here marks every already-
|
|
9
|
-
* deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
|
|
10
|
-
* until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
|
|
11
|
-
* worth that; change this text only alongside a real Worker behavior change.
|
|
12
|
-
*/
|
|
1
|
+
/** Bundle the Worker and its renderer into one uploadable ES module. */
|
|
13
2
|
export declare function renderWorkerScript(): string;
|
|
3
|
+
/** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
|
|
4
|
+
export declare function renderWorkerSource(): string;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { buildSync } from 'esbuild';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
1
4
|
// The Cloudflare Worker that fronts the R2 share bucket.
|
|
2
5
|
//
|
|
3
6
|
// One tiny Worker does both sides:
|
|
@@ -68,11 +71,48 @@
|
|
|
68
71
|
* until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
|
|
69
72
|
* worth that; change this text only alongside a real Worker behavior change.
|
|
70
73
|
*/
|
|
74
|
+
let bundledWorkerScript;
|
|
75
|
+
/** Bundle the Worker and its renderer into one uploadable ES module. */
|
|
71
76
|
export function renderWorkerScript() {
|
|
72
|
-
|
|
77
|
+
if (bundledWorkerScript)
|
|
78
|
+
return bundledWorkerScript;
|
|
79
|
+
const result = buildSync({
|
|
80
|
+
stdin: {
|
|
81
|
+
contents: renderWorkerSource(),
|
|
82
|
+
loader: 'js',
|
|
83
|
+
resolveDir: dirname(fileURLToPath(import.meta.url)),
|
|
84
|
+
sourcefile: 'agents-share-worker.js',
|
|
85
|
+
},
|
|
86
|
+
bundle: true,
|
|
87
|
+
format: 'esm',
|
|
88
|
+
platform: 'browser',
|
|
89
|
+
target: 'es2022',
|
|
90
|
+
write: false,
|
|
91
|
+
minify: true,
|
|
92
|
+
loader: { '.wasm': 'binary', '.woff': 'binary' },
|
|
93
|
+
});
|
|
94
|
+
const output = result.outputFiles[0];
|
|
95
|
+
if (!output)
|
|
96
|
+
throw new Error('Worker bundling produced no JavaScript output.');
|
|
97
|
+
bundledWorkerScript = output.text;
|
|
98
|
+
return bundledWorkerScript;
|
|
99
|
+
}
|
|
100
|
+
/** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
|
|
101
|
+
export function renderWorkerSource() {
|
|
102
|
+
return `import satori, { init as initSatori } from 'satori/wasm';
|
|
103
|
+
import initYoga from 'yoga-wasm-web';
|
|
104
|
+
import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
|
|
105
|
+
import yogaWasm from 'yoga-wasm-web/dist/yoga.wasm';
|
|
106
|
+
import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
|
|
107
|
+
import interRegular from '@fontsource/inter/files/inter-latin-400-normal.woff';
|
|
108
|
+
import interBold from '@fontsource/inter/files/inter-latin-700-normal.woff';
|
|
109
|
+
import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff';
|
|
110
|
+
|
|
111
|
+
// GENERATED by agents-cli agents share setup — do not edit here; edit
|
|
73
112
|
// src/lib/share/worker-template.ts and re-run setup.
|
|
74
113
|
export const hooks = {
|
|
75
114
|
verifyPhoenixToken: defaultVerifyPhoenixToken,
|
|
115
|
+
renderOgCard: renderOgCard,
|
|
76
116
|
};
|
|
77
117
|
|
|
78
118
|
export default {
|
|
@@ -91,6 +131,21 @@ export default {
|
|
|
91
131
|
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
92
132
|
}
|
|
93
133
|
|
|
134
|
+
// POST /__ticket — mint a short-lived, single-navigation login ticket for the
|
|
135
|
+
// authenticated owner (PHNX-3370). The CLI (which holds the Phoenix bearer)
|
|
136
|
+
// calls this, then opens the owner's page with ?phoenix_ticket=<t> so the
|
|
137
|
+
// browser trades it for the __share cookie and the inline visibility control
|
|
138
|
+
// goes live. The ticket is SELF-signed with the Worker's own HMAC secret — no
|
|
139
|
+
// external ticket service — and carries the SAME identity authorizeWrite
|
|
140
|
+
// already proved, so it grants nothing the caller didn't already have.
|
|
141
|
+
if (request.method === 'POST' && firstSeg === '__ticket') {
|
|
142
|
+
const auth = await authorizeWrite(request, env);
|
|
143
|
+
if (auth.error) return auth.error;
|
|
144
|
+
const ticket = await signSelfTicket({ userId: auth.owner, email: auth.email || '' }, env);
|
|
145
|
+
if (!ticket) return json({ error: 'ticket minting is not configured' }, 501);
|
|
146
|
+
return json({ ticket: ticket }, 200);
|
|
147
|
+
}
|
|
148
|
+
|
|
94
149
|
if (request.method === 'PUT') {
|
|
95
150
|
const auth = await authorizeWrite(request, env);
|
|
96
151
|
if (auth.error) return auth.error;
|
|
@@ -162,6 +217,8 @@ export default {
|
|
|
162
217
|
const date = readText('date');
|
|
163
218
|
const avatar = readText('avatar');
|
|
164
219
|
const label = readText('label');
|
|
220
|
+
const ogTitle = readText('og-title');
|
|
221
|
+
const ogDescription = readText('og-description');
|
|
165
222
|
const labelSource = request.headers.get('x-share-label-source') || '';
|
|
166
223
|
let extraMeta = {};
|
|
167
224
|
// The -u meta companion carries the whole raw object percent-encoded, so a
|
|
@@ -210,6 +267,8 @@ export default {
|
|
|
210
267
|
if (avatar) customMetadata['avatar'] = avatar;
|
|
211
268
|
if (label) customMetadata['label'] = label;
|
|
212
269
|
if (labelSource) customMetadata['label-source'] = labelSource;
|
|
270
|
+
if (ogTitle) customMetadata['og-title'] = ogTitle;
|
|
271
|
+
if (ogDescription) customMetadata['og-description'] = ogDescription;
|
|
213
272
|
|
|
214
273
|
// Revision retention (RUSH-2683): R2 has no native object versioning, so a
|
|
215
274
|
// republish over an EXISTING key first copies the current object to
|
|
@@ -238,6 +297,10 @@ export default {
|
|
|
238
297
|
httpMetadata: { contentType },
|
|
239
298
|
customMetadata,
|
|
240
299
|
});
|
|
300
|
+
// A managed republish may change the title/description. Invalidate only
|
|
301
|
+
// its generated sibling so the next crawler receives a fresh card; BYO
|
|
302
|
+
// publishes send no OG metadata and keep their explicitly uploaded cover.
|
|
303
|
+
if (ogTitle) await env.BUCKET.delete(path + '.png');
|
|
241
304
|
return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
|
|
242
305
|
}
|
|
243
306
|
|
|
@@ -263,6 +326,7 @@ export default {
|
|
|
263
326
|
if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
|
|
264
327
|
const metadata = { ...(existing.customMetadata || {}) };
|
|
265
328
|
const previousVisibility = metadata.visibility || 'public';
|
|
329
|
+
let visibilityChanged = false;
|
|
266
330
|
if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
|
|
267
331
|
if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
|
|
268
332
|
if (typeof edit.label === 'string' && (edit.label.trim() !== edit.label || !edit.label || edit.label.length > 200)) return json({ error: 'label must be 1-200 trimmed characters' }, 400);
|
|
@@ -289,6 +353,7 @@ export default {
|
|
|
289
353
|
}
|
|
290
354
|
}
|
|
291
355
|
metadata.visibility = visibility;
|
|
356
|
+
visibilityChanged = visibility !== previousVisibility;
|
|
292
357
|
// org_domain is meaningful only for org — set it there, drop the stale
|
|
293
358
|
// value on any move away from org so it can never gate a later read.
|
|
294
359
|
if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
|
|
@@ -327,6 +392,16 @@ export default {
|
|
|
327
392
|
if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
|
|
328
393
|
const putResult = await env.BUCKET.put(path, existing.body, putOpts);
|
|
329
394
|
if (putResult === null) return json({ error: 'conflict', key: path }, 409);
|
|
395
|
+
// A generated cover copies the canonical visibility gate into its own R2
|
|
396
|
+
// metadata. Invalidate it after a successful visibility rewrite so the
|
|
397
|
+
// next cover GET re-gates and renders from the canonical page. Explicitly
|
|
398
|
+
// uploaded BYO siblings carry no marker and remain independent assets.
|
|
399
|
+
if (visibilityChanged) {
|
|
400
|
+
const cover = await env.BUCKET.get(path + '.png');
|
|
401
|
+
if (cover && cover.customMetadata && cover.customMetadata['og-generated'] === 'true') {
|
|
402
|
+
await env.BUCKET.delete(path + '.png');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
330
405
|
return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
|
|
331
406
|
}
|
|
332
407
|
|
|
@@ -383,6 +458,70 @@ export default {
|
|
|
383
458
|
);
|
|
384
459
|
}
|
|
385
460
|
|
|
461
|
+
// Managed OG cover: generated siblings are caches, never visibility
|
|
462
|
+
// authorities. Gate every request against the current canonical page and
|
|
463
|
+
// bind each cached render to that page's etag. Re-read after rendering and
|
|
464
|
+
// after storing so a concurrent PATCH cannot publish a card from the old
|
|
465
|
+
// visibility/title/description snapshot. Explicit BYO siblings carry no
|
|
466
|
+
// generated marker and continue through the ordinary object route below.
|
|
467
|
+
if (segments.length === 2 && path.endsWith('.png')) {
|
|
468
|
+
let existingCover = await env.BUCKET.get(path);
|
|
469
|
+
if (!existingCover || (existingCover.customMetadata && existingCover.customMetadata['og-generated'] === 'true')) {
|
|
470
|
+
const pagePath = path.slice(0, -4);
|
|
471
|
+
const viewer = await resolveViewer(request, env, url);
|
|
472
|
+
if (viewer.redirect) return viewer.redirect;
|
|
473
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
474
|
+
const page = await env.BUCKET.get(pagePath);
|
|
475
|
+
if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
476
|
+
const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
|
|
477
|
+
if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
|
|
478
|
+
const denied = gateVisibility(url, env, page, viewer.identity || null);
|
|
479
|
+
if (denied) return denied;
|
|
480
|
+
|
|
481
|
+
if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
|
|
482
|
+
const current = await env.BUCKET.get(pagePath);
|
|
483
|
+
if (!current || current.etag !== page.etag) { existingCover = null; continue; }
|
|
484
|
+
const currentDenied = gateVisibility(url, env, current, viewer.identity || null);
|
|
485
|
+
if (currentDenied) return currentDenied;
|
|
486
|
+
return new Response(request.method === 'HEAD' ? null : existingCover.body, {
|
|
487
|
+
status: 200,
|
|
488
|
+
headers: managedCoverHeaders(pageVisibility),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const pageHtml = await page.text();
|
|
493
|
+
const meta = page.customMetadata || {};
|
|
494
|
+
const png = await hooks.renderOgCard({
|
|
495
|
+
title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
|
|
496
|
+
description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
|
|
497
|
+
handle: segments[0],
|
|
498
|
+
visibility: pageVisibility,
|
|
499
|
+
orgDomain: meta.org_domain || '',
|
|
500
|
+
});
|
|
501
|
+
const beforeStore = await env.BUCKET.get(pagePath);
|
|
502
|
+
if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
|
|
503
|
+
await env.BUCKET.put(path, png, {
|
|
504
|
+
httpMetadata: { contentType: 'image/png' },
|
|
505
|
+
customMetadata: { ...meta, 'og-generated': 'true', 'og-source-etag': page.etag },
|
|
506
|
+
});
|
|
507
|
+
const beforeServe = await env.BUCKET.get(pagePath);
|
|
508
|
+
if (!beforeServe || beforeServe.etag !== page.etag) {
|
|
509
|
+
const justStored = await env.BUCKET.get(path);
|
|
510
|
+
if (justStored && justStored.customMetadata && justStored.customMetadata['og-source-etag'] === page.etag) {
|
|
511
|
+
await env.BUCKET.delete(path);
|
|
512
|
+
}
|
|
513
|
+
existingCover = null;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
return new Response(request.method === 'HEAD' ? null : png, {
|
|
517
|
+
status: 200,
|
|
518
|
+
headers: managedCoverHeaders(pageVisibility),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return new Response('cover changed during rendering; retry', { status: 503, headers: { 'content-type': 'text/plain', 'retry-after': '1' } });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
386
525
|
const obj = await env.BUCKET.get(path);
|
|
387
526
|
if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
388
527
|
const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
|
|
@@ -694,7 +833,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
|
|
|
694
833
|
// before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
|
|
695
834
|
// One list, reused both to strip a same-named --meta collision on write and
|
|
696
835
|
// to split arbitrary --meta entries back out on read.
|
|
697
|
-
var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source'];
|
|
836
|
+
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'];
|
|
698
837
|
var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
|
|
699
838
|
var SHARE_COOKIE = '__Host-phoenix_share';
|
|
700
839
|
var SHARE_COOKIE_MAX_AGE = 604800;
|
|
@@ -1140,6 +1279,18 @@ function isIdentityGated(visibility) {
|
|
|
1140
1279
|
return visibility === 'me' || visibility === 'org';
|
|
1141
1280
|
}
|
|
1142
1281
|
|
|
1282
|
+
function managedCoverHeaders(visibility) {
|
|
1283
|
+
const headers = new Headers({ 'content-type': 'image/png' });
|
|
1284
|
+
if (isIdentityGated(visibility)) {
|
|
1285
|
+
headers.set('cache-control', 'private, no-store');
|
|
1286
|
+
headers.set('X-Robots-Tag', 'noindex');
|
|
1287
|
+
} else {
|
|
1288
|
+
headers.set('cache-control', 'public, max-age=31536000, immutable');
|
|
1289
|
+
if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
|
|
1290
|
+
}
|
|
1291
|
+
return headers;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1143
1294
|
// Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
|
|
1144
1295
|
// caller resolves the viewer once (it also needs the identity for the ownership
|
|
1145
1296
|
// check) and both the page GET and the ?revisions=json path share this gate.
|
|
@@ -1257,6 +1408,66 @@ async function signShareCookie(identity, env) {
|
|
|
1257
1408
|
return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
|
|
1258
1409
|
}
|
|
1259
1410
|
|
|
1411
|
+
// A short-lived login ticket, self-signed with the same HMAC secret as the cookie
|
|
1412
|
+
// but DOMAIN-SEPARATED (the signature covers a 'ticket:'-prefixed payload), so a
|
|
1413
|
+
// ticket value can never be replayed as a cookie value or vice versa. It rides in
|
|
1414
|
+
// the ?phoenix_ticket= param for exactly one navigation; resolveViewer verifies it
|
|
1415
|
+
// locally and exchanges it for the __share cookie. TTL is deliberately tiny — it
|
|
1416
|
+
// only has to survive the round-trip from CLI mint to the browser opening the URL.
|
|
1417
|
+
var SELF_TICKET_MAX_AGE = 120;
|
|
1418
|
+
|
|
1419
|
+
async function signSelfTicket(identity, env) {
|
|
1420
|
+
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1421
|
+
if (!secret || !identity || !identity.userId) return '';
|
|
1422
|
+
const exp = Math.floor(Date.now() / 1000) + SELF_TICKET_MAX_AGE;
|
|
1423
|
+
const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
|
|
1424
|
+
const sig = await hmacHex(secret, 'ticket:' + payload);
|
|
1425
|
+
return toB64Url(payload) + '.' + sig;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
async function verifySelfTicket(ticket, env) {
|
|
1429
|
+
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1430
|
+
if (!secret || !ticket) return null;
|
|
1431
|
+
const dot = ticket.lastIndexOf('.');
|
|
1432
|
+
if (dot < 1) return null;
|
|
1433
|
+
let payload;
|
|
1434
|
+
try {
|
|
1435
|
+
payload = fromB64Url(ticket.slice(0, dot));
|
|
1436
|
+
} catch {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
const sig = ticket.slice(dot + 1);
|
|
1440
|
+
const expected = await hmacHex(secret, 'ticket:' + payload);
|
|
1441
|
+
if (!safeEqual(sig, expected)) return null;
|
|
1442
|
+
const first = payload.indexOf('|');
|
|
1443
|
+
const last = payload.lastIndexOf('|');
|
|
1444
|
+
if (first < 0 || last <= first) return null;
|
|
1445
|
+
const userId = payload.slice(0, first);
|
|
1446
|
+
const email = payload.slice(first + 1, last);
|
|
1447
|
+
const exp = Number(payload.slice(last + 1));
|
|
1448
|
+
if (!userId || !Number.isFinite(exp) || Math.floor(Date.now() / 1000) > exp) return null;
|
|
1449
|
+
return { userId: userId, email: email };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Single-use enforcement for a self-signed ticket: claim its signature the first
|
|
1453
|
+
// time it is redeemed, and reject any later presentation of the same ticket. The
|
|
1454
|
+
// HMAC + short TTL stop forgery and bound the window, but without this a ticket the
|
|
1455
|
+
// CLI printed (--json / --no-open / a browser-open failure) could be replayed by
|
|
1456
|
+
// anyone who captured that output before it expired, minting the owner's 7-day
|
|
1457
|
+
// cookie a second time. Get-then-put mirrors claimHandle's one-time-claim pattern;
|
|
1458
|
+
// the marker rides a __-prefixed key (GET-blocked, excluded from every listing).
|
|
1459
|
+
async function consumeSelfTicket(env, sig) {
|
|
1460
|
+
if (!sig) return false;
|
|
1461
|
+
const key = '__ticket-used/' + sig;
|
|
1462
|
+
const existing = await env.BUCKET.get(key);
|
|
1463
|
+
if (existing) return false;
|
|
1464
|
+
await env.BUCKET.put(key, '1', {
|
|
1465
|
+
httpMetadata: { contentType: 'text/plain' },
|
|
1466
|
+
customMetadata: { visibility: 'unlisted' },
|
|
1467
|
+
});
|
|
1468
|
+
return true;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1260
1471
|
async function identityFromCookie(request, env) {
|
|
1261
1472
|
const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
|
|
1262
1473
|
if (!secret) return null;
|
|
@@ -1316,7 +1527,17 @@ async function resolveViewer(request, env, url) {
|
|
|
1316
1527
|
if (cookieId) return { identity: cookieId };
|
|
1317
1528
|
const ticket = url.searchParams.get('phoenix_ticket');
|
|
1318
1529
|
if (!ticket) return {};
|
|
1319
|
-
|
|
1530
|
+
// Prefer the locally-verifiable self-signed ticket (PHNX-3370, no network), and
|
|
1531
|
+
// enforce single use by claiming its signature on first redemption — a captured
|
|
1532
|
+
// ticket replayed within its TTL finds it already spent. Fall back to the
|
|
1533
|
+
// external identity-server redeem for tickets it issued.
|
|
1534
|
+
let redeemed = await verifySelfTicket(ticket, env);
|
|
1535
|
+
if (redeemed) {
|
|
1536
|
+
const sig = ticket.slice(ticket.lastIndexOf('.') + 1);
|
|
1537
|
+
if (!(await consumeSelfTicket(env, sig))) redeemed = null;
|
|
1538
|
+
} else {
|
|
1539
|
+
redeemed = await redeemTicket(ticket, env);
|
|
1540
|
+
}
|
|
1320
1541
|
if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
|
|
1321
1542
|
const cookie = await signShareCookie(redeemed, env);
|
|
1322
1543
|
if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
|
|
@@ -1327,6 +1548,61 @@ async function resolveViewer(request, env, url) {
|
|
|
1327
1548
|
return { redirect: new Response(null, { status: 302, headers: headers }) };
|
|
1328
1549
|
}
|
|
1329
1550
|
|
|
1551
|
+
function extractHtmlMeta(html, field) {
|
|
1552
|
+
const pattern = field === 'title'
|
|
1553
|
+
? /<title[^>]*>([\\s\\S]*?)<\\/title>/i
|
|
1554
|
+
: /<meta[^>]+(?:name|property)=["'](?:description|og:description)["'][^>]+content=["']([^"']*)["'][^>]*>/i;
|
|
1555
|
+
const match = pattern.exec(html);
|
|
1556
|
+
return match ? match[1].replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').trim() : '';
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
let rendererReady;
|
|
1560
|
+
async function renderOgCard(input) {
|
|
1561
|
+
if (!rendererReady) {
|
|
1562
|
+
rendererReady = Promise.all([
|
|
1563
|
+
initYoga(yogaWasm).then(function (yoga) { initSatori(yoga); }),
|
|
1564
|
+
initResvg(resvgWasm),
|
|
1565
|
+
]);
|
|
1566
|
+
}
|
|
1567
|
+
await rendererReady;
|
|
1568
|
+
const visibilityLabels = {
|
|
1569
|
+
public: 'PUBLIC',
|
|
1570
|
+
unlisted: 'UNLISTED',
|
|
1571
|
+
me: 'ONLY YOU',
|
|
1572
|
+
org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
|
|
1573
|
+
};
|
|
1574
|
+
const node = {
|
|
1575
|
+
type: 'div',
|
|
1576
|
+
props: {
|
|
1577
|
+
style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
|
|
1578
|
+
children: [
|
|
1579
|
+
{ type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'AGI · agents-cli.sh' } },
|
|
1580
|
+
{ type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
|
|
1581
|
+
{ type: 'div', props: { style: { display: 'flex', fontSize: 66, lineHeight: 1.06, fontWeight: 700, letterSpacing: '-2.8px', maxHeight: 218, overflow: 'hidden' }, children: input.title || 'Shared artifact' } },
|
|
1582
|
+
input.description ? { type: 'div', props: { style: { display: 'flex', marginTop: 24, color: '#a3a3a3', fontSize: 27, lineHeight: 1.35, maxHeight: 74, overflow: 'hidden' }, children: input.description } } : null,
|
|
1583
|
+
].filter(Boolean) } },
|
|
1584
|
+
{ type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid #262626', paddingTop: 25, fontSize: 21, color: '#a3a3a3' }, children: [
|
|
1585
|
+
{ type: 'div', props: { style: { display: 'flex' }, children: [
|
|
1586
|
+
{ type: 'span', props: { style: { color: '#737373' }, children: 'shared by ' } },
|
|
1587
|
+
{ type: 'span', props: { style: { marginLeft: 7, color: '#f5f5f5', fontWeight: 700 }, children: '@' + input.handle } },
|
|
1588
|
+
] } },
|
|
1589
|
+
{ type: 'div', props: { style: { display: 'flex', border: '1px solid #3f3f46', borderRadius: 999, padding: '9px 17px', color: '#d4d4d8', fontFamily: 'JetBrains Mono', fontSize: 16, letterSpacing: '1px' }, children: visibilityLabels[input.visibility] || 'PUBLIC' } },
|
|
1590
|
+
] } },
|
|
1591
|
+
],
|
|
1592
|
+
},
|
|
1593
|
+
};
|
|
1594
|
+
const svg = await satori(node, {
|
|
1595
|
+
width: 1200,
|
|
1596
|
+
height: 630,
|
|
1597
|
+
fonts: [
|
|
1598
|
+
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
|
|
1599
|
+
{ name: 'Inter', data: interBold, weight: 700, style: 'normal' },
|
|
1600
|
+
{ name: 'JetBrains Mono', data: jetbrainsMono, weight: 600, style: 'normal' },
|
|
1601
|
+
],
|
|
1602
|
+
});
|
|
1603
|
+
return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1330
1606
|
async function defaultVerifyPhoenixToken(request, env) {
|
|
1331
1607
|
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
1332
1608
|
if (!presented) return null;
|
package/dist/lib/view-types.d.ts
CHANGED
|
@@ -21,7 +21,12 @@ export interface ViewJsonVersion {
|
|
|
21
21
|
amount: number;
|
|
22
22
|
currency: string;
|
|
23
23
|
} | null;
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Human-readable reason a usage snapshot is absent: a live-refresh failure, or
|
|
26
|
+
* (without `--refresh`, on a never-cached account) a plain "not collected yet"
|
|
27
|
+
* pending state. Always a full sentence — never the internal `'stale'` cache
|
|
28
|
+
* sentinel, which `usageErrorForDisplay` normalizes away (PHNX-3348).
|
|
29
|
+
*/
|
|
25
30
|
usageError?: string | null;
|
|
26
31
|
windows: Array<{
|
|
27
32
|
key: 'session' | 'week' | 'sonnet_week' | 'month';
|
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.55",
|
|
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",
|
|
@@ -86,8 +86,11 @@
|
|
|
86
86
|
"@homebridge/node-pty-prebuilt-multiarch"
|
|
87
87
|
],
|
|
88
88
|
"dependencies": {
|
|
89
|
+
"@fontsource/inter": "^5.3.0",
|
|
90
|
+
"@fontsource/jetbrains-mono": "^5.3.0",
|
|
89
91
|
"@homebridge/node-pty-prebuilt-multiarch": "0.13.1",
|
|
90
92
|
"@inquirer/prompts": "8.5.2",
|
|
93
|
+
"@resvg/resvg-wasm": "^2.6.2",
|
|
91
94
|
"@types/proper-lockfile": "4.1.4",
|
|
92
95
|
"@xterm/headless": "6.0.0",
|
|
93
96
|
"@zed-industries/agent-client-protocol": "0.4.5",
|
|
@@ -98,17 +101,20 @@
|
|
|
98
101
|
"commander": "15.0.0",
|
|
99
102
|
"croner": "10.0.1",
|
|
100
103
|
"diff": "9.0.0",
|
|
104
|
+
"esbuild": "^0.28.2",
|
|
101
105
|
"lru-cache": "11.5.2",
|
|
102
106
|
"marked": "15.0.12",
|
|
103
107
|
"marked-terminal": "7.3.0",
|
|
104
108
|
"ora": "9.4.1",
|
|
105
109
|
"proper-lockfile": "4.1.2",
|
|
110
|
+
"satori": "0.12.2",
|
|
106
111
|
"shlex": "3.0.0",
|
|
107
112
|
"simple-git": "3.36.0",
|
|
108
113
|
"smol-toml": "1.7.0",
|
|
109
114
|
"unbash": "4.0.5",
|
|
110
115
|
"ws": "^8.21.0",
|
|
111
|
-
"yaml": "2.9.0"
|
|
116
|
+
"yaml": "2.9.0",
|
|
117
|
+
"yoga-wasm-web": "^0.3.3"
|
|
112
118
|
},
|
|
113
119
|
"devDependencies": {
|
|
114
120
|
"@types/diff": "8.0.0",
|