@edgehero/pi-dispatch 4.3.1 → 5.1.0
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/.env.example +84 -3
- package/README.md +21 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +118 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +136 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1657 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/envelope.mjs +5 -3
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +4 -2
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +12 -2
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +36 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +402 -53
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State this build still meets in the shape, or at the place, an older release left it (issue #761): one finding each,
|
|
3
|
+
* which doctor prints as `state.older` and `up`'s summary repeats for the Valkey container.
|
|
4
|
+
*
|
|
5
|
+
* THE RULE for every later release: one that moves or reshapes a store and keeps the old shape readable registers a
|
|
6
|
+
* detector here, gated so that it retires itself (it fires only while the old shape is really there, and says nothing
|
|
7
|
+
* once it is gone), and adds a row to docs/upgrading.md. A detector fires on an EXACT older reference, never on
|
|
8
|
+
* something an operator chose (an image they picked, a file they edited, a path they set): a warning that cannot go
|
|
9
|
+
* away, or that argues with a deliberate choice, is one nobody reads.
|
|
10
|
+
*
|
|
11
|
+
* No data store has an older shape yet. Every versioned file and record this build reads, it also still writes:
|
|
12
|
+
* versions are stamped by need (DES-STORE-VERSION-WHEN-NEEDED), so a version 1 file or record is what this build writes
|
|
13
|
+
* too while nothing in it needs more, and job data's version 2 and the scoped limits' 3 are stamped only where a field
|
|
14
|
+
* needs them. The older LOCATIONS of issues #290 and #464 (the run history and the settings overlay under the OS temp
|
|
15
|
+
* dir, retained workspaces under the old shared jobs root) keep their own lines in doctor's `state` and `dirs` sections.
|
|
16
|
+
*
|
|
17
|
+
* What is here: the Valkey image older releases shipped, the floating `valkey/valkey:8`, which this release pins as
|
|
18
|
+
* `VALKEY_IMAGE` (valkey-auth.mjs says why). An upgrade rewrites none of the three places that keep it:
|
|
19
|
+
* - a compose file in the deployment folder, which /dispatch setup copies create-only into `deploy/` (an earlier setup
|
|
20
|
+
* copied it to the folder's top);
|
|
21
|
+
* - the installed Quadlet unit, which only `service install --force` rewrites (`up` leaves a changed one as it is);
|
|
22
|
+
* - the `pi-dispatch-valkey` container an older `up` started, which keeps its image until it is recreated.
|
|
23
|
+
*
|
|
24
|
+
* Pure but for two seams, and it never throws: `readFile(path)` gives a file's text (a throw, a missing file, is no
|
|
25
|
+
* finding), and `valkeyContainer()` gives `valkeyContainerOwner`'s verdict on the `pi-dispatch-valkey` container
|
|
26
|
+
* (valkey-auth.mjs; null, a throw or no seam is no finding), which `upValkeyContainerImage` judges with the folder's
|
|
27
|
+
* `handedOver`, and which also decides whether the compose file's fix recreates compose's valkey. A detector that cannot
|
|
28
|
+
* read must not say something false.
|
|
29
|
+
*/
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { COMPOSE_FILE, COMPOSE_VALKEY_OVERRIDE, VALKEY_IMAGE, VALKEY_VOLUME, composeArgs, composeProjectName } from "./valkey-auth.mjs";
|
|
32
|
+
import { QUADLET_FILES, quadletDir } from "./podman-stack.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every Valkey image reference an older release shipped, as each place spelled it: compose and `up`'s `docker run` the
|
|
36
|
+
* short name, the Quadlet unit the fully qualified one. Matched EXACTLY, so `valkey/valkey:8.1`, `:8-alpine` and
|
|
37
|
+
* `:8@sha256:...` (each an operator's or this release's choice) never are. When `VALKEY_IMAGE` moves on, the value it
|
|
38
|
+
* leaves is added here, in both spellings; a test holds `VALKEY_IMAGE` out of this list.
|
|
39
|
+
*/
|
|
40
|
+
export const OLDER_VALKEY_IMAGES = Object.freeze(["valkey/valkey:8", "docker.io/valkey/valkey:8"]);
|
|
41
|
+
|
|
42
|
+
/** The container `up` starts Valkey as (`valkeyDockerRunArgs`), the one `valkeyContainer` gives the verdict on. */
|
|
43
|
+
export const VALKEY_CONTAINER = "pi-dispatch-valkey";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The image of the Valkey container `owner` describes (`valkeyContainerOwner`'s verdict, valkey-auth.mjs), when `up`
|
|
47
|
+
* runs that container itself, else null. THE one rule `up`'s summary and doctor's `state.older` both judge the container
|
|
48
|
+
* by, so neither names a container the other leaves alone:
|
|
49
|
+
* - it is provably this deployment's (`ours`: up's label naming this folder, compose's working dir here, or,
|
|
50
|
+
* unlabelled, this deployment's VALKEY_URL port on 127.0.0.1). Another deployment's container, or one nothing ties
|
|
51
|
+
* to this folder, is never stopped or removed from here, and the fix below does both;
|
|
52
|
+
* - and the folder is not handed to compose (`handedOver` false: no `COMPOSE_VALKEY_OVERRIDE`). There compose runs the
|
|
53
|
+
* Valkey and recreates it from the compose file, which its own detector judges, and `pi-dispatch up` starts compose's
|
|
54
|
+
* valkey, never a container of its own.
|
|
55
|
+
* Anything else, an unknown or absent verdict and a hand-over fact that is not a boolean included, is null: a fix that
|
|
56
|
+
* could act on the wrong container is worse than no finding.
|
|
57
|
+
*/
|
|
58
|
+
export function upValkeyContainerImage(owner, { handedOver } = {}) {
|
|
59
|
+
if (!upRunsValkey(owner, { handedOver })) return null;
|
|
60
|
+
return typeof owner.image === "string" ? owner.image : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether this deployment's `up` runs its Valkey as that container, whatever its image: the first half of
|
|
65
|
+
* `upValkeyContainerImage`'s rule. Then compose does not run this deployment's Valkey, so the compose file's fix must
|
|
66
|
+
* not recreate compose's valkey, which would start a second, empty one beside the queue.
|
|
67
|
+
*/
|
|
68
|
+
function upRunsValkey(owner, { handedOver } = {}) {
|
|
69
|
+
return owner?.ours === true && handedOver === false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The legacy place an earlier /dispatch setup copied the compose file to, relative to the deployment folder. */
|
|
73
|
+
export const LEGACY_COMPOSE_FILE = "docker-compose.yml";
|
|
74
|
+
|
|
75
|
+
// Said by every fix below, as `podman-stack.mjs` says it for a Valkey restart: the cost, and how to avoid it.
|
|
76
|
+
const INTERRUPTS = "A job running right now is interrupted: pause first if one is (pi-dispatch pause, wait for active jobs, then pi-dispatch resume)";
|
|
77
|
+
const PINS = `this release pins ${VALKEY_IMAGE}`;
|
|
78
|
+
|
|
79
|
+
/** `ref` when it is exactly one of `OLDER_VALKEY_IMAGES` (surrounding whitespace aside), else null. */
|
|
80
|
+
export function olderValkeyImage(ref) {
|
|
81
|
+
const s = typeof ref === "string" ? ref.trim() : "";
|
|
82
|
+
return OLDER_VALKEY_IMAGES.includes(s) ? s : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every `image:` of the compose file's `valkey` service (the top-level `services.valkey` block), quotes and a trailing
|
|
87
|
+
* comment aside, in file order, or none. That service's only: another service on `valkey/valkey:8` (a cache an operator
|
|
88
|
+
* added beside the queue, say) is the operator's choice, and a finding about it is one no upgrade step clears. A line
|
|
89
|
+
* scan by indentation rather than a YAML parse, since the only question is which image the queue's Valkey names:
|
|
90
|
+
* `services:` at the top, `valkey:` one level under it, and `image:` one level under that, where a nested map's
|
|
91
|
+
* `image:`, a comment and a blank line are not it. A spelling the scan does not follow (a flow mapping, an anchor, a
|
|
92
|
+
* quoted key) is no finding, never a false one.
|
|
93
|
+
*
|
|
94
|
+
* A merge left unresolved in the service (git's conflict markers at column 0, which would otherwise end the block as a
|
|
95
|
+
* top-level key does) is read through: the marker lines count as blank and EVERY `image:` at the service's own level
|
|
96
|
+
* counts, so a conflict between `:8` and `:8.1` is a finding, whichever side holds the older line. Under git's diff3 and
|
|
97
|
+
* zdiff3 styles the merge base sits between a `|||||||` marker and the `=======`: it is neither side, so it counts as
|
|
98
|
+
* blank too, and an older line in the base alone is no finding. The markers are read inside the service only; elsewhere
|
|
99
|
+
* they still end the scan, as any top-level line does.
|
|
100
|
+
*/
|
|
101
|
+
function composeValkeyImages(text) {
|
|
102
|
+
const lines = String(text).split(/\r?\n/);
|
|
103
|
+
const depth = (line) => /^[ \t]*/.exec(line)[0].length;
|
|
104
|
+
const said = (line) => !/^\s*(?:#.*)?$/.test(line);
|
|
105
|
+
// git's markers: seven of one character at column 0, every one but `=======` followed by a name (a side, or the base).
|
|
106
|
+
const conflictMarker = (line) => /^(?:(?:<{7}|>{7}|\|{7})(?:[ \t].*)?|={7}[ \t]*)$/.test(line);
|
|
107
|
+
const baseMarker = (line) => /^\|{7}(?:[ \t].*)?$/.test(line);
|
|
108
|
+
const sidesMarker = (line) => /^={7}[ \t]*$/.test(line);
|
|
109
|
+
let i = lines.findIndex((line) => /^services:\s*(?:#.*)?$/.test(line));
|
|
110
|
+
if (i < 0) return [];
|
|
111
|
+
// The services, at the depth of the first one, until the next top-level key.
|
|
112
|
+
let services = null;
|
|
113
|
+
for (i += 1; i < lines.length; i++) {
|
|
114
|
+
if (!said(lines[i])) continue;
|
|
115
|
+
const d = depth(lines[i]);
|
|
116
|
+
if (d === 0) return [];
|
|
117
|
+
services ??= d;
|
|
118
|
+
if (d === services && /^\s*valkey:\s*(?:#.*)?$/.test(lines[i])) break;
|
|
119
|
+
}
|
|
120
|
+
// The valkey service's own keys, at the depth of its first one, until the next service.
|
|
121
|
+
const images = [];
|
|
122
|
+
let keys = null;
|
|
123
|
+
// Inside a conflict's merge base, from its `|||||||` to the `=======`.
|
|
124
|
+
let inBase = false;
|
|
125
|
+
for (i += 1; i < lines.length; i++) {
|
|
126
|
+
if (baseMarker(lines[i])) inBase = true;
|
|
127
|
+
else if (sidesMarker(lines[i])) inBase = false;
|
|
128
|
+
if (inBase || !said(lines[i]) || conflictMarker(lines[i])) continue;
|
|
129
|
+
const d = depth(lines[i]);
|
|
130
|
+
if (d <= services) break;
|
|
131
|
+
keys ??= d;
|
|
132
|
+
const m = d === keys ? /^\s*image:\s*(["']?)([^"'\s#]+)\1\s*(?:#.*)?$/.exec(lines[i]) : null;
|
|
133
|
+
if (m) images.push(m[2]);
|
|
134
|
+
}
|
|
135
|
+
return images;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The value of each `Image=` line of a unit file (systemd: a `#` or `;` line is a comment, the value is trimmed). */
|
|
139
|
+
function quadletImages(text) {
|
|
140
|
+
const found = [];
|
|
141
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
142
|
+
const m = /^\s*Image\s*=\s*(.*?)\s*$/.exec(line);
|
|
143
|
+
if (m) found.push(m[1]);
|
|
144
|
+
}
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A file's text through the seam, or null when it cannot be read (absent above all). */
|
|
149
|
+
async function textOf(readFile, path) {
|
|
150
|
+
try {
|
|
151
|
+
const text = await readFile(path);
|
|
152
|
+
return text === null || text === undefined ? null : String(text);
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The verdict `valkeyContainer()` gives, synchronously or not, or null for no seam or one that throws. */
|
|
159
|
+
async function verdictOf(valkeyContainer) {
|
|
160
|
+
if (typeof valkeyContainer !== "function") return null;
|
|
161
|
+
try {
|
|
162
|
+
return await valkeyContainer();
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The finding for the `pi-dispatch-valkey` container, or null: `upValkeyContainerImage` of the verdict
|
|
170
|
+
* `valkeyContainer()` gives and `handedOver`, held against the older images. Exported apart for `up`, which hands it
|
|
171
|
+
* the verdict of the inspect it already made and repeats this in its summary. `valkeyContainer` may answer
|
|
172
|
+
* synchronously or not.
|
|
173
|
+
*/
|
|
174
|
+
export async function olderValkeyContainerFinding({ valkeyContainer, handedOver } = {}) {
|
|
175
|
+
return containerFinding(await verdictOf(valkeyContainer), handedOver);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function containerFinding(owner, handedOver) {
|
|
179
|
+
const found = olderValkeyImage(upValkeyContainerImage(owner, { handedOver }));
|
|
180
|
+
if (!found) return null;
|
|
181
|
+
return {
|
|
182
|
+
store: "valkey-container",
|
|
183
|
+
label: `the ${VALKEY_CONTAINER} container runs ${found}, the floating tag an older \`pi-dispatch up\` started it with, and keeps it until it is recreated; ${PINS}`,
|
|
184
|
+
// stop, then rm, never `rm -f`: SIGTERM is what makes Valkey fsync its AOF and save a final snapshot before it exits
|
|
185
|
+
// (measured on 8.0.2 and 8.1.11), where `rm -f` kills it and can lose the last second of writes to the queue.
|
|
186
|
+
fix: `\`docker stop ${VALKEY_CONTAINER} && docker rm ${VALKEY_CONTAINER} && pi-dispatch up\` in the deployment folder starts ${VALKEY_IMAGE} on the same ${VALKEY_VOLUME} volume (stopped first, so Valkey writes its AOF out; the queue is kept). ${INTERRUPTS}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The first of a compose file's valkey images (`composeValkeyImages`) that is an older one, or null. */
|
|
191
|
+
const olderComposeImage = (text) => composeValkeyImages(text).map(olderValkeyImage).find(Boolean) ?? null;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Every finding for this deployment, in a fixed order: the folder's compose file, the earlier setup's copy at the
|
|
195
|
+
* folder's top, the installed Quadlet unit, the container. `cwd` is the deployment folder, `home` this account's home
|
|
196
|
+
* (where the Quadlet unit is installed); either missing skips its detectors. `handedOver` is whether the folder holds
|
|
197
|
+
* `COMPOSE_VALKEY_OVERRIDE`, as `up` reads it. The container's verdict is asked once, and decides the compose fix too:
|
|
198
|
+
* where this deployment's `up` runs the Valkey (`upRunsValkey`), the compose file only needs its line changed, since
|
|
199
|
+
* recreating compose's valkey would start a second, empty Valkey beside the queue. Where compose runs it, or the verdict
|
|
200
|
+
* cannot tell (no seam, a throw, a container that is not provably ours), the fix keeps the recreate.
|
|
201
|
+
*/
|
|
202
|
+
export async function olderStateFindings({ cwd, home, readFile, valkeyContainer, handedOver } = {}) {
|
|
203
|
+
const findings = [];
|
|
204
|
+
try {
|
|
205
|
+
const owner = await verdictOf(valkeyContainer);
|
|
206
|
+
if (typeof readFile === "function" && typeof cwd === "string" && cwd !== "") {
|
|
207
|
+
const path = join(cwd, COMPOSE_FILE);
|
|
208
|
+
const found = olderComposeImage((await textOf(readFile, path)) ?? "");
|
|
209
|
+
if (found) {
|
|
210
|
+
// The command /dispatch setup runs for this folder: its project, and the hand-over override where it wrote one.
|
|
211
|
+
const project = composeProjectName(cwd);
|
|
212
|
+
const override = (await textOf(readFile, join(cwd, COMPOSE_VALKEY_OVERRIDE))) !== null;
|
|
213
|
+
const line = `change that line to \`image: ${VALKEY_IMAGE}\` (the installed package's ${COMPOSE_FILE} has it)`;
|
|
214
|
+
findings.push({
|
|
215
|
+
store: "valkey-compose",
|
|
216
|
+
label: `${path} names ${found} for Valkey, the floating tag an older pi-dispatch shipped, which moves this host to a newer Valkey minor on its next pull; ${PINS}`,
|
|
217
|
+
fix: upRunsValkey(owner, { handedOver })
|
|
218
|
+
? `${line} and nothing more: this deployment's Valkey is the ${VALKEY_CONTAINER} container \`pi-dispatch up\` runs, not compose's, so recreating compose's valkey from this file would start a second, empty Valkey beside the queue`
|
|
219
|
+
: `${line}, then recreate Valkey from ${cwd} with \`docker ${composeArgs({ project, override }).join(" ")} up -d valkey\` (leave out \`-p ${project}\` if you run this file without it), which stops it first, so it writes its AOF out, and keeps its volume and the queue in it. ${INTERRUPTS}`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const legacy = join(cwd, LEGACY_COMPOSE_FILE);
|
|
223
|
+
const old = olderComposeImage((await textOf(readFile, legacy)) ?? "");
|
|
224
|
+
if (old) {
|
|
225
|
+
findings.push({
|
|
226
|
+
store: "valkey-compose",
|
|
227
|
+
label: `${legacy} names ${old} for Valkey, the floating tag an older pi-dispatch shipped; ${PINS}`,
|
|
228
|
+
fix: `that file is an earlier /dispatch setup's copy, which setup no longer runs (${COMPOSE_FILE} is the one, and this copy's ../ paths reach the folder above ${cwd}): move any edits of yours there and remove it, or, if you still run it, change that line to \`image: ${VALKEY_IMAGE}\``,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (typeof readFile === "function" && typeof home === "string" && home !== "") {
|
|
233
|
+
const path = join(quadletDir(home), QUADLET_FILES.valkey.file);
|
|
234
|
+
const found = quadletImages((await textOf(readFile, path)) ?? "").map(olderValkeyImage).find(Boolean);
|
|
235
|
+
if (found) {
|
|
236
|
+
findings.push({
|
|
237
|
+
store: "valkey-quadlet",
|
|
238
|
+
label: `${path} runs Valkey as ${found}, the floating tag an older pi-dispatch installed; this release pins docker.io/${VALKEY_IMAGE}`,
|
|
239
|
+
fix: `\`pi-dispatch service install --force\` in the deployment folder rewrites the unit and restarts ${QUADLET_FILES.valkey.unit} on the same ${VALKEY_VOLUME} volume (stopped first, so Valkey writes its AOF out; the queue is kept); \`pi-dispatch up\` leaves a changed unit file as it is. ${INTERRUPTS}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const container = containerFinding(owner, handedOver);
|
|
244
|
+
if (container) findings.push(container);
|
|
245
|
+
} catch {
|
|
246
|
+
// Nothing above throws by design (every read goes through a guarded seam); a finding already made still stands.
|
|
247
|
+
}
|
|
248
|
+
return findings;
|
|
249
|
+
}
|
package/src/on-failure.mjs
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* is the right event regardless).
|
|
12
12
|
* - **No verdict, no fault count, no profile table.** The hook changes NOTHING about the job: it runs
|
|
13
13
|
* after the outcome is decided, fire and forget, and a hook fault must never flip a result
|
|
14
|
-
* (CONST-RETRY-INFRA-ONLY). Its exit code is logged and
|
|
14
|
+
* (CONST-RETRY-INFRA-ONLY). Its exit code is logged and published for display only (#775).
|
|
15
15
|
* - **The reason is shape-guarded HERE, the single normalizing door.** `InfraRetry.reason` defaults to
|
|
16
16
|
* the whole message when no token was given, and messages carry things like a library's own words --
|
|
17
|
-
* so anything that
|
|
18
|
-
*
|
|
17
|
+
* so anything that is not a record reason token (`RECORD_REASON_RE`, the run record's own rule, from
|
|
18
|
+
* its leaf: so a reason the record keeps reaches the hook as itself, never as "infra") flattens to
|
|
19
|
+
* "infra" before it can become an argv element. Id-only argv is the contract
|
|
19
20
|
* (`INT-ON-FAILURE-HOOK-CONTRACT`), and one guard at the spawn is worth ten at the call sites.
|
|
20
21
|
*
|
|
21
22
|
* What transfers unchanged: the executable resolved at CALL time (an operator who fixes the path
|
|
@@ -26,28 +27,31 @@
|
|
|
26
27
|
|
|
27
28
|
import { realpathSync, statSync } from "node:fs";
|
|
28
29
|
import { spawn } from "node:child_process";
|
|
30
|
+
import { RECORD_REASON_RE } from "./record-reason.mjs";
|
|
29
31
|
|
|
30
32
|
/** SIGTERM, then SIGKILL after this. secrets.mjs's grace, for its reason. */
|
|
31
33
|
const KILL_GRACE_MS = 2000;
|
|
32
34
|
|
|
33
|
-
/** A reason that may ride argv: a fixed lowercase token, never a message. */
|
|
34
|
-
const REASON_SHAPE = /^[a-z0-9-]{1,40}$/;
|
|
35
|
-
|
|
36
35
|
/**
|
|
37
36
|
* Build the hook runner. Returns `fire({ jobId, outcome, reason })` -> void: spawns the operator's
|
|
38
37
|
* command as `cmd <jobId> <outcome> <reason> <host>`, logs one `on_failure` line with the pinned key
|
|
39
38
|
* set { jobId, code, detail }, and never throws, rejects, or delays anything.
|
|
40
39
|
*/
|
|
41
|
-
export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, realExecutablePath = defaultRealExecutablePath, hostEnv = process.env, host = "", log = () => {} }) {
|
|
42
|
-
const note = (jobId, code, detail) => {
|
|
40
|
+
export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, realExecutablePath = defaultRealExecutablePath, hostEnv = process.env, host = "", log = () => {}, publish = () => {} }) {
|
|
41
|
+
const note = (jobId, code, detail, outcome, reason) => {
|
|
43
42
|
try {
|
|
44
43
|
log("on_failure", { jobId, code, detail });
|
|
45
44
|
} catch {
|
|
46
45
|
// an injected sink that throws must not break the fire-and-forget contract
|
|
47
46
|
}
|
|
47
|
+
// The log's existing keys and the hook's return value stay unchanged. Independent guards mean a broken log
|
|
48
|
+
// sink does not silence the display, and a display fault cannot become a job retry or delay another hook.
|
|
49
|
+
try { void Promise.resolve(publish({ jobId, outcome, reason, detail, code })).catch(() => {}); } catch {}
|
|
48
50
|
};
|
|
49
51
|
|
|
50
52
|
return function fire({ jobId, outcome, reason }) {
|
|
53
|
+
const safeReason = typeof reason === "string" && RECORD_REASON_RE.test(reason) ? reason : "infra";
|
|
54
|
+
const finishNote = (code, detail) => note(typeof jobId === "string" ? jobId : null, code, detail, outcome, safeReason);
|
|
51
55
|
// Resolved at CALL time, not construction (wait-check's rule): realpath + the executable bit,
|
|
52
56
|
// answering the same question spawn will.
|
|
53
57
|
let path;
|
|
@@ -56,14 +60,14 @@ export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, re
|
|
|
56
60
|
} catch {
|
|
57
61
|
path = null;
|
|
58
62
|
}
|
|
59
|
-
if (!path) return
|
|
63
|
+
if (!path) return finishNote(null, "unresolvable");
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
// A reason rides argv only as a record reason token (the record's own rule), never as a message.
|
|
62
66
|
// Argv must be non-empty id-only strings, and none may start with a dash -- this is argv where a
|
|
63
67
|
// dash parses as a flag, and the option parser is the operator's. `host` may honestly be "".
|
|
64
68
|
const argv = [jobId, outcome, safeReason, host];
|
|
65
69
|
if (argv.slice(0, 3).some((a) => typeof a !== "string" || a === "") || argv.some((a) => typeof a === "string" && a.startsWith("-"))) {
|
|
66
|
-
return
|
|
70
|
+
return finishNote(null, "argv-unusable");
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
let child;
|
|
@@ -74,7 +78,7 @@ export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, re
|
|
|
74
78
|
shell: false,
|
|
75
79
|
});
|
|
76
80
|
} catch {
|
|
77
|
-
return
|
|
81
|
+
return finishNote(null, "spawn");
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
let done = false;
|
|
@@ -84,7 +88,7 @@ export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, re
|
|
|
84
88
|
if (done) return;
|
|
85
89
|
done = true;
|
|
86
90
|
clearTimeout(timer);
|
|
87
|
-
|
|
91
|
+
finishNote(code, detail);
|
|
88
92
|
};
|
|
89
93
|
// The kill ladder: SIGTERM, and SIGKILL for a child that ignored it. The escalation timer is
|
|
90
94
|
// deliberately not cleared by finish -- it still has to land on a stubborn child -- and both
|
package/src/open-browser.mjs
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The opener's argv for `url` on `platform`. On win32 it is `rundll32 url.dll,FileProtocolHandler <url>`, the shell's
|
|
5
|
+
* own URL handler, and NOT `cmd /c start "" <url>` (what it was until issue #708): `cmd` re-parses its command line,
|
|
6
|
+
* so an unquoted `&` ends the command there (`start` opened the URL up to it and `cmd` ran the rest as a second
|
|
7
|
+
* command), and node quotes an argument only when it holds a space or a quote, which a URL never does. A login link
|
|
8
|
+
* (`login/code#<code>&next=/runs`) and a GitHub install URL both carry one. rundll32 is no shell: it hands the rest of
|
|
9
|
+
* its command line to the handler as it is, `&` and `#` included. Quoting the URL inside `start` was rejected because
|
|
10
|
+
* it relies on node's quoting rules and `cmd`'s together, and `^`-escaping because `cmd /c` and `start` each strip one
|
|
11
|
+
* level.
|
|
12
|
+
*/
|
|
13
|
+
export function openerArgv(url, platform = process.platform) {
|
|
14
|
+
if (platform === "darwin") return ["open", [url]];
|
|
15
|
+
if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
16
|
+
return ["xdg-open", [url]];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Can a browser opened here be in front of the person at this terminal (issue #708)? Not over SSH (any of
|
|
21
|
+
* `SSH_CONNECTION`, `SSH_CLIENT` or `SSH_TTY` set: the browser would open on the remote host, or nowhere), and on
|
|
22
|
+
* Linux and the BSDs only with a display (`DISPLAY` or `WAYLAND_DISPLAY`: without one `xdg-open` falls back to a text
|
|
23
|
+
* browser or fails). macOS and Windows always have their desktop's opener. A false answer means the caller prints the
|
|
24
|
+
* URL and spawns nothing; the printed URL is the contract either way.
|
|
25
|
+
*/
|
|
26
|
+
export function isLocalBrowserSession({ env = process.env, platform = process.platform } = {}) {
|
|
27
|
+
const set = (k) => typeof env?.[k] === "string" && env[k] !== "";
|
|
28
|
+
if (set("SSH_CONNECTION") || set("SSH_CLIENT") || set("SSH_TTY")) return false;
|
|
29
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
30
|
+
return set("DISPLAY") || set("WAYLAND_DISPLAY");
|
|
31
|
+
}
|
|
32
|
+
|
|
3
33
|
/**
|
|
4
34
|
* Best-effort platform browser opener. ALWAYS paired with the URL printed to the terminal -- a
|
|
5
35
|
* headless or SSH'd operator has no opener that works, and the printed URL pasted into any browser
|
|
@@ -12,8 +42,7 @@ import { spawn as nodeSpawn } from "node:child_process";
|
|
|
12
42
|
* without launching anything.
|
|
13
43
|
*/
|
|
14
44
|
export function openBrowser(url, { spawn = nodeSpawn, platform = process.platform } = {}) {
|
|
15
|
-
const [cmd, args] =
|
|
16
|
-
platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
45
|
+
const [cmd, args] = openerArgv(url, platform);
|
|
17
46
|
try {
|
|
18
47
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
19
48
|
child.on("error", () => {});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** An explicit import enrolls trusted Fleet mappings. A link never changes configuration. */
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { execFile as nodeExecFile, spawn as nodeSpawn } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join, resolve, win32 } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { parseSandboxOpenUrl } from "./opener-protocol.mjs";
|
|
11
|
+
import { readSandboxHosts, sandboxCommand } from "./sandbox-hosts.mjs";
|
|
12
|
+
import { spawnTerminal, terminalLaunch, windowsPowerShellEnv } from "./opener-terminal.mjs";
|
|
13
|
+
import { trustedChain } from "./service-env.mjs";
|
|
14
|
+
|
|
15
|
+
// env-internal XDG_CONFIG_HOME, HOME, USERPROFILE: the native handler belongs to the current account and uses its config directory.
|
|
16
|
+
export function openerDirectory(env = process.env) { return join(env.XDG_CONFIG_HOME || join(env.HOME || env.USERPROFILE || homedir(), ".config"), "pi-dispatch", "opener"); }
|
|
17
|
+
const apple = (value) => `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
18
|
+
// Desktop-entry values unescape first, then Exec unquotes. Both layers need their own backslash.
|
|
19
|
+
const desktop = (value) => `"${String(value).replaceAll("%", "%%").replace(/[\\"`$]/g, "\\$&").replaceAll("\\", "\\\\")}"`;
|
|
20
|
+
|
|
21
|
+
export function openerRegistration({ platform, home, directory, nodePath, cliPath, dataHome = join(home, ".local", "share") }) {
|
|
22
|
+
if (platform === "darwin") {
|
|
23
|
+
const app = join(home, "Applications", "pi-dispatch Opener.app");
|
|
24
|
+
const source = `on open location theURL\n do shell script (quoted form of ${apple(nodePath)} & " " & quoted form of ${apple(cliPath)} & " opener handle " & quoted form of theURL)\nend open location\n`;
|
|
25
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>CFBundleExecutable</key><string>applet</string><key>CFBundleIdentifier</key><string>com.pi-dispatch.opener</string><key>CFBundleName</key><string>pi-dispatch Opener</string><key>CFBundlePackageType</key><string>APPL</string><key>LSUIElement</key><true/><key>CFBundleURLTypes</key><array><dict><key>CFBundleTypeRole</key><string>Editor</string><key>CFBundleURLName</key><string>pi-dispatch sandbox</string><key>CFBundleURLSchemes</key><array><string>pi-dispatch-open</string></array></dict></array></dict></plist>\n`;
|
|
26
|
+
// osacompile signs its bundle. Our URL metadata changes its signed resources, so sign again after the
|
|
27
|
+
// plist write; registering a modified signature can compile cleanly and still refuse at native launch.
|
|
28
|
+
return { commands: [["/usr/bin/osacompile", ["-o", app, "-e", source]], ["/usr/bin/codesign", ["--force", "--sign", "-", app]], ["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", app]]], between: { path: join(app, "Contents", "Info.plist"), content: plist }, directory: dirname(app) };
|
|
29
|
+
}
|
|
30
|
+
if (platform === "linux") {
|
|
31
|
+
if (nodePath.includes("=")) throw new Error("the desktop Exec format cannot name a Node executable whose path contains '='; use Copy or install Node at another path");
|
|
32
|
+
if (!dataHome.startsWith("/") || /[\x00-\x1f\x7f]/.test(dataHome)) throw new Error("XDG_DATA_HOME must be an absolute desktop data directory without control bytes");
|
|
33
|
+
const path = join(dataHome, "applications", "pi-dispatch-opener.desktop");
|
|
34
|
+
const content = `[Desktop Entry]\nType=Application\nName=pi-dispatch sandbox opener\nNoDisplay=true\nTerminal=false\nExec=${desktop(nodePath)} ${desktop(cliPath)} opener handle %u\nMimeType=x-scheme-handler/pi-dispatch-open;\n`;
|
|
35
|
+
return { files: [{ path, content }], commands: [["xdg-mime", ["default", "pi-dispatch-opener.desktop", "x-scheme-handler/pi-dispatch-open"]]], directory: dirname(path) };
|
|
36
|
+
}
|
|
37
|
+
if (platform === "win32") {
|
|
38
|
+
// HKCU registration requires no elevation. reg.exe takes values as argv, never through cmd.exe.
|
|
39
|
+
const key = "HKCU\\Software\\Classes\\pi-dispatch-open";
|
|
40
|
+
// ShellExecute expands percent placeholders in this registry template, even inside quoted paths.
|
|
41
|
+
// Escape only the fixed paths; the final %1 remains the single URI substitution.
|
|
42
|
+
const quoted = (value) => `"${String(value).replaceAll("%", "%%").replaceAll('"', '\\"')}"`;
|
|
43
|
+
return { commands: [["reg.exe", ["add", key, "/ve", "/d", "URL:pi-dispatch sandbox opener", "/f"]], ["reg.exe", ["add", key, "/v", "URL Protocol", "/d", "", "/f"]], ["reg.exe", ["add", `${key}\\shell\\open\\command`, "/ve", "/d", `${quoted(nodePath)} ${quoted(cliPath)} opener handle "%1"`, "/f"]]] };
|
|
44
|
+
}
|
|
45
|
+
throw new Error("this operating system has no supported opener installer; copy the sandbox command instead");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A blank line is the entire approval vocabulary. A pasted command, y, Escape or EOF never runs anything. */
|
|
49
|
+
export async function promptSandboxCommand(command, { input = process.stdin, output = process.stdout, spawn = nodeSpawn, isTty = Boolean(input.isTTY && output.isTTY) } = {}) {
|
|
50
|
+
if (!isTty) return { refused: true, message: "the opener needs a real terminal; copy the command into your terminal instead" };
|
|
51
|
+
output.write(`\n${command.display}\n\nPress Enter to run this command, or type anything to cancel.\n`);
|
|
52
|
+
const accepted = await new Promise((resolveAnswer) => {
|
|
53
|
+
const lines = createInterface({ input, output, terminal: false });
|
|
54
|
+
let answered = false;
|
|
55
|
+
lines.once("line", (line) => { answered = true; lines.close(); resolveAnswer(line === ""); });
|
|
56
|
+
lines.once("close", () => { if (!answered) resolveAnswer(false); });
|
|
57
|
+
});
|
|
58
|
+
if (!accepted) return { cancelled: true, code: 0 };
|
|
59
|
+
const code = await new Promise((resolveCode, reject) => {
|
|
60
|
+
const child = spawn(command.argv[0], command.argv.slice(1), { stdio: "inherit" });
|
|
61
|
+
child.once("error", reject);
|
|
62
|
+
child.once("exit", (code) => resolveCode(code ?? 1));
|
|
63
|
+
});
|
|
64
|
+
return { code };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runOpener(argv = [], { env = process.env, deps = {} } = {}) {
|
|
68
|
+
// env-internal HOME, USERPROFILE: the current account's home, for current-user native protocol registration.
|
|
69
|
+
const { fs = nodeFs, platform = process.platform, home = env.HOME || env.USERPROFILE || homedir(), nodePath = process.execPath, cliPath = fileURLToPath(new URL("./cli.mjs", import.meta.url)), out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s), execFile = promisify(nodeExecFile), terminal = null, showTerminal = spawnTerminal, prompt = promptSandboxCommand, uid = process.geteuid?.() } = deps;
|
|
70
|
+
const directory = openerDirectory(env);
|
|
71
|
+
const configPath = join(directory, "hosts.json");
|
|
72
|
+
try {
|
|
73
|
+
if (argv[0] === "install") {
|
|
74
|
+
if (argv.length !== 3 || argv[1] !== "--config") throw new Error("usage: pi-dispatch opener install --config <approved Fleet export.json>");
|
|
75
|
+
const source = readSandboxHosts(resolve(argv[2]), { fs, uid, allowMissing: false });
|
|
76
|
+
if (source.refusal) throw new Error(source.refusal);
|
|
77
|
+
if (!Object.keys(source.config.hosts).length) throw new Error("the approved Fleet export has no sandbox hosts");
|
|
78
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
79
|
+
const configStat = fs.lstatSync(directory);
|
|
80
|
+
if (!configStat.isDirectory() || configStat.isSymbolicLink() || (Number.isInteger(uid) && (configStat.uid !== uid || trustedChain(fs.realpathSync(directory), { fs, uid })))) throw new Error("the opener configuration folder must belong to this account, have a trusted parent and cannot be a link");
|
|
81
|
+
fs.chmodSync(directory, 0o700);
|
|
82
|
+
if (platform === "win32") {
|
|
83
|
+
// chmod does not express a Windows DACL. Protect the imported mapping using this account's SID,
|
|
84
|
+
// independently of a directory's inherited grants; the URL never supplies the principal or path.
|
|
85
|
+
const identity = await execFile("whoami.exe", ["/user", "/fo", "csv", "/nh"], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
86
|
+
const sid = /S-1-\d+(?:-\d+)+/.exec(identity.stdout ?? "")?.[0];
|
|
87
|
+
if (!sid) throw new Error("the Windows account SID could not be resolved; the opener configuration was not imported");
|
|
88
|
+
// /grant:r replaces only this SID's grants. Reset first so a pre-existing explicit Everyone ACE is
|
|
89
|
+
// not retained beside it; removing inheritance then leaves only the grant below on this directory.
|
|
90
|
+
await execFile("icacls.exe", [directory, "/reset"], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
91
|
+
await execFile("icacls.exe", [directory, "/inheritance:r", "/grant:r", `*${sid}:(OI)(CI)F`], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
92
|
+
}
|
|
93
|
+
const temporary = join(directory, `import-${randomBytes(12).toString("hex")}.json`);
|
|
94
|
+
try {
|
|
95
|
+
fs.writeFileSync(temporary, JSON.stringify(source.config, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
96
|
+
fs.renameSync(temporary, configPath);
|
|
97
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
98
|
+
// env-internal XDG_DATA_HOME: xdg-mime and xdg-open look up desktop handlers in this same OS data directory.
|
|
99
|
+
const plan = openerRegistration({ platform, home, directory, nodePath, cliPath, dataHome: env.XDG_DATA_HOME || join(home, ".local", "share") });
|
|
100
|
+
if (plan.directory) {
|
|
101
|
+
fs.mkdirSync(plan.directory, { recursive: true, mode: 0o700 });
|
|
102
|
+
if (Number.isInteger(uid) && trustedChain(fs.realpathSync(plan.directory), { fs, uid })) throw new Error("the native handler directory is in a folder another account can change");
|
|
103
|
+
}
|
|
104
|
+
for (const file of plan.files ?? []) fs.writeFileSync(file.path, file.content, { mode: 0o600 });
|
|
105
|
+
for (let i = 0; i < plan.commands.length; i++) {
|
|
106
|
+
const [bin, args] = plan.commands[i];
|
|
107
|
+
// The MIME registration and the desktop entry must use the same XDG directories.
|
|
108
|
+
await execFile(bin, args, { env, timeout: 30_000, maxBuffer: 1024 * 1024 });
|
|
109
|
+
if (i === 0 && plan.between) fs.writeFileSync(plan.between.path, plan.between.content, { mode: 0o600 });
|
|
110
|
+
}
|
|
111
|
+
if (platform === "win32") {
|
|
112
|
+
// Windows requires SHCNE_ASSOCCHANGED for registered protocols too; writing HKCU alone can leave
|
|
113
|
+
// the Shell's old association cached until reboot. No URL or configurable text enters this script.
|
|
114
|
+
const notification = `Add-Type -Namespace PiDispatch -Name ShellChange -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll")] public static extern void SHChangeNotify(int eventId, uint flags, System.IntPtr item1, System.IntPtr item2);'; [PiDispatch.ShellChange]::SHChangeNotify(0x08000000, 0x1000, [System.IntPtr]::Zero, [System.IntPtr]::Zero)`;
|
|
115
|
+
// env-internal SystemRoot: Windows' own PowerShell binary for its Shell association notification.
|
|
116
|
+
await execFile(win32.join(env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoProfile", "-NonInteractive", "-Command", notification], { env: windowsPowerShellEnv(env), timeout: 30_000, maxBuffer: 64 * 1024 });
|
|
117
|
+
}
|
|
118
|
+
out("installed pi-dispatch-open for this account; commands wait for Enter in the terminal\n");
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
if (!["handle", "prompt"].includes(argv[0]) || argv.length !== 2) throw new Error("usage: pi-dispatch opener install --config <file> (or handle <pi-dispatch-open://link>)");
|
|
122
|
+
const fields = parseSandboxOpenUrl(argv[1]);
|
|
123
|
+
const stored = readSandboxHosts(configPath, { fs, uid, allowMissing: false });
|
|
124
|
+
if (stored.refusal) throw new Error("the local opener has no usable approved configuration; run pi-dispatch opener install --config <Fleet export.json>");
|
|
125
|
+
const command = sandboxCommand(stored.config, fields);
|
|
126
|
+
if (argv[0] === "prompt") {
|
|
127
|
+
const result = await prompt(command);
|
|
128
|
+
if (result.refused) throw new Error(result.message);
|
|
129
|
+
return result.code ?? 0;
|
|
130
|
+
}
|
|
131
|
+
const plan = terminalLaunch({ platform, nodePath, cliPath, url: argv[1], directory: join(directory, "prompts"), env, fs, ...(terminal ? { terminal } : {}) });
|
|
132
|
+
await showTerminal(plan);
|
|
133
|
+
return 0;
|
|
134
|
+
} catch (error) { err(`opener: ${error.message}\n`); return 1; }
|
|
135
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Secret-free terminal handoff. No URL field is ever interpreted as a command or configuration. */
|
|
2
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
3
|
+
|
|
4
|
+
export const SANDBOX_OPEN_VERBS = Object.freeze(["open", "attach"]);
|
|
5
|
+
export const SANDBOX_OPEN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
6
|
+
export function sandboxOpenFields({ verb, id, host } = {}) {
|
|
7
|
+
if (!SANDBOX_OPEN_VERBS.includes(verb)) throw new Error("the opener verb must be open or attach");
|
|
8
|
+
if (typeof id !== "string" || !SANDBOX_OPEN_ID_RE.test(id)) throw new Error("the opener id is not a run or sandbox id");
|
|
9
|
+
if (typeof host !== "string" || !WORKER_NAME_RE.test(host) || /\.(?:json|log)$/i.test(host)) throw new Error("the opener host is not a worker name");
|
|
10
|
+
return { verb, id, host };
|
|
11
|
+
}
|
|
12
|
+
export function sandboxOpenUrl(fields) {
|
|
13
|
+
const { verb, id, host } = sandboxOpenFields(fields);
|
|
14
|
+
return `pi-dispatch-open://${verb}/${encodeURIComponent(id)}?host=${encodeURIComponent(host)}`;
|
|
15
|
+
}
|
|
16
|
+
export const buildSandboxOpenUrl = sandboxOpenUrl;
|
|
17
|
+
export function parseSandboxOpenUrl(value) {
|
|
18
|
+
if (typeof value !== "string" || value.length > 1024) throw new Error("the opener link is invalid");
|
|
19
|
+
// Match the wire grammar before URL normalisation can hide a path traversal or a second decoding.
|
|
20
|
+
const match = /^pi-dispatch-open:\/\/(open|attach)\/([^/?#]+)\?host=([^&#]+)$/.exec(value);
|
|
21
|
+
if (!match) throw new Error("the opener link must name one open or attach verb, id and host");
|
|
22
|
+
let fields;
|
|
23
|
+
try { fields = sandboxOpenFields({ verb: match[1], id: decodeURIComponent(match[2]), host: decodeURIComponent(match[3]) }); }
|
|
24
|
+
catch (error) { throw new Error(error instanceof URIError ? "the opener link has invalid encoding" : error.message); }
|
|
25
|
+
if (sandboxOpenUrl(fields) !== value) throw new Error("the opener link is not canonical");
|
|
26
|
+
return fields;
|
|
27
|
+
}
|