@flowriki/agent 0.1.0-beta.65 → 0.1.0-beta.66

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.
@@ -28,6 +28,8 @@ import {
28
28
  adjacentLabelPattern,
29
29
  caseDebugPlan,
30
30
  checkMobileAssertion,
31
+ commandFor,
32
+ commandSystemOf,
31
33
  describeAssertion,
32
34
  describeLocators,
33
35
  expandForRun,
@@ -69,7 +71,7 @@ import {
69
71
  usageKindOf,
70
72
  validateActors,
71
73
  webCloudOf
72
- } from "./chunk-PDUMGI6V.js";
74
+ } from "./chunk-YD7F7JMA.js";
73
75
 
74
76
  // src/engine/machine.ts
75
77
  import fs from "fs";
@@ -77,7 +79,7 @@ import os from "os";
77
79
  import path from "path";
78
80
  import { createRequire } from "module";
79
81
  import { spawnSync } from "child_process";
80
- var AGENT_VERSION = "0.1.0-beta.65";
82
+ var AGENT_VERSION = "0.1.0-beta.66";
81
83
  async function prepareBrowsers() {
82
84
  if (browsersReady()) {
83
85
  return { ok: true, already: true, message: "chromium is already installed" };
@@ -137,7 +139,7 @@ async function checkMachine() {
137
139
  }
138
140
  } catch {
139
141
  }
140
- const { mobileDoctor: mobileDoctor2 } = await import("./dist-3PMNCKSW.js");
142
+ const { mobileDoctor: mobileDoctor2 } = await import("./dist-CUDUSTFO.js");
141
143
  return {
142
144
  agentVersion: AGENT_VERSION,
143
145
  node: process.versions.node,
@@ -236,7 +238,7 @@ function forgetConnection(service, env = process.env) {
236
238
  }
237
239
  var fingerprint = (secret2) => crypto2.createHash("sha256").update(secret2).digest("hex").slice(0, 12);
238
240
 
239
- // ../contracts/dist/chunk-NTBXG5EZ.js
241
+ // ../contracts/dist/chunk-VTO4SX4J.js
240
242
  import { z } from "zod";
241
243
  var PROTOCOL_VERSION = 1;
242
244
  var PAIRING_CODE_TTL_MS = 5 * 60 * 1e3;
@@ -289,7 +291,10 @@ var CommandNameSchema = z.enum([
289
291
  // a recording's — as a picture, for the live mirror. Reading.
290
292
  "mobile.peek",
291
293
  // What this computer has installed for web and phone testing. Reading.
292
- "machine.check"
294
+ "machine.check",
295
+ // A helper run HERE — "Try it" on a command helper, which only the
296
+ // tester's computer can run. NOT baseline.
297
+ "helper.run"
293
298
  ]);
294
299
  var EFFECTFUL = /* @__PURE__ */ new Set([
295
300
  "record.start",
@@ -333,7 +338,9 @@ var EFFECTFUL = /* @__PURE__ */ new Set([
333
338
  "project.claim",
334
339
  "run.cancel",
335
340
  // A phone session taps and types on somebody's device.
336
- "mobile.session"
341
+ "mobile.session",
342
+ // Runs a command on somebody's computer.
343
+ "helper.run"
337
344
  ]);
338
345
  var CommandSchema = z.object({
339
346
  /** Unique per command. The agent stores it, so a redelivery after a
@@ -484,7 +491,7 @@ function compareAgentVersions(a, b2) {
484
491
  }
485
492
  return 0;
486
493
  }
487
- var agentUpdateFor = (running, latest) => Boolean(running && latest && running !== "test" && compareAgentVersions(latest, running) > 0);
494
+ var agentUpdateFor = (running2, latest) => Boolean(running2 && latest && running2 !== "test" && compareAgentVersions(latest, running2) > 0);
488
495
 
489
496
  // src/service.ts
490
497
  var BadServiceUrl = class extends Error {
@@ -1843,7 +1850,7 @@ function installDebugPanel() {
1843
1850
  pendingRender = false;
1844
1851
  const p = root.getElementById("p");
1845
1852
  p.className = "p" + (vertical ? " v" : "");
1846
- const running = state.status === "running";
1853
+ const running2 = state.status === "running";
1847
1854
  const done = state.status === "finished";
1848
1855
  const stCls = state.failure ? "failed" : state.recording ? "running" : state.status;
1849
1856
  const stLabel = state.recording ? "recording" : state.failure ? "paused at a failure" : state.status === "idle" ? "not started" : state.status;
@@ -1936,7 +1943,7 @@ function installDebugPanel() {
1936
1943
  });
1937
1944
  return;
1938
1945
  }
1939
- p.innerHTML = '<div class="bar"><span class="grip" title="Drag to move"><i></i><i></i><i></i></span><span class="st ' + esc(stCls) + '"><span class="d"></span><span>' + esc(stLabel) + '</span><span class="prog">&#160;&#183;&#160;' + ran + "/" + state.steps.length + '</span></span><span class="dv"></span><button id="play" class="primary" title="' + (state.failure ? "Give up on this step and carry on" : "Run until the next breakpoint or failure") + '" ' + (running || done ? "disabled" : "") + ">" + (state.failure ? "Skip &amp; continue" : "Play") + "</button>" + (state.failure ? '<button id="retry" title="Run this step again">Retry</button>' : "") + '<button id="step" title="Run exactly one step" ' + (running || done ? "disabled" : "") + '>Step</button><button id="pause" title="Stop after the step in flight" ' + (running ? "" : "disabled") + '>Pause</button><button id="replay" title="Start over from the first step" ' + (running ? "disabled" : "") + '>Replay</button><span class="dv"></span>' + // While armed, this is the only way to stop it — so it says so, rather
1946
+ p.innerHTML = '<div class="bar"><span class="grip" title="Drag to move"><i></i><i></i><i></i></span><span class="st ' + esc(stCls) + '"><span class="d"></span><span>' + esc(stLabel) + '</span><span class="prog">&#160;&#183;&#160;' + ran + "/" + state.steps.length + '</span></span><span class="dv"></span><button id="play" class="primary" title="' + (state.failure ? "Give up on this step and carry on" : "Run until the next breakpoint or failure") + '" ' + (running2 || done ? "disabled" : "") + ">" + (state.failure ? "Skip &amp; continue" : "Play") + "</button>" + (state.failure ? '<button id="retry" title="Run this step again">Retry</button>' : "") + '<button id="step" title="Run exactly one step" ' + (running2 || done ? "disabled" : "") + '>Step</button><button id="pause" title="Stop after the step in flight" ' + (running2 ? "" : "disabled") + '>Pause</button><button id="replay" title="Start over from the first step" ' + (running2 ? "disabled" : "") + '>Replay</button><span class="dv"></span>' + // While armed, this is the only way to stop it — so it says so, rather
1940
1947
  // than reading as a status label nobody realises is a button.
1941
1948
  (state.recording ? '<button id="rec" class="danger" title="Stop capturing"><span class="rec"><span class="dot"></span>Stop recording</span></button>' : '<button id="rec" title="Capture new steps at the cursor">Record</button>') + // AUTHORING A CHECK BELONGS HERE MOST OF ALL. Recording had it and
1942
1949
  // debugging did not, which is the wrong way round: the moment worth
@@ -2474,9 +2481,9 @@ var RunActors = class {
2474
2481
  const previous = this.sessions.get(id);
2475
2482
  const round = (this.resets.get(id) ?? 0) + 1;
2476
2483
  this.resets.set(id, round);
2477
- const home = `actor-${id}-${round}`;
2478
- const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(home) });
2479
- this.homes.set(session, home);
2484
+ const home2 = `actor-${id}-${round}`;
2485
+ const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(home2) });
2486
+ this.homes.set(session, home2);
2480
2487
  this.sessions.set(id, session);
2481
2488
  this.current = session;
2482
2489
  this.currentId = id;
@@ -7025,7 +7032,7 @@ function ensureRootCommit(root) {
7025
7032
  }
7026
7033
 
7027
7034
  // ../helpers/dist/index.js
7028
- import { createHmac as createHmac2 } from "crypto";
7035
+ import { createHash, createHmac as createHmac2 } from "crypto";
7029
7036
  var HelperFailure = class extends Error {
7030
7037
  constructor(message) {
7031
7038
  super(message);
@@ -7279,6 +7286,82 @@ async function runHttp(config, ctx) {
7279
7286
  }
7280
7287
  }
7281
7288
  }
7289
+ function commandFingerprint(config, system) {
7290
+ const line = commandFor(config, system);
7291
+ const written = JSON.stringify({
7292
+ program: line.program,
7293
+ args: line.args,
7294
+ folder: config.folder,
7295
+ env: config.env,
7296
+ secretEnv: Object.keys(config.secretEnv).sort(),
7297
+ mode: config.mode,
7298
+ readyPattern: config.readyPattern
7299
+ });
7300
+ return createHash("sha256").update(written).digest("hex").slice(0, 16);
7301
+ }
7302
+ var lastLine = (text) => text.trim().split(/\r?\n/).filter(Boolean).pop() ?? "";
7303
+ async function runCommand(config, ctx, name) {
7304
+ if (!ctx.commands) {
7305
+ throw new HelperFailure("this helper runs a command on a tester's computer \u2014 try it, and run tests that use it, from a computer running the Flowriki agent");
7306
+ }
7307
+ const system = ctx.system ?? commandSystemOf(process.platform);
7308
+ const line = commandFor(config, system);
7309
+ const env = {};
7310
+ for (const [key, value] of Object.entries(config.env)) env[key] = fill(value, ctx.inputs);
7311
+ const redact2 = [];
7312
+ for (const [key, ref] of Object.entries(config.secretEnv)) {
7313
+ const value = secret(ctx, ref, `${key} secret`);
7314
+ env[key] = value;
7315
+ redact2.push(value);
7316
+ }
7317
+ const outcome = await ctx.commands.run({
7318
+ name,
7319
+ fingerprint: commandFingerprint(config, system),
7320
+ program: fill(line.program, ctx.inputs),
7321
+ // EACH ARGUMENT FILLED ON ITS OWN: a value is never split into two, and
7322
+ // never read by a shell.
7323
+ args: line.args.map((arg) => fill(arg, ctx.inputs)),
7324
+ folder: fill(config.folder, ctx.inputs),
7325
+ env,
7326
+ redact: redact2,
7327
+ mode: config.mode,
7328
+ readyPattern: config.readyPattern,
7329
+ timeoutMs: config.timeoutSeconds * 1e3
7330
+ });
7331
+ const evidence = {
7332
+ ...outcome.exitCode === null ? { state: config.mode === "background" ? "running until the test ends" : "stopped" } : { "exit code": String(outcome.exitCode) },
7333
+ ...lastLine(outcome.stdout) ? { "last line": lastLine(outcome.stdout).slice(0, 200) } : {}
7334
+ };
7335
+ if (config.mode === "run" && outcome.exitCode !== config.expectExit) {
7336
+ const why = lastLine(outcome.stderr) || lastLine(outcome.stdout);
7337
+ throw new HelperFailure(`the command ended with exit code ${outcome.exitCode ?? "?"}${config.expectExit ? ` (expected ${config.expectExit})` : ""}${why ? `: ${why.slice(0, 300)}` : ""}`);
7338
+ }
7339
+ const text = outcome.stdout;
7340
+ switch (config.extract.take) {
7341
+ case "nothing":
7342
+ return { value: "", detail: config.mode === "background" ? "started, and runs until the test ends" : "it ran", evidence };
7343
+ case "output": {
7344
+ const value = text.trim();
7345
+ return { value: value.length > 2e3 ? lastLine(value) : value, detail: "what the command printed", evidence };
7346
+ }
7347
+ case "pattern": {
7348
+ const value = takeCode(text, config.extract.pattern);
7349
+ if (value === null) throw new HelperFailure(`the command printed nothing matching ${config.extract.pattern}`);
7350
+ return { value, detail: "the match in what the command printed", evidence };
7351
+ }
7352
+ case "json": {
7353
+ let parsed;
7354
+ try {
7355
+ parsed = JSON.parse(text);
7356
+ } catch {
7357
+ throw new HelperFailure("the command did not print JSON");
7358
+ }
7359
+ const value = atPath(parsed, config.extract.path);
7360
+ if (value === void 0 || value === null) throw new HelperFailure(`what the command printed has no "${config.extract.path}"`);
7361
+ return { value: typeof value === "string" ? value : JSON.stringify(value), detail: `"${config.extract.path}" from what the command printed`, evidence };
7362
+ }
7363
+ }
7364
+ }
7282
7365
  async function runHelper(helper, ctx) {
7283
7366
  const config = "config" in helper ? helper.config : helper;
7284
7367
  switch (config.kind) {
@@ -7292,11 +7375,17 @@ async function runHelper(helper, ctx) {
7292
7375
  return runSms(config, ctx);
7293
7376
  case "http":
7294
7377
  return runHttp(config, ctx);
7378
+ case "command":
7379
+ return runCommand(config, ctx, "name" in helper ? helper.name : "this command");
7295
7380
  }
7296
7381
  }
7297
7382
 
7298
7383
  // ../execution/dist/index.js
7384
+ import { spawn as spawn2 } from "child_process";
7299
7385
  import fs24 from "fs";
7386
+ import os5 from "os";
7387
+ import path24 from "path";
7388
+ import fs34 from "fs";
7300
7389
  import crypto3 from "crypto";
7301
7390
  async function cloudBrowserFor(config, secrets, name) {
7302
7391
  const settings = config ?? {};
@@ -7470,6 +7559,7 @@ async function executeFlow(opts) {
7470
7559
  writes.push(emit({ type: "run.step", runId: run?.id ?? "", flowId: flow.id, result }));
7471
7560
  }
7472
7561
  });
7562
+ store.runHelper?.close?.();
7473
7563
  if (storage && taskId && run) {
7474
7564
  const reached = new Set(outcome.steps.map((r) => r.stepId));
7475
7565
  for (const step of flow.steps) {
@@ -7635,27 +7725,203 @@ function toStepRow(ordinal, result, step) {
7635
7725
  checks
7636
7726
  };
7637
7727
  }
7728
+ var home = () => process.env["FLOWRIKI_HOME"] || path24.join(os5.homedir(), ".flowriki");
7729
+ var approvalsFile = () => path24.join(home(), "allowed-commands.json");
7730
+ function readApprovals() {
7731
+ try {
7732
+ const parsed = JSON.parse(fs24.readFileSync(approvalsFile(), "utf8"));
7733
+ return { allowed: parsed.allowed ?? {}, waiting: parsed.waiting ?? {} };
7734
+ } catch {
7735
+ return { allowed: {}, waiting: {} };
7736
+ }
7737
+ }
7738
+ function writeApprovals(approvals) {
7739
+ fs24.mkdirSync(home(), { recursive: true, mode: 448 });
7740
+ const temporary = `${approvalsFile()}.${process.pid}.tmp`;
7741
+ fs24.writeFileSync(temporary, JSON.stringify(approvals, null, 2), { mode: 384 });
7742
+ fs24.renameSync(temporary, approvalsFile());
7743
+ }
7744
+ function allowCommands(which) {
7745
+ const approvals = readApprovals();
7746
+ const wanted = which.trim().toLowerCase();
7747
+ const chosen = Object.values(approvals.waiting).filter((c) => wanted === "all" || c.fingerprint === wanted || c.name.toLowerCase() === wanted);
7748
+ for (const command of chosen) {
7749
+ approvals.allowed[command.fingerprint] = { ...command, at: (/* @__PURE__ */ new Date()).toISOString() };
7750
+ delete approvals.waiting[command.fingerprint];
7751
+ }
7752
+ if (chosen.length) writeApprovals(approvals);
7753
+ return chosen;
7754
+ }
7755
+ function revokeCommands(which) {
7756
+ const approvals = readApprovals();
7757
+ const wanted = which.trim().toLowerCase();
7758
+ const chosen = Object.values(approvals.allowed).filter((c) => wanted === "all" || c.fingerprint === wanted || c.name.toLowerCase() === wanted);
7759
+ for (const command of chosen) delete approvals.allowed[command.fingerprint];
7760
+ if (chosen.length) writeApprovals(approvals);
7761
+ return chosen;
7762
+ }
7763
+ var CommandNotAllowed = class extends HelperFailure {
7764
+ constructor(command) {
7765
+ super(`"${command.name}" has not been allowed on this computer yet. In a terminal here, run: flowriki-agent allow "${command.name}" \u2014 it shows the command before allowing it.`);
7766
+ this.command = command;
7767
+ this.name = "CommandNotAllowed";
7768
+ }
7769
+ command;
7770
+ code = "command_not_allowed";
7771
+ };
7772
+ var KEEP_BYTES = 64 * 1024;
7773
+ var BACKGROUND_LIMIT_MS = 30 * 6e4;
7774
+ var SETTLE_MS = 2e3;
7775
+ var running = /* @__PURE__ */ new Set();
7776
+ process.once("exit", () => {
7777
+ for (const child of running) {
7778
+ try {
7779
+ child.kill("SIGKILL");
7780
+ } catch {
7781
+ }
7782
+ }
7783
+ });
7784
+ function stop(child) {
7785
+ if (child.exitCode !== null || child.signalCode !== null) return;
7786
+ try {
7787
+ child.kill("SIGTERM");
7788
+ } catch {
7789
+ }
7790
+ const hard = setTimeout(() => {
7791
+ try {
7792
+ child.kill("SIGKILL");
7793
+ } catch {
7794
+ }
7795
+ }, 2e3);
7796
+ hard.unref();
7797
+ }
7798
+ function launchLine(program, args) {
7799
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(program)) {
7800
+ const unsafe = [program, ...args].find((part) => /[&|<>^%!"]/.test(part));
7801
+ if (unsafe !== void 0) throw new HelperFailure(`on Windows a .cmd or .bat cannot take "${unsafe}" \u2014 those characters mean something to cmd.exe`);
7802
+ return { file: process.env["ComSpec"] || "cmd.exe", args: ["/d", "/c", program, ...args] };
7803
+ }
7804
+ return { file: program, args };
7805
+ }
7806
+ function localCommandRunner(opts) {
7807
+ const mine = /* @__PURE__ */ new Set();
7808
+ const trustAll = opts.trustAll ?? process.env["FLOWRIKI_TRUST_COMMANDS"] === "1";
7809
+ const run = async (request) => {
7810
+ const record = {
7811
+ fingerprint: request.fingerprint,
7812
+ name: request.name,
7813
+ program: request.program,
7814
+ args: request.args,
7815
+ folder: request.folder,
7816
+ env: Object.keys(request.env),
7817
+ mode: request.mode,
7818
+ at: (/* @__PURE__ */ new Date()).toISOString()
7819
+ };
7820
+ if (!trustAll) {
7821
+ const approvals = readApprovals();
7822
+ if (!approvals.allowed[request.fingerprint]) {
7823
+ approvals.waiting[request.fingerprint] = record;
7824
+ try {
7825
+ writeApprovals(approvals);
7826
+ } catch {
7827
+ }
7828
+ throw new CommandNotAllowed(record);
7829
+ }
7830
+ }
7831
+ const cwd = request.folder ? path24.resolve(opts.root, request.folder) : opts.root;
7832
+ if (!fs24.existsSync(cwd)) throw new HelperFailure(`the folder ${cwd} does not exist on this computer`);
7833
+ const blank = (text) => request.redact.reduce((out, secret2) => secret2 ? out.split(secret2).join("\u2022\u2022\u2022") : out, text);
7834
+ const line = launchLine(request.program, request.args);
7835
+ return await new Promise((resolve2, reject) => {
7836
+ let stdout = "", stderr = "", settled = false;
7837
+ let child;
7838
+ try {
7839
+ child = spawn2(line.file, line.args, { cwd, env: { ...process.env, ...request.env }, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
7840
+ } catch (error) {
7841
+ reject(new HelperFailure(`${request.program} could not be started: ${error.message}`));
7842
+ return;
7843
+ }
7844
+ running.add(child);
7845
+ mine.add(child);
7846
+ const forget = () => {
7847
+ running.delete(child);
7848
+ mine.delete(child);
7849
+ };
7850
+ const finish = (outcome) => {
7851
+ if (settled) return;
7852
+ settled = true;
7853
+ clearTimeout(timer);
7854
+ if (outcome instanceof Error) reject(outcome);
7855
+ else resolve2(outcome);
7856
+ };
7857
+ const ready = request.readyPattern ? new RegExp(request.readyPattern) : null;
7858
+ const take = (into) => (chunk) => {
7859
+ const text = chunk.toString();
7860
+ if (into === "out") stdout = (stdout + text).slice(-KEEP_BYTES);
7861
+ else stderr = (stderr + text).slice(-KEEP_BYTES);
7862
+ if (request.mode === "background" && ready && ready.test(into === "out" ? stdout : stderr)) {
7863
+ finish({ exitCode: null, stdout: blank(stdout), stderr: blank(stderr) });
7864
+ }
7865
+ };
7866
+ child.stdout?.on("data", take("out"));
7867
+ child.stderr?.on("data", take("err"));
7868
+ child.on("error", (error) => {
7869
+ forget();
7870
+ finish(new HelperFailure(error.code === "ENOENT" ? `${request.program} was not found on this computer \u2014 install it, or give its full path` : `${request.program} could not be started: ${error.message}`));
7871
+ });
7872
+ child.on("close", (code) => {
7873
+ forget();
7874
+ if (request.mode === "background" && !settled) {
7875
+ const why = (stderr || stdout).trim().split(/\r?\n/).pop() ?? "";
7876
+ finish(new HelperFailure(`the command stopped before it was ready (exit ${code ?? "?"})${why ? `: ${blank(why).slice(0, 300)}` : ""}`));
7877
+ return;
7878
+ }
7879
+ finish({ exitCode: code, stdout: blank(stdout), stderr: blank(stderr) });
7880
+ });
7881
+ const timer = request.mode === "background" && !ready ? setTimeout(() => finish({ exitCode: null, stdout: blank(stdout), stderr: blank(stderr) }), Math.min(SETTLE_MS, request.timeoutMs)) : setTimeout(() => {
7882
+ stop(child);
7883
+ finish(new HelperFailure(request.mode === "background" ? `the command did not say it was ready within ${Math.round(request.timeoutMs / 1e3)}s` : `the command did not finish within ${Math.round(request.timeoutMs / 1e3)}s, so it was stopped`));
7884
+ }, request.timeoutMs);
7885
+ if (request.mode === "background") {
7886
+ const limit = setTimeout(() => stop(child), BACKGROUND_LIMIT_MS);
7887
+ limit.unref();
7888
+ }
7889
+ });
7890
+ };
7891
+ return {
7892
+ run,
7893
+ /** Stop every background command this runner started — the test is over. */
7894
+ close: () => {
7895
+ for (const child of mine) stop(child);
7896
+ mine.clear();
7897
+ }
7898
+ };
7899
+ }
7638
7900
  var CLOCK_SLACK_MS = 1e4;
7639
- function helperRunner(delivered, secrets, startedAt = /* @__PURE__ */ new Date()) {
7901
+ function helperRunner(delivered, secrets, startedAt = /* @__PURE__ */ new Date(), root = process.cwd()) {
7640
7902
  if (!Array.isArray(delivered) || delivered.length === 0) return void 0;
7641
7903
  const helpers = new Map(
7642
7904
  DeliveredHelperSchema.array().parse(delivered).map((helper) => [helper.id, helper])
7643
7905
  );
7644
7906
  const since = new Date(startedAt.getTime() - CLOCK_SLACK_MS);
7645
- return async (helperId, inputs) => {
7907
+ const commands = [...helpers.values()].some((h) => h.config.kind === "command") ? localCommandRunner({ root }) : void 0;
7908
+ const run = (async (helperId, inputs) => {
7646
7909
  const helper = helpers.get(helperId);
7647
7910
  if (!helper) throw new Error("that helper was deleted, or was not sent with this run \u2014 choose another");
7648
7911
  try {
7649
- const { value, detail } = await runHelper(helper.config, {
7912
+ const { value, detail } = await runHelper(helper, {
7650
7913
  resolveSecret: (ref) => secrets?.[ref],
7651
7914
  inputs,
7652
- since
7915
+ since,
7916
+ ...commands ? { commands } : {}
7653
7917
  });
7654
7918
  return { value, detail: `${helper.name}: ${detail}` };
7655
7919
  } catch (error) {
7656
7920
  throw new Error(`${helper.name}: ${error.message}`);
7657
7921
  }
7658
- };
7922
+ });
7923
+ run.close = () => commands?.close();
7924
+ return run;
7659
7925
  }
7660
7926
  var LOCAL_CHROMIUM2 = { id: "chromium", provider: "local", engine: "chromium" };
7661
7927
  async function summaryOf2(storage, runId) {
@@ -7829,6 +8095,7 @@ async function executeCase(opts) {
7829
8095
  };
7830
8096
  }
7831
8097
  await cloud?.close().catch(() => void 0);
8098
+ helpers?.close();
7832
8099
  if (lane) outcome = { ...outcome, artifacts: [...outcome.artifacts ?? [], ...await lane.close(outcome.status === "passed")] };
7833
8100
  await Promise.all(writes);
7834
8101
  if (storage && run) {
@@ -7854,7 +8121,7 @@ async function executeCase(opts) {
7854
8121
  await storage.finishRun(run.id, {
7855
8122
  status: outcome.status === "passed" ? "passed" : outcome.error === "the run was cancelled" ? "cancelled" : outcome.status === "failed" ? "failed" : "errored",
7856
8123
  errorMessage: outcome.error,
7857
- artifactsDir: runDir && fs24.existsSync(runDir) ? runDir : null
8124
+ artifactsDir: runDir && fs34.existsSync(runDir) ? runDir : null
7858
8125
  });
7859
8126
  }
7860
8127
  await emit({ type: "case.finished", runId: run?.id ?? "", caseId: testCase.id, outcome });
@@ -8539,10 +8806,10 @@ async function verifyFlowSteps(deps, opts) {
8539
8806
  }
8540
8807
  function deliveredRun(delivered) {
8541
8808
  const { flow, secrets, config } = delivered;
8542
- const runHelper2 = helperRunner(delivered.helpers, secrets);
8809
+ const runHelper3 = helperRunner(delivered.helpers, secrets);
8543
8810
  return {
8544
8811
  resolveAuth: authenticationResolver(delivered.authentications),
8545
- ...runHelper2 ? { runHelper: runHelper2 } : {},
8812
+ ...runHelper3 ? { runHelper: runHelper3 } : {},
8546
8813
  readConfig: () => config,
8547
8814
  // By id, and only the one that was sent. A machine serving no checkout has
8548
8815
  // no other flow to confuse it with.
@@ -9017,10 +9284,10 @@ var Agent = class {
9017
9284
  if (!allowed.ok) return this.refuse(command.id, allowed.reason);
9018
9285
  if (this.closed) return this.refuse(command.id, "storage_unavailable", "the agent is closed");
9019
9286
  const fingerprint2 = AgentState.fingerprint(command);
9020
- const running = this.inFlight.get(command.id);
9021
- if (running) {
9022
- if (running.fingerprint !== fingerprint2) return this.refuse(command.id, "command_conflict", "this command ID names different work");
9023
- return { ...await running.promise, status: "duplicate" };
9287
+ const running2 = this.inFlight.get(command.id);
9288
+ if (running2) {
9289
+ if (running2.fingerprint !== fingerprint2) return this.refuse(command.id, "command_conflict", "this command ID names different work");
9290
+ return { ...await running2.promise, status: "duplicate" };
9024
9291
  }
9025
9292
  const previous = this.previous(command.id, fingerprint2);
9026
9293
  if (previous) return previous;
@@ -9221,18 +9488,19 @@ var AgentLink = class {
9221
9488
 
9222
9489
  // ../provider-local/dist/index.js
9223
9490
  import fs10 from "fs";
9224
- import os5 from "os";
9491
+ import os6 from "os";
9225
9492
  import path10 from "path";
9226
9493
  import fs25 from "fs";
9227
- import path24 from "path";
9494
+ import path25 from "path";
9228
9495
  import { randomUUID } from "crypto";
9229
9496
  import { execFile } from "child_process";
9230
- import fs34 from "fs";
9497
+ import fs35 from "fs";
9231
9498
  import path34 from "path";
9232
9499
  import { randomUUID as randomUUID2 } from "crypto";
9500
+ import os22 from "os";
9233
9501
  import { spawn as nodeSpawn } from "child_process";
9234
9502
  import fs43 from "fs";
9235
- import os22 from "os";
9503
+ import os32 from "os";
9236
9504
  import path42 from "path";
9237
9505
  var NO_CHECKOUT = {
9238
9506
  ok: false,
@@ -9243,7 +9511,7 @@ var scratchRoots = /* @__PURE__ */ new Map();
9243
9511
  function scratchRoot(projectId) {
9244
9512
  let root = scratchRoots.get(projectId);
9245
9513
  if (!root) {
9246
- root = fs10.mkdtempSync(path10.join(os5.tmpdir(), `flowriki-record-${projectId.replace(/[^\w-]/g, "")}-`));
9514
+ root = fs10.mkdtempSync(path10.join(os6.tmpdir(), `flowriki-record-${projectId.replace(/[^\w-]/g, "")}-`));
9247
9515
  scratchRoots.set(projectId, root);
9248
9516
  }
9249
9517
  return root;
@@ -9477,7 +9745,7 @@ function historyHandlers({ storage, artifactLimit }) {
9477
9745
  const artifact = run.artifacts.find((a) => a.id === artifactId);
9478
9746
  if (!artifact) return { ok: false, reason: "failed", detail: "no such artifact on that run" };
9479
9747
  const dir = run.artifactsDir ?? runArtifactsDir(runId, run.projectId);
9480
- const file2 = path24.join(dir, artifact.relPath);
9748
+ const file2 = path25.join(dir, artifact.relPath);
9481
9749
  if (!isInside(dir, file2) || !fs25.existsSync(file2) || !isInside(fs25.realpathSync(dir), fs25.realpathSync(file2))) {
9482
9750
  return { ok: false, reason: "failed", detail: "the artifact file is not on this machine" };
9483
9751
  }
@@ -9908,7 +10176,7 @@ function debuggingHandlers(ctx) {
9908
10176
  var file = () => path34.join(flowrikiHome(), "assistant-folders.json");
9909
10177
  function readAll() {
9910
10178
  try {
9911
- const parsed = JSON.parse(fs34.readFileSync(file(), "utf8"));
10179
+ const parsed = JSON.parse(fs35.readFileSync(file(), "utf8"));
9912
10180
  if (!parsed || typeof parsed !== "object") return {};
9913
10181
  const out = {};
9914
10182
  for (const [id, value] of Object.entries(parsed)) {
@@ -9921,18 +10189,18 @@ function readAll() {
9921
10189
  }
9922
10190
  }
9923
10191
  function writeAll(all) {
9924
- fs34.mkdirSync(path34.dirname(file()), { recursive: true, mode: 448 });
9925
- fs34.writeFileSync(file(), JSON.stringify(all, null, 2), { mode: 384 });
10192
+ fs35.mkdirSync(path34.dirname(file()), { recursive: true, mode: 448 });
10193
+ fs35.writeFileSync(file(), JSON.stringify(all, null, 2), { mode: 384 });
9926
10194
  }
9927
10195
  function gitHead(folder) {
9928
10196
  try {
9929
- const head = fs34.readFileSync(path34.join(folder, ".git", "HEAD"), "utf8").trim();
10197
+ const head = fs35.readFileSync(path34.join(folder, ".git", "HEAD"), "utf8").trim();
9930
10198
  const ref = /^ref: (.+)$/.exec(head)?.[1];
9931
10199
  if (!ref) return { commit: /^[0-9a-f]{40}$/.test(head) ? head : null, branch: null };
9932
10200
  const branch = ref.replace(/^refs\/heads\//, "");
9933
10201
  const loose = path34.join(folder, ".git", ref);
9934
- if (fs34.existsSync(loose)) return { commit: fs34.readFileSync(loose, "utf8").trim(), branch };
9935
- const packed = fs34.readFileSync(path34.join(folder, ".git", "packed-refs"), "utf8");
10202
+ if (fs35.existsSync(loose)) return { commit: fs35.readFileSync(loose, "utf8").trim(), branch };
10203
+ const packed = fs35.readFileSync(path34.join(folder, ".git", "packed-refs"), "utf8");
9936
10204
  const line = packed.split("\n").find((l) => l.endsWith(` ${ref}`));
9937
10205
  return { commit: line?.split(" ")[0] ?? null, branch };
9938
10206
  } catch {
@@ -9942,7 +10210,7 @@ function gitHead(folder) {
9942
10210
  function describeFolder(folder, name = path34.basename(folder)) {
9943
10211
  const exists = (() => {
9944
10212
  try {
9945
- return fs34.statSync(folder).isDirectory();
10213
+ return fs35.statSync(folder).isDirectory();
9946
10214
  } catch {
9947
10215
  return false;
9948
10216
  }
@@ -10363,6 +10631,32 @@ function finished(l) {
10363
10631
  };
10364
10632
  return { flow, secrets: Object.fromEntries(l.typed) };
10365
10633
  }
10634
+ function helperHandlers(_ctx) {
10635
+ return {
10636
+ "helper.run": async (command, project) => {
10637
+ const parsed = DeliveredHelperSchema.safeParse(command.args["helper"]);
10638
+ if (!parsed.success) return { ok: false, reason: "failed", detail: "that helper could not be read" };
10639
+ const secrets = command.args["secrets"] ?? {};
10640
+ const inputs = command.args["inputs"] ?? {};
10641
+ const commands = localCommandRunner({ root: project.root || os22.homedir() });
10642
+ const started = Date.now();
10643
+ try {
10644
+ const result = await runHelper(parsed.data, { resolveSecret: (ref) => secrets[ref], inputs, commands });
10645
+ return { ok: true, result: { ...result, tookMs: Date.now() - started } };
10646
+ } catch (error) {
10647
+ const notAllowed = error instanceof CommandNotAllowed;
10648
+ return {
10649
+ ok: false,
10650
+ reason: "failed",
10651
+ detail: error instanceof HelperFailure ? error.message : `the helper failed: ${error.message}`,
10652
+ ...notAllowed ? { code: "command_not_allowed" } : {}
10653
+ };
10654
+ } finally {
10655
+ commands.close();
10656
+ }
10657
+ }
10658
+ };
10659
+ }
10366
10660
  function buildHandlers(deps) {
10367
10661
  const context = {
10368
10662
  deps,
@@ -10391,6 +10685,7 @@ function buildHandlers(deps) {
10391
10685
  ...recordingHandlers(context),
10392
10686
  ...assistantHandlers(context),
10393
10687
  ...mobileHandlers(context),
10688
+ ...helperHandlers(context),
10394
10689
  ...debug
10395
10690
  };
10396
10691
  }
@@ -10667,7 +10962,7 @@ function eventsFromLine(askId, line) {
10667
10962
  }
10668
10963
  return out;
10669
10964
  }
10670
- function stop(child) {
10965
+ function stop2(child) {
10671
10966
  child.kill("SIGTERM");
10672
10967
  const force = setTimeout(() => {
10673
10968
  if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
@@ -10675,14 +10970,14 @@ function stop(child) {
10675
10970
  force.unref?.();
10676
10971
  }
10677
10972
  function createClaudeCli(options) {
10678
- const spawn2 = options.spawn ?? nodeSpawn;
10973
+ const spawn3 = options.spawn ?? nodeSpawn;
10679
10974
  const binary = options.binary ?? process.env["FLOWRIKI_CLAUDE_BIN"] ?? "claude";
10680
10975
  const maxAskMs = options.maxAskMs ?? 60 * 6e4;
10681
- const scratchRoot2 = options.scratchRoot ?? path42.join(os22.tmpdir(), "flowriki-claude");
10682
- const running = /* @__PURE__ */ new Map();
10976
+ const scratchRoot2 = options.scratchRoot ?? path42.join(os32.tmpdir(), "flowriki-claude");
10977
+ const running2 = /* @__PURE__ */ new Map();
10683
10978
  const MAX_AT_ONCE = 3;
10684
10979
  process.once("exit", () => {
10685
- for (const child of running.values()) {
10980
+ for (const child of running2.values()) {
10686
10981
  try {
10687
10982
  child.kill("SIGTERM");
10688
10983
  } catch {
@@ -10692,7 +10987,7 @@ function createClaudeCli(options) {
10692
10987
  const cancelled = /* @__PURE__ */ new Set();
10693
10988
  const retrying = /* @__PURE__ */ new Set();
10694
10989
  const sessionsInUse = /* @__PURE__ */ new Set();
10695
- const occupied = () => running.size + retrying.size;
10990
+ const occupied = () => running2.size + retrying.size;
10696
10991
  function capture(args, timeoutMs = 15e3) {
10697
10992
  return new Promise((resolve2) => {
10698
10993
  let out = "", err = "", settled = false;
@@ -10704,7 +10999,7 @@ function createClaudeCli(options) {
10704
10999
  };
10705
11000
  let child;
10706
11001
  try {
10707
- child = spawn2(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
11002
+ child = spawn3(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
10708
11003
  } catch {
10709
11004
  return resolve2({ code: null, out: "", err: "", failed: true });
10710
11005
  }
@@ -10764,10 +11059,10 @@ function createClaudeCli(options) {
10764
11059
  return { installed: true, version: version.out.trim().split(/\s+/)[0] ?? null, ...parsed };
10765
11060
  },
10766
11061
  connect(emit) {
10767
- if (running.size > 0) return { started: false, detail: "The assistant is busy answering a question." };
11062
+ if (running2.size > 0) return { started: false, detail: "The assistant is busy answering a question." };
10768
11063
  let child;
10769
11064
  try {
10770
- child = spawn2(binary, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"] });
11065
+ child = spawn3(binary, ["auth", "login"], { stdio: ["ignore", "pipe", "pipe"] });
10771
11066
  } catch {
10772
11067
  return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10773
11068
  }
@@ -10826,10 +11121,10 @@ function createClaudeCli(options) {
10826
11121
  cancelled.add(askId);
10827
11122
  return true;
10828
11123
  }
10829
- const child = running.get(askId);
11124
+ const child = running2.get(askId);
10830
11125
  if (!child) return false;
10831
11126
  cancelled.add(askId);
10832
- stop(child);
11127
+ stop2(child);
10833
11128
  setTimeout(() => child.kill("SIGKILL"), 3e3).unref?.();
10834
11129
  return true;
10835
11130
  },
@@ -10842,7 +11137,7 @@ function createClaudeCli(options) {
10842
11137
  function launch(ask, dir, mcpConfigPath, sessionId, attempt = 1) {
10843
11138
  let child;
10844
11139
  try {
10845
- child = spawn2(
11140
+ child = spawn3(
10846
11141
  binary,
10847
11142
  buildAskArgs({
10848
11143
  prompt: ask.prompt,
@@ -10864,7 +11159,7 @@ function createClaudeCli(options) {
10864
11159
  } catch {
10865
11160
  return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10866
11161
  }
10867
- running.set(ask.askId, child);
11162
+ running2.set(ask.askId, child);
10868
11163
  if (sessionId) sessionsInUse.add(sessionId);
10869
11164
  let buffer = "", stderr = "", sawDone = false, ended = false;
10870
11165
  let silentFailure = false;
@@ -10906,7 +11201,7 @@ function createClaudeCli(options) {
10906
11201
  ended = true;
10907
11202
  clearTimeout(timer);
10908
11203
  if (buffer.trim()) handleLine(buffer);
10909
- running.delete(ask.askId);
11204
+ running2.delete(ask.askId);
10910
11205
  if (sessionId) sessionsInUse.delete(sessionId);
10911
11206
  const stopped = cancelled.has(ask.askId);
10912
11207
  if (toolsMissing && !stopped) {
@@ -10941,7 +11236,7 @@ function createClaudeCli(options) {
10941
11236
  cancelled.delete(ask.askId);
10942
11237
  };
10943
11238
  const timer = setTimeout(() => {
10944
- stop(child);
11239
+ stop2(child);
10945
11240
  end(false, `Stopped after ${Math.max(1, Math.round(maxAskMs / 6e4))} minutes. Whatever it saved so far is on the Branches page \u2014 send \u201Ccontinue\u201D to carry on.`);
10946
11241
  }, maxAskMs);
10947
11242
  timer.unref?.();
@@ -11157,11 +11452,11 @@ function createAgentRuntime(options) {
11157
11452
 
11158
11453
  // src/lock.ts
11159
11454
  import fs11 from "fs";
11160
- import os6 from "os";
11455
+ import os7 from "os";
11161
11456
  function takeLock(env = process.env) {
11162
11457
  ensureDataDir(env);
11163
11458
  const file2 = lockPath(env);
11164
- const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os6.hostname() };
11459
+ const mine = { pid: process.pid, since: (/* @__PURE__ */ new Date()).toISOString(), host: os7.hostname() };
11165
11460
  for (let attempt = 0; attempt < 2; attempt += 1) {
11166
11461
  try {
11167
11462
  fs11.writeFileSync(file2, JSON.stringify(mine), { flag: "wx", mode: 384 });
@@ -11317,7 +11612,7 @@ function status(serviceArg) {
11317
11612
  const service = resolveService(serviceArg);
11318
11613
  const connection = connectionFor(service);
11319
11614
  const lock = takeLock();
11320
- const running = lock.ok ? null : lock.held;
11615
+ const running2 = lock.ok ? null : lock.held;
11321
11616
  if (lock.ok) lock.lock.release();
11322
11617
  const dirs = connection?.checkouts ?? [];
11323
11618
  const { projects, skipped } = resolveCheckouts(dirs);
@@ -11329,7 +11624,7 @@ function status(serviceArg) {
11329
11624
  ...projects.map((p) => ({ root: p.root, project: p.name })),
11330
11625
  ...skipped.map((s2) => ({ root: s2.root, project: null, why: s2.why }))
11331
11626
  ],
11332
- running
11627
+ running: running2
11333
11628
  };
11334
11629
  }
11335
11630
  async function doctor(serviceArg) {
@@ -11409,6 +11704,9 @@ export {
11409
11704
  backupsFor,
11410
11705
  withBackups,
11411
11706
  flowrikiHome,
11707
+ readApprovals,
11708
+ allowCommands,
11709
+ revokeCommands,
11412
11710
  helperRunner,
11413
11711
  resolveCheckouts,
11414
11712
  start,
@@ -1454,11 +1454,52 @@ var HttpHelperSchema = z5.object({
1454
1454
  pattern: z5.string().max(500).default("")
1455
1455
  }).strict().default({})
1456
1456
  }).strict();
1457
+ var CommandLineSchema = z5.object({
1458
+ program: z5.string().trim().min(1).max(500),
1459
+ args: z5.array(z5.string().max(2e3)).max(50).default([])
1460
+ }).strict();
1461
+ var EnvName = z5.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "an environment variable name is letters, digits and _");
1462
+ var CommandHelperSchema = z5.object({
1463
+ kind: z5.literal("command"),
1464
+ /** What runs everywhere, unless a system has its own below. */
1465
+ run: CommandLineSchema,
1466
+ /** The same job on another system — a different program, or a .cmd on
1467
+ * Windows. A tester's computer uses its own when there is one. */
1468
+ perSystem: z5.object({
1469
+ macos: CommandLineSchema.optional(),
1470
+ windows: CommandLineSchema.optional(),
1471
+ linux: CommandLineSchema.optional()
1472
+ }).strict().default({}),
1473
+ /** Where it runs: a folder inside the project's checkout, or empty for the
1474
+ * project's folder (the agent's own folder when there is no checkout). */
1475
+ folder: z5.string().max(500).default(""),
1476
+ /** Plain environment variables, in the repository. */
1477
+ env: z5.record(EnvName, z5.string().max(2e3)).default({}),
1478
+ /** Environment variable → vault reference: how a token reaches the command
1479
+ * without ever being on its command line. */
1480
+ secretEnv: z5.record(EnvName, SecretRef).default({}),
1481
+ /** `run` waits for it to finish; `background` starts it, waits until it is
1482
+ * ready, and stops it when the test ends — a mock server, a simulator. */
1483
+ mode: z5.enum(["run", "background"]).default("run"),
1484
+ /** Background: the output line that says it is ready. Empty waits a
1485
+ * couple of seconds instead. */
1486
+ readyPattern: z5.string().max(500).default(""),
1487
+ timeoutSeconds: z5.number().int().min(1).max(600).default(60),
1488
+ /** Refused unless it ends with this exit code. */
1489
+ expectExit: z5.number().int().min(0).max(255).default(0),
1490
+ extract: z5.object({
1491
+ take: z5.enum(["output", "pattern", "json", "nothing"]).default("output"),
1492
+ pattern: z5.string().max(500).default(""),
1493
+ /** For json: a dot path — `device.id`. */
1494
+ path: z5.string().max(300).default("")
1495
+ }).strict().default({})
1496
+ }).strict();
1457
1497
  var HelperConfigSchema = z5.discriminatedUnion("kind", [
1458
1498
  MailboxHelperSchema,
1459
1499
  TotpHelperSchema,
1460
1500
  SmsHelperSchema,
1461
- HttpHelperSchema
1501
+ HttpHelperSchema,
1502
+ CommandHelperSchema
1462
1503
  ]);
1463
1504
  var HelperSchema = z5.object({
1464
1505
  schemaVersion: z5.literal(HELPER_SCHEMA_VERSION),
@@ -1475,7 +1516,9 @@ var HelperSchema = z5.object({
1475
1516
  const patterns = [
1476
1517
  helper.config.kind === "mailbox" && helper.config.extract.take === "code" ? helper.config.extract.pattern : "",
1477
1518
  helper.config.kind === "sms" ? helper.config.extract.pattern : "",
1478
- helper.config.kind === "http" && helper.config.extract.take === "pattern" ? helper.config.extract.pattern : ""
1519
+ helper.config.kind === "http" && helper.config.extract.take === "pattern" ? helper.config.extract.pattern : "",
1520
+ helper.config.kind === "command" && helper.config.extract.take === "pattern" ? helper.config.extract.pattern : "",
1521
+ helper.config.kind === "command" ? helper.config.readyPattern : ""
1479
1522
  ];
1480
1523
  for (const pattern of patterns) {
1481
1524
  if (pattern && !PatternSchema.safeParse(pattern).success) {
@@ -1483,6 +1526,12 @@ var HelperSchema = z5.object({
1483
1526
  }
1484
1527
  }
1485
1528
  });
1529
+ function commandFor(config, system) {
1530
+ return config.perSystem[system] ?? config.run;
1531
+ }
1532
+ function commandSystemOf(platform) {
1533
+ return platform === "win32" ? "windows" : platform === "darwin" ? "macos" : "linux";
1534
+ }
1486
1535
  var DeliveredHelperSchema = z5.object({
1487
1536
  id: z5.string().min(1),
1488
1537
  name: z5.string(),
@@ -4452,6 +4501,8 @@ export {
4452
4501
  sameIdentity,
4453
4502
  PrerequisiteProblem,
4454
4503
  expandForRun,
4504
+ commandFor,
4505
+ commandSystemOf,
4455
4506
  DeliveredHelperSchema,
4456
4507
  exploreSummary,
4457
4508
  CODEBASE_TAG_NAME,
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  AGENT_LATEST_URL,
4
4
  AGENT_VERSION,
5
5
  agentUpdateFor,
6
+ allowCommands,
6
7
  authenticationResolver,
7
8
  backupsFor,
8
9
  checkAssertion,
@@ -18,15 +19,17 @@ import {
18
19
  helperRunner,
19
20
  openRunSession,
20
21
  prepareBrowsers,
22
+ readApprovals,
21
23
  replayCase,
22
24
  replayFlow,
23
25
  resolveCheckouts,
24
26
  resolveService,
27
+ revokeCommands,
25
28
  runtimeValue,
26
29
  start,
27
30
  status,
28
31
  withBackups
29
- } from "./chunk-FJVNOLHT.js";
32
+ } from "./chunk-WUK3X43D.js";
30
33
  import {
31
34
  CLOUD_PROVIDERS,
32
35
  CLOUD_PROVIDER_NAME,
@@ -54,7 +57,7 @@ import {
54
57
  slugify,
55
58
  stepRefusedOn,
56
59
  targetFor
57
- } from "./chunk-PDUMGI6V.js";
60
+ } from "./chunk-YD7F7JMA.js";
58
61
 
59
62
  // src/cli.ts
60
63
  import { parseArgs } from "util";
@@ -631,6 +634,8 @@ var AuthoringSession = class _AuthoringSession {
631
634
  mobile = null;
632
635
  values = /* @__PURE__ */ new Map();
633
636
  resolvers = null;
637
+ /** Every helper runner made in this session, so background commands stop with it. */
638
+ helperRunners = [];
634
639
  lastTree = null;
635
640
  /** The navigation as last shown, so a step repeats it only when it changed. */
636
641
  lastNavigation = null;
@@ -677,6 +682,7 @@ var AuthoringSession = class _AuthoringSession {
677
682
  draft.device?.kind === "cloud" ? { cloud: draft.device.provider } : {}
678
683
  );
679
684
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
685
+ if (runHelper) this.helperRunners.push(runHelper);
680
686
  this.resolvers = {
681
687
  resolveAuth: authenticationResolver(prepared.authentications),
682
688
  resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
@@ -1296,6 +1302,7 @@ ${describeRows(built.rows, built.testCase)}`;
1296
1302
  const steps = testCase.occurrences.flatMap((o) => o.steps);
1297
1303
  const prepared = await this.service.prepare(projectId, steps, null);
1298
1304
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
1305
+ if (runHelper) this.helperRunners.push(runHelper);
1299
1306
  const base = catalog.baseUrl;
1300
1307
  const phoneRows = testCase.occurrences.filter((o) => isMobile(o.platform));
1301
1308
  if (!base && phoneRows.length < testCase.occurrences.length) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
@@ -1344,6 +1351,7 @@ ${lines.join("\n")}
1344
1351
  Review and publish it here: ${review}`;
1345
1352
  }
1346
1353
  async close() {
1354
+ for (const runner of this.helperRunners.splice(0)) runner.close();
1347
1355
  if (this.reportTimer) await this.sendReport();
1348
1356
  const run = this.run;
1349
1357
  const mobile = this.mobile;
@@ -2638,7 +2646,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
2638
2646
  {
2639
2647
  name: "create_helper",
2640
2648
  title: "Make a helper",
2641
- description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
2649
+ description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. command (runs on the tester's computer): {kind, run: {program, args: [..]}, perSystem?: {macos|windows|linux: {program, args}}, folder?, env?, secretEnv?: {NAME: ""}, mode?: run|background, readyPattern?, timeoutSeconds?, expectExit?, extract?: {take: output|pattern|json|nothing, pattern, path}} with secretValues {"env:<NAME>": value}; no shell \u2014 one program, its arguments a list. A command runs only after the person allows it on their computer (flowriki-agent allow "<name>"): when use_helper says it is not allowed, stop and tell them that exact command \u2014 never work around it. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
2642
2650
  inputSchema: {
2643
2651
  type: "object",
2644
2652
  properties: {
@@ -3044,6 +3052,7 @@ async function runMcp(opts) {
3044
3052
  }
3045
3053
 
3046
3054
  // src/cli.ts
3055
+ var quoteArg = (arg) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : JSON.stringify(arg);
3047
3056
  var paint = (code) => (s) => `\x1B[${code}m${s}\x1B[0m`;
3048
3057
  var bold = paint("1");
3049
3058
  var dim = paint("2");
@@ -3066,6 +3075,11 @@ ${bold("USAGE")}
3066
3075
  needs (Appium and its drivers)
3067
3076
  flowriki-agent mobile doctor is this computer ready to test phones
3068
3077
  flowriki-agent mobile devices the phones, emulators and simulators here
3078
+ flowriki-agent commands command helpers waiting to be allowed here,
3079
+ and the ones already allowed
3080
+ flowriki-agent allow <name> allow a command helper to run on this
3081
+ computer \u2014 it is shown to you first
3082
+ flowriki-agent revoke <name> stop allowing one
3069
3083
  flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
3070
3084
  Claude Code or Claude Desktop, not by you:
3071
3085
  claude mcp add flowriki -- flowriki-agent mcp
@@ -3097,6 +3111,7 @@ async function main(argv) {
3097
3111
  service: { type: "string" },
3098
3112
  json: { type: "boolean" },
3099
3113
  "no-open": { type: "boolean" },
3114
+ yes: { type: "boolean", short: "y" },
3100
3115
  help: { type: "boolean", short: "h" },
3101
3116
  version: { type: "boolean", short: "v" }
3102
3117
  }
@@ -3315,6 +3330,69 @@ Run \`flowriki-agent --help\`.`);
3315
3330
  console.error(`unknown: mobile ${sub} \u2014 use setup, doctor or devices`);
3316
3331
  return 2;
3317
3332
  }
3333
+ // COMMAND HELPERS: written by anybody with the project, run on THIS
3334
+ // computer only once somebody sitting here has seen and allowed them.
3335
+ case "commands": {
3336
+ const { allowed, waiting } = readApprovals();
3337
+ const show = (c) => `${[c.program, ...c.args].map(quoteArg).join(" ")}${c.folder ? dim(` in ${c.folder}`) : ""}${c.mode === "background" ? dim(" (keeps running during the test)") : ""}`;
3338
+ if (values.json) {
3339
+ console.log(JSON.stringify({ allowed: Object.values(allowed), waiting: Object.values(waiting) }, null, 2));
3340
+ return 0;
3341
+ }
3342
+ console.log(bold("Waiting to be allowed"));
3343
+ if (!Object.keys(waiting).length) console.log(dim(" none"));
3344
+ for (const c of Object.values(waiting)) console.log(` ${yellow("\u2022")} ${c.name}
3345
+ ${show(c)}`);
3346
+ console.log(bold("\nAllowed on this computer"));
3347
+ if (!Object.keys(allowed).length) console.log(dim(" none"));
3348
+ for (const c of Object.values(allowed)) console.log(` ${green("\u2713")} ${c.name}
3349
+ ${show(c)}`);
3350
+ return 0;
3351
+ }
3352
+ case "allow":
3353
+ case "revoke": {
3354
+ const which = positionals.slice(1).join(" ").trim();
3355
+ if (!which) {
3356
+ console.error(`say which: flowriki-agent ${command} "<helper name>" (or all)`);
3357
+ return 2;
3358
+ }
3359
+ if (command === "revoke") {
3360
+ const gone = revokeCommands(which);
3361
+ console.log(gone.length ? `${green("no longer allowed:")} ${gone.map((c) => c.name).join(", ")}` : yellow(`nothing allowed matches "${which}"`));
3362
+ return gone.length ? 0 : 1;
3363
+ }
3364
+ const { waiting } = readApprovals();
3365
+ const wanted = which.toLowerCase();
3366
+ const matching = Object.values(waiting).filter((c) => wanted === "all" || c.fingerprint === wanted || c.name.toLowerCase() === wanted);
3367
+ if (!matching.length) {
3368
+ console.log(yellow(`nothing is waiting that matches "${which}". A command is listed here after a test or Try it asks to run it \u2014 see flowriki-agent commands.`));
3369
+ return 1;
3370
+ }
3371
+ for (const c of matching) {
3372
+ console.log(`${bold(c.name)}
3373
+ runs ${[c.program, ...c.args].map(quoteArg).join(" ")}
3374
+ in ${c.folder || "the project's folder"}${c.env.length ? `
3375
+ env ${c.env.join(", ")}` : ""}${c.mode === "background" ? "\n keeps running until the test ends" : ""}`);
3376
+ }
3377
+ if (!values.yes) {
3378
+ if (!process.stdin.isTTY) {
3379
+ console.error("add --yes to allow without being asked");
3380
+ return 2;
3381
+ }
3382
+ const { createInterface } = await import("readline/promises");
3383
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3384
+ const answer = (await rl.question(`
3385
+ Allow ${matching.length === 1 ? "this command" : `these ${matching.length} commands`} to run on this computer? [y/N] `)).trim().toLowerCase();
3386
+ rl.close();
3387
+ if (answer !== "y" && answer !== "yes") {
3388
+ console.log("Not allowed.");
3389
+ return 1;
3390
+ }
3391
+ }
3392
+ const done = allowCommands(which);
3393
+ console.log(`${green("allowed:")} ${done.map((c) => c.name).join(", ")}. Tests and Try it can run ${done.length === 1 ? "it" : "them"} here now.`);
3394
+ return 0;
3395
+ }
3318
3396
  case "projects": {
3319
3397
  const { projects: found, skipped } = resolveCheckouts(projects.length ? projects : [process.cwd()]);
3320
3398
  for (const p of found) console.log(` ${p.name} ${dim(p.root)}`);
@@ -48,7 +48,7 @@ import {
48
48
  textOf,
49
49
  toolEnv,
50
50
  typesSecrets
51
- } from "./chunk-PDUMGI6V.js";
51
+ } from "./chunk-YD7F7JMA.js";
52
52
  export {
53
53
  AGENT_HOME,
54
54
  APPIUM_DIR,
package/dist/engine.js CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  resolveCheckouts,
10
10
  start,
11
11
  status
12
- } from "./chunk-FJVNOLHT.js";
13
- import "./chunk-PDUMGI6V.js";
12
+ } from "./chunk-WUK3X43D.js";
13
+ import "./chunk-YD7F7JMA.js";
14
14
  export {
15
15
  AGENT_VERSION,
16
16
  browsersReady,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowriki/agent",
3
- "version": "0.1.0-beta.65",
3
+ "version": "0.1.0-beta.66",
4
4
  "description": "Run Flowriki UI tests on your own computer. Connects this machine to your Flowriki account.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",