@codyswann/lisa 2.322.0 → 2.322.2
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/all/copy-contents/gitignore +6 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +8 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-remote-dispatch/scripts/dispatch.mjs +206 -10
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +25 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +20 -9
- package/plugins/lisa/skills/lisa-remote-dispatch/scripts/dispatch.mjs +206 -10
- package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +25 -5
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +20 -9
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-remote-dispatch/scripts/dispatch.mjs +206 -10
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +25 -5
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +20 -9
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-remote-dispatch/scripts/dispatch.mjs +206 -10
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +25 -5
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +20 -9
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-remote-dispatch/scripts/dispatch.mjs +206 -10
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +25 -5
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +20 -9
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-remote-dispatch/scripts/dispatch.mjs +206 -10
- package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +25 -5
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +20 -9
- package/scripts/lisa-remote-env/setup.sh +74 -5
|
@@ -21,14 +21,53 @@
|
|
|
21
21
|
|
|
22
22
|
import { execFileSync } from "node:child_process";
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
25
26
|
|
|
26
27
|
/** Surfaces this dispatcher knows how to reach. `local` means "do not dispatch". */
|
|
27
|
-
export const EXECUTION_ENVS = new Set(["local", "codex-cloud"]);
|
|
28
|
+
export const EXECUTION_ENVS = new Set(["local", "codex-cloud", "claude-web"]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What each surface must have recorded before anything may dispatch to it.
|
|
32
|
+
*
|
|
33
|
+
* Not uniform, because the two surfaces do not bind the same way. A Codex Cloud
|
|
34
|
+
* environment is bound to one repository, so naming both is what proves the
|
|
35
|
+
* environment is the right one. A Claude cloud environment binds no repository
|
|
36
|
+
* at all — it is account-scoped configuration and the repository arrives per
|
|
37
|
+
* session — so its durable handle is the routine that dispatch fires.
|
|
38
|
+
*/
|
|
39
|
+
const SURFACE_PRECONDITIONS = {
|
|
40
|
+
"codex-cloud": ["environmentId", "repository"],
|
|
41
|
+
"claude-web": ["routineId", "fireUrl"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The beta this endpoint ships under.
|
|
46
|
+
*
|
|
47
|
+
* Dated and rotating: the two most recent previous versions keep working, so a
|
|
48
|
+
* bump here is a migration with a window rather than a break. Stated once so
|
|
49
|
+
* there is a single place to move it.
|
|
50
|
+
*/
|
|
51
|
+
const ROUTINE_BETA = "experimental-cc-routine-2026-04-01";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How long to wait for the routine to accept the dispatch.
|
|
55
|
+
*
|
|
56
|
+
* `fetch` has no timeout of its own, so a routine endpoint that accepts the
|
|
57
|
+
* connection and then says nothing leaves dispatch waiting forever — the one
|
|
58
|
+
* failure mode that never reaches the error path below and never reaches the
|
|
59
|
+
* operator either. Generous, because the endpoint only has to *accept* the
|
|
60
|
+
* dispatch: the session's own work happens long after this call returns, so
|
|
61
|
+
* this bounds a handshake rather than a run.
|
|
62
|
+
*/
|
|
63
|
+
const FIRE_TIMEOUT_MS = 30_000;
|
|
28
64
|
|
|
29
65
|
/** Where dispatched work is recorded so a later session can find it. */
|
|
30
66
|
const LEDGER = join(".lisa", "remote-dispatch.json");
|
|
31
67
|
|
|
68
|
+
/** This file's directory, for locating the sibling secrets skill. */
|
|
69
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
|
|
32
71
|
/**
|
|
33
72
|
* Split `key=value` parameters from the rest of an invocation.
|
|
34
73
|
*
|
|
@@ -101,9 +140,11 @@ export function readSurfaceConfig(surface, cwd = process.cwd()) {
|
|
|
101
140
|
* @param {string} surface Execution surface.
|
|
102
141
|
*/
|
|
103
142
|
export function assertPreconditions(block, surface) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
143
|
+
const required = SURFACE_PRECONDITIONS[surface] ?? [
|
|
144
|
+
"environmentId",
|
|
145
|
+
"repository",
|
|
146
|
+
];
|
|
147
|
+
const missing = required.filter(field => !block[field]);
|
|
107
148
|
if (missing.length) {
|
|
108
149
|
throw new Error(
|
|
109
150
|
`remoteEnv.surfaces["${surface}"] is missing: ${missing.join(", ")}.\n` +
|
|
@@ -230,7 +271,152 @@ export function splitSkillFlag(argv) {
|
|
|
230
271
|
return { skill, raw: rest.join(" ") };
|
|
231
272
|
}
|
|
232
273
|
|
|
233
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Read the response a routine returns when it accepts a dispatch.
|
|
276
|
+
*
|
|
277
|
+
* Kept separate from the request so the shape can be exercised against a
|
|
278
|
+
* recorded body. The identifier is a field here rather than something scraped
|
|
279
|
+
* out of console output, which is the one respect in which this surface is
|
|
280
|
+
* easier to reconcile than the other.
|
|
281
|
+
* @param {string} body Raw response body.
|
|
282
|
+
* @returns {{sessionId: string, url: string}} The accepted session.
|
|
283
|
+
*/
|
|
284
|
+
export function readFireResponse(body) {
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(body);
|
|
288
|
+
} catch {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`routine returned a body that is not JSON:\n${String(body).slice(0, 400)}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const sessionId = parsed.claude_code_session_id;
|
|
294
|
+
const url = parsed.claude_code_session_url;
|
|
295
|
+
if (!sessionId) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`routine accepted the request but returned no session identifier.\n` +
|
|
298
|
+
`${JSON.stringify(parsed).slice(0, 400)}\n` +
|
|
299
|
+
`Refusing to report success: without the identifier nothing can ` +
|
|
300
|
+
`reconcile this run, and a retry would duplicate it.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return { sessionId, url: url ?? "" };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Fire a routine and record the session it created.
|
|
308
|
+
*
|
|
309
|
+
* The payload is deliberately the work item and nothing else. It arrives on the
|
|
310
|
+
* far side wrapped in a block the platform marks as untrusted data, and a
|
|
311
|
+
* routine acts on it only because its saved prompt says to — which is the
|
|
312
|
+
* boundary this plan wanted anyway, enforced by the platform rather than by
|
|
313
|
+
* convention. Nothing here can widen the remote run's authority.
|
|
314
|
+
*
|
|
315
|
+
* The bearer token is resolved through the secrets chokepoint at the moment of
|
|
316
|
+
* use and never stored in configuration.
|
|
317
|
+
* Its three side effects — resolving a credential, making a request, writing the
|
|
318
|
+
* ledger — are all injectable, so the accept and refuse paths can be exercised
|
|
319
|
+
* without a token, a network, or a write into the working repository.
|
|
320
|
+
* @param {object} block Surface configuration.
|
|
321
|
+
* @param {string} prompt The thin skill invocation.
|
|
322
|
+
* @param {string} payload The caller's original payload, for the record.
|
|
323
|
+
* @param {{post?: Function, getToken?: Function, cwd?: string}} [options] Seams for tests.
|
|
324
|
+
* @returns {Promise<{sessionId: string, url: string}>} The accepted session.
|
|
325
|
+
*/
|
|
326
|
+
export async function dispatchClaudeWeb(block, prompt, payload, options = {}) {
|
|
327
|
+
const {
|
|
328
|
+
post = fetch,
|
|
329
|
+
getToken = resolveBearerToken,
|
|
330
|
+
cwd = process.cwd(),
|
|
331
|
+
} = options;
|
|
332
|
+
const token = getToken(block);
|
|
333
|
+
let response;
|
|
334
|
+
try {
|
|
335
|
+
response = await post(block.fireUrl, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
authorization: `Bearer ${token}`,
|
|
339
|
+
"anthropic-beta": ROUTINE_BETA,
|
|
340
|
+
"anthropic-version": "2023-06-01",
|
|
341
|
+
"content-type": "application/json",
|
|
342
|
+
},
|
|
343
|
+
body: JSON.stringify({ text: prompt }),
|
|
344
|
+
signal: AbortSignal.timeout(FIRE_TIMEOUT_MS),
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// A timeout arrives here as an abort rather than a network error, and
|
|
348
|
+
// "could not reach" is the true and useful thing to say about both. It is
|
|
349
|
+
// named separately so the operator can tell a silent endpoint from a
|
|
350
|
+
// refused connection, since only one of those is worth retrying.
|
|
351
|
+
const reason =
|
|
352
|
+
err.name === "TimeoutError"
|
|
353
|
+
? `no response within ${FIRE_TIMEOUT_MS / 1000}s`
|
|
354
|
+
: err.message;
|
|
355
|
+
throw new Error(`could not reach the routine endpoint: ${reason}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const body = await response.text();
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
// The token is the usual cause and the usual thing to leak, so the message
|
|
361
|
+
// names the status and the routine rather than echoing the request.
|
|
362
|
+
throw new Error(
|
|
363
|
+
`routine ${block.routineId} refused the dispatch (HTTP ${response.status}).\n` +
|
|
364
|
+
`${body.slice(0, 400)}\n` +
|
|
365
|
+
`A 401 means the bearer token is wrong, revoked, or regenerated.`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const { sessionId, url } = readFireResponse(body);
|
|
370
|
+
record(
|
|
371
|
+
{
|
|
372
|
+
taskId: sessionId,
|
|
373
|
+
surface: "claude-web",
|
|
374
|
+
routineId: block.routineId,
|
|
375
|
+
sessionUrl: url,
|
|
376
|
+
prompt,
|
|
377
|
+
payload,
|
|
378
|
+
dispatchedAt: new Date().toISOString(),
|
|
379
|
+
},
|
|
380
|
+
cwd
|
|
381
|
+
);
|
|
382
|
+
return { sessionId, url };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the dispatcher's own credential through the secrets chokepoint.
|
|
387
|
+
*
|
|
388
|
+
* Never read from configuration: the token authorises starting work on someone
|
|
389
|
+
* else's infrastructure, and it can be regenerated and revoked, so it belongs
|
|
390
|
+
* in the credential manager and in `secrets.rotating` alongside it.
|
|
391
|
+
* @param {object} block Surface configuration.
|
|
392
|
+
* @returns {string} The bearer token.
|
|
393
|
+
*/
|
|
394
|
+
function resolveBearerToken(block) {
|
|
395
|
+
const name = block.tokenKey ?? "CLAUDE_ROUTINE_TOKEN";
|
|
396
|
+
const resolver = resolve(
|
|
397
|
+
HERE,
|
|
398
|
+
"..",
|
|
399
|
+
"..",
|
|
400
|
+
"lisa-secrets-access",
|
|
401
|
+
"scripts",
|
|
402
|
+
"resolve-secret.mjs"
|
|
403
|
+
);
|
|
404
|
+
try {
|
|
405
|
+
return execFileSync("node", [resolver, "get", name], {
|
|
406
|
+
encoding: "utf8",
|
|
407
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
408
|
+
}).trim();
|
|
409
|
+
} catch (err) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`could not resolve ${name} through lisa-secrets-access.\n` +
|
|
412
|
+
`${String(err.stderr ?? err.message).trim()}\n` +
|
|
413
|
+
`It is the dispatcher's own credential; store it in the provider ` +
|
|
414
|
+
`rather than in .lisa.config.json.`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function main() {
|
|
234
420
|
const { skill, raw } = splitSkillFlag(process.argv.slice(2));
|
|
235
421
|
const { params, rest } = parseInvocation(raw);
|
|
236
422
|
const surface = resolveExecutionEnv(params);
|
|
@@ -247,6 +433,15 @@ function main() {
|
|
|
247
433
|
// the repository-local skill, so an interactive run, a scheduled run, and a
|
|
248
434
|
// recovery run all execute one contract.
|
|
249
435
|
const prompt = `$${skill} ${rest}`.trim();
|
|
436
|
+
|
|
437
|
+
if (surface === "claude-web") {
|
|
438
|
+
const { sessionId, url } = await dispatchClaudeWeb(block, prompt, rest);
|
|
439
|
+
console.log(`dispatched: ${sessionId}`);
|
|
440
|
+
if (url) console.log(url);
|
|
441
|
+
console.log(`recorded in ${LEDGER}; not polling — this process is done.`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
250
445
|
const taskId = dispatchCodexCloud(block, prompt, rest);
|
|
251
446
|
|
|
252
447
|
console.log(`dispatched: ${taskId}`);
|
|
@@ -255,10 +450,11 @@ function main() {
|
|
|
255
450
|
}
|
|
256
451
|
|
|
257
452
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
453
|
+
// Awaited rather than called bare: one dispatch path is async, so a synchronous
|
|
454
|
+
// try/catch would let a rejection escape as an unhandled rejection and exit 0 —
|
|
455
|
+
// reporting dispatched work that was never accepted.
|
|
456
|
+
main().catch(err => {
|
|
261
457
|
console.error(err.message);
|
|
262
458
|
process.exit(1);
|
|
263
|
-
}
|
|
459
|
+
});
|
|
264
460
|
}
|
|
@@ -13,11 +13,31 @@ Prepare a remote surface so a host project can execute there. Today that means *
|
|
|
13
13
|
The remote environment's own configuration fields stay **one line into the repository**. Nothing else is pasted into a vendor UI.
|
|
14
14
|
|
|
15
15
|
```text
|
|
16
|
-
setup:
|
|
17
|
-
maintenance:
|
|
16
|
+
setup: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
17
|
+
maintenance: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
**That line is identical for every project.** Nothing in it names the
|
|
20
|
+
**That line is identical for every project AND every surface.** Nothing in it names the
|
|
21
|
+
repository, the package manager, or a home directory, so it can be pasted unchanged anywhere.
|
|
22
|
+
|
|
23
|
+
Locating the checkout is the only hard part, because the surfaces disagree about where the
|
|
24
|
+
field runs:
|
|
25
|
+
|
|
26
|
+
| Surface | cwd when the field runs | Checkout |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| Codex Cloud | **is** the checkout | `/workspace/<repo>` |
|
|
29
|
+
| Claude Code web | `$HOME` | `$HOME/<repo>` |
|
|
30
|
+
|
|
31
|
+
So both candidates are tried **relative to cwd** — the checkout itself, then one level down —
|
|
32
|
+
and neither mentions `$HOME`. An earlier version of this field used
|
|
33
|
+
`bash "$HOME"/*/scripts/...`, which works on Claude Code web and fails on Codex Cloud, where
|
|
34
|
+
the checkout is not under `$HOME` at all: the glob matches nothing and bash reports
|
|
35
|
+
`No such file or directory` for a path still containing a literal `*`.
|
|
36
|
+
|
|
37
|
+
`exec` on the first hit means no subshell and no `ls` output to parse, and the explicit
|
|
38
|
+
`exit 1` means a missing entrypoint says so rather than the field silently succeeding. The
|
|
39
|
+
script then anchors itself on the repository root, so it behaves the same however it was
|
|
40
|
+
reached.
|
|
21
41
|
|
|
22
42
|
They are the same command. A container may be built fresh or resumed from cache; every step is idempotent and version-aware, so running it twice is correct, and running it on resume is what picks up a rotated value, an edited note, or a changed version pin.
|
|
23
43
|
|
|
@@ -136,8 +156,8 @@ When emitting, produce exactly:
|
|
|
136
156
|
```text
|
|
137
157
|
Environment name: <project> remote executor
|
|
138
158
|
Repository: <org>/<repo> (must be the default checkout)
|
|
139
|
-
Setup script:
|
|
140
|
-
Maintenance:
|
|
159
|
+
Setup script: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
160
|
+
Maintenance: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
141
161
|
(identical for every project — the script finds the
|
|
142
162
|
checkout and installs from the committed lockfile)
|
|
143
163
|
Environment vars: LISA_SECRETS_SURFACE=codex-cloud
|
|
@@ -299,18 +299,29 @@ export function installAssets(cwd = process.cwd()) {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
/**
|
|
302
|
-
* The setup field, identical for every project.
|
|
302
|
+
* The setup field, identical for every project and every surface.
|
|
303
303
|
*
|
|
304
|
-
* Names neither the repository nor its package manager
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
304
|
+
* Names neither the repository nor its package manager, and deliberately not
|
|
305
|
+
* `$HOME` either: the surfaces disagree about where the field runs. Codex Cloud
|
|
306
|
+
* runs it inside the checkout, Claude Code web runs it from `$HOME` with the
|
|
307
|
+
* checkout one level down, and on Codex Cloud the checkout is not under `$HOME`
|
|
308
|
+
* at all — so a `$HOME` glob matched nothing there and bash was handed a path
|
|
309
|
+
* still containing a literal asterisk.
|
|
308
310
|
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
311
|
+
* Both candidates are therefore tried relative to cwd. `exec` on the first hit
|
|
312
|
+
* avoids a subshell and avoids parsing `ls`; the explicit `exit 1` means a
|
|
313
|
+
* missing entrypoint says so rather than the field quietly succeeding. The
|
|
314
|
+
* script then anchors itself on the repository root and installs dependencies
|
|
315
|
+
* from whichever lockfile the project commits.
|
|
316
|
+
*
|
|
317
|
+
* A field that named the repository and package manager was a string a human
|
|
318
|
+
* had to get right, in a settings box with no review, no version history and no
|
|
319
|
+
* test — and the logic it encoded belongs in a file that has all three.
|
|
312
320
|
*/
|
|
313
|
-
const SETUP_FIELD =
|
|
321
|
+
export const SETUP_FIELD =
|
|
322
|
+
"for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; " +
|
|
323
|
+
'do [ -f "$f" ] && exec bash "$f"; done; ' +
|
|
324
|
+
'echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1';
|
|
314
325
|
|
|
315
326
|
/**
|
|
316
327
|
* The settings block that wires the session-start hook into a repository.
|
|
@@ -21,14 +21,53 @@
|
|
|
21
21
|
|
|
22
22
|
import { execFileSync } from "node:child_process";
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
25
26
|
|
|
26
27
|
/** Surfaces this dispatcher knows how to reach. `local` means "do not dispatch". */
|
|
27
|
-
export const EXECUTION_ENVS = new Set(["local", "codex-cloud"]);
|
|
28
|
+
export const EXECUTION_ENVS = new Set(["local", "codex-cloud", "claude-web"]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What each surface must have recorded before anything may dispatch to it.
|
|
32
|
+
*
|
|
33
|
+
* Not uniform, because the two surfaces do not bind the same way. A Codex Cloud
|
|
34
|
+
* environment is bound to one repository, so naming both is what proves the
|
|
35
|
+
* environment is the right one. A Claude cloud environment binds no repository
|
|
36
|
+
* at all — it is account-scoped configuration and the repository arrives per
|
|
37
|
+
* session — so its durable handle is the routine that dispatch fires.
|
|
38
|
+
*/
|
|
39
|
+
const SURFACE_PRECONDITIONS = {
|
|
40
|
+
"codex-cloud": ["environmentId", "repository"],
|
|
41
|
+
"claude-web": ["routineId", "fireUrl"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The beta this endpoint ships under.
|
|
46
|
+
*
|
|
47
|
+
* Dated and rotating: the two most recent previous versions keep working, so a
|
|
48
|
+
* bump here is a migration with a window rather than a break. Stated once so
|
|
49
|
+
* there is a single place to move it.
|
|
50
|
+
*/
|
|
51
|
+
const ROUTINE_BETA = "experimental-cc-routine-2026-04-01";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How long to wait for the routine to accept the dispatch.
|
|
55
|
+
*
|
|
56
|
+
* `fetch` has no timeout of its own, so a routine endpoint that accepts the
|
|
57
|
+
* connection and then says nothing leaves dispatch waiting forever — the one
|
|
58
|
+
* failure mode that never reaches the error path below and never reaches the
|
|
59
|
+
* operator either. Generous, because the endpoint only has to *accept* the
|
|
60
|
+
* dispatch: the session's own work happens long after this call returns, so
|
|
61
|
+
* this bounds a handshake rather than a run.
|
|
62
|
+
*/
|
|
63
|
+
const FIRE_TIMEOUT_MS = 30_000;
|
|
28
64
|
|
|
29
65
|
/** Where dispatched work is recorded so a later session can find it. */
|
|
30
66
|
const LEDGER = join(".lisa", "remote-dispatch.json");
|
|
31
67
|
|
|
68
|
+
/** This file's directory, for locating the sibling secrets skill. */
|
|
69
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
|
|
32
71
|
/**
|
|
33
72
|
* Split `key=value` parameters from the rest of an invocation.
|
|
34
73
|
*
|
|
@@ -101,9 +140,11 @@ export function readSurfaceConfig(surface, cwd = process.cwd()) {
|
|
|
101
140
|
* @param {string} surface Execution surface.
|
|
102
141
|
*/
|
|
103
142
|
export function assertPreconditions(block, surface) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
143
|
+
const required = SURFACE_PRECONDITIONS[surface] ?? [
|
|
144
|
+
"environmentId",
|
|
145
|
+
"repository",
|
|
146
|
+
];
|
|
147
|
+
const missing = required.filter(field => !block[field]);
|
|
107
148
|
if (missing.length) {
|
|
108
149
|
throw new Error(
|
|
109
150
|
`remoteEnv.surfaces["${surface}"] is missing: ${missing.join(", ")}.\n` +
|
|
@@ -230,7 +271,152 @@ export function splitSkillFlag(argv) {
|
|
|
230
271
|
return { skill, raw: rest.join(" ") };
|
|
231
272
|
}
|
|
232
273
|
|
|
233
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Read the response a routine returns when it accepts a dispatch.
|
|
276
|
+
*
|
|
277
|
+
* Kept separate from the request so the shape can be exercised against a
|
|
278
|
+
* recorded body. The identifier is a field here rather than something scraped
|
|
279
|
+
* out of console output, which is the one respect in which this surface is
|
|
280
|
+
* easier to reconcile than the other.
|
|
281
|
+
* @param {string} body Raw response body.
|
|
282
|
+
* @returns {{sessionId: string, url: string}} The accepted session.
|
|
283
|
+
*/
|
|
284
|
+
export function readFireResponse(body) {
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(body);
|
|
288
|
+
} catch {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`routine returned a body that is not JSON:\n${String(body).slice(0, 400)}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const sessionId = parsed.claude_code_session_id;
|
|
294
|
+
const url = parsed.claude_code_session_url;
|
|
295
|
+
if (!sessionId) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`routine accepted the request but returned no session identifier.\n` +
|
|
298
|
+
`${JSON.stringify(parsed).slice(0, 400)}\n` +
|
|
299
|
+
`Refusing to report success: without the identifier nothing can ` +
|
|
300
|
+
`reconcile this run, and a retry would duplicate it.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return { sessionId, url: url ?? "" };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Fire a routine and record the session it created.
|
|
308
|
+
*
|
|
309
|
+
* The payload is deliberately the work item and nothing else. It arrives on the
|
|
310
|
+
* far side wrapped in a block the platform marks as untrusted data, and a
|
|
311
|
+
* routine acts on it only because its saved prompt says to — which is the
|
|
312
|
+
* boundary this plan wanted anyway, enforced by the platform rather than by
|
|
313
|
+
* convention. Nothing here can widen the remote run's authority.
|
|
314
|
+
*
|
|
315
|
+
* The bearer token is resolved through the secrets chokepoint at the moment of
|
|
316
|
+
* use and never stored in configuration.
|
|
317
|
+
* Its three side effects — resolving a credential, making a request, writing the
|
|
318
|
+
* ledger — are all injectable, so the accept and refuse paths can be exercised
|
|
319
|
+
* without a token, a network, or a write into the working repository.
|
|
320
|
+
* @param {object} block Surface configuration.
|
|
321
|
+
* @param {string} prompt The thin skill invocation.
|
|
322
|
+
* @param {string} payload The caller's original payload, for the record.
|
|
323
|
+
* @param {{post?: Function, getToken?: Function, cwd?: string}} [options] Seams for tests.
|
|
324
|
+
* @returns {Promise<{sessionId: string, url: string}>} The accepted session.
|
|
325
|
+
*/
|
|
326
|
+
export async function dispatchClaudeWeb(block, prompt, payload, options = {}) {
|
|
327
|
+
const {
|
|
328
|
+
post = fetch,
|
|
329
|
+
getToken = resolveBearerToken,
|
|
330
|
+
cwd = process.cwd(),
|
|
331
|
+
} = options;
|
|
332
|
+
const token = getToken(block);
|
|
333
|
+
let response;
|
|
334
|
+
try {
|
|
335
|
+
response = await post(block.fireUrl, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
authorization: `Bearer ${token}`,
|
|
339
|
+
"anthropic-beta": ROUTINE_BETA,
|
|
340
|
+
"anthropic-version": "2023-06-01",
|
|
341
|
+
"content-type": "application/json",
|
|
342
|
+
},
|
|
343
|
+
body: JSON.stringify({ text: prompt }),
|
|
344
|
+
signal: AbortSignal.timeout(FIRE_TIMEOUT_MS),
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// A timeout arrives here as an abort rather than a network error, and
|
|
348
|
+
// "could not reach" is the true and useful thing to say about both. It is
|
|
349
|
+
// named separately so the operator can tell a silent endpoint from a
|
|
350
|
+
// refused connection, since only one of those is worth retrying.
|
|
351
|
+
const reason =
|
|
352
|
+
err.name === "TimeoutError"
|
|
353
|
+
? `no response within ${FIRE_TIMEOUT_MS / 1000}s`
|
|
354
|
+
: err.message;
|
|
355
|
+
throw new Error(`could not reach the routine endpoint: ${reason}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const body = await response.text();
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
// The token is the usual cause and the usual thing to leak, so the message
|
|
361
|
+
// names the status and the routine rather than echoing the request.
|
|
362
|
+
throw new Error(
|
|
363
|
+
`routine ${block.routineId} refused the dispatch (HTTP ${response.status}).\n` +
|
|
364
|
+
`${body.slice(0, 400)}\n` +
|
|
365
|
+
`A 401 means the bearer token is wrong, revoked, or regenerated.`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const { sessionId, url } = readFireResponse(body);
|
|
370
|
+
record(
|
|
371
|
+
{
|
|
372
|
+
taskId: sessionId,
|
|
373
|
+
surface: "claude-web",
|
|
374
|
+
routineId: block.routineId,
|
|
375
|
+
sessionUrl: url,
|
|
376
|
+
prompt,
|
|
377
|
+
payload,
|
|
378
|
+
dispatchedAt: new Date().toISOString(),
|
|
379
|
+
},
|
|
380
|
+
cwd
|
|
381
|
+
);
|
|
382
|
+
return { sessionId, url };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the dispatcher's own credential through the secrets chokepoint.
|
|
387
|
+
*
|
|
388
|
+
* Never read from configuration: the token authorises starting work on someone
|
|
389
|
+
* else's infrastructure, and it can be regenerated and revoked, so it belongs
|
|
390
|
+
* in the credential manager and in `secrets.rotating` alongside it.
|
|
391
|
+
* @param {object} block Surface configuration.
|
|
392
|
+
* @returns {string} The bearer token.
|
|
393
|
+
*/
|
|
394
|
+
function resolveBearerToken(block) {
|
|
395
|
+
const name = block.tokenKey ?? "CLAUDE_ROUTINE_TOKEN";
|
|
396
|
+
const resolver = resolve(
|
|
397
|
+
HERE,
|
|
398
|
+
"..",
|
|
399
|
+
"..",
|
|
400
|
+
"lisa-secrets-access",
|
|
401
|
+
"scripts",
|
|
402
|
+
"resolve-secret.mjs"
|
|
403
|
+
);
|
|
404
|
+
try {
|
|
405
|
+
return execFileSync("node", [resolver, "get", name], {
|
|
406
|
+
encoding: "utf8",
|
|
407
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
408
|
+
}).trim();
|
|
409
|
+
} catch (err) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`could not resolve ${name} through lisa-secrets-access.\n` +
|
|
412
|
+
`${String(err.stderr ?? err.message).trim()}\n` +
|
|
413
|
+
`It is the dispatcher's own credential; store it in the provider ` +
|
|
414
|
+
`rather than in .lisa.config.json.`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function main() {
|
|
234
420
|
const { skill, raw } = splitSkillFlag(process.argv.slice(2));
|
|
235
421
|
const { params, rest } = parseInvocation(raw);
|
|
236
422
|
const surface = resolveExecutionEnv(params);
|
|
@@ -247,6 +433,15 @@ function main() {
|
|
|
247
433
|
// the repository-local skill, so an interactive run, a scheduled run, and a
|
|
248
434
|
// recovery run all execute one contract.
|
|
249
435
|
const prompt = `$${skill} ${rest}`.trim();
|
|
436
|
+
|
|
437
|
+
if (surface === "claude-web") {
|
|
438
|
+
const { sessionId, url } = await dispatchClaudeWeb(block, prompt, rest);
|
|
439
|
+
console.log(`dispatched: ${sessionId}`);
|
|
440
|
+
if (url) console.log(url);
|
|
441
|
+
console.log(`recorded in ${LEDGER}; not polling — this process is done.`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
250
445
|
const taskId = dispatchCodexCloud(block, prompt, rest);
|
|
251
446
|
|
|
252
447
|
console.log(`dispatched: ${taskId}`);
|
|
@@ -255,10 +450,11 @@ function main() {
|
|
|
255
450
|
}
|
|
256
451
|
|
|
257
452
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
453
|
+
// Awaited rather than called bare: one dispatch path is async, so a synchronous
|
|
454
|
+
// try/catch would let a rejection escape as an unhandled rejection and exit 0 —
|
|
455
|
+
// reporting dispatched work that was never accepted.
|
|
456
|
+
main().catch(err => {
|
|
261
457
|
console.error(err.message);
|
|
262
458
|
process.exit(1);
|
|
263
|
-
}
|
|
459
|
+
});
|
|
264
460
|
}
|
|
@@ -13,11 +13,31 @@ Prepare a remote surface so a host project can execute there. Today that means *
|
|
|
13
13
|
The remote environment's own configuration fields stay **one line into the repository**. Nothing else is pasted into a vendor UI.
|
|
14
14
|
|
|
15
15
|
```text
|
|
16
|
-
setup:
|
|
17
|
-
maintenance:
|
|
16
|
+
setup: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
17
|
+
maintenance: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
**That line is identical for every project.** Nothing in it names the
|
|
20
|
+
**That line is identical for every project AND every surface.** Nothing in it names the
|
|
21
|
+
repository, the package manager, or a home directory, so it can be pasted unchanged anywhere.
|
|
22
|
+
|
|
23
|
+
Locating the checkout is the only hard part, because the surfaces disagree about where the
|
|
24
|
+
field runs:
|
|
25
|
+
|
|
26
|
+
| Surface | cwd when the field runs | Checkout |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| Codex Cloud | **is** the checkout | `/workspace/<repo>` |
|
|
29
|
+
| Claude Code web | `$HOME` | `$HOME/<repo>` |
|
|
30
|
+
|
|
31
|
+
So both candidates are tried **relative to cwd** — the checkout itself, then one level down —
|
|
32
|
+
and neither mentions `$HOME`. An earlier version of this field used
|
|
33
|
+
`bash "$HOME"/*/scripts/...`, which works on Claude Code web and fails on Codex Cloud, where
|
|
34
|
+
the checkout is not under `$HOME` at all: the glob matches nothing and bash reports
|
|
35
|
+
`No such file or directory` for a path still containing a literal `*`.
|
|
36
|
+
|
|
37
|
+
`exec` on the first hit means no subshell and no `ls` output to parse, and the explicit
|
|
38
|
+
`exit 1` means a missing entrypoint says so rather than the field silently succeeding. The
|
|
39
|
+
script then anchors itself on the repository root, so it behaves the same however it was
|
|
40
|
+
reached.
|
|
21
41
|
|
|
22
42
|
They are the same command. A container may be built fresh or resumed from cache; every step is idempotent and version-aware, so running it twice is correct, and running it on resume is what picks up a rotated value, an edited note, or a changed version pin.
|
|
23
43
|
|
|
@@ -136,8 +156,8 @@ When emitting, produce exactly:
|
|
|
136
156
|
```text
|
|
137
157
|
Environment name: <project> remote executor
|
|
138
158
|
Repository: <org>/<repo> (must be the default checkout)
|
|
139
|
-
Setup script:
|
|
140
|
-
Maintenance:
|
|
159
|
+
Setup script: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
160
|
+
Maintenance: for f in scripts/lisa-remote-env/setup.sh */scripts/lisa-remote-env/setup.sh; do [ -f "$f" ] && exec bash "$f"; done; echo "lisa-remote-env entrypoint not found under $PWD" >&2; exit 1
|
|
141
161
|
(identical for every project — the script finds the
|
|
142
162
|
checkout and installs from the committed lockfile)
|
|
143
163
|
Environment vars: LISA_SECRETS_SURFACE=codex-cloud
|