@phnx-labs/agents-cli 1.22.106 → 1.22.109
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 +78 -0
- package/README.md +9 -5
- package/dist/commands/daemon.js +45 -40
- package/dist/commands/doctor.js +7 -0
- package/dist/commands/exec.d.ts +78 -8
- package/dist/commands/exec.js +276 -53
- package/dist/commands/monitors.js +15 -2
- package/dist/commands/routines.js +46 -6
- package/dist/commands/run-account-picker.d.ts +11 -0
- package/dist/commands/run-account-picker.js +11 -1
- package/dist/commands/sessions-backup-setup.d.ts +12 -0
- package/dist/commands/sessions-backup-setup.js +65 -0
- package/dist/commands/sessions-resume.d.ts +14 -1
- package/dist/commands/sessions-resume.js +56 -9
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/share.js +15 -2
- package/dist/commands/sync.js +30 -1
- package/dist/lib/accounts/slots.js +7 -0
- package/dist/lib/agent-spec/agents.d.ts +60 -8
- package/dist/lib/agent-spec/agents.js +118 -45
- package/dist/lib/daemon/daemon.d.ts +34 -1
- package/dist/lib/daemon/daemon.js +63 -9
- package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
- package/dist/lib/daemon/leaked-daemons.js +180 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +32 -1
- package/dist/lib/hooks/install.js +70 -63
- package/dist/lib/hosts/dispatch.d.ts +1 -1
- package/dist/lib/hosts/dispatch.js +1 -1
- package/dist/lib/models.js +76 -5
- package/dist/lib/session/cloud.js +3 -1
- package/dist/lib/session/parse.d.ts +1 -0
- package/dist/lib/session/parse.js +136 -2
- package/dist/lib/session/recovery.d.ts +9 -1
- package/dist/lib/session/recovery.js +14 -4
- package/dist/lib/session/tool-calls.d.ts +1 -1
- package/dist/lib/session/tool-calls.js +40 -5
- package/dist/lib/share/backend.d.ts +23 -0
- package/dist/lib/share/backend.js +24 -0
- package/dist/lib/share/provision.d.ts +12 -0
- package/dist/lib/share/provision.js +30 -0
- package/dist/lib/share/worker-template.js +273 -0
- package/dist/lib/terminal/engine.js +13 -1
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ export declare const TOOL_CHANGED_MAX_CALLS = 10000;
|
|
|
10
10
|
export declare const TOOL_INDEX_LIMIT_ORDINAL: number;
|
|
11
11
|
export declare const TOOL_TEXT_PROCESSING_MAX_BYTES: number;
|
|
12
12
|
export declare const TOOL_SHELL_PARSE_MAX_BYTES: number;
|
|
13
|
-
export declare const TOOL_INDEX_VERSION =
|
|
13
|
+
export declare const TOOL_INDEX_VERSION = 9;
|
|
14
14
|
export type ToolCallOutcome = 'ok' | 'error' | 'unknown';
|
|
15
15
|
export interface IndexedToolCall {
|
|
16
16
|
ordinal: number;
|
|
@@ -12,10 +12,12 @@ export const TOOL_CHANGED_MAX_CALLS = 10_000;
|
|
|
12
12
|
export const TOOL_INDEX_LIMIT_ORDINAL = Number.MAX_SAFE_INTEGER;
|
|
13
13
|
export const TOOL_TEXT_PROCESSING_MAX_BYTES = 64 * 1024;
|
|
14
14
|
export const TOOL_SHELL_PARSE_MAX_BYTES = 64 * 1024;
|
|
15
|
-
// Bumped to
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
15
|
+
// Bumped to 9 for Codex tool-outcome classification (PHNX-3761): Codex results
|
|
16
|
+
// arrive as a string or input_text[] rather than an object, so every one used to
|
|
17
|
+
// store as 'unknown' and error rates read 0%. A re-index re-derives outcome for
|
|
18
|
+
// rows stored by an older extractor.
|
|
19
|
+
// (8 added the per-call end timestamp, PHNX-3437.)
|
|
20
|
+
export const TOOL_INDEX_VERSION = 9;
|
|
19
21
|
const BASE64_BLOCK = /(?:[A-Za-z0-9+/]{256,}={0,2})/g;
|
|
20
22
|
const SECRET_FIELD = /(?:token|secret|password|authorization|cookie|api[_-]?key|private[_-]?key)$/i;
|
|
21
23
|
const KNOWN_SECRET_VALUES = knownSecretValuesFromEnv();
|
|
@@ -258,9 +260,37 @@ function structuredString(source, name) {
|
|
|
258
260
|
const value = structuredField(source, name);
|
|
259
261
|
return typeof value === 'string' ? value : undefined;
|
|
260
262
|
}
|
|
263
|
+
// Codex tool results arrive as a plain string or an array of {type:'input_text',
|
|
264
|
+
// text} blocks (never a plain object), so the text lives across the blocks and
|
|
265
|
+
// the only outcome signal is the leading "Script completed"/"Script failed"
|
|
266
|
+
// marker the exec harness prefixes onto the first block.
|
|
267
|
+
function textSegments(value) {
|
|
268
|
+
if (typeof value === 'string')
|
|
269
|
+
return [value];
|
|
270
|
+
if (Array.isArray(value)) {
|
|
271
|
+
return value
|
|
272
|
+
.map((block) => block && typeof block === 'object' && typeof block.text === 'string'
|
|
273
|
+
? block.text
|
|
274
|
+
: '')
|
|
275
|
+
.filter((text) => text.length > 0);
|
|
276
|
+
}
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
function codexScriptOutcome(value) {
|
|
280
|
+
const lead = textSegments(value)[0]?.trimStart();
|
|
281
|
+
if (!lead)
|
|
282
|
+
return undefined;
|
|
283
|
+
if (lead.startsWith('Script failed'))
|
|
284
|
+
return 'error';
|
|
285
|
+
if (lead.startsWith('Script completed'))
|
|
286
|
+
return 'ok';
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
261
289
|
function resultText(value) {
|
|
262
290
|
if (typeof value === 'string')
|
|
263
291
|
return value;
|
|
292
|
+
if (Array.isArray(value))
|
|
293
|
+
return textSegments(value).join('\n');
|
|
264
294
|
const object = resultObject(value);
|
|
265
295
|
if (object) {
|
|
266
296
|
for (const key of ['output', 'stdout', 'content', 'text', 'message']) {
|
|
@@ -280,9 +310,14 @@ export function structuredToolResult(value) {
|
|
|
280
310
|
const structured = resultObject(value);
|
|
281
311
|
const success = structuredField(structured, 'success');
|
|
282
312
|
const isError = success === false || structuredField(structured, 'is_error') === true;
|
|
313
|
+
const outcome = isError
|
|
314
|
+
? 'error'
|
|
315
|
+
: success === true
|
|
316
|
+
? 'ok'
|
|
317
|
+
: codexScriptOutcome(value) ?? 'unknown';
|
|
283
318
|
return {
|
|
284
319
|
text: resultText(value),
|
|
285
|
-
outcome
|
|
320
|
+
outcome,
|
|
286
321
|
exitCode: structuredNumber(structured, 'exit_code'),
|
|
287
322
|
statusCode: structuredNumber(structured, 'status_code'),
|
|
288
323
|
errorCode: structuredString(structured, 'error_code'),
|
|
@@ -89,6 +89,29 @@ export declare function phoenixIdBaseForDeploy(opts?: {
|
|
|
89
89
|
baseUrl?: string;
|
|
90
90
|
domain?: string;
|
|
91
91
|
}): string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Managed collaboration backend config to bind on a managed Worker deploy
|
|
94
|
+
* (PHNX-3835): `PRIX_ARTIFACT_COLLAB_BASE` (the Prix artifact-collaboration API
|
|
95
|
+
* base) and `ARTIFACT_COLLAB_SERVICE_TOKEN` (the service credential the Worker
|
|
96
|
+
* sends as `Authorization: Bearer`, NEVER surfaced to the browser). Both are read
|
|
97
|
+
* from the deploy environment.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately dormant-by-default: the two secrets are applied ONLY when BOTH are
|
|
100
|
+
* present. Until the Prix API counterpart is live an operator leaves them unset,
|
|
101
|
+
* and the Worker fails `/__collab` closed (404) with the artifact page GET
|
|
102
|
+
* unaffected. A pure-BYO endpoint never gets them (it has no Phoenix identity).
|
|
103
|
+
* Unlike `phoenixIdBaseForDeploy` this never throws on absence — collaboration is
|
|
104
|
+
* additive, not required for a working managed deploy.
|
|
105
|
+
*/
|
|
106
|
+
export declare function collabConfigForDeploy(opts?: {
|
|
107
|
+
managed?: boolean;
|
|
108
|
+
}, cfg?: {
|
|
109
|
+
baseUrl?: string;
|
|
110
|
+
domain?: string;
|
|
111
|
+
}): {
|
|
112
|
+
collabBase?: string;
|
|
113
|
+
collabServiceToken?: string;
|
|
114
|
+
};
|
|
92
115
|
/**
|
|
93
116
|
* Pick the principal via the shared selection policy. Signed-in AND no explicit
|
|
94
117
|
* BYO override → managed; otherwise BYO.
|
|
@@ -84,6 +84,30 @@ export function phoenixIdBaseForDeploy(opts = {}, cfg) {
|
|
|
84
84
|
}
|
|
85
85
|
return base;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Managed collaboration backend config to bind on a managed Worker deploy
|
|
89
|
+
* (PHNX-3835): `PRIX_ARTIFACT_COLLAB_BASE` (the Prix artifact-collaboration API
|
|
90
|
+
* base) and `ARTIFACT_COLLAB_SERVICE_TOKEN` (the service credential the Worker
|
|
91
|
+
* sends as `Authorization: Bearer`, NEVER surfaced to the browser). Both are read
|
|
92
|
+
* from the deploy environment.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately dormant-by-default: the two secrets are applied ONLY when BOTH are
|
|
95
|
+
* present. Until the Prix API counterpart is live an operator leaves them unset,
|
|
96
|
+
* and the Worker fails `/__collab` closed (404) with the artifact page GET
|
|
97
|
+
* unaffected. A pure-BYO endpoint never gets them (it has no Phoenix identity).
|
|
98
|
+
* Unlike `phoenixIdBaseForDeploy` this never throws on absence — collaboration is
|
|
99
|
+
* additive, not required for a working managed deploy.
|
|
100
|
+
*/
|
|
101
|
+
export function collabConfigForDeploy(opts = {}, cfg) {
|
|
102
|
+
const managed = opts.managed === true || (cfg != null && isManagedShareEndpoint(cfg));
|
|
103
|
+
if (!managed)
|
|
104
|
+
return {};
|
|
105
|
+
const base = (process.env.PRIX_ARTIFACT_COLLAB_BASE || '').replace(/\/+$/, '').trim();
|
|
106
|
+
const token = (process.env.ARTIFACT_COLLAB_SERVICE_TOKEN || '').trim();
|
|
107
|
+
if (!base || !token)
|
|
108
|
+
return {};
|
|
109
|
+
return { collabBase: base, collabServiceToken: token };
|
|
110
|
+
}
|
|
87
111
|
/**
|
|
88
112
|
* The share surface's BYO-override signals: an explicit `--byo`, a
|
|
89
113
|
* caller-supplied static write token, or `AGENTS_SHARE_BACKEND=byo`. Detecting
|
|
@@ -64,6 +64,10 @@ export interface UpdateWorkerResult {
|
|
|
64
64
|
}
|
|
65
65
|
/** Cloudflare `secret_text` name the Worker reads as `env.PHOENIX_ID_BASE`. */
|
|
66
66
|
export declare const WORKER_PHOENIX_ID_BASE_SECRET = "PHOENIX_ID_BASE";
|
|
67
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.PRIX_ARTIFACT_COLLAB_BASE`. */
|
|
68
|
+
export declare const WORKER_COLLAB_BASE_SECRET = "PRIX_ARTIFACT_COLLAB_BASE";
|
|
69
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.ARTIFACT_COLLAB_SERVICE_TOKEN`. */
|
|
70
|
+
export declare const WORKER_COLLAB_SERVICE_TOKEN_SECRET = "ARTIFACT_COLLAB_SERVICE_TOKEN";
|
|
67
71
|
export type UpdateWorkerOpts = ProvisionOptions & {
|
|
68
72
|
force?: boolean;
|
|
69
73
|
/**
|
|
@@ -73,6 +77,14 @@ export type UpdateWorkerOpts = ProvisionOptions & {
|
|
|
73
77
|
* fails loud — do not pass a blank to "skip".
|
|
74
78
|
*/
|
|
75
79
|
phoenixIdBase?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Managed collaboration backend (PHNX-3835). BOTH must be present to enable
|
|
82
|
+
* `/__collab`; pass neither to leave the surface disabled (the Worker fails it
|
|
83
|
+
* closed). The service token rides Cloudflare's Secrets API, never rendered
|
|
84
|
+
* HTML or browser JS. Applied like `phoenixIdBase` — only when defined.
|
|
85
|
+
*/
|
|
86
|
+
collabBase?: string;
|
|
87
|
+
collabServiceToken?: string;
|
|
76
88
|
};
|
|
77
89
|
/**
|
|
78
90
|
* Re-deploy the Worker script against an ALREADY-provisioned endpoint (same
|
|
@@ -107,6 +107,10 @@ export function hashWorkerScript(script) {
|
|
|
107
107
|
}
|
|
108
108
|
/** Cloudflare `secret_text` name the Worker reads as `env.PHOENIX_ID_BASE`. */
|
|
109
109
|
export const WORKER_PHOENIX_ID_BASE_SECRET = 'PHOENIX_ID_BASE';
|
|
110
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.PRIX_ARTIFACT_COLLAB_BASE`. */
|
|
111
|
+
export const WORKER_COLLAB_BASE_SECRET = 'PRIX_ARTIFACT_COLLAB_BASE';
|
|
112
|
+
/** Cloudflare `secret_text` name the Worker reads as `env.ARTIFACT_COLLAB_SERVICE_TOKEN`. */
|
|
113
|
+
export const WORKER_COLLAB_SERVICE_TOKEN_SECRET = 'ARTIFACT_COLLAB_SERVICE_TOKEN';
|
|
110
114
|
/**
|
|
111
115
|
* Re-deploy the Worker script against an ALREADY-provisioned endpoint (same
|
|
112
116
|
* account/worker/bucket `deployWorker` was first called with) and idempotently
|
|
@@ -145,6 +149,7 @@ export async function updateWorker(apiToken, accountId, workerName, bucketName,
|
|
|
145
149
|
if (opts.phoenixIdBase !== undefined) {
|
|
146
150
|
await applyPhoenixIdBaseSecret(apiToken, accountId, workerName, opts.phoenixIdBase, opts);
|
|
147
151
|
}
|
|
152
|
+
await applyCollabSecrets(apiToken, accountId, workerName, opts);
|
|
148
153
|
return { templateHash, skipped: true };
|
|
149
154
|
}
|
|
150
155
|
await deployWorker(apiToken, accountId, workerName, worker, bucketName, opts);
|
|
@@ -168,8 +173,33 @@ export async function updateWorker(apiToken, accountId, workerName, bucketName,
|
|
|
168
173
|
throw new Error(`Worker deployed but PHOENIX_ID_BASE failed to re-apply — Phoenix-bearer publishes will 401 until you re-run \`agents artifacts share update\`. (${detail})`);
|
|
169
174
|
}
|
|
170
175
|
}
|
|
176
|
+
// The script upload wiped bindings/secrets (see JSDoc above), so re-apply the
|
|
177
|
+
// collaboration secrets too — but only when the caller provided them. An
|
|
178
|
+
// ordinary deploy with collaboration still dormant passes neither and the
|
|
179
|
+
// Worker keeps failing `/__collab` closed.
|
|
180
|
+
try {
|
|
181
|
+
await applyCollabSecrets(apiToken, accountId, workerName, opts);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
185
|
+
throw new Error(`Worker deployed but the collaboration secrets failed to re-apply — \`/__collab\` stays disabled until you re-run \`agents artifacts share update\`. (${detail})`);
|
|
186
|
+
}
|
|
171
187
|
return { templateHash, skipped: false };
|
|
172
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Bind the managed collaboration secrets (PHNX-3835) — both or neither. A no-op
|
|
191
|
+
* when the caller passed neither (collaboration dormant), so a plain managed
|
|
192
|
+
* deploy never touches them; fails loud on a half-config (one without the other).
|
|
193
|
+
*/
|
|
194
|
+
async function applyCollabSecrets(apiToken, accountId, workerName, opts) {
|
|
195
|
+
if (opts.collabBase === undefined && opts.collabServiceToken === undefined)
|
|
196
|
+
return;
|
|
197
|
+
if (opts.collabBase === undefined || opts.collabServiceToken === undefined) {
|
|
198
|
+
throw new Error('Managed collaboration needs BOTH PRIX_ARTIFACT_COLLAB_BASE and ARTIFACT_COLLAB_SERVICE_TOKEN — set both or neither.');
|
|
199
|
+
}
|
|
200
|
+
await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_BASE_SECRET, opts.collabBase, opts);
|
|
201
|
+
await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_SERVICE_TOKEN_SECRET, opts.collabServiceToken, opts);
|
|
202
|
+
}
|
|
173
203
|
/** Add/update a secret_text binding using Cloudflare's Workers Secrets API. */
|
|
174
204
|
export async function putWorkerSecret(apiToken, accountId, workerName, name, text, opts = {}) {
|
|
175
205
|
const request = opts.request ?? defaultCloudflareRequester;
|
|
@@ -85,6 +85,7 @@ import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin
|
|
|
85
85
|
export const hooks = {
|
|
86
86
|
verifyPhoenixToken: defaultVerifyPhoenixToken,
|
|
87
87
|
renderOgCard: renderOgCard,
|
|
88
|
+
collabFetch: defaultCollabFetch,
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
export default {
|
|
@@ -99,6 +100,19 @@ export default {
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
const firstSeg = path.split('/').filter(Boolean)[0] || '';
|
|
103
|
+
|
|
104
|
+
// Same-origin human collaboration transport (PHNX-3835). Every /__collab/*
|
|
105
|
+
// verb loads the R2 share object FIRST, re-derives identity/visibility/
|
|
106
|
+
// provenance server-side, reuses the exact page read gate, then proxies to
|
|
107
|
+
// the Prix artifact-collaboration API with the service token (never surfaced
|
|
108
|
+
// to the browser). Routed BEFORE the __-prefix GET 404 guard below because
|
|
109
|
+
// GET /__collab/context|threads|events are first-class. Fails closed (404)
|
|
110
|
+
// when the managed backend is unconfigured — BYO has no Phoenix identity, so
|
|
111
|
+
// managed collaboration correctly does not exist there.
|
|
112
|
+
if (firstSeg === '__collab') {
|
|
113
|
+
return handleCollab(request, env, url, path, ctx);
|
|
114
|
+
}
|
|
115
|
+
|
|
102
116
|
if ((request.method === 'GET' || request.method === 'HEAD') && firstSeg.startsWith('__')) {
|
|
103
117
|
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
104
118
|
}
|
|
@@ -836,6 +850,26 @@ export default {
|
|
|
836
850
|
freeCanonical: delSegs.length === 2,
|
|
837
851
|
});
|
|
838
852
|
}
|
|
853
|
+
// PHNX-3835: best-effort purge of the deleted share's collaboration
|
|
854
|
+
// threads. Object-first gating already makes comments inaccessible the
|
|
855
|
+
// instant the R2 object is gone (every /__collab request 404s on the
|
|
856
|
+
// missing object), so this is durable cleanup on the Prix side, never the
|
|
857
|
+
// security boundary. Fire-and-forget so a purge failure never fails the
|
|
858
|
+
// delete. Only a managed (Phoenix) canonical page (2 segments, not a
|
|
859
|
+
// cover, not an internal __-key) can carry collaboration.
|
|
860
|
+
const collabCfgDel = collabConfig(env);
|
|
861
|
+
if (
|
|
862
|
+
collabCfgDel &&
|
|
863
|
+
auth.kind === 'phoenix' &&
|
|
864
|
+
!isCover &&
|
|
865
|
+
!firstSeg.startsWith('__') &&
|
|
866
|
+
path.split('/').filter(Boolean).length === 2
|
|
867
|
+
) {
|
|
868
|
+
const purgeOwner = (doomed && doomed.customMetadata && doomed.customMetadata.owner) || auth.owner;
|
|
869
|
+
const purge = purgeCollab(env, collabCfgDel, purgeOwner, path);
|
|
870
|
+
if (ctx && typeof ctx.waitUntil === 'function') ctx.waitUntil(purge);
|
|
871
|
+
else await purge;
|
|
872
|
+
}
|
|
839
873
|
return json({ ok: true, deleted: path }, 200);
|
|
840
874
|
}
|
|
841
875
|
|
|
@@ -2147,5 +2181,244 @@ async function authorizeWrite(request, env) {
|
|
|
2147
2181
|
}
|
|
2148
2182
|
return { error: json({ error: 'unauthorized' }, 401) };
|
|
2149
2183
|
}
|
|
2184
|
+
|
|
2185
|
+
// ---- Same-origin human collaboration transport (PHNX-3835) ----
|
|
2186
|
+
//
|
|
2187
|
+
// The Worker is the trust boundary for collaboration exactly as it is for the
|
|
2188
|
+
// page GET. For every /__collab/* request it:
|
|
2189
|
+
// 1. loads the R2 share object FIRST (object-first) and 404s if absent — share
|
|
2190
|
+
// existence is never enumerable, and a deleted share drops its comments the
|
|
2191
|
+
// instant the object is gone (no separate revocation step);
|
|
2192
|
+
// 2. re-derives share identity, current revision (etag), owner, visibility,
|
|
2193
|
+
// org domain, and producer provenance SERVER-SIDE from R2 metadata, ignoring
|
|
2194
|
+
// anything the browser supplied (the 'share' field is a LOOKUP key only,
|
|
2195
|
+
// never trusted as identity);
|
|
2196
|
+
// 3. reuses the EXACT page read gate (me/org identity gate + private token
|
|
2197
|
+
// gate), mapping any denial to 404 so an out-of-scope viewer cannot tell the
|
|
2198
|
+
// share exists; every WRITE additionally requires a verified signed-in
|
|
2199
|
+
// Phoenix human;
|
|
2200
|
+
// 4. proxies to the Prix artifact-collaboration API with the service token in
|
|
2201
|
+
// Authorization (never surfaced to the browser) plus trusted X-Artifact-* /
|
|
2202
|
+
// X-Phoenix-* headers, streaming SSE through without buffering.
|
|
2203
|
+
//
|
|
2204
|
+
// Missing PRIX_ARTIFACT_COLLAB_BASE or ARTIFACT_COLLAB_SERVICE_TOKEN disables the
|
|
2205
|
+
// whole surface (404) without touching artifact page GET.
|
|
2206
|
+
|
|
2207
|
+
var COLLAB_VISIBILITIES = ['public', 'unlisted', 'private', 'me', 'org'];
|
|
2208
|
+
|
|
2209
|
+
// The managed collaboration backend, or null when unconfigured. Both the base
|
|
2210
|
+
// URL and the service token must be present; either missing fails the whole
|
|
2211
|
+
// surface closed.
|
|
2212
|
+
function collabConfig(env) {
|
|
2213
|
+
const base = typeof env.PRIX_ARTIFACT_COLLAB_BASE === 'string' ? env.PRIX_ARTIFACT_COLLAB_BASE.replace(/\\/+$/, '').trim() : '';
|
|
2214
|
+
const token = typeof env.ARTIFACT_COLLAB_SERVICE_TOKEN === 'string' ? env.ARTIFACT_COLLAB_SERVICE_TOKEN : '';
|
|
2215
|
+
if (!base || !token) return null;
|
|
2216
|
+
return { base: base, token: token };
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// No collaboration response may be cached (visibility can downgrade on any
|
|
2220
|
+
// request), and every collab body is JSON unless overridden (SSE).
|
|
2221
|
+
function collabHeaders(extra) {
|
|
2222
|
+
const h = new Headers(extra || {});
|
|
2223
|
+
if (!h.has('content-type')) h.set('content-type', 'application/json');
|
|
2224
|
+
h.set('cache-control', 'no-store');
|
|
2225
|
+
return h;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function collabError(message, status) {
|
|
2229
|
+
return new Response(JSON.stringify({ error: message }), { status: status, headers: collabHeaders() });
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// A collab denial is ALWAYS a 404 — never a 302 login bounce or a 401 for a read
|
|
2233
|
+
// — so an out-of-scope viewer cannot enumerate share existence.
|
|
2234
|
+
function collabNotFound() {
|
|
2235
|
+
return collabError('not found', 404);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// The page READ gate, reused verbatim for collaboration reads/subscriptions but
|
|
2239
|
+
// with every denial normalized to 404. Returns null when the read is allowed.
|
|
2240
|
+
async function collabReadGate(request, url, obj, identity) {
|
|
2241
|
+
const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
|
|
2242
|
+
// Unknown visibility fails closed.
|
|
2243
|
+
if (COLLAB_VISIBILITIES.indexOf(visibility) === -1) return collabNotFound();
|
|
2244
|
+
if (isIdentityGated(visibility)) {
|
|
2245
|
+
// me/org: require a matching identity; a missing/wrong viewer 404s (never the
|
|
2246
|
+
// page GET's 302 login bounce, which is meaningless to a fetch()).
|
|
2247
|
+
if (!identity || !viewerMayRead(visibility, obj.customMetadata, identity)) return collabNotFound();
|
|
2248
|
+
return null;
|
|
2249
|
+
}
|
|
2250
|
+
if (visibility === 'private') {
|
|
2251
|
+
// private: require the viewer token, exactly as the page GET does.
|
|
2252
|
+
const denied = await gateTokenRead(request, url, obj, identity);
|
|
2253
|
+
if (denied) return collabNotFound();
|
|
2254
|
+
return null;
|
|
2255
|
+
}
|
|
2256
|
+
return null; // public | unlisted — readable when the object exists
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Map a browser /__collab sub-route to the Prix artifact-collaboration route +
|
|
2260
|
+
// method. Returns null for any unsupported shape (fail loud, never a wrong path).
|
|
2261
|
+
// Only the cursor rides through as a query param; the browser's own \`share\`
|
|
2262
|
+
// param is dropped (Prix derives identity from the trusted header, not the path).
|
|
2263
|
+
function collabTarget(rest, method, url) {
|
|
2264
|
+
if (rest.length === 1 && rest[0] === 'context' && method === 'GET') {
|
|
2265
|
+
return { path: '/context', search: '', sse: false };
|
|
2266
|
+
}
|
|
2267
|
+
if (rest.length === 1 && rest[0] === 'threads') {
|
|
2268
|
+
if (method === 'GET') {
|
|
2269
|
+
const after = url.searchParams.get('after');
|
|
2270
|
+
return { path: '/threads', search: after ? '?after=' + encodeURIComponent(after) : '', sse: false };
|
|
2271
|
+
}
|
|
2272
|
+
if (method === 'POST') return { path: '/threads', search: '', sse: false };
|
|
2273
|
+
}
|
|
2274
|
+
if (rest.length === 2 && rest[0] === 'threads' && method === 'PATCH') {
|
|
2275
|
+
return { path: '/threads/' + encodeURIComponent(rest[1]), search: '', sse: false };
|
|
2276
|
+
}
|
|
2277
|
+
if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'replies' && method === 'POST') {
|
|
2278
|
+
return { path: '/threads/' + encodeURIComponent(rest[1]) + '/replies', search: '', sse: false };
|
|
2279
|
+
}
|
|
2280
|
+
if (rest.length === 2 && rest[0] === 'comments' && method === 'PATCH') {
|
|
2281
|
+
return { path: '/comments/' + encodeURIComponent(rest[1]), search: '', sse: false };
|
|
2282
|
+
}
|
|
2283
|
+
if (rest.length === 1 && rest[0] === 'events' && method === 'GET') {
|
|
2284
|
+
return { path: '/events', search: '', sse: true };
|
|
2285
|
+
}
|
|
2286
|
+
return null;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
async function handleCollab(request, env, url, path, ctx) {
|
|
2290
|
+
const cfg = collabConfig(env);
|
|
2291
|
+
// Fail closed: no managed backend → the surface does not exist.
|
|
2292
|
+
if (!cfg) return collabNotFound();
|
|
2293
|
+
|
|
2294
|
+
const segs = path.split('/').filter(Boolean); // ['__collab', ...]
|
|
2295
|
+
const rest = segs.slice(1);
|
|
2296
|
+
const method = request.method;
|
|
2297
|
+
|
|
2298
|
+
// Resolve the share path. GET carries it in ?share=, mutations in the JSON
|
|
2299
|
+
// body — read that body ONCE here so \`share\` extraction and verbatim upstream
|
|
2300
|
+
// forwarding share a single read.
|
|
2301
|
+
const isMutation = method === 'POST' || method === 'PATCH';
|
|
2302
|
+
let sharePath = '';
|
|
2303
|
+
let bodyText = '';
|
|
2304
|
+
if (isMutation) {
|
|
2305
|
+
bodyText = await request.text();
|
|
2306
|
+
let parsed;
|
|
2307
|
+
try { parsed = JSON.parse(bodyText || '{}'); } catch { return collabError('body must be JSON', 400); }
|
|
2308
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return collabError('body must be a JSON object', 400);
|
|
2309
|
+
sharePath = typeof parsed.share === 'string' ? parsed.share : '';
|
|
2310
|
+
} else {
|
|
2311
|
+
sharePath = url.searchParams.get('share') || '';
|
|
2312
|
+
}
|
|
2313
|
+
sharePath = String(sharePath).replace(/^\\/+/, '');
|
|
2314
|
+
const shareFirst = sharePath.split('/').filter(Boolean)[0] || '';
|
|
2315
|
+
// Never let a collab lookup reach an internal __-key or an empty share.
|
|
2316
|
+
if (!sharePath || shareFirst.startsWith('__')) return collabNotFound();
|
|
2317
|
+
|
|
2318
|
+
// OBJECT-FIRST: load the share before deriving anything. A missing share
|
|
2319
|
+
// (never provisioned, or just deleted) 404s.
|
|
2320
|
+
const obj = await env.BUCKET.get(sharePath);
|
|
2321
|
+
if (!obj) return collabNotFound();
|
|
2322
|
+
|
|
2323
|
+
// Re-derive identity SERVER-SIDE and apply the page read gate. Collab clients
|
|
2324
|
+
// authenticate with the Bearer or the __share cookie, never a ?phoenix_ticket=
|
|
2325
|
+
// (that is a page-navigation param the CLI appends when OPENING a page). So we
|
|
2326
|
+
// use only the resolved identity and ignore any redirect outcome — a 302 login
|
|
2327
|
+
// bounce is meaningless to a fetch(). If a ticket were present it would be
|
|
2328
|
+
// redeemed (and, being single-use, consumed) here but its cookie discarded, so
|
|
2329
|
+
// the request falls through as anonymous — the fail-safe (more restrictive)
|
|
2330
|
+
// direction, never an escalation.
|
|
2331
|
+
const viewer = await resolveViewer(request, env, url);
|
|
2332
|
+
const identity = viewer.identity || null;
|
|
2333
|
+
const readDenied = await collabReadGate(request, url, obj, identity);
|
|
2334
|
+
if (readDenied) return readDenied;
|
|
2335
|
+
|
|
2336
|
+
// Every write requires a verified signed-in Phoenix human, ON TOP of the read
|
|
2337
|
+
// gate (public/unlisted readers may read anonymously but never write).
|
|
2338
|
+
if (isMutation && (!identity || !identity.userId)) {
|
|
2339
|
+
return collabError('sign in with Phoenix to comment', 401);
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
const target = collabTarget(rest, method, url);
|
|
2343
|
+
if (!target) return collabError('unsupported collaboration route', 404);
|
|
2344
|
+
|
|
2345
|
+
// Trusted, server-derived values. owner + NUL + normalized path → a stable
|
|
2346
|
+
// share id; the raw private access token is NEVER used as identity.
|
|
2347
|
+
const meta = obj.customMetadata || {};
|
|
2348
|
+
const visibility = meta.visibility || 'public';
|
|
2349
|
+
const owner = meta.owner || '';
|
|
2350
|
+
const shareId = await sha256Hex(owner + '\\u0000' + sharePath);
|
|
2351
|
+
const revision = obj.etag || '';
|
|
2352
|
+
|
|
2353
|
+
const headers = new Headers();
|
|
2354
|
+
headers.set('authorization', 'Bearer ' + cfg.token);
|
|
2355
|
+
headers.set('x-artifact-share-id', shareId);
|
|
2356
|
+
if (revision) headers.set('x-artifact-revision', revision);
|
|
2357
|
+
headers.set('x-artifact-visibility', visibility);
|
|
2358
|
+
if (owner) headers.set('x-artifact-owner-id', owner);
|
|
2359
|
+
if (meta.org_domain) headers.set('x-artifact-org-domain', meta.org_domain);
|
|
2360
|
+
if (identity && identity.userId) headers.set('x-phoenix-actor-id', identity.userId);
|
|
2361
|
+
if (identity && identity.email) headers.set('x-phoenix-actor-email', identity.email);
|
|
2362
|
+
// Preserve existing R2 producer provenance (RUSH-2683) so Prix can attribute a
|
|
2363
|
+
// comment thread to the run that made the artifact.
|
|
2364
|
+
if (meta.agent) headers.set('x-artifact-agent', meta.agent);
|
|
2365
|
+
if (meta.session) headers.set('x-artifact-session', meta.session);
|
|
2366
|
+
if (meta.host) headers.set('x-artifact-host', meta.host);
|
|
2367
|
+
if (meta.repo) headers.set('x-artifact-repo', meta.repo);
|
|
2368
|
+
// Idempotency + resumable-SSE headers ride through unchanged.
|
|
2369
|
+
const idem = request.headers.get('idempotency-key');
|
|
2370
|
+
if (idem) headers.set('idempotency-key', idem);
|
|
2371
|
+
const lastEvent = request.headers.get('last-event-id');
|
|
2372
|
+
if (lastEvent) headers.set('last-event-id', lastEvent);
|
|
2373
|
+
if (target.sse) headers.set('accept', 'text/event-stream');
|
|
2374
|
+
else if (isMutation) headers.set('content-type', 'application/json');
|
|
2375
|
+
|
|
2376
|
+
const init = { method: method, headers: headers };
|
|
2377
|
+
if (isMutation) init.body = bodyText;
|
|
2378
|
+
// Propagate a client disconnect to the upstream fetch (SSE cancel).
|
|
2379
|
+
if (request.signal) init.signal = request.signal;
|
|
2380
|
+
|
|
2381
|
+
let upstream;
|
|
2382
|
+
try {
|
|
2383
|
+
upstream = await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration' + target.path + target.search, init));
|
|
2384
|
+
} catch (e) {
|
|
2385
|
+
return collabError('collaboration backend unavailable', 502);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// Forward Prix status + body. Never echo the service token; every collab
|
|
2389
|
+
// response is no-store. SSE streams through unbuffered — event IDs and content
|
|
2390
|
+
// type ride the bytes, and disabling proxy buffering keeps the stream live.
|
|
2391
|
+
const ct = upstream.headers.get('content-type');
|
|
2392
|
+
const outHeaders = collabHeaders();
|
|
2393
|
+
if (target.sse) {
|
|
2394
|
+
outHeaders.set('content-type', ct || 'text/event-stream');
|
|
2395
|
+
outHeaders.set('x-accel-buffering', 'no');
|
|
2396
|
+
} else if (ct) {
|
|
2397
|
+
outHeaders.set('content-type', ct);
|
|
2398
|
+
}
|
|
2399
|
+
return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// Best-effort authenticated purge of a deleted share's collaboration threads.
|
|
2403
|
+
// Swallows every error — a purge failure must never fail the share delete, and
|
|
2404
|
+
// object-first gating already made the comments inaccessible.
|
|
2405
|
+
async function purgeCollab(env, cfg, owner, sharePath) {
|
|
2406
|
+
try {
|
|
2407
|
+
const shareId = await sha256Hex((owner || '') + '\\u0000' + sharePath);
|
|
2408
|
+
const headers = new Headers();
|
|
2409
|
+
headers.set('authorization', 'Bearer ' + cfg.token);
|
|
2410
|
+
headers.set('x-artifact-share-id', shareId);
|
|
2411
|
+
await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration/purge', { method: 'POST', headers: headers }));
|
|
2412
|
+
} catch (e) {
|
|
2413
|
+
// best-effort: a purge failure must never fail the share delete
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// The real upstream hop to Prix. Overridable in tests (like verifyPhoenixToken)
|
|
2418
|
+
// so the generated Worker's derivation/gating is exercised against a simulated
|
|
2419
|
+
// backend without a live network.
|
|
2420
|
+
async function defaultCollabFetch(request) {
|
|
2421
|
+
return fetch(request);
|
|
2422
|
+
}
|
|
2150
2423
|
`;
|
|
2151
2424
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { BACKENDS } from './backends/index.js';
|
|
2
2
|
import { planLayouts } from './policy.js';
|
|
3
3
|
import { runSpec } from './transport.js';
|
|
4
|
+
import { homeRemainder, remoteCdPrefix } from '../project-root.js';
|
|
5
|
+
import { sshExec } from '../ssh-exec.js';
|
|
4
6
|
const DEFAULT_STAGGER_MS = 400;
|
|
5
7
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
8
|
/** The concrete launch command for a request (pure — no side effects). */
|
|
@@ -21,7 +23,17 @@ export async function openSurface(req, opts = {}) {
|
|
|
21
23
|
// Both can throw: specForRequest on an unknown backend, runSpec when the
|
|
22
24
|
// SSH transport rejects an invalid --device target. Keep them inside the
|
|
23
25
|
// catch so a bad request degrades to a per-surface failure, never a throw.
|
|
24
|
-
|
|
26
|
+
let resolved = req;
|
|
27
|
+
if (req.host && req.host !== 'local' && homeRemainder(req.cwd) !== null) {
|
|
28
|
+
const target = opts.resolveHost ? opts.resolveHost(req.host) : req.host;
|
|
29
|
+
const result = sshExec(target, remoteCdPrefix(req.cwd) + 'pwd -P', { multiplex: true });
|
|
30
|
+
const cwd = result.stdout.replace(/\r?\n$/, '');
|
|
31
|
+
if (result.code !== 0 || !cwd.startsWith('/') || /[\r\n]/.test(cwd)) {
|
|
32
|
+
throw new Error(`Cannot resolve directory ${req.cwd} on ${req.host}: ${(result.stderr || '').trim() || 'no absolute working directory returned'}`);
|
|
33
|
+
}
|
|
34
|
+
resolved = { ...req, cwd };
|
|
35
|
+
}
|
|
36
|
+
const spec = specForRequest(resolved);
|
|
25
37
|
const res = await runSpec(spec, req.host, opts.resolveHost);
|
|
26
38
|
return { ok: res.ok, request: req, error: res.error };
|
|
27
39
|
}
|
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.109",
|
|
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",
|