@edgehero/pi-dispatch 1.1.0 → 1.3.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 +22 -1
- package/deploy/worker-env-wrapper.cmd +8 -0
- package/deploy/worker-env-wrapper.sh +58 -5
- package/package.json +1 -1
- package/src/config.mjs +33 -0
- package/src/doctor.mjs +105 -4
- package/src/env-allowlist.mjs +31 -1
- package/src/forges.mjs +14 -0
- package/src/index.mjs +11 -0
- package/src/outbox.mjs +8 -0
- package/src/processor.mjs +138 -6
- package/src/queue.mjs +14 -2
- package/src/reserved-env.mjs +40 -0
- package/src/run-container.mjs +16 -7
- package/src/run-history.mjs +40 -2
- package/src/runtime-settings.mjs +40 -0
- package/src/schedules.mjs +1 -1
- package/src/secret-profiles.mjs +119 -0
- package/src/secrets.mjs +319 -0
- package/src/session-store.mjs +294 -15
- package/src/start.mjs +19 -1
- package/src/triggers.mjs +166 -6
package/src/triggers.mjs
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* Custom: triggers validated inline per config.mjs/schedules.mjs precedent; zod not in deps
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { configError } from "./config.mjs";
|
|
27
|
-
import { FORGE_KINDS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
|
|
26
|
+
import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
|
|
27
|
+
import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
|
|
28
|
+
import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
|
|
28
29
|
|
|
29
30
|
const ON_TYPES = new Set(["cron", "label", "comment", "pull_request"]);
|
|
30
31
|
|
|
@@ -100,6 +101,41 @@ const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
|
100
101
|
*/
|
|
101
102
|
const REPLICAS_MAX = 3;
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The ceiling on how many references one trigger may name (issue #225). Not tidiness: the worker resolves
|
|
106
|
+
* them SEQUENTIALLY, before the container, each with its own timeout, so N references hold a
|
|
107
|
+
* `PI_CONCURRENCY` slot for up to N x PI_SECRET_RESOLVE_TIMEOUT_MS inside the job's own 30-minute kill
|
|
108
|
+
* timer. A cap at load turns an unbounded slot occupancy into a bounded one for free, before anything
|
|
109
|
+
* runs. It bounds the host argv too: every resolved value is pushed as `-e NAME=VALUE` (docker-run.mjs).
|
|
110
|
+
*
|
|
111
|
+
* Sixteen rather than three: unlike `REPLICAS_MAX` this multiplies no spend, and a deploy job legitimately
|
|
112
|
+
* wants a handful of credentials. A literal for REPLICAS_MAX's reason -- this validator is pure and fs-free.
|
|
113
|
+
*/
|
|
114
|
+
const SECRETS_MAX = 16;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A POSIX environment variable name. Nothing in this repo validated one before `run.secrets` (checked), so
|
|
118
|
+
* this is the definition rather than a copy of one. Deliberately stricter than what `execve` would accept:
|
|
119
|
+
* a name is `-e NAME=VALUE` in the docker argv, so `=` would split in the wrong place, and the leading
|
|
120
|
+
* digit is excluded because a shell cannot expand `$1FOO` as that variable.
|
|
121
|
+
*/
|
|
122
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The env variable names a trigger may NOT bind, and why each set is here.
|
|
126
|
+
*
|
|
127
|
+
* These are the STATICALLY KNOWABLE half. `parseTriggers` is pure, fs-free and env-free, so it cannot see
|
|
128
|
+
* the resolved provider's credential variable names (they come from `findEnvKeys(provider, hostEnv)`) or
|
|
129
|
+
* the deployment's `PI_FORWARD_ENV` list. Those two are refused PRE-SPEND in the processor, where both are
|
|
130
|
+
* in hand -- the same load-time / deployment-state split `run.resume` already makes against
|
|
131
|
+
* `PI_SESSIONS_DIR`. Refusing here what can be answered here keeps the file's own mistakes in the file's
|
|
132
|
+
* own error.
|
|
133
|
+
*
|
|
134
|
+
* Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
|
|
135
|
+
* forge added to the table later must not need a second edit here to stay covered.
|
|
136
|
+
*/
|
|
137
|
+
const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES]);
|
|
138
|
+
|
|
103
139
|
function isNonEmptyString(value) {
|
|
104
140
|
return typeof value === "string" && value.trim() !== "";
|
|
105
141
|
}
|
|
@@ -228,6 +264,8 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
228
264
|
// Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
|
|
229
265
|
// returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
|
|
230
266
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
267
|
+
const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
|
|
268
|
+
const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
|
|
231
269
|
|
|
232
270
|
// provider/model/maxTurns stay absent when omitted so the value resolves at job start against the
|
|
233
271
|
// settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). github/packages/image stay
|
|
@@ -236,7 +274,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
236
274
|
// freeze today's default into every stored repeatable.
|
|
237
275
|
return {
|
|
238
276
|
on: { type: "cron", id, pattern },
|
|
239
|
-
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 !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }) },
|
|
277
|
+
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 !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
240
278
|
};
|
|
241
279
|
}
|
|
242
280
|
|
|
@@ -635,6 +673,122 @@ function validateReplicas(run, at, path) {
|
|
|
635
673
|
return replicas;
|
|
636
674
|
}
|
|
637
675
|
|
|
676
|
+
/**
|
|
677
|
+
* `run.secrets` -- the env variables this trigger's job receives, and the opaque references an operator's
|
|
678
|
+
* own resolver turns into values (REQ-TRIGGER-SECRETS, issue #225).
|
|
679
|
+
*
|
|
680
|
+
* THE GRAMMAR OF THE VALUES IS NOT OURS. `op://vault/item/field`, `secret/data/ci#stripe` and a bare name
|
|
681
|
+
* are all valid here, because what parses them is a script the operator wrote and named in
|
|
682
|
+
* `PI_SECRET_PROFILES`. This file validates the SHAPE of the map and never the meaning of a reference --
|
|
683
|
+
* the posture #206 and #209 already set ("the seam is a command"), and the same one
|
|
684
|
+
* `DES-SERVICE-ENV-SETUP-SEAM` implements one layer up. A regex over `op://` here would bless a vendor.
|
|
685
|
+
*
|
|
686
|
+
* WHY EACH REFUSAL:
|
|
687
|
+
* - not a plain object, or a value that is not a non-empty string. An array or a nested object is an
|
|
688
|
+
* operator writing a different feature than the one that exists.
|
|
689
|
+
* - more than SECRETS_MAX entries -- see that constant: it bounds a worker slot, not a preference.
|
|
690
|
+
* - a key that is not an environment variable name. There is no downstream check: `-e NAME=VALUE` goes
|
|
691
|
+
* into the docker argv as written, so a key with an `=` in it silently binds a different variable.
|
|
692
|
+
* - a key in RESERVED_ENV_NAMES. The mint, the egress policy and the closed map all write AFTER this
|
|
693
|
+
* feature does, so such a key would be accepted, overwritten, and the job would run without the value
|
|
694
|
+
* it named on a clean exit 0. Ordering is the backstop; this is the refusal, which is the same
|
|
695
|
+
* division of labour `PI_FORWARD_ENV` already keeps (config.mjs refuses the names, env-allowlist.mjs
|
|
696
|
+
* orders the assignments so a slip cannot matter).
|
|
697
|
+
* - a reference starting with `-`. It is passed as `argv[1]` of the resolver, where a leading dash
|
|
698
|
+
* parses as a flag -- exactly `validateImageRef`'s reason for the same refusal on `run.image`. We do
|
|
699
|
+
* NOT pass `--` before it instead: that would be a claim about the resolver's option parser, and the
|
|
700
|
+
* option parser is the operator's.
|
|
701
|
+
* - `run.resume: true`. `assertResumeAllowedOnGhSource` (get-token.mjs) already refuses the `gh` token
|
|
702
|
+
* source for a resumed job, and states the argument this inherits whole: every property
|
|
703
|
+
* CONST-TOKEN-SCOPED-PER-JOB relies on assumes the credential is an ENV VALUE that dies with the
|
|
704
|
+
* container, while a persisted transcript is a FILE on host disk, replayed into the next job on that
|
|
705
|
+
* key. A resolved vault value is an env value the agent can echo, and nothing here redacts a
|
|
706
|
+
* transcript. Refused with NO escape hatch, unlike that one's PI_SESSIONS_ALLOW_GH_SOURCE: a pure
|
|
707
|
+
* validator cannot read an env var to find one, and the reason is stronger anyway, since that refusal
|
|
708
|
+
* complains a `gh` login is "full-scope and NON-EXPIRING" and a vault password does not expire either.
|
|
709
|
+
*
|
|
710
|
+
* Called from ALL FOUR normalizers, cron included. Unlike `run.replicas` this is NOT refused on a local
|
|
711
|
+
* job: nothing about resolving a secret is forge-specific, and a nightly deploy is the obvious user. The
|
|
712
|
+
* replica refusal turns on a local `/workspace` being the operator's own folder with no clone, which is a
|
|
713
|
+
* fact about two agents sharing a working tree, not about a credential. That folder does bring its own
|
|
714
|
+
* hazard (an agent that writes a credential into `.env` writes it into the operator's real repository),
|
|
715
|
+
* and `doctor` warns about exactly that rather than this validator refusing the use case outright.
|
|
716
|
+
*
|
|
717
|
+
* Returns the map, undefined when absent, so an unflagged trigger normalizes byte-identically.
|
|
718
|
+
*/
|
|
719
|
+
function validateSecrets(run, at, path) {
|
|
720
|
+
const secrets = run.secrets;
|
|
721
|
+
if (secrets === undefined) return undefined;
|
|
722
|
+
if (secrets === null || typeof secrets !== "object" || Array.isArray(secrets)) {
|
|
723
|
+
throw configError(`${at}: run.secrets must be an object mapping environment variable names to references when present (got ${JSON.stringify(secrets)}): ${path}`);
|
|
724
|
+
}
|
|
725
|
+
const names = Object.keys(secrets);
|
|
726
|
+
if (names.length === 0) {
|
|
727
|
+
throw configError(`${at}: run.secrets is empty -- an empty map is a field that does nothing, and this trigger reads as though it binds a secret: ${path}`);
|
|
728
|
+
}
|
|
729
|
+
if (names.length > SECRETS_MAX) {
|
|
730
|
+
throw configError(`${at}: run.secrets names ${names.length} variables, over the ${SECRETS_MAX} cap -- each one is resolved before the container starts, holding a concurrency slot while it runs: ${path}`);
|
|
731
|
+
}
|
|
732
|
+
for (const name of names) {
|
|
733
|
+
if (!ENV_NAME.test(name)) {
|
|
734
|
+
throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is not an environment variable name (letters, digits and underscore, not starting with a digit): ${path}`);
|
|
735
|
+
}
|
|
736
|
+
if (RESERVED_ENV_NAMES.has(name)) {
|
|
737
|
+
throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is a variable the worker sets itself -- the job container would receive the worker's value, not this trigger's, and the trigger would look like it worked: ${path}`);
|
|
738
|
+
}
|
|
739
|
+
const reference = secrets[name];
|
|
740
|
+
if (!isNonEmptyString(reference)) {
|
|
741
|
+
throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
|
|
742
|
+
}
|
|
743
|
+
if (reference !== reference.trim()) {
|
|
744
|
+
throw configError(`${at}: run.secrets.${name} must not have leading or trailing whitespace -- the file is the reviewed artifact and silently trimming it would make the file disagree with what the resolver is asked for: ${path}`);
|
|
745
|
+
}
|
|
746
|
+
if (reference.startsWith("-")) {
|
|
747
|
+
throw configError(`${at}: run.secrets.${name} must not start with "-" -- it is passed as the resolver's first argument, where a leading dash parses as a flag: ${path}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (run.resume === true) {
|
|
751
|
+
throw configError(`${at}: run.secrets and run.resume cannot be combined -- a resumed job replays a transcript kept on host disk, and any command the agent ran that echoed a resolved value wrote it into that transcript, which is then prefilled into every later job on the same key: ${path}`);
|
|
752
|
+
}
|
|
753
|
+
return { ...secrets };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* `run.secretsProfile` -- WHICH of the operator's declared resolvers reads this trigger's references.
|
|
758
|
+
*
|
|
759
|
+
* A NAME, never a path, and that distinction is the whole reason this field is allowed to exist.
|
|
760
|
+
* `DES-SERVICE-ENV-SETUP-SEAM` rejected "making this reachable from configuration, which would turn a
|
|
761
|
+
* boot-time root-adjacent exec into something a trigger file could name". A profile name selects among
|
|
762
|
+
* execs the operator already declared in `PI_SECRET_PROFILES`; it cannot introduce one, cannot name a
|
|
763
|
+
* path, and cannot reach a script nobody wired. The rejected thing is a trigger file NAMING an exec.
|
|
764
|
+
*
|
|
765
|
+
* The charset is `ID_CHARSET`, shared with cron ids, for two reasons that both bite: the name is echoed in
|
|
766
|
+
* a refusal that `comment` posts PUBLICLY on the issue, and `PI_SECRET_PROFILES` is a `,`-separated list
|
|
767
|
+
* of `name:path` pairs, so a name containing either separator could not round-trip through the very
|
|
768
|
+
* variable that declares it.
|
|
769
|
+
*
|
|
770
|
+
* WHETHER the named profile exists is NOT checked here and cannot be: which profiles a deployment declares
|
|
771
|
+
* is env and overlay state, and this validator is pure and fs-free. That is refused pre-spend, per
|
|
772
|
+
* delivery, as `secret-profile-unknown` -- the same split `run.resume` makes against `PI_SESSIONS_DIR`,
|
|
773
|
+
* where the file answers what the file knows and `doctor` plus a per-delivery refusal carry the rest.
|
|
774
|
+
*
|
|
775
|
+
* Absent selects the profile named `default`, so a single-manager deployment never writes the field.
|
|
776
|
+
*/
|
|
777
|
+
function validateSecretsProfile(run, at, path) {
|
|
778
|
+
const profile = run.secretsProfile;
|
|
779
|
+
if (profile === undefined) return undefined;
|
|
780
|
+
if (!isNonEmptyString(profile)) {
|
|
781
|
+
throw configError(`${at}: run.secretsProfile must be a non-empty string naming one of the resolver profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
|
|
782
|
+
}
|
|
783
|
+
if (!ID_CHARSET.test(profile)) {
|
|
784
|
+
throw configError(`${at}: run.secretsProfile ${JSON.stringify(profile)} may use letters, digits, dot, dash and underscore only -- PI_SECRET_PROFILES is a comma-separated list of name:path pairs, so a name carrying either separator cannot be declared: ${path}`);
|
|
785
|
+
}
|
|
786
|
+
if (run.secrets === undefined) {
|
|
787
|
+
throw configError(`${at}: run.secretsProfile is set but run.secrets names nothing -- a profile that resolves no references is a field that does nothing: ${path}`);
|
|
788
|
+
}
|
|
789
|
+
return profile;
|
|
790
|
+
}
|
|
791
|
+
|
|
638
792
|
function normalizeLabel(on, run, index, path) {
|
|
639
793
|
const at = `trigger at index ${index}`;
|
|
640
794
|
const predicate = validatePredicate(on, index, path, true);
|
|
@@ -650,9 +804,11 @@ function normalizeLabel(on, run, index, path) {
|
|
|
650
804
|
const resume = validateResumeFlag(run, at, path);
|
|
651
805
|
const repository = validateRepository(run, "label", at, path);
|
|
652
806
|
const replicas = validateReplicas(run, at, path);
|
|
807
|
+
const secrets = validateSecrets(run, at, path);
|
|
808
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
653
809
|
return {
|
|
654
810
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
655
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
811
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
656
812
|
};
|
|
657
813
|
}
|
|
658
814
|
|
|
@@ -682,9 +838,11 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
682
838
|
const resume = validateResumeFlag(run, at, path);
|
|
683
839
|
const repository = validateRepository(run, "comment", at, path);
|
|
684
840
|
const replicas = validateReplicas(run, at, path);
|
|
841
|
+
const secrets = validateSecrets(run, at, path);
|
|
842
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
685
843
|
return {
|
|
686
844
|
on: { type: "comment", phrase: on.phrase },
|
|
687
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
845
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
688
846
|
};
|
|
689
847
|
}
|
|
690
848
|
|
|
@@ -737,6 +895,8 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
737
895
|
const resume = validateResumeFlag(run, at, path);
|
|
738
896
|
validateRepository(run, "pull_request", at, path);
|
|
739
897
|
const replicas = validateReplicas(run, at, path);
|
|
898
|
+
const secrets = validateSecrets(run, at, path);
|
|
899
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
740
900
|
return {
|
|
741
901
|
on: {
|
|
742
902
|
type: "pull_request",
|
|
@@ -748,7 +908,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
748
908
|
all: predicate.all,
|
|
749
909
|
none: predicate.none,
|
|
750
910
|
},
|
|
751
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
|
|
911
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
752
912
|
};
|
|
753
913
|
}
|
|
754
914
|
|