@edgehero/pi-dispatch-admin 1.9.0 → 1.10.1
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/dist/index.mjs +426 -65
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -7598,6 +7598,214 @@ var init_build = __esm({
|
|
|
7598
7598
|
}
|
|
7599
7599
|
});
|
|
7600
7600
|
|
|
7601
|
+
// ../worker/src/backends.mjs
|
|
7602
|
+
function backendFor(name) {
|
|
7603
|
+
const key = name ?? DEFAULT_BACKEND;
|
|
7604
|
+
return Object.hasOwn(BACKENDS, key) ? BACKENDS[key] : void 0;
|
|
7605
|
+
}
|
|
7606
|
+
function parseBackendList(raw) {
|
|
7607
|
+
const names = (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
7608
|
+
if (names.length === 0) return [DEFAULT_BACKEND];
|
|
7609
|
+
for (const name of names) {
|
|
7610
|
+
if (!Object.hasOwn(BACKENDS, name)) {
|
|
7611
|
+
throw new Error(`PI_BACKENDS names an unknown backend ${JSON.stringify(name)} (known: ${BACKEND_NAMES.join(", ")})`);
|
|
7612
|
+
}
|
|
7613
|
+
}
|
|
7614
|
+
const unique = [...new Set(names)];
|
|
7615
|
+
if (!unique.includes(DEFAULT_BACKEND)) {
|
|
7616
|
+
throw new Error(`PI_BACKENDS must include ${JSON.stringify(DEFAULT_BACKEND)}: a trigger that names no backend is dispatched there, so a set without it leaves every unflagged trigger nowhere to run`);
|
|
7617
|
+
}
|
|
7618
|
+
return unique;
|
|
7619
|
+
}
|
|
7620
|
+
var DOCKER_NEVER_STARTED_EXITS, ENFORCED, ASSERTED, ABSENT, RANK, PROPERTIES_TABLE, PROPERTIES, PROPERTY_NAMES, BACKENDS_TABLE, BACKENDS, BACKEND_NAMES, DEFAULT_BACKEND;
|
|
7621
|
+
var init_backends = __esm({
|
|
7622
|
+
"../worker/src/backends.mjs"() {
|
|
7623
|
+
DOCKER_NEVER_STARTED_EXITS = Object.freeze([125, 126, 127]);
|
|
7624
|
+
ENFORCED = "enforced";
|
|
7625
|
+
ASSERTED = "asserted";
|
|
7626
|
+
ABSENT = "absent";
|
|
7627
|
+
RANK = { [ABSENT]: 0, [ASSERTED]: 1, [ENFORCED]: 2 };
|
|
7628
|
+
PROPERTIES_TABLE = {
|
|
7629
|
+
isolation: {
|
|
7630
|
+
question: "capabilities dropped, no-new-privileges, pid and memory bounds on the job container",
|
|
7631
|
+
armedBy: null
|
|
7632
|
+
},
|
|
7633
|
+
ephemeral: {
|
|
7634
|
+
// CONST-ISOLATION-CONTAINER-PER-JOB's core sentence, and NOT covered by `isolation`: a backend
|
|
7635
|
+
// reusing one long-lived container satisfies every flag above word for word and still leaks state
|
|
7636
|
+
// between mutually untrusting issue authors, which is the whole reason that constraint exists.
|
|
7637
|
+
question: "one container per job, destroyed after it, never reused across jobs",
|
|
7638
|
+
armedBy: null
|
|
7639
|
+
},
|
|
7640
|
+
mountSet: {
|
|
7641
|
+
// The Acceptance clause of CONST-ISOLATION-CONTAINER-PER-JOB: "the agent has no filesystem path to
|
|
7642
|
+
// the host outside the declared mounts", and it names PI_SESSIONS_DIR as never bind-mounted into any
|
|
7643
|
+
// container. Without this property a backend could declare everything else enforced and mount the
|
|
7644
|
+
// docker socket, which is not a weakened boundary but no boundary at all.
|
|
7645
|
+
question: "only the declared mounts exist; no docker socket, no home dir, no shared session store",
|
|
7646
|
+
armedBy: null
|
|
7647
|
+
},
|
|
7648
|
+
egress: {
|
|
7649
|
+
// ARMED, not unconditional, and the table would be re-proposing a rejected constitution entry if it
|
|
7650
|
+
// said otherwise. See the header.
|
|
7651
|
+
question: "the job reaches only what the allowlist proxy permits (CONST-EGRESS-POLICY-IN-THE-ARGV)",
|
|
7652
|
+
armedBy: "PI_EGRESS"
|
|
7653
|
+
},
|
|
7654
|
+
jobToJobIsolation: {
|
|
7655
|
+
// The measured half of REQ-EGRESS-ALLOWLIST. `egress` is about OUTBOUND reach; this is about whether
|
|
7656
|
+
// two jobs can reach each other, which a backend could fail while passing the egress question by
|
|
7657
|
+
// routing every job through the proxy on one shared segment. Also armed: with PI_EGRESS=0 both job
|
|
7658
|
+
// containers sit on docker's default bridge, where egress.mjs records they can reach each other by
|
|
7659
|
+
// IP today.
|
|
7660
|
+
question: "two jobs cannot reach each other, structurally rather than by policy",
|
|
7661
|
+
armedBy: "PI_EGRESS"
|
|
7662
|
+
},
|
|
7663
|
+
imagePinning: {
|
|
7664
|
+
question: "the image cannot be fetched at run time, so a typo'd name is unreachable (--pull=never)",
|
|
7665
|
+
armedBy: null
|
|
7666
|
+
},
|
|
7667
|
+
exitCodes: {
|
|
7668
|
+
// Narrowed deliberately. An earlier draft also claimed "un-interleaved stdout arrives undistorted",
|
|
7669
|
+
// which this project does not have: run-container.mjs tees two independently buffered pipes into one
|
|
7670
|
+
// sink, run-history.mjs reads only the last 8KB, and `OQ-003` records that anything sharing the
|
|
7671
|
+
// container's stdout can land a partial write inside a runner line. The integer is the part that is
|
|
7672
|
+
// actually guaranteed, and it is the part every retry decision rests on.
|
|
7673
|
+
question: "the container's integer exit code reaches the processor unmodified (INT-RUNNER-EXIT-CODE-PROTOCOL)",
|
|
7674
|
+
armedBy: null
|
|
7675
|
+
},
|
|
7676
|
+
abortable: {
|
|
7677
|
+
// REQ-JOB-TIMEOUT-30M. Declared two slices before the seam existed, on purpose: `stopContainer` was
|
|
7678
|
+
// hard-wired in index.mjs's abort path, so a second backend could have passed every other check with
|
|
7679
|
+
// no way to stop a runaway container and nothing in this table would have moved. A gap that is
|
|
7680
|
+
// declared is a refusal; a gap that is unnamed is a surprise. The seam landed in slice 4.
|
|
7681
|
+
question: "a running job can be stopped on the 30-minute timeout or a shutdown, and the abort is distinguishable from a crash",
|
|
7682
|
+
armedBy: null
|
|
7683
|
+
},
|
|
7684
|
+
readOnlyJobInputs: {
|
|
7685
|
+
question: "/job is read-only by the kernel, not by convention (INT-CONTAINER-JOB-INPUTS)",
|
|
7686
|
+
armedBy: null
|
|
7687
|
+
},
|
|
7688
|
+
nonRoot: {
|
|
7689
|
+
question: "the agent runs as a non-root user",
|
|
7690
|
+
armedBy: null
|
|
7691
|
+
},
|
|
7692
|
+
secretsCustody: {
|
|
7693
|
+
// Stated as what the code actually earns and a test can read back, rather than as "values stay on the
|
|
7694
|
+
// operator's host", which is a topological fact about `remote: false` and not something the worker
|
|
7695
|
+
// builds. The host-side argv exposure under a default `hidepid` is real, pre-existing, disclosed in
|
|
7696
|
+
// SECURITY.md, and deliberately outside this sentence rather than denied by it. WHETHER THE VALUES
|
|
7697
|
+
// CROSS A NETWORK is deliberately not asked here either -- that is `credentialTransit`, which answers
|
|
7698
|
+
// it honestly, and folding the two would let this one's verified half carry the other's unverified.
|
|
7699
|
+
question: "no resolved secret VALUE reaches a log, a run record or a forge comment",
|
|
7700
|
+
armedBy: null
|
|
7701
|
+
},
|
|
7702
|
+
credentialTransit: {
|
|
7703
|
+
// CONST-TOKEN-SCOPED-PER-JOB is what CONST-EGRESS-POLICY-IN-THE-ARGV calls "what actually bounds the
|
|
7704
|
+
// damage", and the first draft of this table had no property for it at all. A remote backend must
|
|
7705
|
+
// ship the provider key and the per-job forge token to a daemon it does not own; that is a different
|
|
7706
|
+
// question from secretsCustody, which is about this host's own logs and records.
|
|
7707
|
+
question: "the provider key and the per-job forge token reach the container without crossing a network this deployment does not own",
|
|
7708
|
+
armedBy: null
|
|
7709
|
+
},
|
|
7710
|
+
localFolders: {
|
|
7711
|
+
question: "a local folder can be bind-mounted and edited in place (DES-WORKER-ON-HOST finding 2)",
|
|
7712
|
+
armedBy: null
|
|
7713
|
+
}
|
|
7714
|
+
};
|
|
7715
|
+
PROPERTIES = Object.freeze(
|
|
7716
|
+
Object.fromEntries(Object.entries(PROPERTIES_TABLE).map(([name, entry]) => [name, Object.freeze({ ...entry })]))
|
|
7717
|
+
);
|
|
7718
|
+
PROPERTY_NAMES = Object.freeze(Object.keys(PROPERTIES));
|
|
7719
|
+
BACKENDS_TABLE = {
|
|
7720
|
+
local: {
|
|
7721
|
+
/**
|
|
7722
|
+
* The Docker daemon on the worker's own host -- what every deployment has always used, and what
|
|
7723
|
+
* `DES-WORKER-ON-HOST` chose deliberately: the worker runs on the host and shells out to the real
|
|
7724
|
+
* `docker` CLI, inheriting its cross-platform path translation rather than reimplementing it.
|
|
7725
|
+
*/
|
|
7726
|
+
describe: "the Docker daemon on this worker's host",
|
|
7727
|
+
remote: false,
|
|
7728
|
+
declares: {
|
|
7729
|
+
// Built by this worker's own argv, in `ISOLATION_FLAGS`, and asserted back member-by-member
|
|
7730
|
+
// against the imported array by two tests. `dockerArgsFromSpec` additionally refuses a
|
|
7731
|
+
// `dockerExtra` carrying a flag that would supersede one of them -- membership in an argv is not
|
|
7732
|
+
// effectiveness of that argv, and without that guard those two assertions would pass on an argv
|
|
7733
|
+
// with no boundary left. It is a deny-list, so it NARROWS that gap rather than closing it; what
|
|
7734
|
+
// closes it today is that the only production caller passes fixed literals.
|
|
7735
|
+
isolation: ENFORCED,
|
|
7736
|
+
// `--rm` leads ISOLATION_FLAGS and the container name carries the job id, so no container is
|
|
7737
|
+
// reachable to reuse even in principle.
|
|
7738
|
+
ephemeral: ENFORCED,
|
|
7739
|
+
// The mount list is built by `containerSpec` from a fixed set of named host paths and nothing
|
|
7740
|
+
// else. There is no pass-through, and the shared session store under PI_SESSIONS_DIR is never
|
|
7741
|
+
// among them -- only this job's own copy.
|
|
7742
|
+
mountSet: ENFORCED,
|
|
7743
|
+
// CAPABILITY, gated by PI_EGRESS. When armed: `--network=pi-job-<id>-net`, `--internal`, created
|
|
7744
|
+
// before the spawn and removed in a finally. When PI_EGRESS=0 the flag is absent and the job is
|
|
7745
|
+
// on docker's default bridge -- which is why this word is `armedBy` rather than flat.
|
|
7746
|
+
egress: ENFORCED,
|
|
7747
|
+
// Same switch, and the same reason: an `--internal` network holding exactly two endpoints is what
|
|
7748
|
+
// makes job-to-job structurally impossible. Unarmed, egress.mjs records the opposite is true.
|
|
7749
|
+
jobToJobIsolation: ENFORCED,
|
|
7750
|
+
// `--pull=never`, which makes the fetch branch unreachable rather than merely unlikely. Every
|
|
7751
|
+
// path that starts a container from the job image carries it, doctor's probes included.
|
|
7752
|
+
imagePinning: ENFORCED,
|
|
7753
|
+
// `spawn` on the CLI: the integer comes from the `close` event and is passed to `decideRetry`
|
|
7754
|
+
// unmodified.
|
|
7755
|
+
exitCodes: ENFORCED,
|
|
7756
|
+
// `docker stop` on the name, and the abort FLAG rather than the code is the discriminator -- a
|
|
7757
|
+
// worker SIGKILL and a kernel OOM both surface as 137. The worker also BOUNDS the wait after the
|
|
7758
|
+
// abort, so a stop that does not take frees the slot instead of holding it forever.
|
|
7759
|
+
abortable: ENFORCED,
|
|
7760
|
+
// `-v <host>:/job:ro` -- the kernel enforces it. `verify-image.sh` proves it with a live write
|
|
7761
|
+
// attempt rather than trusting the flag.
|
|
7762
|
+
readOnlyJobInputs: ENFORCED,
|
|
7763
|
+
// ASSERTED, not enforced, and this is the honest one. Non-root is `USER pi` in the IMAGE, not in
|
|
7764
|
+
// the worker's argv -- SECURITY.md says so in terms: "Non-root is not in that argv." An
|
|
7765
|
+
// operator-built image can run as root and nothing here would refuse it (`OQ-012`).
|
|
7766
|
+
nonRoot: ASSERTED,
|
|
7767
|
+
// Traced end to end: the resolver logs a stderr BYTE COUNT and never the bytes, the refusal
|
|
7768
|
+
// comment carries neither the reference nor the resolver's path nor a byte of what it printed,
|
|
7769
|
+
// and `buildRecord` is an explicit literal with no spread. Where the values GO is
|
|
7770
|
+
// `credentialTransit`'s question, not this one's.
|
|
7771
|
+
secretsCustody: ENFORCED,
|
|
7772
|
+
// ASSERTED, and this is the second honest one. The intent is that the daemon is on this host, so
|
|
7773
|
+
// nothing leaves it -- but every spawn is `docker` with the worker's own environment inherited,
|
|
7774
|
+
// and `DOCKER_HOST=tcp://...` or a `docker context` redirects that connection to another machine
|
|
7775
|
+
// with the provider key and the per-job forge token riding along as `-e NAME=VALUE`. `DOCKER_HOST`
|
|
7776
|
+
// appears NOWHERE in this repository: no code sets it, no check refuses it, no test reads it back.
|
|
7777
|
+
// By this file's own definition of `enforced` -- "this worker CAN build it, in its own code, and a
|
|
7778
|
+
// test in this repo reads it back" -- there is no such code, so the word would be exactly the
|
|
7779
|
+
// overclaim `nonRoot` avoids one property up. A boot check on DOCKER_HOST would earn `enforced`.
|
|
7780
|
+
credentialTransit: ASSERTED,
|
|
7781
|
+
// The whole reason `DES-WORKER-ON-HOST` reversed the containerised worker.
|
|
7782
|
+
localFolders: ENFORCED
|
|
7783
|
+
},
|
|
7784
|
+
/**
|
|
7785
|
+
* WHO is asserting each `asserted` property. Required for every property this backend declares
|
|
7786
|
+
* ASSERTED and meaningless for the others, which a test pins both ways.
|
|
7787
|
+
*
|
|
7788
|
+
* Exists because "asserted" alone is not actionable: it tells an operator the worker is not the one
|
|
7789
|
+
* providing the property without telling them who is, so they cannot go and check. `doctor` prints
|
|
7790
|
+
* this beside the word, which is what lets the claim "asserted names who is asserting it" be true
|
|
7791
|
+
* rather than aspirational. For a vendor adapter this is where "the vendor's documentation" goes.
|
|
7792
|
+
*/
|
|
7793
|
+
asserts: {
|
|
7794
|
+
nonRoot: "the job image's USER directive (this repo's builds `USER pi`; an operator-built image may not)",
|
|
7795
|
+
credentialTransit: "the docker endpoint DOCKER_HOST resolves to, which is this host unless something redirects it"
|
|
7796
|
+
}
|
|
7797
|
+
}
|
|
7798
|
+
};
|
|
7799
|
+
BACKENDS = Object.freeze(
|
|
7800
|
+
Object.fromEntries(
|
|
7801
|
+
Object.entries(BACKENDS_TABLE).map(([name, entry]) => [name, Object.freeze({ ...entry, declares: Object.freeze({ ...entry.declares }) })])
|
|
7802
|
+
)
|
|
7803
|
+
);
|
|
7804
|
+
BACKEND_NAMES = Object.freeze(Object.keys(BACKENDS));
|
|
7805
|
+
DEFAULT_BACKEND = "local";
|
|
7806
|
+
}
|
|
7807
|
+
});
|
|
7808
|
+
|
|
7601
7809
|
// ../worker/src/egress.mjs
|
|
7602
7810
|
var init_egress = __esm({
|
|
7603
7811
|
"../worker/src/egress.mjs"() {
|
|
@@ -7738,6 +7946,7 @@ function defaultSettingsFile() {
|
|
|
7738
7946
|
var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT, WORKER_ONLY_SECRET_VARS, EGRESS_ENV_VARS;
|
|
7739
7947
|
var init_config2 = __esm({
|
|
7740
7948
|
"../worker/src/config.mjs"() {
|
|
7949
|
+
init_backends();
|
|
7741
7950
|
init_egress();
|
|
7742
7951
|
init_forges();
|
|
7743
7952
|
init_secret_profiles();
|
|
@@ -8557,10 +8766,11 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8557
8766
|
validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8558
8767
|
const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
|
|
8559
8768
|
const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
|
|
8769
|
+
const backend = validateBackend(on, run, `cron trigger "${id}"`, path, { localWorkspace: true });
|
|
8560
8770
|
validateWaitFor(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8561
8771
|
return {
|
|
8562
8772
|
on: { type: "cron", id, pattern },
|
|
8563
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
|
|
8773
|
+
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...backend !== void 0 && { backend } }
|
|
8564
8774
|
};
|
|
8565
8775
|
}
|
|
8566
8776
|
function validatePackagesFlag(run, at, path) {
|
|
@@ -8807,6 +9017,45 @@ function validateSecretsProfile(run, at, path) {
|
|
|
8807
9017
|
}
|
|
8808
9018
|
return profile;
|
|
8809
9019
|
}
|
|
9020
|
+
function refusesLocalWorkspace(entry, localWorkspace) {
|
|
9021
|
+
return Boolean(localWorkspace) && entry?.remote !== false;
|
|
9022
|
+
}
|
|
9023
|
+
function validateBackend(on, run, at, path, { localWorkspace }) {
|
|
9024
|
+
for (const [label, source, exactIsLegal] of [
|
|
9025
|
+
["run", run, true],
|
|
9026
|
+
["on", on, false]
|
|
9027
|
+
]) {
|
|
9028
|
+
for (const key of Object.keys(source ?? {})) {
|
|
9029
|
+
if (exactIsLegal && key === "backend") continue;
|
|
9030
|
+
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
9031
|
+
const targets = ["backend", "backends"];
|
|
9032
|
+
const isSubsequence = (needle, hay) => {
|
|
9033
|
+
let i = 0;
|
|
9034
|
+
for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
|
|
9035
|
+
return i === needle.length;
|
|
9036
|
+
};
|
|
9037
|
+
const suspicious = /^[\x20-\x7E]*$/.test(key) ? targets.includes(normalized) : normalized.length >= 4 && targets.some((t) => isSubsequence(normalized, t));
|
|
9038
|
+
if (!suspicious) continue;
|
|
9039
|
+
throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.backend? A venue the loader drops runs the job somewhere else while the file reads as though it chose, so a near miss is refused rather than dropped: ${path}`);
|
|
9040
|
+
}
|
|
9041
|
+
}
|
|
9042
|
+
const backend = run.backend;
|
|
9043
|
+
if (backend === void 0) return void 0;
|
|
9044
|
+
if (!isNonEmptyString5(backend)) {
|
|
9045
|
+
throw configError(`${at}: run.backend must be a non-empty string naming one of the backends this deployment can run jobs in (got ${JSON.stringify(backend)}): ${path}`);
|
|
9046
|
+
}
|
|
9047
|
+
if (!ID_CHARSET.test(backend)) {
|
|
9048
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} may use letters, digits, dot, dash and underscore only -- PI_BACKENDS is a comma-separated list, so a name carrying that separator could not be blessed: ${path}`);
|
|
9049
|
+
}
|
|
9050
|
+
const entry = backendFor(backend);
|
|
9051
|
+
if (!entry) {
|
|
9052
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} is not a backend this build knows (known: ${BACKEND_NAMES.join(", ")}) -- whether your deployment BLESSES it is PI_BACKENDS, checked before the job spends: ${path}`);
|
|
9053
|
+
}
|
|
9054
|
+
if (refusesLocalWorkspace(entry, localWorkspace)) {
|
|
9055
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} is remote, and this trigger runs against a folder on THIS machine that has to be bind-mounted and edited in place -- there is no volume to hide behind (DES-WORKER-ON-HOST): ${path}`);
|
|
9056
|
+
}
|
|
9057
|
+
return backend;
|
|
9058
|
+
}
|
|
8810
9059
|
function validateWaitFor(on, run, at, path, { onType }) {
|
|
8811
9060
|
for (const [label, source, exactIsLegal] of [
|
|
8812
9061
|
["run", run, true],
|
|
@@ -8902,10 +9151,11 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8902
9151
|
const replicas = validateReplicas(run, at, path);
|
|
8903
9152
|
const secrets = validateSecrets(run, at, path);
|
|
8904
9153
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9154
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
8905
9155
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8906
9156
|
return {
|
|
8907
9157
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
8908
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9158
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
8909
9159
|
};
|
|
8910
9160
|
}
|
|
8911
9161
|
function normalizeComment(on, run, index, path, state) {
|
|
@@ -8934,10 +9184,11 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8934
9184
|
const replicas = validateReplicas(run, at, path);
|
|
8935
9185
|
const secrets = validateSecrets(run, at, path);
|
|
8936
9186
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9187
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
8937
9188
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8938
9189
|
return {
|
|
8939
9190
|
on: { type: "comment", phrase: on.phrase },
|
|
8940
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9191
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
8941
9192
|
};
|
|
8942
9193
|
}
|
|
8943
9194
|
function normalizeIssue(on, run, index, path) {
|
|
@@ -8976,6 +9227,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8976
9227
|
const replicas = validateReplicas(run, at, path);
|
|
8977
9228
|
const secrets = validateSecrets(run, at, path);
|
|
8978
9229
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9230
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
8979
9231
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8980
9232
|
return {
|
|
8981
9233
|
on: {
|
|
@@ -8986,7 +9238,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8986
9238
|
...number !== void 0 && { number },
|
|
8987
9239
|
...once !== void 0 && { once }
|
|
8988
9240
|
},
|
|
8989
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9241
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
8990
9242
|
};
|
|
8991
9243
|
}
|
|
8992
9244
|
function normalizePullRequest(on, run, index, path) {
|
|
@@ -9034,6 +9286,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
9034
9286
|
const replicas = validateReplicas(run, at, path);
|
|
9035
9287
|
const secrets = validateSecrets(run, at, path);
|
|
9036
9288
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9289
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
9037
9290
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
9038
9291
|
return {
|
|
9039
9292
|
on: {
|
|
@@ -9049,7 +9302,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
9049
9302
|
...number !== void 0 && { number },
|
|
9050
9303
|
...once !== void 0 && { once }
|
|
9051
9304
|
},
|
|
9052
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9305
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
9053
9306
|
};
|
|
9054
9307
|
}
|
|
9055
9308
|
function validateReviewState(on, actions, run, at, path) {
|
|
@@ -9074,6 +9327,7 @@ function validateReviewState(on, actions, run, at, path) {
|
|
|
9074
9327
|
var ON_TYPES, DISARMED_TYPE, PR_ACTIONS, PR_CLOSE_ACTIONS, ISSUE_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
|
|
9075
9328
|
var init_triggers = __esm({
|
|
9076
9329
|
"../worker/src/triggers.mjs"() {
|
|
9330
|
+
init_backends();
|
|
9077
9331
|
init_config2();
|
|
9078
9332
|
init_flow_gate();
|
|
9079
9333
|
init_forges();
|
|
@@ -9133,7 +9387,7 @@ function fleetQueueNames(hosts) {
|
|
|
9133
9387
|
function makeQueue(connection, { name = QUEUE } = {}) {
|
|
9134
9388
|
return new Queue(name, { connection });
|
|
9135
9389
|
}
|
|
9136
|
-
async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
9390
|
+
async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, backend, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
9137
9391
|
const minute = now.toISOString().slice(0, 16);
|
|
9138
9392
|
const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
|
|
9139
9393
|
const data = {
|
|
@@ -9149,6 +9403,12 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
|
|
|
9149
9403
|
model,
|
|
9150
9404
|
maxTurns,
|
|
9151
9405
|
...image !== void 0 && { image },
|
|
9406
|
+
// #227. WHERE this job's container is built. Conditional like `image`, so a trigger that named no
|
|
9407
|
+
// venue produces byte-identical job data -- and at JOB level, never inside `trigger`, for the reason
|
|
9408
|
+
// `image` and `skillsDir` are: `trigger` is copied VERBATIM into `/job/event.json`, so a key added
|
|
9409
|
+
// there becomes agent-visible input, and where the box was built is the worker's business rather
|
|
9410
|
+
// than the agent's.
|
|
9411
|
+
...backend !== void 0 && { backend },
|
|
9152
9412
|
// The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
|
|
9153
9413
|
// Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
|
|
9154
9414
|
// than inside `trigger` because a worker-host path is an execution knob, not a fact about the
|
|
@@ -9346,7 +9606,7 @@ var init_import_pi = __esm({
|
|
|
9346
9606
|
}
|
|
9347
9607
|
});
|
|
9348
9608
|
|
|
9349
|
-
// ../worker/src/
|
|
9609
|
+
// ../worker/src/container-spec.mjs
|
|
9350
9610
|
function containerSpec({
|
|
9351
9611
|
image,
|
|
9352
9612
|
env,
|
|
@@ -9378,59 +9638,20 @@ function containerSpec({
|
|
|
9378
9638
|
network,
|
|
9379
9639
|
// UNCONDITIONALLY true, and there is deliberately no parameter that can unset it. The boundary is
|
|
9380
9640
|
// not a thing a caller opts into -- CONST-ISOLATION-CONTAINER-PER-JOB is why every other flag here
|
|
9381
|
-
// exists -- so the spec is simply unable to describe an unisolated container, and the builder
|
|
9382
|
-
// refuses one it is handed. A field that could be false would be a way to ask for less.
|
|
9641
|
+
// exists -- so the spec is simply unable to describe an unisolated container, and the builder in
|
|
9642
|
+
// docker-run.mjs refuses one it is handed. A field that could be false would be a way to ask for less.
|
|
9383
9643
|
isolated: true,
|
|
9384
9644
|
mounts,
|
|
9385
9645
|
env,
|
|
9386
9646
|
dockerExtra: extraFlags
|
|
9387
9647
|
};
|
|
9388
9648
|
}
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
if (spec.network) args.push(`--network=${spec.network}`);
|
|
9393
|
-
args.push(...spec.dockerExtra ?? []);
|
|
9394
|
-
for (const [k, v] of Object.entries(spec.env ?? {})) {
|
|
9395
|
-
if (v === void 0 || v === null) continue;
|
|
9396
|
-
args.push("-e", `${k}=${v}`);
|
|
9397
|
-
}
|
|
9398
|
-
for (const m of spec.mounts ?? []) args.push("-v", `${m.host}:${m.container}${m.readOnly ? ":ro" : ""}`);
|
|
9399
|
-
args.push(spec.image);
|
|
9400
|
-
return args;
|
|
9401
|
-
}
|
|
9402
|
-
function buildDockerRunArgs(opts) {
|
|
9403
|
-
return dockerArgsFromSpec(containerSpec(opts));
|
|
9404
|
-
}
|
|
9405
|
-
var CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE, ISOLATION_FLAGS;
|
|
9406
|
-
var init_docker_run = __esm({
|
|
9407
|
-
"../worker/src/docker-run.mjs"() {
|
|
9649
|
+
var CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE;
|
|
9650
|
+
var init_container_spec = __esm({
|
|
9651
|
+
"../worker/src/container-spec.mjs"() {
|
|
9408
9652
|
CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
|
|
9409
9653
|
CONTAINER_SESSION_DIR = "/session";
|
|
9410
9654
|
CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
9411
|
-
ISOLATION_FLAGS = [
|
|
9412
|
-
// The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
|
|
9413
|
-
// a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
|
|
9414
|
-
// image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
|
|
9415
|
-
// this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
|
|
9416
|
-
// PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
|
|
9417
|
-
// install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
|
|
9418
|
-
// no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
|
|
9419
|
-
// part that cannot be raced.
|
|
9420
|
-
"--pull=never",
|
|
9421
|
-
"--rm",
|
|
9422
|
-
// ephemeral: gone after the run
|
|
9423
|
-
"--init",
|
|
9424
|
-
// reap zombies (Chromium spawns many); node is PID 1 and does not reap
|
|
9425
|
-
"--cap-drop=ALL",
|
|
9426
|
-
// pi would otherwise inherit the launching user's capabilities
|
|
9427
|
-
"--security-opt",
|
|
9428
|
-
"no-new-privileges",
|
|
9429
|
-
"--pids-limit=512",
|
|
9430
|
-
// bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
|
|
9431
|
-
"--shm-size=1g"
|
|
9432
|
-
// Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
|
|
9433
|
-
];
|
|
9434
9655
|
}
|
|
9435
9656
|
});
|
|
9436
9657
|
|
|
@@ -9514,7 +9735,7 @@ var init_packages = __esm({
|
|
|
9514
9735
|
"../worker/src/packages.mjs"() {
|
|
9515
9736
|
init_config2();
|
|
9516
9737
|
init_import_pi();
|
|
9517
|
-
|
|
9738
|
+
init_container_spec();
|
|
9518
9739
|
PACKAGES_SUBDIR = "packages";
|
|
9519
9740
|
STAGE_MANIFEST = "packages.json";
|
|
9520
9741
|
CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
|
|
@@ -11431,6 +11652,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11431
11652
|
const command = typeof run.command === "string" ? run.command : null;
|
|
11432
11653
|
const packages = run.packages !== false;
|
|
11433
11654
|
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
11655
|
+
const backend = typeof run.backend === "string" && run.backend.trim() !== "" ? run.backend : null;
|
|
11434
11656
|
const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
|
|
11435
11657
|
const instructions = typeof run.instructions === "string" && run.instructions.trim() !== "";
|
|
11436
11658
|
const resume = run.resume === true;
|
|
@@ -11452,6 +11674,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11452
11674
|
model: typeof run.model === "string" ? run.model : null,
|
|
11453
11675
|
packages,
|
|
11454
11676
|
image,
|
|
11677
|
+
backend,
|
|
11455
11678
|
skillsDir,
|
|
11456
11679
|
instructions,
|
|
11457
11680
|
resume,
|
|
@@ -11459,9 +11682,9 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11459
11682
|
secretsProfile
|
|
11460
11683
|
};
|
|
11461
11684
|
case "label":
|
|
11462
|
-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11685
|
+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, backend, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11463
11686
|
case "comment":
|
|
11464
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11687
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, backend, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11465
11688
|
case "pull_request": {
|
|
11466
11689
|
const prDisarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
|
|
11467
11690
|
return {
|
|
@@ -11477,6 +11700,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11477
11700
|
command,
|
|
11478
11701
|
packages,
|
|
11479
11702
|
image,
|
|
11703
|
+
backend,
|
|
11480
11704
|
skillsDir,
|
|
11481
11705
|
instructions,
|
|
11482
11706
|
resume,
|
|
@@ -11504,6 +11728,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11504
11728
|
command,
|
|
11505
11729
|
packages,
|
|
11506
11730
|
image,
|
|
11731
|
+
backend,
|
|
11507
11732
|
skillsDir,
|
|
11508
11733
|
instructions,
|
|
11509
11734
|
resume,
|
|
@@ -39883,6 +40108,108 @@ var init_pricing = __esm({
|
|
|
39883
40108
|
}
|
|
39884
40109
|
});
|
|
39885
40110
|
|
|
40111
|
+
// ../worker/src/docker-run.mjs
|
|
40112
|
+
function dockerArgsFromSpec(spec) {
|
|
40113
|
+
if (spec?.isolated !== true) throw new Error("docker run: refusing to build an argv for a spec that is not isolated");
|
|
40114
|
+
for (const flag of spec.dockerExtra ?? []) {
|
|
40115
|
+
if (typeof flag !== "string") {
|
|
40116
|
+
throw new Error(`docker run: dockerExtra must contain only strings; got ${typeof flag}`);
|
|
40117
|
+
}
|
|
40118
|
+
if (DOCKER_EXTRA_FORBIDDEN.includes(flag.split("=", 1)[0])) {
|
|
40119
|
+
throw new Error(`docker run: refusing a dockerExtra flag that would supersede the isolation boundary: ${flag}`);
|
|
40120
|
+
}
|
|
40121
|
+
}
|
|
40122
|
+
const args = ["run", `--name=${spec.name}`, ...ISOLATION_FLAGS, `--memory=${spec.memory}`, `--cpus=${spec.cpus}`];
|
|
40123
|
+
if (spec.network) args.push(`--network=${spec.network}`);
|
|
40124
|
+
args.push(...spec.dockerExtra ?? []);
|
|
40125
|
+
for (const [k, v] of Object.entries(spec.env ?? {})) {
|
|
40126
|
+
if (v === void 0 || v === null) continue;
|
|
40127
|
+
args.push("-e", `${k}=${v}`);
|
|
40128
|
+
}
|
|
40129
|
+
for (const m of spec.mounts ?? []) args.push("-v", `${m.host}:${m.container}${m.readOnly ? ":ro" : ""}`);
|
|
40130
|
+
args.push(spec.image);
|
|
40131
|
+
return args;
|
|
40132
|
+
}
|
|
40133
|
+
function buildDockerRunArgs(opts) {
|
|
40134
|
+
return dockerArgsFromSpec(containerSpec(opts));
|
|
40135
|
+
}
|
|
40136
|
+
var ISOLATION_FLAGS, DOCKER_EXTRA_FORBIDDEN;
|
|
40137
|
+
var init_docker_run = __esm({
|
|
40138
|
+
"../worker/src/docker-run.mjs"() {
|
|
40139
|
+
init_container_spec();
|
|
40140
|
+
init_container_spec();
|
|
40141
|
+
ISOLATION_FLAGS = [
|
|
40142
|
+
// The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
|
|
40143
|
+
// a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
|
|
40144
|
+
// image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
|
|
40145
|
+
// this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
|
|
40146
|
+
// PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
|
|
40147
|
+
// install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
|
|
40148
|
+
// no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
|
|
40149
|
+
// part that cannot be raced.
|
|
40150
|
+
"--pull=never",
|
|
40151
|
+
"--rm",
|
|
40152
|
+
// ephemeral: gone after the run
|
|
40153
|
+
"--init",
|
|
40154
|
+
// reap zombies (Chromium spawns many); node is PID 1 and does not reap
|
|
40155
|
+
"--cap-drop=ALL",
|
|
40156
|
+
// pi would otherwise inherit the launching user's capabilities
|
|
40157
|
+
"--security-opt",
|
|
40158
|
+
"no-new-privileges",
|
|
40159
|
+
"--pids-limit=512",
|
|
40160
|
+
// bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
|
|
40161
|
+
"--shm-size=1g"
|
|
40162
|
+
// Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
|
|
40163
|
+
];
|
|
40164
|
+
DOCKER_EXTRA_FORBIDDEN = [
|
|
40165
|
+
// Each of the seven logical flags in ISOLATION_FLAGS, and the argv member beside them that the worker's
|
|
40166
|
+
// own machinery reads back. A flag missing from here is a flag the array cannot defend.
|
|
40167
|
+
"--rm",
|
|
40168
|
+
// `--rm=false` leaves the container behind; verified against docker 27.4.0. `ephemeral` rests on it.
|
|
40169
|
+
"--init",
|
|
40170
|
+
"--shm-size",
|
|
40171
|
+
// The container NAME is not an isolation flag and is the sharpest entry on this list: both boot reapers
|
|
40172
|
+
// match `pi-job-` as a substring and the abort path is `docker stop <name>`, so a second `--name` wins
|
|
40173
|
+
// last and leaves a container no sweep finds and no timeout can stop. `ephemeral` and `abortable` both
|
|
40174
|
+
// rest on it.
|
|
40175
|
+
"--name",
|
|
40176
|
+
"--privileged",
|
|
40177
|
+
"--cap-add",
|
|
40178
|
+
"--security-opt",
|
|
40179
|
+
"--pids-limit",
|
|
40180
|
+
"--memory",
|
|
40181
|
+
"-m",
|
|
40182
|
+
"--cpus",
|
|
40183
|
+
"--network",
|
|
40184
|
+
"--net",
|
|
40185
|
+
"--pull",
|
|
40186
|
+
// Add a mount, which is what `-v`/`--volume`/`--mount` are blocked for; `mountSet` rests on all five.
|
|
40187
|
+
"--volumes-from",
|
|
40188
|
+
"--tmpfs",
|
|
40189
|
+
// Relax the memory bound without repeating `--memory`.
|
|
40190
|
+
"--memory-swap",
|
|
40191
|
+
"--oom-kill-disable",
|
|
40192
|
+
// Widen what the process may do without repeating a flag already listed.
|
|
40193
|
+
"--ulimit",
|
|
40194
|
+
"--sysctl",
|
|
40195
|
+
"--group-add",
|
|
40196
|
+
"--cgroup-parent",
|
|
40197
|
+
"--device-cgroup-rule",
|
|
40198
|
+
"--gpus",
|
|
40199
|
+
"--runtime",
|
|
40200
|
+
"-v",
|
|
40201
|
+
"--volume",
|
|
40202
|
+
"--mount",
|
|
40203
|
+
"--device",
|
|
40204
|
+
"--pid",
|
|
40205
|
+
"--ipc",
|
|
40206
|
+
"--uts",
|
|
40207
|
+
"--userns",
|
|
40208
|
+
"--cgroupns"
|
|
40209
|
+
];
|
|
40210
|
+
}
|
|
40211
|
+
});
|
|
40212
|
+
|
|
39886
40213
|
// ../worker/src/sandbox-store.mjs
|
|
39887
40214
|
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39888
40215
|
import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
|
|
@@ -41413,6 +41740,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
41413
41740
|
}
|
|
41414
41741
|
if (typeof t.command === "string" && t.command.trim() !== "") out.push(kv("command", `/${t.command.trim()}`, "accent"));
|
|
41415
41742
|
out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
|
|
41743
|
+
out.push(kv("backend", t.backend ?? "deployment default", t.backend ? "accent" : "dim"));
|
|
41416
41744
|
out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
|
|
41417
41745
|
out.push(kv("replicas", t.replicas > 1 ? `${t.replicas} sandboxes race this flow` : "one run per delivery", t.replicas > 1 ? "warning" : "dim"));
|
|
41418
41746
|
blank();
|
|
@@ -42277,7 +42605,7 @@ var init_setup_wizard = __esm({
|
|
|
42277
42605
|
init_deployment_pointer();
|
|
42278
42606
|
init_read_model();
|
|
42279
42607
|
init_index();
|
|
42280
|
-
RUNTIME_VERSION = "1.
|
|
42608
|
+
RUNTIME_VERSION = "1.10.2";
|
|
42281
42609
|
RECEIVER_VERSION = "1.5.0";
|
|
42282
42610
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
42283
42611
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
@@ -42588,7 +42916,7 @@ function registerTools(pi) {
|
|
|
42588
42916
|
pi.registerTool({
|
|
42589
42917
|
name: "dispatch_trigger_add",
|
|
42590
42918
|
label: "pi-dispatch add trigger",
|
|
42591
|
-
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request|issue. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. issue fires when an ISSUE closes: it needs flow, its action[] defaults to the forge's close word (github/forgejo closed, gitlab close; azure has no close trigger), and it may set `number` (the forge's own item number, narrowing the rule to that one issue) and `once: true` (a one-shot: it fires once, the run is recorded, then the worker disarms the entry by writing on.disarmed; once requires number). A close-only pull_request rule accepts the same number/once narrowing. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted|closed, gitlab is open|update|reopen|approved|close, forgejo is label_updated|opened|synchronized|reopened|closed, azure is created|updated (no close word). The close word rides alone: it cannot be mixed with other actions in one entry. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
42919
|
+
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request|issue. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. issue fires when an ISSUE closes: it needs flow, its action[] defaults to the forge's close word (github/forgejo closed, gitlab close; azure has no close trigger), and it may set `number` (the forge's own item number, narrowing the rule to that one issue) and `once: true` (a one-shot: it fires once, the run is recorded, then the worker disarms the entry by writing on.disarmed; once requires number). A close-only pull_request rule accepts the same number/once narrowing. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted|closed, gitlab is open|update|reopen|approved|close, forgejo is label_updated|opened|synchronized|reopened|closed, azure is created|updated (no close word). The close word rides alone: it cannot be mixed with other actions in one entry. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model. `backend` (optional, any kind) names WHERE the job's container is built, chosen from the names this deployment blesses in PI_BACKENDS; omitted, the job runs on the deployment default. A name this deployment does not bless is refused here, and refused again before the job spends.",
|
|
42592
42920
|
executionMode: "sequential",
|
|
42593
42921
|
parameters: typebox_exports.Object({
|
|
42594
42922
|
kind: typebox_exports.String(),
|
|
@@ -42612,9 +42940,20 @@ function registerTools(pi) {
|
|
|
42612
42940
|
once: typebox_exports.Optional(typebox_exports.Boolean()),
|
|
42613
42941
|
model: typebox_exports.Optional(typebox_exports.String()),
|
|
42614
42942
|
provider: typebox_exports.Optional(typebox_exports.String()),
|
|
42615
|
-
maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42943
|
+
maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42944
|
+
// #227. WHICH venue this trigger's container is built in. A NAME chosen from what PI_BACKENDS
|
|
42945
|
+
// blessed, never a configuration -- `DES-PER-TRIGGER-JOB-IMAGE` predicted this exact arrival: "If a
|
|
42946
|
+
// future tool ever takes an image parameter, the allowlist arrives with that tool, and this row is
|
|
42947
|
+
// the reason it must." The allowlist is checked in execute, below, because a schema enum would be
|
|
42948
|
+
// frozen at registration while PI_BACKENDS is read per call.
|
|
42949
|
+
backend: typebox_exports.Optional(typebox_exports.String())
|
|
42616
42950
|
}),
|
|
42617
42951
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42952
|
+
const blessed = blessedBackends(process.env);
|
|
42953
|
+
if (params.backend !== void 0) {
|
|
42954
|
+
if (blessed.length === 0) throw new Error("this deployment blesses no backends, so run.backend cannot be set (see PI_BACKENDS)");
|
|
42955
|
+
if (!blessed.includes(params.backend)) throw new Error(`backend '${params.backend}' is not blessed by this deployment (PI_BACKENDS: ${blessed.join(", ")})`);
|
|
42956
|
+
}
|
|
42618
42957
|
const entry = buildTriggerEntry(params.kind, params);
|
|
42619
42958
|
if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request|issue)`);
|
|
42620
42959
|
const result = await confirmedWrite(
|
|
@@ -42633,9 +42972,9 @@ ${JSON.stringify(entry)}` },
|
|
|
42633
42972
|
pi.registerTool({
|
|
42634
42973
|
name: "dispatch_trigger_edit",
|
|
42635
42974
|
label: "pi-dispatch edit trigger",
|
|
42636
|
-
description: "Changes which flow a trigger runs (by array index from dispatch_triggers) and applies it live. The operator MUST approve a confirm dialog showing
|
|
42975
|
+
description: "Changes which flow a trigger runs (by array index from dispatch_triggers) and applies it live, and optionally which `backend` (venue) its container is built in, chosen from the names PI_BACKENDS blesses. The operator MUST approve a confirm dialog showing the before->after; with no interactive operator it is refused.",
|
|
42637
42976
|
executionMode: "sequential",
|
|
42638
|
-
parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }), flow: typebox_exports.String() }),
|
|
42977
|
+
parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }), flow: typebox_exports.String(), backend: typebox_exports.Optional(typebox_exports.String()) }),
|
|
42639
42978
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42640
42979
|
const paths = resolvePaths(process.env);
|
|
42641
42980
|
const list = triggerList(paths);
|
|
@@ -42643,16 +42982,25 @@ ${JSON.stringify(entry)}` },
|
|
|
42643
42982
|
if (!cur) throw new Error(`no trigger at index ${params.index} (have ${list.length})`);
|
|
42644
42983
|
const flow = params.flow.trim();
|
|
42645
42984
|
if (!flow) throw new Error("flow must be non-empty");
|
|
42985
|
+
const backend = optStr(params.backend);
|
|
42986
|
+
if (backend !== void 0) {
|
|
42987
|
+
const blessed = blessedBackends(process.env);
|
|
42988
|
+
if (blessed.length === 0) throw new Error("this deployment blesses no backends, so run.backend cannot be set (see PI_BACKENDS)");
|
|
42989
|
+
if (!blessed.includes(backend)) throw new Error(`backend '${backend}' is not blessed by this deployment (PI_BACKENDS: ${blessed.join(", ")})`);
|
|
42990
|
+
}
|
|
42991
|
+
const venue = backend === void 0 ? "" : `, backend: ${cur.backend ?? "-"} -> ${backend}`;
|
|
42646
42992
|
const result = await confirmedWrite(
|
|
42647
42993
|
ctx,
|
|
42648
|
-
{ title: `Edit trigger #${params.index + 1}`, message: `trigger #${params.index + 1} (${cur.type}) flow: ${cur.flow ?? "-"} -> ${flow}` },
|
|
42994
|
+
{ title: `Edit trigger #${params.index + 1}`, message: `trigger #${params.index + 1} (${cur.type}) flow: ${cur.flow ?? "-"} -> ${flow}${venue}` },
|
|
42649
42995
|
() => {
|
|
42650
42996
|
const res = writeTriggers({
|
|
42651
42997
|
triggersPath: paths.triggersPath,
|
|
42652
|
-
|
|
42998
|
+
// `backend` is merged only when the caller sent one, so an edit that changes just the flow
|
|
42999
|
+
// leaves the entry byte-identical in every other key -- including a venue it already had.
|
|
43000
|
+
mutate: (raw) => raw.map((tr, i) => i === params.index ? { ...tr, run: { ...tr.run, flow, ...backend !== void 0 && { backend } } } : tr)
|
|
42653
43001
|
});
|
|
42654
43002
|
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42655
|
-
return { applied: true, index: params.index, flow };
|
|
43003
|
+
return { applied: true, index: params.index, flow, ...backend !== void 0 && { backend } };
|
|
42656
43004
|
}
|
|
42657
43005
|
);
|
|
42658
43006
|
return toolText(JSON.stringify(result));
|
|
@@ -42949,6 +43297,13 @@ function triggerList(paths) {
|
|
|
42949
43297
|
const t = readTriggers({ triggersPath: paths.triggersPath });
|
|
42950
43298
|
return Array.isArray(t?.triggers) ? t.triggers : [];
|
|
42951
43299
|
}
|
|
43300
|
+
function blessedBackends(env) {
|
|
43301
|
+
try {
|
|
43302
|
+
return parseBackendList(env.PI_BACKENDS);
|
|
43303
|
+
} catch {
|
|
43304
|
+
return [];
|
|
43305
|
+
}
|
|
43306
|
+
}
|
|
42952
43307
|
function buildTriggerEntry(kind, f) {
|
|
42953
43308
|
if (kind === "cron") {
|
|
42954
43309
|
const run = { kind: "local", folder: f.folder, flow: f.flow, task: f.task };
|
|
@@ -42958,11 +43313,14 @@ function buildTriggerEntry(kind, f) {
|
|
|
42958
43313
|
if (model) run.model = model;
|
|
42959
43314
|
if (provider) run.provider = provider;
|
|
42960
43315
|
if (maxTurns !== void 0) run.maxTurns = maxTurns;
|
|
43316
|
+
const backend2 = optStr(f.backend);
|
|
43317
|
+
if (backend2) run.backend = backend2;
|
|
42961
43318
|
return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
|
|
42962
43319
|
}
|
|
42963
43320
|
const forge = optStr(f.forge) ?? "github";
|
|
42964
43321
|
const repository = optStr(f.repository);
|
|
42965
|
-
const
|
|
43322
|
+
const backend = optStr(f.backend);
|
|
43323
|
+
const forgeRun = (rest) => ({ kind: forge, ...rest, ...repository ? { repository } : {}, ...backend ? { backend } : {} });
|
|
42966
43324
|
if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: forgeRun({ flow: f.flow }) };
|
|
42967
43325
|
if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: forgeRun({ flow: f.flow }) };
|
|
42968
43326
|
if (kind === "pull_request") {
|
|
@@ -42972,7 +43330,7 @@ function buildTriggerEntry(kind, f) {
|
|
|
42972
43330
|
const prNumber = optInt(f.number);
|
|
42973
43331
|
if (prNumber !== void 0) on.number = prNumber;
|
|
42974
43332
|
if (typeof f.once === "boolean") on.once = f.once;
|
|
42975
|
-
return { on, run: { kind: forge, flow: f.flow } };
|
|
43333
|
+
return { on, run: { kind: forge, flow: f.flow, ...backend ? { backend } : {} } };
|
|
42976
43334
|
}
|
|
42977
43335
|
if (kind === "issue") {
|
|
42978
43336
|
const action = asWords(f.action);
|
|
@@ -42980,7 +43338,7 @@ function buildTriggerEntry(kind, f) {
|
|
|
42980
43338
|
const number = optInt(f.number);
|
|
42981
43339
|
if (number !== void 0) on.number = number;
|
|
42982
43340
|
if (typeof f.once === "boolean") on.once = f.once;
|
|
42983
|
-
return { on, run: { kind: forge, flow: f.flow } };
|
|
43341
|
+
return { on, run: { kind: forge, flow: f.flow, ...backend ? { backend } : {} } };
|
|
42984
43342
|
}
|
|
42985
43343
|
return null;
|
|
42986
43344
|
}
|
|
@@ -43411,6 +43769,7 @@ cannot open a sandbox for ${jobId}: ${resolved.message}
|
|
|
43411
43769
|
name: resolved.name,
|
|
43412
43770
|
workspace: resolved.manifest.workspace,
|
|
43413
43771
|
jobDir: resolved.manifest.dir,
|
|
43772
|
+
// env-internal TERM: the operator's own terminal type, passed through to the sandbox shell.
|
|
43414
43773
|
term: process.env.TERM,
|
|
43415
43774
|
idleSeconds: (paths.sandboxIdleMinutes ?? 0) * 60
|
|
43416
43775
|
});
|
|
@@ -43839,6 +44198,7 @@ var init_index = __esm({
|
|
|
43839
44198
|
init_read_model();
|
|
43840
44199
|
init_graph_model();
|
|
43841
44200
|
init_insights_html();
|
|
44201
|
+
init_backends();
|
|
43842
44202
|
init_open_browser();
|
|
43843
44203
|
init_budget();
|
|
43844
44204
|
init_deployment_pointer();
|
|
@@ -43895,6 +44255,7 @@ export {
|
|
|
43895
44255
|
SUPPORTED_PI_VERSION,
|
|
43896
44256
|
USED_API,
|
|
43897
44257
|
_setPiVersionAdvisoryForTests,
|
|
44258
|
+
blessedBackends,
|
|
43898
44259
|
buildTriggerEntry,
|
|
43899
44260
|
computePiVersionAdvisory,
|
|
43900
44261
|
admin as default,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|