@edgehero/pi-dispatch-admin 1.2.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/dist/index.mjs CHANGED
@@ -7611,7 +7611,7 @@ function isForgeKind(kind) {
7611
7611
  function forgeSpec(kind) {
7612
7612
  return isForgeKind(kind) ? FORGES[kind] : void 0;
7613
7613
  }
7614
- var FORGES, FORGE_KINDS, RUN_KINDS, MINTED_TOKEN_VARS;
7614
+ var FORGES, FORGE_KINDS, RUN_KINDS, MINTED_TOKEN_VARS, FORGE_HOST_VARS;
7615
7615
  var init_forges = __esm({
7616
7616
  "../worker/src/forges.mjs"() {
7617
7617
  FORGES = {
@@ -7677,6 +7677,13 @@ var init_forges = __esm({
7677
7677
  FORGE_KINDS = Object.freeze(Object.keys(FORGES));
7678
7678
  RUN_KINDS = Object.freeze(["local", ...FORGE_KINDS]);
7679
7679
  MINTED_TOKEN_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].tokenVars));
7680
+ FORGE_HOST_VARS = new Set(FORGE_KINDS.map((kind) => FORGES[kind].hostVar).filter((name) => typeof name === "string"));
7681
+ }
7682
+ });
7683
+
7684
+ // ../worker/src/secret-profiles.mjs
7685
+ var init_secret_profiles = __esm({
7686
+ "../worker/src/secret-profiles.mjs"() {
7680
7687
  }
7681
7688
  });
7682
7689
 
@@ -7701,13 +7708,16 @@ function defaultGraphDir(env = process.env) {
7701
7708
  function defaultSettingsFile() {
7702
7709
  return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/settings.json`.replace(/\\/g, "/");
7703
7710
  }
7704
- var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT;
7711
+ var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT, WORKER_ONLY_SECRET_VARS, EGRESS_ENV_VARS;
7705
7712
  var init_config2 = __esm({
7706
7713
  "../worker/src/config.mjs"() {
7707
7714
  init_egress();
7708
7715
  init_forges();
7716
+ init_secret_profiles();
7709
7717
  CHAIN_DEPTH_MAX_DEFAULT = 1;
7710
7718
  CHAIN_MAX_PER_JOB_DEFAULT = 2;
7719
+ WORKER_ONLY_SECRET_VARS = /* @__PURE__ */ new Set(["GITHUB_APP_PRIVATE_KEY"]);
7720
+ EGRESS_ENV_VARS = /* @__PURE__ */ new Set(["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY"]);
7711
7721
  }
7712
7722
  });
7713
7723
 
@@ -7752,6 +7762,21 @@ function validateOverlay(candidate, log) {
7752
7762
  if (!isIntInRange(value, 1, 10)) return { invalid: "concurrency must be an integer 1-10" };
7753
7763
  overlay[key] = value;
7754
7764
  break;
7765
+ case "secretProfiles": {
7766
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
7767
+ return { invalid: "secretProfiles must be an object mapping profile names to resolver paths" };
7768
+ }
7769
+ for (const name of Object.keys(value)) {
7770
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
7771
+ return { invalid: "secretProfiles names may use letters, digits, dot, dash and underscore only" };
7772
+ }
7773
+ if (typeof value[name] !== "string" || value[name].trim() === "") {
7774
+ return { invalid: `secretProfiles.${name} must be a non-empty absolute path` };
7775
+ }
7776
+ }
7777
+ overlay[key] = { ...value };
7778
+ break;
7779
+ }
7755
7780
  case "softHoldPct":
7756
7781
  if (!isIntInRange(value, 1, 99)) return { invalid: "softHoldPct must be an integer 1-99" };
7757
7782
  overlay[key] = value;
@@ -7861,6 +7886,29 @@ var init_budget = __esm({
7861
7886
  }
7862
7887
  });
7863
7888
 
7889
+ // ../worker/src/reserved-env.mjs
7890
+ var CONTAINER_ENV_NAMES;
7891
+ var init_reserved_env = __esm({
7892
+ "../worker/src/reserved-env.mjs"() {
7893
+ CONTAINER_ENV_NAMES = /* @__PURE__ */ new Set([
7894
+ "PI_PROVIDER",
7895
+ "PI_MODEL",
7896
+ "PI_MAX_TURNS",
7897
+ "PI_MAX_TOKENS",
7898
+ "PI_JOB_ID",
7899
+ "PI_GLOBAL_ALLOW_EXTENSIONS",
7900
+ "PI_PACKAGES",
7901
+ "PI_SESSION_FILE",
7902
+ "PI_FLOW",
7903
+ "PI_COMMAND",
7904
+ "PI_OFFLINE",
7905
+ "PLAYWRIGHT_BROWSERS_PATH",
7906
+ "PLAYWRIGHT_MCP_BROWSER",
7907
+ "PLAYWRIGHT_MCP_SANDBOX"
7908
+ ]);
7909
+ }
7910
+ });
7911
+
7864
7912
  // ../worker/src/triggers.mjs
7865
7913
  function isNonEmptyString2(value) {
7866
7914
  return typeof value === "string" && value.trim() !== "";
@@ -7953,9 +8001,11 @@ function normalizeCron(on, run, index, path, state) {
7953
8001
  validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
7954
8002
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
7955
8003
  validateReplicas(run, `cron trigger "${id}"`, path);
8004
+ const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
8005
+ const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
7956
8006
  return {
7957
8007
  on: { type: "cron", id, pattern },
7958
- 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 } }
8008
+ 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 } }
7959
8009
  };
7960
8010
  }
7961
8011
  function validatePackagesFlag(run, at, path) {
@@ -8086,6 +8136,56 @@ function validateReplicas(run, at, path) {
8086
8136
  }
8087
8137
  return replicas;
8088
8138
  }
8139
+ function validateSecrets(run, at, path) {
8140
+ const secrets = run.secrets;
8141
+ if (secrets === void 0) return void 0;
8142
+ if (secrets === null || typeof secrets !== "object" || Array.isArray(secrets)) {
8143
+ throw configError(`${at}: run.secrets must be an object mapping environment variable names to references when present (got ${JSON.stringify(secrets)}): ${path}`);
8144
+ }
8145
+ const names = Object.keys(secrets);
8146
+ if (names.length === 0) {
8147
+ 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}`);
8148
+ }
8149
+ if (names.length > SECRETS_MAX) {
8150
+ 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}`);
8151
+ }
8152
+ for (const name of names) {
8153
+ if (!ENV_NAME.test(name)) {
8154
+ 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}`);
8155
+ }
8156
+ if (RESERVED_ENV_NAMES.has(name)) {
8157
+ 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}`);
8158
+ }
8159
+ const reference = secrets[name];
8160
+ if (!isNonEmptyString2(reference)) {
8161
+ throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
8162
+ }
8163
+ if (reference !== reference.trim()) {
8164
+ 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}`);
8165
+ }
8166
+ if (reference.startsWith("-")) {
8167
+ 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}`);
8168
+ }
8169
+ }
8170
+ if (run.resume === true) {
8171
+ 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}`);
8172
+ }
8173
+ return { ...secrets };
8174
+ }
8175
+ function validateSecretsProfile(run, at, path) {
8176
+ const profile = run.secretsProfile;
8177
+ if (profile === void 0) return void 0;
8178
+ if (!isNonEmptyString2(profile)) {
8179
+ 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}`);
8180
+ }
8181
+ if (!ID_CHARSET.test(profile)) {
8182
+ 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}`);
8183
+ }
8184
+ if (run.secrets === void 0) {
8185
+ 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}`);
8186
+ }
8187
+ return profile;
8188
+ }
8089
8189
  function normalizeLabel(on, run, index, path) {
8090
8190
  const at = `trigger at index ${index}`;
8091
8191
  const predicate = validatePredicate(on, index, path, true);
@@ -8100,9 +8200,11 @@ function normalizeLabel(on, run, index, path) {
8100
8200
  const resume = validateResumeFlag(run, at, path);
8101
8201
  const repository = validateRepository(run, "label", at, path);
8102
8202
  const replicas = validateReplicas(run, at, path);
8203
+ const secrets = validateSecrets(run, at, path);
8204
+ const secretsProfile = validateSecretsProfile(run, at, path);
8103
8205
  return {
8104
8206
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8105
- 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 } }
8207
+ 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 } }
8106
8208
  };
8107
8209
  }
8108
8210
  function normalizeComment(on, run, index, path, state) {
@@ -8125,9 +8227,11 @@ function normalizeComment(on, run, index, path, state) {
8125
8227
  const resume = validateResumeFlag(run, at, path);
8126
8228
  const repository = validateRepository(run, "comment", at, path);
8127
8229
  const replicas = validateReplicas(run, at, path);
8230
+ const secrets = validateSecrets(run, at, path);
8231
+ const secretsProfile = validateSecretsProfile(run, at, path);
8128
8232
  return {
8129
8233
  on: { type: "comment", phrase: on.phrase },
8130
- 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 } }
8234
+ 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 } }
8131
8235
  };
8132
8236
  }
8133
8237
  function normalizePullRequest(on, run, index, path) {
@@ -8161,6 +8265,8 @@ function normalizePullRequest(on, run, index, path) {
8161
8265
  const resume = validateResumeFlag(run, at, path);
8162
8266
  validateRepository(run, "pull_request", at, path);
8163
8267
  const replicas = validateReplicas(run, at, path);
8268
+ const secrets = validateSecrets(run, at, path);
8269
+ const secretsProfile = validateSecretsProfile(run, at, path);
8164
8270
  return {
8165
8271
  on: {
8166
8272
  type: "pull_request",
@@ -8172,7 +8278,7 @@ function normalizePullRequest(on, run, index, path) {
8172
8278
  all: predicate.all,
8173
8279
  none: predicate.none
8174
8280
  },
8175
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
8281
+ 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 } }
8176
8282
  };
8177
8283
  }
8178
8284
  function validateReviewState(on, actions, run, at, path) {
@@ -8194,11 +8300,12 @@ function validateReviewState(on, actions, run, at, path) {
8194
8300
  }
8195
8301
  return [...on.reviewState];
8196
8302
  }
8197
- var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, INSTRUCTIONS_MAX;
8303
+ var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
8198
8304
  var init_triggers = __esm({
8199
8305
  "../worker/src/triggers.mjs"() {
8200
8306
  init_config2();
8201
8307
  init_forges();
8308
+ init_reserved_env();
8202
8309
  ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
8203
8310
  PR_ACTIONS = {
8204
8311
  github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
@@ -8221,6 +8328,9 @@ var init_triggers = __esm({
8221
8328
  REVIEW_ACTION = "review_submitted";
8222
8329
  ID_CHARSET = /^[A-Za-z0-9._-]+$/;
8223
8330
  REPLICAS_MAX = 3;
8331
+ SECRETS_MAX = 16;
8332
+ ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
8333
+ RESERVED_ENV_NAMES = /* @__PURE__ */ new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES]);
8224
8334
  INSTRUCTIONS_MAX = 2e3;
8225
8335
  }
8226
8336
  });
@@ -8553,7 +8663,7 @@ import { Queue } from "bullmq";
8553
8663
  function makeQueue(connection) {
8554
8664
  return new Queue(QUEUE, { connection });
8555
8665
  }
8556
- async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8666
+ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
8557
8667
  const minute = now.toISOString().slice(0, 16);
8558
8668
  const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
8559
8669
  const data = {
@@ -8574,6 +8684,11 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
8574
8684
  // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
8575
8685
  // delivery -- and `trigger` is the object copied into /job/event.json.
8576
8686
  ...skillsDir !== void 0 && { skillsDir },
8687
+ // REQ-TRIGGER-SECRETS, on the forge path's terms: references only, resolved by the worker at job
8688
+ // start. A cron trigger may bind secrets (a nightly deploy is the obvious user), which is where
8689
+ // this differs from `replicas` -- that one is refused on a local job and this one is not.
8690
+ ...secrets !== void 0 && { secrets },
8691
+ ...secretsProfile !== void 0 && { secretsProfile },
8577
8692
  ...chainDepth !== void 0 && { chainDepth },
8578
8693
  ...parentJobId !== void 0 && { parentJobId }
8579
8694
  };
@@ -10393,6 +10508,8 @@ function normalizeTriggerForDisplay(entry) {
10393
10508
  const resume = run.resume === true;
10394
10509
  const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
10395
10510
  const replicas = Number.isInteger(run.replicas) && run.replicas > 1 ? run.replicas : null;
10511
+ const secrets = run.secrets !== null && typeof run.secrets === "object" && !Array.isArray(run.secrets) ? Object.keys(run.secrets).length : 0;
10512
+ const secretsProfile = typeof run.secretsProfile === "string" && run.secretsProfile.trim() !== "" ? run.secretsProfile : null;
10396
10513
  switch (on.type) {
10397
10514
  case "cron":
10398
10515
  return {
@@ -10409,12 +10526,14 @@ function normalizeTriggerForDisplay(entry) {
10409
10526
  image,
10410
10527
  skillsDir,
10411
10528
  instructions,
10412
- resume
10529
+ resume,
10530
+ secrets,
10531
+ secretsProfile
10413
10532
  };
10414
10533
  case "label":
10415
- return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
10534
+ 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 };
10416
10535
  case "comment":
10417
- return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
10536
+ return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
10418
10537
  case "pull_request":
10419
10538
  return {
10420
10539
  type: "pull_request",
@@ -10429,6 +10548,8 @@ function normalizeTriggerForDisplay(entry) {
10429
10548
  skillsDir,
10430
10549
  instructions,
10431
10550
  resume,
10551
+ secrets,
10552
+ secretsProfile,
10432
10553
  replicas,
10433
10554
  forge
10434
10555
  };
@@ -38917,17 +39038,18 @@ function triggerLine(t) {
38917
39038
  const ins = t?.instructions === true ? " [instructions]" : "";
38918
39039
  const res = t?.resume === true ? " [resume]" : "";
38919
39040
  const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
39041
+ const sec = t?.secrets > 0 ? ` [secrets ${t.secrets}${t.secretsProfile ? ` via ${t.secretsProfile}` : ""}]` : "";
38920
39042
  switch (t?.type) {
38921
39043
  case "cron":
38922
- return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39044
+ return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
38923
39045
  case "label":
38924
- return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39046
+ return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
38925
39047
  case "comment":
38926
- return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39048
+ return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
38927
39049
  case "pull_request": {
38928
39050
  const clauses = ruleClauses(t);
38929
39051
  const action = `action[${(t.action ?? []).join(",")}]`;
38930
- return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
39052
+ return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}${sec}`;
38931
39053
  }
38932
39054
  default:
38933
39055
  return "(unknown trigger)";
@@ -40038,6 +40160,12 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
40038
40160
  keeps("issue text, file contents, tool output, the agent's own reasoning");
40039
40161
  keeps("replayed into the next job on the same branch; forks never resume");
40040
40162
  }
40163
+ if (t.secrets > 0) {
40164
+ const holds = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
40165
+ holds(`${t.secrets} vault secret(s) injected${t.secretsProfile ? ` via profile ${t.secretsProfile}` : ""}`);
40166
+ holds("resolved on the host before the container; the job holds no vault credential");
40167
+ holds("the agent can read them, and the forge is on the egress allowlist");
40168
+ }
40041
40169
  if (t.replicas > 1) {
40042
40170
  const races = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
40043
40171
  races(`each delivery starts ${t.replicas} independent jobs`);
@@ -40867,8 +40995,8 @@ var init_setup_wizard = __esm({
40867
40995
  init_deployment_pointer();
40868
40996
  init_read_model();
40869
40997
  init_index();
40870
- RUNTIME_VERSION = "1.2.0";
40871
- RECEIVER_VERSION = "1.1.0";
40998
+ RUNTIME_VERSION = "1.3.0";
40999
+ RECEIVER_VERSION = "1.2.0";
40872
41000
  RUNTIME_PKG = "@edgehero/pi-dispatch";
40873
41001
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
40874
41002
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -40883,6 +41011,104 @@ var init_setup_wizard = __esm({
40883
41011
  }
40884
41012
  });
40885
41013
 
41014
+ // src/secrets-command.ts
41015
+ var secrets_command_exports = {};
41016
+ __export(secrets_command_exports, {
41017
+ runSecretsCommand: () => runSecretsCommand
41018
+ });
41019
+ function renderProfiles(profiles) {
41020
+ const names = Object.keys(profiles).sort();
41021
+ if (names.length === 0) return "No resolver profiles are declared in the settings overlay.";
41022
+ return ["Resolver profiles (settings overlay):", ...names.map((n) => ` ${n} -> ${profiles[n]}`)].join("\n");
41023
+ }
41024
+ async function runSecretsCommand(paths, ctx, notify, tokens, deps = {}) {
41025
+ const ui = ctx?.ui;
41026
+ const fsSeam = deps.fs ? { fs: deps.fs } : {};
41027
+ const view = readSettingsView({ settingsFile: paths.settingsFile, ...fsSeam });
41028
+ if (view.invalid) {
41029
+ notify?.(`settings overlay is unreadable (${view.invalid}) \u2014 fix it before declaring a profile`, "error");
41030
+ return;
41031
+ }
41032
+ const profiles = { ...view.overlay?.secretProfiles ?? {} };
41033
+ const action = tokens[0] ?? "list";
41034
+ if (action === "list") {
41035
+ notify?.(renderProfiles(profiles), "info");
41036
+ return;
41037
+ }
41038
+ if (typeof ui?.input !== "function" || typeof ui?.confirm !== "function") {
41039
+ notify?.("declaring a profile needs dialogs (newer pi) \u2014 no input/confirm available", "warning");
41040
+ return;
41041
+ }
41042
+ if (action === "add") {
41043
+ const nameAnswer = await ui.input("profile name \u2014 what a trigger's run.secretsProfile will say (e.g. prod)", "default");
41044
+ const name = (nameAnswer ?? "").trim();
41045
+ if (name === "") return;
41046
+ if (!PROFILE_NAME.test(name)) {
41047
+ notify?.("a profile name may use letters, digits, dot, dash and underscore only \u2014 PI_SECRET_PROFILES is a comma-separated list of name:path pairs", "error");
41048
+ return;
41049
+ }
41050
+ const pathAnswer = await ui.input("resolver \u2014 ABSOLUTE path to the script that reads one reference", "/opt/pi/resolve.sh");
41051
+ const path = (pathAnswer ?? "").trim();
41052
+ if (path === "") return;
41053
+ if (!(path.startsWith("/") || /^([A-Za-z]:[\\/]|\\\\)/.test(path))) {
41054
+ notify?.("the resolver needs an ABSOLUTE path: a service manager's working directory is not your shell's", "error");
41055
+ return;
41056
+ }
41057
+ const replacing = profiles[name] ? `
41058
+
41059
+ This REPLACES the current ${name} -> ${profiles[name]}` : "";
41060
+ const ok = await ui.confirm(
41061
+ "Declare a secret resolver",
41062
+ `${paths.settingsFile} gets:
41063
+ ${JSON.stringify({ secretProfiles: { ...profiles, [name]: path } }, null, 2)}${replacing}
41064
+
41065
+ The worker EXECUTES this script, as the account it runs as, once per reference. It must also sit inside PI_SECRET_RESOLVER_ROOTS or the worker will refuse it.`
41066
+ );
41067
+ if (!ok) {
41068
+ notify?.("not declared", "info");
41069
+ return;
41070
+ }
41071
+ const res = writeSettings({ settingsFile: paths.settingsFile, mutate: (o) => ({ ...o, secretProfiles: { ...profiles, [name]: path } }), ...fsSeam });
41072
+ if (res.invalid) {
41073
+ notify?.(`rejected: ${res.invalid}`, "error");
41074
+ return;
41075
+ }
41076
+ notify?.(`declared ${name} -> ${path}. A trigger reaches it with "secretsProfile": "${name}" beside its "secrets" map, which is an edit to triggers.json.`, "info");
41077
+ return;
41078
+ }
41079
+ if (action === "remove") {
41080
+ const name = (tokens[1] ?? "").trim();
41081
+ if (!name || !(name in profiles)) {
41082
+ notify?.(`no such profile. ${renderProfiles(profiles)}`, "warning");
41083
+ return;
41084
+ }
41085
+ const ok = await ui.confirm("Remove a secret resolver", `${name} -> ${profiles[name]}
41086
+
41087
+ Every trigger naming it refuses PRE-SPEND after this, rather than running without its secrets.`);
41088
+ if (!ok) {
41089
+ notify?.("not removed", "info");
41090
+ return;
41091
+ }
41092
+ const next = { ...profiles };
41093
+ delete next[name];
41094
+ const res = writeSettings({ settingsFile: paths.settingsFile, mutate: (o) => ({ ...o, secretProfiles: next }), ...fsSeam });
41095
+ if (res.invalid) {
41096
+ notify?.(`rejected: ${res.invalid}`, "error");
41097
+ return;
41098
+ }
41099
+ notify?.(`removed ${name}`, "info");
41100
+ return;
41101
+ }
41102
+ notify?.("usage: /dispatch secrets <list|add|remove <name>>", "warning");
41103
+ }
41104
+ var PROFILE_NAME;
41105
+ var init_secrets_command = __esm({
41106
+ "src/secrets-command.ts"() {
41107
+ init_read_model();
41108
+ PROFILE_NAME = /^[A-Za-z0-9._-]+$/;
41109
+ }
41110
+ });
41111
+
40886
41112
  // src/index.ts
40887
41113
  import { VERSION } from "@earendil-works/pi-coding-agent";
40888
41114
  import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
@@ -41458,6 +41684,11 @@ ${renderBudget({ budget, settings: settings2 })}`);
41458
41684
  applyUnset(paths.settingsFile, tokens, notify);
41459
41685
  return;
41460
41686
  }
41687
+ case "secrets": {
41688
+ const { runSecretsCommand: runSecretsCommand2 } = await Promise.resolve().then(() => (init_secrets_command(), secrets_command_exports));
41689
+ await runSecretsCommand2(paths, ctx, notify, tokens.slice(1));
41690
+ return;
41691
+ }
41461
41692
  case "setup": {
41462
41693
  const { runSetupWizard: runSetupWizard2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
41463
41694
  await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard });
@@ -42063,7 +42294,7 @@ var init_index = __esm({
42063
42294
  runtimeSkewNoticed = false;
42064
42295
  CHANNEL = "pi-dispatch-admin";
42065
42296
  REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
42066
- USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|insights|triggers|settings|set|unset|setup>";
42297
+ USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|insights|triggers|settings|set|unset|setup|secrets>";
42067
42298
  KNOWN_SUBCOMMANDS = [
42068
42299
  "status",
42069
42300
  "pause",
@@ -42077,7 +42308,8 @@ var init_index = __esm({
42077
42308
  "settings",
42078
42309
  "set",
42079
42310
  "unset",
42080
- "setup"
42311
+ "setup",
42312
+ "secrets"
42081
42313
  ];
42082
42314
  FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
42083
42315
  PR_ACTION_VOCAB = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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",
@@ -120,6 +120,36 @@ cannot, and that it is an edit they make to `triggers.json` themselves.** Do not
120
120
  built or pulled on the worker's own host, because jobs run with `--pull=never` and nothing is fetched at
121
121
  job time; and `pi-dispatch doctor` lists every image their triggers name and flags one that is missing.
122
122
 
123
+ ## Vault secrets — `run.secrets`, and why you cannot set it
124
+
125
+ A trigger may carry `run.secrets`: a map of environment variable name to an opaque **reference**, plus
126
+ `run.secretsProfile` naming which of the operator's resolvers reads them. Before the container starts, the
127
+ worker runs that resolver once per reference on the HOST and injects the values into the job's environment.
128
+ It exists so one trigger can hold a deploy key while every other job on the deployment holds none.
129
+
130
+ Report it when asked, and be precise about the shape of the trade. **The job holds no vault credential.** It
131
+ receives values, never the thing that can fetch values, so it cannot enumerate the vault or reach anything
132
+ the operator did not name in a reviewed file. What that does *not* do is bound where a value can go once the
133
+ agent has it: the agent can read its own environment, and the forge is on the egress allowlist by necessity.
134
+ The panel shows the count and the profile name in the trigger list and in the drill-in, never the references.
135
+
136
+ **You cannot set it, in either direction.** No `dispatch_*` tool has a `secrets` parameter, and none has a
137
+ `secretsProfile` parameter either. The second absence is worth understanding rather than treating as an
138
+ oversight: a profile that resolves nothing is refused at load, and no tool can write `run.secrets`, so a
139
+ profile picker could never produce a valid trigger even once. It would be a control that looks like a grant
140
+ and is only ever an error.
141
+
142
+ You also cannot declare a **resolver profile**. That is `/dispatch secrets add`, which the operator types
143
+ themselves, because declaring one means naming an absolute host path the worker executes.
144
+
145
+ So if a user asks you to give a trigger access to a vault, or to wire up 1Password: **say plainly that you
146
+ cannot, and that both halves are theirs.** Three useful things you *can* say: declaring the manager is
147
+ `/dispatch secrets add`, and it is two questions (a name and the path to a one-line script such as
148
+ `exec op read --no-newline "$1"`); binding it to a trigger is an edit to `triggers.json` beside that
149
+ trigger's `flow`; and `pi-dispatch doctor` lists every declared profile, fails loudly when a trigger names
150
+ one that is not declared, and warns when a **local** trigger binds secrets, because a local job edits the
151
+ operator's own folder in place and a credential the agent writes into `.env` lands in their real repository.
152
+
123
153
  ## The forge a trigger listens to — `run.kind`
124
154
 
125
155
  A webhook trigger names its forge: `"kind"` is `github`, `gitlab`, `forgejo` or `azure`. Everything else