@rubytech/create-maxy-code 0.1.334 → 0.1.337

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.
@@ -1,22 +1,29 @@
1
- // Contract for the hardware watchdog arming point.
1
+ // Contract for the hardware watchdog feature.
2
2
  //
3
- // On a constrained Pi the installer's `RuntimeWatchdogSec=20s` drop-in,
4
- // armed at install step 1, reboot-looped its own install: the heavy npm/swap
5
- // steps stalled PID1 past 20s, the board reset, the relaunch stalled again.
6
- // The fix arms the watchdog only as the final install action, once the heavy
7
- // build/swap window is over, and applies it via `daemon-reexec` so it is
8
- // actually active (RuntimeWatchdogUSec non-zero) without waiting for a reboot.
3
+ // History: an early version armed RuntimeWatchdogSec=20s at install step 1
4
+ // and the install reboot-looped its own build. A follow-up deferred the
5
+ // arm to install end and applied it via daemon-reexec. That arm landed at
6
+ // the peak of post-install load (services initialising, chattr +i sweep
7
+ // just finished, neo4j seeding); the 20s timer fired before PID1 caught up
8
+ // and beacons reset immediately after install end with the connection
9
+ // dropped mid-banner.
9
10
  //
10
- // Static-grep, not behaviour: the call site and the apply command live in the
11
- // top-level install flow of index.ts, which executes real sudo/systemctl.
12
- // Same pattern as base-toolchain-deps.test.ts read src/index.ts at runtime
13
- // as the authoritative source.
11
+ // Raising RuntimeWatchdogSec is inert above the kernel watchdog cap
12
+ // (~15s on BCM2835), so the timer cannot simply be loosened. The feature
13
+ // has been retired. The contract is now:
14
+ // 1. configureHardwareWatchdog() arms nothing — no drop-in write, no
15
+ // daemon-reexec, no RuntimeWatchdogSec=20s reference.
16
+ // 2. disableHardwareWatchdogForInstall() runs at install start and
17
+ // removes the drop-in (rm -f) on devices that carry one from an
18
+ // earlier installer version, plus a daemon-reexec to apply.
19
+ //
20
+ // Static-grep, not behaviour: the call sites live in the top-level install
21
+ // flow of index.ts. Read src/index.ts at runtime as the authoritative source.
14
22
  import test from "node:test";
15
23
  import assert from "node:assert/strict";
16
24
  import { readFileSync } from "node:fs";
17
25
  import { fileURLToPath } from "node:url";
18
26
  import { dirname, resolve } from "node:path";
19
- // dist/__tests__/watchdog-deferred-arming.test.js → ../../src/index.ts
20
27
  const here = dirname(fileURLToPath(import.meta.url));
21
28
  const INDEX_TS = resolve(here, "../../src/index.ts");
22
29
  const SRC = readFileSync(INDEX_TS, "utf-8");
@@ -28,35 +35,30 @@ function watchdogFnBody() {
28
35
  assert.ok(end > start, "could not bound configureHardwareWatchdog body");
29
36
  return SRC.slice(start, end);
30
37
  }
31
- test("watchdog is armed after buildPlatform and installService, not at step 1", () => {
32
- const watchdogCall = SRC.indexOf("configureHardwareWatchdog();");
33
- const build = SRC.indexOf("buildPlatform();");
34
- const service = SRC.indexOf("installService();");
35
- assert.ok(watchdogCall >= 0, "no configureHardwareWatchdog() call in the install flow");
36
- assert.ok(build >= 0 && service >= 0, "expected buildPlatform()/installService() calls");
37
- assert.ok(watchdogCall > build, "watchdog must be armed after buildPlatform()");
38
- assert.ok(watchdogCall > service, "watchdog must be armed after installService()");
39
- // the old step-1 placement (immediately after installSystemDeps()) is gone
40
- assert.equal(SRC.includes("installSystemDeps();\n configureHardwareWatchdog();"), false, "watchdog must not be armed at step 1, right after installSystemDeps()");
41
- });
42
- test("watchdog drop-in is applied with daemon-reexec, not daemon-reload", () => {
43
- const body = watchdogFnBody();
44
- // match the quoted systemctl argument so explanatory comments naming the old
45
- // command do not skew the assertion.
46
- assert.ok(body.includes('"daemon-reexec"'), "configureHardwareWatchdog must apply via daemon-reexec");
47
- assert.equal(body.includes('"daemon-reload"'), false, "daemon-reload does not apply RuntimeWatchdogSec");
38
+ /** Body of disableHardwareWatchdogForInstall(), up to the next top-level function. */
39
+ function disableFnBody() {
40
+ const start = SRC.indexOf("function disableHardwareWatchdogForInstall");
41
+ assert.ok(start >= 0, "could not locate disableHardwareWatchdogForInstall in index.ts");
42
+ // The function lives immediately before configureHardwareWatchdog().
43
+ const end = SRC.indexOf("function configureHardwareWatchdog", start);
44
+ assert.ok(end > start, "could not bound disableHardwareWatchdogForInstall body");
45
+ return SRC.slice(start, end);
46
+ }
47
+ test("watchdog disable runs at install start, before any heavy step", () => {
48
+ const disableCall = SRC.indexOf("disableHardwareWatchdogForInstall();");
49
+ const installSystemDeps = SRC.indexOf("installSystemDeps();");
50
+ assert.ok(disableCall >= 0, "no disableHardwareWatchdogForInstall() call in the install flow");
51
+ assert.ok(installSystemDeps >= 0, "expected installSystemDeps() call");
52
+ assert.ok(disableCall < installSystemDeps, "disableHardwareWatchdogForInstall() must precede installSystemDeps() so the heavy steps run unarmed");
48
53
  });
49
- test("idempotent re-run only skips re-arming when the watchdog is live-armed", () => {
50
- // A drop-in on disk does not prove the live PID1 has the watchdog active
51
- // (prior daemon-reload, or a just-removed disable override). The function must
52
- // read RuntimeWatchdogUSec and re-arm when it is 0, so the muvin remediation
53
- // (drop-in present, previously disabled) actually arms.
54
+ test("watchdog post-install arm is retired no drop-in write, no daemon-reexec", () => {
54
55
  const body = watchdogFnBody();
55
- assert.ok(body.includes("RuntimeWatchdogUSec"), "must read live RuntimeWatchdogUSec before skipping the arm");
56
- assert.ok(body.includes('"--value"'), "must read the live watchdog value, not just the drop-in content");
56
+ assert.equal(body.includes("RuntimeWatchdogSec=20s"), false, "configureHardwareWatchdog must not re-introduce the 20s timer that reboot-looped constrained Pis");
57
+ assert.equal(body.includes("RebootWatchdogSec=2min"), false, "configureHardwareWatchdog must not re-introduce the reboot timer drop-in line");
58
+ assert.equal(body.includes('"daemon-reexec"'), false, "configureHardwareWatchdog must not arm via daemon-reexec — the post-install arm killed beacons");
57
59
  });
58
- test("watchdog keeps the 20s runtime / 2min reboot values", () => {
59
- const body = watchdogFnBody();
60
- assert.ok(body.includes("RuntimeWatchdogSec=20s"), "RuntimeWatchdogSec stays 20s");
61
- assert.ok(body.includes("RebootWatchdogSec=2min"), "RebootWatchdogSec stays 2min");
60
+ test("disable-for-install scrubs the drop-in (rm) and daemon-reexecs", () => {
61
+ const body = disableFnBody();
62
+ assert.ok(body.includes('"-f"') && body.includes("dropinPath"), "must remove the drop-in (rm -f dropinPath) so the live RuntimeWatchdogUSec returns to 0");
63
+ assert.ok(body.includes('"daemon-reexec"'), "must daemon-reexec after removing the drop-in so the change applies live without waiting for reboot");
62
64
  });
package/dist/index.js CHANGED
@@ -608,115 +608,77 @@ function writeChromiumBinaryPathFile() {
608
608
  console.log(` Wrote ${target} → ${RESOLVED_CHROMIUM_BIN}`);
609
609
  logFile(` [snap-chromium] wrote ${target} contents=${RESOLVED_CHROMIUM_BIN}`);
610
610
  }
611
- // Arm the systemd system-manager hardware watchdog so a *kernel hang* (the
612
- // freeze mode: board powered but stuck, /dev/watchdog never petted) reboots the
613
- // board without a manual power cycle. Distinct from the brand user-service's
614
- // software watchdog (Type=notify WATCHDOG_USEC), which only restarts the UI
615
- // process this resets the whole SoC. Idempotent host-wide drop-in; gated on
616
- // /dev/watchdog existing (driver loaded) and Linux. Best-effort: a failure
617
- // warns, never aborts the install. Cannot recover a latched-off brownout (no
618
- // power = no watchdog) that needs the external power-cycler documented in
619
- // deployment.md.
611
+ // Scrub any hardware-watchdog drop-in this installer (or an earlier version)
612
+ // left on the box, then daemon-reexec so the live RuntimeWatchdogUSec returns
613
+ // to systemd's default (0 = off). The hardware-watchdog feature is retired:
614
+ // a RuntimeWatchdogSec=20s timer (inert above the ~15s BCM2835 kernel cap, so
615
+ // it cannot be loosened) reboot-looped constrained Pis mid-install when armed
616
+ // early, and again at the post-install load peak when armed at install end. It
617
+ // bit both muvin and beacons. configureHardwareWatchdog() no longer arms
618
+ // anything; see its comment for the full rationale. Kernel-freeze recovery is
619
+ // left to the brand service's software watchdog (Type=notify WATCHDOG_USEC),
620
+ // which restarts the UI process rather than resetting the whole SoC.
620
621
  //
621
- // Called as the FINAL install action (not step 1): RuntimeWatchdogSec=20s is a
622
- // tight timer, and the heavy npm-install/swap-thrash steps stall PID1 past 20s
623
- // on a constrained Pi. Arming early reboot-looped the install (the board reset
624
- // mid-build, relaunched under the same load, reset again). Deferring the arm to
625
- // after the build/swap window, then applying it with `daemon-reexec` so it is
626
- // active immediately (RuntimeWatchdogUSec non-zero), keeps kernel-hang recovery
627
- // without the loop.
628
- // Disable the hardware watchdog drop-in for the duration of the install.
629
- // configureHardwareWatchdog() writes /etc/systemd/system.conf.d/10-maxy-watchdog.conf
630
- // (RuntimeWatchdogSec=20s) at the END of every install. That file persists
631
- // across reboots, so on every install AFTER the first, PID1 already has the
632
- // watchdog armed from boot. The heavy npm/Playwright/swap step in step [3/11]
633
- // can stall PID1 past 20s on a constrained Pi, the SoC resets mid-install,
634
- // boot resumes with the watchdog re-armed, the install never completes, and
635
- // the box wedges (SSH never returns; observed on beacons).
636
- //
637
- // Neutralise the watchdog at install start by overwriting the drop-in with
638
- // RuntimeWatchdogSec=0 and daemon-reexec to apply it immediately. Re-armed
639
- // at install end by configureHardwareWatchdog(). If install fails between
640
- // the two, the box is unarmed — recoverable, far better than reboot-loop.
622
+ // Runs unconditionally at install start. Idempotent: if no drop-in is on
623
+ // disk, this is a no-op.
641
624
  function disableHardwareWatchdogForInstall() {
642
625
  try {
643
626
  if (!isLinux()) {
644
- logFile(" hardware watchdog: disable-for-install skipped (not Linux)");
627
+ logFile(" hardware watchdog: scrub-on-install skipped (not Linux)");
645
628
  return;
646
629
  }
647
- if (!existsSync("/dev/watchdog")) {
648
- logFile(" hardware watchdog: disable-for-install skipped (/dev/watchdog absent)");
630
+ const dropinPath = "/etc/systemd/system.conf.d/10-maxy-watchdog.conf";
631
+ if (!existsSync(dropinPath)) {
632
+ logFile(` hardware watchdog: no prior drop-in to scrub (${dropinPath})`);
649
633
  return;
650
634
  }
651
- const disabled = "[Manager]\nRuntimeWatchdogSec=0\n";
652
- const dropinDir = "/etc/systemd/system.conf.d";
653
- const dropinPath = `${dropinDir}/10-maxy-watchdog.conf`;
654
- const tmpPath = "/tmp/10-maxy-watchdog.conf";
655
- writeFileSync(tmpPath, disabled);
656
- console.log(" [privileged] disable hardware watchdog for install duration (RuntimeWatchdogSec=0)");
657
- shell("mkdir", ["-p", dropinDir], { sudo: true });
658
- shell("cp", [tmpPath, dropinPath], { sudo: true });
659
- spawnSync("rm", ["-f", tmpPath]);
635
+ console.log(` [privileged] remove prior hardware-watchdog drop-in (${dropinPath}) — feature retired`);
636
+ shell("rm", ["-f", dropinPath], { sudo: true });
660
637
  spawnSync("sudo", ["systemctl", "daemon-reexec"], { stdio: "inherit" });
661
- logFile(` hardware watchdog: disabled for install (${dropinPath}, RuntimeWatchdogSec=0; re-armed at install end)`);
638
+ logFile(` hardware watchdog: drop-in removed; daemon-reexec applied RuntimeWatchdogUSec back to default`);
662
639
  }
663
640
  catch (err) {
664
- console.error(` WARNING: failed to disable hardware watchdog for install: ${err instanceof Error ? err.message : String(err)}`);
641
+ console.error(` WARNING: failed to scrub hardware-watchdog drop-in: ${err instanceof Error ? err.message : String(err)}`);
665
642
  }
666
643
  }
667
644
  function configureHardwareWatchdog() {
645
+ // The hardware-watchdog drop-in was hostile to constrained Pis. Task 997
646
+ // deferred the arm to install end so a tight 20s timer would not reboot the
647
+ // box mid-build. But arming via daemon-reexec at install end lands the live
648
+ // timer at the very peak of post-install load (services initialising,
649
+ // chattr +i sweep just finished, neo4j seeding). PID1 cannot feed
650
+ // /dev/watchdog fast enough — the SoC resets, next boot starts the same
651
+ // services under the same load, resets again. Beacons reboot-looped on
652
+ // every install after the previous fix until the operator deleted the
653
+ // drop-in by hand.
654
+ //
655
+ // Raising RuntimeWatchdogSec is inert above the kernel watchdog cap
656
+ // (~15s on BCM2835), so the timer cannot simply be loosened. Until a
657
+ // version of this feature actually survives a constrained-Pi install
658
+ // unattended, it is removed. The disable-at-install-start path
659
+ // (disableHardwareWatchdogForInstall) stays — it scrubs any prior
660
+ // drop-in off devices that had it from earlier installer versions, so the
661
+ // hostile timer is cleared from the fleet on first run of this installer
662
+ // version. Kernel-freeze recovery is deferred to the in-server software
663
+ // watchdog (Type=notify WATCHDOG_USEC, already shipped on the brand
664
+ // service) which restarts the UI process, not the whole SoC.
668
665
  try {
669
666
  if (!isLinux()) {
670
667
  logFile(" hardware watchdog: skipped (not Linux)");
671
668
  return;
672
669
  }
673
- if (!existsSync("/dev/watchdog")) {
674
- logFile(" hardware watchdog: skipped (/dev/watchdog absent)");
675
- return;
676
- }
677
- const desired = "[Manager]\nRuntimeWatchdogSec=20s\nRebootWatchdogSec=2min\n";
678
- const dropinDir = "/etc/systemd/system.conf.d";
679
- const dropinPath = `${dropinDir}/10-maxy-watchdog.conf`;
680
- let current = "";
670
+ const dropinPath = "/etc/systemd/system.conf.d/10-maxy-watchdog.conf";
681
671
  if (existsSync(dropinPath)) {
682
- try {
683
- current = readFileSync(dropinPath, "utf-8");
684
- }
685
- catch {
686
- // Unreadable existing drop-in — treat as changed so we rewrite.
687
- current = "";
688
- }
689
- }
690
- if (current !== desired) {
691
- const tmpPath = "/tmp/10-maxy-watchdog.conf";
692
- writeFileSync(tmpPath, desired);
693
- console.log(" [privileged] install systemd hardware watchdog drop-in (RuntimeWatchdogSec=20s RebootWatchdogSec=2min)");
694
- shell("mkdir", ["-p", dropinDir], { sudo: true });
695
- shell("cp", [tmpPath, dropinPath], { sudo: true });
696
- spawnSync("rm", ["-f", tmpPath]);
697
- }
698
- // Arm now, idempotently. The drop-in being on disk does NOT mean the live
699
- // PID1 has the watchdog active — a prior install that only ran daemon-reload,
700
- // or a just-removed disable override (RuntimeWatchdogSec=0), leaves the file
701
- // present but RuntimeWatchdogUSec=0. So read the live value and only skip the
702
- // re-exec when the watchdog is genuinely armed already; otherwise daemon-reexec
703
- // re-execs PID1 in place (host-wide; running services are preserved) to apply
704
- // the system-manager setting. daemon-reload does not apply RuntimeWatchdogSec.
705
- const liveUsec = spawnSync("systemctl", ["show", "-p", "RuntimeWatchdogUSec", "--value"], {
706
- encoding: "utf-8",
707
- stdio: "pipe",
708
- timeout: 5_000,
709
- });
710
- const usec = (liveUsec.stdout ?? "").trim();
711
- if (current === desired && usec !== "" && usec !== "0") {
712
- logFile(` hardware watchdog: already active (${dropinPath}, RuntimeWatchdogUSec=${usec})`);
672
+ // Defence in depth — disableHardwareWatchdogForInstall() already runs
673
+ // at install start, but log here too so the install summary makes the
674
+ // policy explicit even if step 0 was skipped.
675
+ logFile(` hardware watchdog: drop-in present at ${dropinPath} but feature is disabled (see disableHardwareWatchdogForInstall)`);
713
676
  return;
714
677
  }
715
- spawnSync("sudo", ["systemctl", "daemon-reexec"], { stdio: "inherit" });
716
- logFile(` hardware watchdog: enabled (${dropinPath}; armed now via daemon-reexec, after the build/swap window)`);
678
+ logFile(" hardware watchdog: feature disabled (constrained-Pi reboot loops)");
717
679
  }
718
680
  catch (err) {
719
- console.error(` WARNING: failed to configure hardware watchdog: ${err instanceof Error ? err.message : String(err)}`);
681
+ console.error(` WARNING: hardware watchdog post-install probe failed: ${err instanceof Error ? err.message : String(err)}`);
720
682
  }
721
683
  }
722
684
  function installSystemDeps() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.334",
3
+ "version": "0.1.337",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -13,7 +13,7 @@
13
13
  "build": "tsc",
14
14
  "bundle": "node scripts/bundle.js",
15
15
  "test": "npm run build && node --test 'dist/__tests__/*.test.js'",
16
- "prepublishOnly": "bash ../../platform/scripts/verify-skill-tool-surface.sh && node ../../platform/scripts/check-plugin-tools-mcp-consistency.mjs && node ../../platform/scripts/check-specialist-tool-surface.mjs && node ../../platform/scripts/check-no-task-id-leaks.mjs && node ../../platform/scripts/check-no-esm-require.mjs && node ../../platform/scripts/check-no-raw-mcp-registrations.mjs && node ../../platform/scripts/check-skill-load-coverage.mjs && node ../../platform/scripts/check-architecture-skill-no-drift.mjs && node ../../platform/ui/scripts/check-route-wiring.mjs && node ../../platform/ui/scripts/check-edge-admin-routes.mjs && npm run build && node --test 'dist/__tests__/*.test.js' && chmod +x dist/index.js && npm run bundle && bash ../../platform/scripts/smoke-boot-services.sh && node ../../platform/ui/scripts/check-bundle-node-imports.mjs --dir=./payload/server/public/assets"
16
+ "prepublishOnly": "bash ../../platform/scripts/verify-skill-tool-surface.sh && node ../../platform/scripts/check-plugin-tools-mcp-consistency.mjs && node ../../platform/scripts/check-specialist-tool-surface.mjs && node ../../platform/scripts/check-routing-prose-bash.mjs && node ../../platform/scripts/check-no-task-id-leaks.mjs && node ../../platform/scripts/check-no-esm-require.mjs && node ../../platform/scripts/check-no-raw-mcp-registrations.mjs && node ../../platform/scripts/check-skill-load-coverage.mjs && node ../../platform/scripts/check-architecture-skill-no-drift.mjs && node ../../platform/ui/scripts/check-route-wiring.mjs && node ../../platform/ui/scripts/check-edge-admin-routes.mjs && npm run build && node --test 'dist/__tests__/*.test.js' && chmod +x dist/index.js && npm run bundle && bash ../../platform/scripts/smoke-boot-services.sh && node ../../platform/ui/scripts/check-bundle-node-imports.mjs --dir=./payload/server/public/assets"
17
17
  },
18
18
  "files": [
19
19
  "dist",
@@ -247,7 +247,8 @@ The tally is **names only** — a token value is never printed (the redaction bi
247
247
 
248
248
  ## When to use which path
249
249
 
250
- - **Tunnel create/route/run** → `cloudflared` (OAuth cert), per `manual-setup.md`. The API can manage tunnels too, but the cert path is the established one.
250
+ - **Tunnel create/route** → `cloudflared` with the OAuth cert, per `manual-setup.md`. The API can manage tunnels too, but the cert path is the established one for create/route.
251
+ - **Tunnel run/resume** → `cloudflared tunnel run` authenticates with the per-tunnel credentials-file named in `config.yml`, not the cert, so the connector (and the `resume-tunnel.sh` supervisor) respawns a configured tunnel with no cert present. This is what lets a tunnel created via the API-token credentials-file path resume on reboot like an OAuth one.
251
252
  - **Apex CNAME** → API (above) or dashboard (`dashboard-guide.md`). Either flattens correctly.
252
253
  - **Pages deploy** → `wrangler` with the reused `<brand>-pages-d1` token, per `hosting-sites.md`.
253
254
  - **D1 read/write** → `wrangler d1 execute --remote` with the reused D1-Edit token, per `d1-data-capture.md`.
@@ -431,6 +431,8 @@ The correct production pattern is already in place on every Maxy device: the bra
431
431
 
432
432
  **The connector is a supervised, self-healing unit.** `resume-tunnel.sh` does not run cloudflared as a child of the brand service. It spawns it into its own transient systemd **service** — `cloudflared-<brand>.service` under `cloudflared.slice` — with `Restart=always`. Two consequences: a brand-service restart no longer reaps the connector (the cgroup decoupling), and systemd resurrects the connector on **any** exit — crash, OOM, manual kill. The connector also runs with `--no-autoupdate`, so it never downloads a new binary and replaces itself; the version is installer-owned (pinned in the installer, bumped deliberately). `StartLimitIntervalSec=300`/`StartLimitBurst=5` mean a genuinely broken binary surfaces as a `failed` unit instead of looping forever.
433
433
 
434
+ **What the supervisor needs to resume.** `resume-tunnel.sh` respawns the connector with `cloudflared … --config "${CFG_DIR}/config.yml" tunnel run`, which authenticates with the per-tunnel **credentials-file** named in that config — not `cert.pem`. The account cert is a create/route credential; the run/resume path never reads it. So a tunnel resumes on reboot whenever `config.yml` and its `credentials-file` are present, whether the tunnel was created via OAuth (this runbook) or supplied as an API-token credentials-file. The supervisor passes `--origincert` only when `cert.pem` exists; absent it, the token connector still runs. For a configured tunnel (a valid `tunnel.state`) where neither a usable credentials-file nor a cert is present, `resume-tunnel.sh` logs `op=resume-refused reason=missing-run-credential` at brand-service start instead of skipping silently — so a non-resumable tunnel is visible at the next start rather than discovered as an outage.
435
+
434
436
  **Observability.** A connector death is now a logged, countable event, not an invisible gap:
435
437
 
436
438
  ```
@@ -5,7 +5,7 @@ description: Cloudflare operations for the install — tunnel setup/diagnosis/re
5
5
 
6
6
  # Cloudflare operations
7
7
 
8
- Invoked from `specialists:personal-assistant`.
8
+ Run by the admin/main session, the operator-infra surface that holds the `Bash` tool. Every step here issues `cloudflared` / `wrangler` over a shell, so this is not specialist work; the admin session loads this skill and runs it directly.
9
9
 
10
10
  This is the entry point for every Cloudflare task on the install. Pick the operation class, read the matching reference, run the command via Bash, relay the literal output, and prove the outcome with a live signal. There are no Cloudflare MCP tools; the plugin registers none. The agent never browser-automates the dashboard — the operator clicks where a click is genuinely required.
11
11
 
@@ -21,7 +21,7 @@ This is the entry point for every Cloudflare task on the install. Pick the opera
21
21
 
22
22
  ## Two auth paths, by operation class
23
23
 
24
- - **Tunnel auth is OAuth.** `cloudflared tunnel login` writes `cert.pem`; the operator clicks the linkified OAuth URL in their own browser. This is the only path for tunnel create/route/run. See `references/manual-setup.md`.
24
+ - **Tunnel create/route auth is OAuth.** `cloudflared tunnel login` writes `cert.pem`; the operator clicks the linkified OAuth URL in their own browser. The cert authorises tunnel **create** and **route**, and OAuth is the prescribed path for them. **Running** a tunnel is a separate credential: `cloudflared tunnel run` — and the `resume-tunnel.sh` supervisor that respawns the connector on reboot — authenticates with the per-tunnel **credentials-file** named in `config.yml`, not the cert. So a tunnel resumes whether or not `cert.pem` is present; `--origincert` is passed on the run only when the cert exists. See `references/manual-setup.md`.
25
25
  - **API auth is the master token.** The operator provisions one fully-scoped master token once (an **advanced**, operator-guided dashboard step — the agent does not automate it) and stores it at the account-scoped secrets file. The agent reads the master only to **provision one stable narrow token per scope** — `<brand>-pages-d1`, `<brand>-dns`, `<brand>-access` — loading it from the secrets file, minting it once if absent (no expiry), persisting it, and reusing it for every later operation in that scope. A standing **reconcile** pass revokes strays. See `references/api.md` for the storage convention, the provisioning-and-reuse flow, the reconcile pass, and the binding redaction discipline.
26
26
 
27
27
  Neither the master token nor any per-scope token is ever written into a project tree, committed, or echoed into chat — the per-scope tokens persist only in the account-scoped secrets file, never in a deployable project tree.
@@ -522,6 +522,103 @@ TBL
522
522
  return 0
523
523
  }
524
524
 
525
+ # ---------------------------------------------------------------------------
526
+ # Credentials-file (API-token) tunnel resume.
527
+ #
528
+ # A tunnel created via the API-token path has a credentials-file in config.yml
529
+ # and NO cert.pem. `cloudflared tunnel run` authenticates with the
530
+ # credentials-file, not the account cert, so resume-tunnel must spawn such a
531
+ # tunnel WITHOUT --origincert and must NOT refuse it for the missing cert.
532
+
533
+ # CASE H: token tunnel (credentials-file present, no cert) → spawns without --origincert.
534
+ case_token_tunnel_spawns_without_origincert() {
535
+ local root; root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
536
+ setup "$root" ".maxy-code" "inactive" "yes"
537
+ local home="$root/home"
538
+ # Token model: remove the OAuth cert; point config.yml at an existing creds json.
539
+ rm -f "$home/.maxy-code/cloudflared/cert.pem"
540
+ local creds="$home/.maxy-code/cloudflared/test-tunnel-id.json"
541
+ echo '{"AccountTag":"x","TunnelSecret":"y","TunnelID":"test-tunnel-id"}' > "$creds"
542
+ cat > "$home/.maxy-code/cloudflared/config.yml" <<YML
543
+ tunnel: test-tunnel-id
544
+ credentials-file: $creds
545
+ ingress:
546
+ - service: http_status:404
547
+ YML
548
+
549
+ HOME="$home" \
550
+ PATH="$root/bin:$PATH" \
551
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
552
+ bash "$RESUME"
553
+ local rc=$?
554
+ local log; log=$(cat "$home/.maxy-code/logs/cloudflared.log" 2>/dev/null || echo "")
555
+ cleanup "$root"
556
+
557
+ if [[ $rc -ne 0 ]]; then echo " expected exit 0, got $rc"; return 1; fi
558
+ echo "$log" | grep -q "spawned service=cloudflared-maxy-code.service" \
559
+ || { echo " token tunnel did not spawn"; echo "$log"; return 1; }
560
+ echo "$log" | grep -q "op=resume-refused" \
561
+ && { echo " token tunnel wrongly refused"; echo "$log"; return 1; }
562
+ echo "$log" | grep -q "\[stub\] arg=--origincert" \
563
+ && { echo " --origincert passed for a token tunnel (cert absent)"; echo "$log"; return 1; }
564
+ return 0
565
+ }
566
+
567
+ # CASE I: configured tunnel, no usable run credential → loud resume-refused, no spawn.
568
+ # config.yml references a missing credentials-file AND there is no cert.pem.
569
+ case_refuse_when_no_run_credential() {
570
+ local root; root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
571
+ setup "$root" ".maxy-code" "inactive" "yes"
572
+ local home="$root/home"
573
+ rm -f "$home/.maxy-code/cloudflared/cert.pem"
574
+ cat > "$home/.maxy-code/cloudflared/config.yml" <<YML
575
+ tunnel: test-tunnel-id
576
+ credentials-file: $home/.maxy-code/cloudflared/does-not-exist.json
577
+ ingress:
578
+ - service: http_status:404
579
+ YML
580
+
581
+ HOME="$home" \
582
+ PATH="$root/bin:$PATH" \
583
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
584
+ bash "$RESUME"
585
+ local rc=$?
586
+ local log; log=$(cat "$home/.maxy-code/logs/cloudflared.log" 2>/dev/null || echo "")
587
+ cleanup "$root"
588
+
589
+ if [[ $rc -ne 0 ]]; then echo " expected exit 0, got $rc"; return 1; fi
590
+ echo "$log" | grep -q "op=resume-refused brand=maxy-code tunnel=test-tunnel-id reason=missing-run-credential" \
591
+ || { echo " missing resume-refused line"; echo "$log"; return 1; }
592
+ echo "$log" | grep -q "spawned service=" \
593
+ && { echo " spawned despite no run credential"; echo "$log"; return 1; }
594
+ return 0
595
+ }
596
+
597
+ # CASE J: corrupt tunnel.state → loud resume-refused, exits 0 (must never block
598
+ # the brand service). Exercises resume_refused on the invalid-state-json path,
599
+ # where TUNNEL_ID is not yet assigned — confirms the helper survives under set -u.
600
+ case_refuse_on_invalid_state_json() {
601
+ local root; root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
602
+ setup "$root" ".maxy-code" "inactive" "yes"
603
+ local home="$root/home"
604
+ echo 'this is not json {{{' > "$home/.maxy-code/cloudflared/tunnel.state"
605
+
606
+ HOME="$home" \
607
+ PATH="$root/bin:$PATH" \
608
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
609
+ bash "$RESUME"
610
+ local rc=$?
611
+ local log; log=$(cat "$home/.maxy-code/logs/cloudflared.log" 2>/dev/null || echo "")
612
+ cleanup "$root"
613
+
614
+ if [[ $rc -ne 0 ]]; then echo " expected exit 0 on corrupt state, got $rc"; return 1; fi
615
+ echo "$log" | grep -q "op=resume-refused brand=maxy-code tunnel=unknown reason=invalid-state-json" \
616
+ || { echo " missing invalid-state-json resume-refused line"; echo "$log"; return 1; }
617
+ echo "$log" | grep -q "spawned service=" \
618
+ && { echo " spawned on corrupt state"; echo "$log"; return 1; }
619
+ return 0
620
+ }
621
+
525
622
  run_case "skip when service active" case_skip_when_scope_active
526
623
  run_case "spawn succeeds and logs verified" case_spawn_succeeds
527
624
  run_case "spawn with no connection logs ERROR exits 0" case_spawn_no_connection
@@ -535,6 +632,9 @@ run_case "reap: audit line names brand tunnel id" case_reap_audit_tunnel_id
535
632
  run_case "reap: foreign co-resident connector untouched" case_reap_foreign_untouched
536
633
  run_case "reap: SIGTERM survivor escalates to SIGKILL" case_reap_sigkill_escalation
537
634
  run_case "reap: skip when active unit's MainPID is unresolved" case_reap_skip_when_mainpid_unresolved
635
+ run_case "token tunnel spawns without --origincert" case_token_tunnel_spawns_without_origincert
636
+ run_case "configured tunnel with no run credential refuses loudly" case_refuse_when_no_run_credential
637
+ run_case "corrupt tunnel.state refuses loudly and exits 0" case_refuse_on_invalid_state_json
538
638
 
539
639
  echo ""
540
640
  echo "Results: $PASS passed, $FAIL failed"
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // Routing-prose ↔ `tools:` reconciliation gate (Task 1000).
3
+ //
4
+ // A specialist agent file can name a shell-only Cloudflare CLI in its routing
5
+ // prose — `cloudflared` or `wrangler` — neither of which has an MCP tool: the
6
+ // only way to run them is the CC-native `Bash` tool. When the prose tells the
7
+ // agent to drive one of those CLIs but the file's `tools:` frontmatter grants
8
+ // no `Bash`, the dispatched specialist is structurally unable to do step one.
9
+ // It emits no event; it just flails (Task 1000: `personal-assistant` owned the
10
+ // Cloudflare runbook, lacked `Bash`, and degenerated into VNC pixel-driving to
11
+ // reach a terminal). This gate reconciles declared-need against declared-tools
12
+ // at publish time so the mismatch never ships.
13
+ //
14
+ // Scope: every specialist agent .md under
15
+ // platform/templates/specialists/agents/ and premium-plugins/**/agents/ that
16
+ // carries a `tools:` line. A file with NO `tools:` line is unrestricted
17
+ // (admin-like — inherits all tools including Bash) and is skipped: the gate is
18
+ // about a gated grant contradicting its own prose, not about whether a CLI is
19
+ // named at all.
20
+ //
21
+ // Pure-JS, no build step: runs in `prepublishOnly` before `npm run build`,
22
+ // mirroring check-specialist-tool-surface.mjs. Exits 1 with one
23
+ // `[routing-prose-bash] specialist=… cli=… reason=missing-bash-tool` line per
24
+ // `(specialist, cli)` defect, matching that script's grep-able log shape.
25
+
26
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
27
+ import { join, resolve, basename, dirname } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
31
+ const DEFAULT_MAXY_CODE_ROOT = resolve(SCRIPT_DIR, '..', '..');
32
+
33
+ // Shell-only Cloudflare CLIs: no MCP tool exists, so naming one in routing
34
+ // prose implies the agent must hold `Bash`.
35
+ const SHELL_ONLY_CLIS = ['cloudflared', 'wrangler'];
36
+
37
+ const SKIP_AGENT_BASENAMES = new Set(['IDENTITY', 'SOUL', 'AGENTS', 'LEARNINGS']);
38
+
39
+ function splitFrontmatter(raw) {
40
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
41
+ if (!m) return { frontmatter: null, body: raw };
42
+ return { frontmatter: m[1], body: m[2] };
43
+ }
44
+
45
+ // True only when the `tools:` line lists the bare CC-native `Bash` token.
46
+ // Returns null when there is no `tools:` line at all (unrestricted file).
47
+ export function toolsHasBash(frontmatter) {
48
+ if (!frontmatter) return null;
49
+ const m = frontmatter.match(/^tools:\s*(.+)$/m);
50
+ if (!m) return null;
51
+ const tokens = m[1].split(',').map(t => t.trim());
52
+ return tokens.includes('Bash');
53
+ }
54
+
55
+ // Returns the sorted list of shell-only CLIs the body names (word-boundary,
56
+ // case-insensitive). Empty when none are named.
57
+ export function proseNamesClis(body) {
58
+ const found = [];
59
+ for (const cli of SHELL_ONLY_CLIS) {
60
+ if (new RegExp(`\\b${cli}\\b`, 'i').test(body)) found.push(cli);
61
+ }
62
+ return found;
63
+ }
64
+
65
+ // Walk the given agent dirs; return one defect per (specialist, cli) where the
66
+ // prose names a shell-only CLI but the gated `tools:` line lacks `Bash`.
67
+ export function runForDirs(agentDirs) {
68
+ const defects = [];
69
+ let inspected = 0;
70
+ for (const dir of agentDirs) {
71
+ let entries;
72
+ try { entries = readdirSync(dir); } catch { continue; }
73
+ for (const entry of entries.sort()) {
74
+ const fullPath = join(dir, entry);
75
+ let st;
76
+ try { st = statSync(fullPath); } catch { continue; }
77
+ if (!st.isFile() || !entry.endsWith('.md')) continue;
78
+ if (SKIP_AGENT_BASENAMES.has(basename(entry, '.md').toUpperCase())) continue;
79
+ const { frontmatter, body } = splitFrontmatter(readFileSync(fullPath, 'utf-8'));
80
+ const hasBash = toolsHasBash(frontmatter);
81
+ if (hasBash === null) continue; // unrestricted file — not a gated grant
82
+ inspected += 1;
83
+ if (hasBash) continue;
84
+ for (const cli of proseNamesClis(body)) {
85
+ defects.push({ specialistPath: fullPath, cli });
86
+ }
87
+ }
88
+ }
89
+ return { defects, inspected };
90
+ }
91
+
92
+ function collectAgentDirs(maxyCodeRoot) {
93
+ const dirs = [];
94
+ const templates = join(maxyCodeRoot, 'platform', 'templates', 'specialists', 'agents');
95
+ if (existsSync(templates)) dirs.push(templates);
96
+ const premiumRoot = join(maxyCodeRoot, 'premium-plugins');
97
+ if (existsSync(premiumRoot)) {
98
+ for (const bundle of readdirSync(premiumRoot).sort()) {
99
+ const bundleAgents = join(premiumRoot, bundle, 'agents');
100
+ try { if (statSync(bundleAgents).isDirectory()) dirs.push(bundleAgents); } catch { /* skip */ }
101
+ const subPluginsDir = join(premiumRoot, bundle, 'plugins');
102
+ try {
103
+ if (statSync(subPluginsDir).isDirectory()) {
104
+ for (const sub of readdirSync(subPluginsDir).sort()) {
105
+ const subAgents = join(subPluginsDir, sub, 'agents');
106
+ try { if (statSync(subAgents).isDirectory()) dirs.push(subAgents); } catch { /* skip */ }
107
+ }
108
+ }
109
+ } catch { /* skip */ }
110
+ }
111
+ }
112
+ return dirs;
113
+ }
114
+
115
+ function main(maxyCodeRoot) {
116
+ const { defects, inspected } = runForDirs(collectAgentDirs(maxyCodeRoot));
117
+ for (const d of defects) {
118
+ const rel = d.specialistPath.startsWith(maxyCodeRoot + '/')
119
+ ? d.specialistPath.slice(maxyCodeRoot.length + 1)
120
+ : d.specialistPath;
121
+ console.error(`[routing-prose-bash] specialist=${rel} cli=${d.cli} reason=missing-bash-tool`);
122
+ }
123
+ if (defects.length > 0) {
124
+ console.error(`[routing-prose-bash] FAIL: ${defects.length} defect(s) across ${inspected} gated specialist(s).`);
125
+ console.error(` A specialist whose routing prose names cloudflared/wrangler must list Bash in its tools:, or the prose must move to a Bash-capable surface.`);
126
+ process.exit(1);
127
+ }
128
+ console.log(`[routing-prose-bash] ok inspected=${inspected}`);
129
+ }
130
+
131
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
132
+ const override = process.env.MAXY_CODE_ROOT;
133
+ main(override ? resolve(override) : DEFAULT_MAXY_CODE_ROOT);
134
+ }
@@ -303,26 +303,14 @@ else:
303
303
  PY
304
304
  fi
305
305
 
306
- # --- stamp install owner into admins[] (idempotent) ------------------------
307
- if [ -n "${USERS_FILE:-}" ] && [ -f "$USERS_FILE" ]; then
308
- local USER_ID
309
- USER_ID=$(python3 -c "import json; print(json.load(open('$USERS_FILE'))[0]['userId'])" 2>/dev/null || true)
310
- if [ -n "$USER_ID" ] && [ -f "$ACCOUNT_DIR/account.json" ]; then
311
- USER_ID="$USER_ID" python3 - "$ACCOUNT_DIR/account.json" <<'PY'
312
- import json, os, sys
313
- path = sys.argv[1]
314
- uid = os.environ["USER_ID"]
315
- with open(path) as f:
316
- cfg = json.load(f)
317
- cfg.setdefault("admins", [])
318
- if not any(a.get("userId") == uid for a in cfg["admins"]):
319
- cfg["admins"].append({"userId": uid, "role": "owner"})
320
- with open(path, "w") as f:
321
- json.dump(cfg, f, indent=2); f.write("\n")
322
- print(f" Stamped userId={uid} as owner in account.json admins")
323
- PY
324
- fi
325
- fi
306
+ # Task 999 no admins[] owner stamp at provision time. account.json admins[]
307
+ # is no longer the access authority: every authenticated install admin sees
308
+ # every account. The old stamp wrote users.json[0] (the first user, not the
309
+ # operating admin) into every new account, so a client account a non-first
310
+ # admin created listed the wrong owner and stayed invisible to them. The
311
+ # house account's owner record is authored durably by set-pin (writeAdminEntry
312
+ # in onboarding) and account.json survives reinstalls, so nothing load-bearing
313
+ # is lost here.
326
314
 
327
315
  echo " Account $ACCOUNT_ID at $ACCOUNT_DIR (role=$ROLE)"
328
316
  }
@@ -36,13 +36,29 @@ log() {
36
36
  echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] resume-tunnel: $1" >> "$LOG_FILE"
37
37
  }
38
38
 
39
+ # Derive brand identifiers + the OAuth cert path up front so every refusal log
40
+ # below can name the brand. BRAND strips the leading dot from CONFIG_DIR
41
+ # (.maxy-code → cloudflared-maxy-code.service). CERT_PATH is the OAuth account
42
+ # cert: it authorises tunnel create/route, NOT tunnel run — a tunnel created via
43
+ # the API-token path runs from its credentials-file with no cert at all.
44
+ BRAND="${CONFIG_DIR#.}"
45
+ SERVICE_UNIT="cloudflared-${BRAND}.service"
46
+ CERT_PATH="$HOME/$CONFIG_DIR/cloudflared/cert.pem"
47
+
48
+ # A configured tunnel (tunnel.state present) that we decline to spawn is logged
49
+ # loudly, so a non-resumable tunnel is visible at the next service start rather
50
+ # than discovered as an outage. "No state file" stays a silent normal boot.
51
+ resume_refused() {
52
+ log "[tunnel-supervise] op=resume-refused brand=${BRAND} tunnel=${TUNNEL_ID:-unknown} reason=$1"
53
+ }
54
+
39
55
  # No state file — no tunnel configured. Normal boot.
40
56
  [ -f "$STATE_FILE" ] || exit 0
41
57
 
42
58
  log "reading state from $STATE_FILE (configDir=$CONFIG_DIR)"
43
59
 
44
60
  if ! STATE=$(jq -r '.' "$STATE_FILE" 2>/dev/null); then
45
- log "WARNING: tunnel.state is not valid JSON — skipping resume"
61
+ resume_refused "invalid-state-json"
46
62
  exit 0
47
63
  fi
48
64
 
@@ -51,17 +67,10 @@ DOMAIN=$(echo "$STATE" | jq -r '.domain // empty')
51
67
  CONFIG_PATH=$(echo "$STATE" | jq -r '.configPath // empty')
52
68
 
53
69
  if [ -z "$TUNNEL_ID" ]; then
54
- log "WARNING: tunnel.state has no tunnelId — skipping resume"
70
+ resume_refused "no-tunnel-id"
55
71
  exit 0
56
72
  fi
57
73
 
58
- # Derive service unit name: strip leading dot from CONFIG_DIR.
59
- # .maxy-code → cloudflared-maxy-code.service
60
- BRAND="${CONFIG_DIR#.}"
61
- SERVICE_UNIT="cloudflared-${BRAND}.service"
62
-
63
- CERT_PATH="$HOME/$CONFIG_DIR/cloudflared/cert.pem"
64
-
65
74
  # Task 833 — single-instance enforcement. cloudflared does not hot-reload
66
75
  # config.yml; a config change needs a connector restart. resume-tunnel is the
67
76
  # single spawner but historically managed only its OWN connector, so a connector
@@ -156,12 +165,27 @@ fi
156
165
  CLOUDFLARED_BIN=$(command -v cloudflared 2>/dev/null || true)
157
166
 
158
167
  if [ -z "$CLOUDFLARED_BIN" ]; then
159
- log "ERROR: cloudflared binary not found — cannot resume tunnel"
168
+ resume_refused "no-cloudflared-binary"
169
+ exit 0
170
+ fi
171
+
172
+ # tunnel run authenticates with the per-tunnel credentials-file declared in
173
+ # config.yml (written by `tunnel create`, present in both the OAuth and the
174
+ # API-token models). The OAuth account cert (--origincert) is a create/route
175
+ # credential; tunnel run never needs it. So a tunnel is resumable whenever its
176
+ # config exists AND a usable run credential is present — either cert.pem (OAuth)
177
+ # or the credentials-file the config references (API-token). --origincert is
178
+ # added to the spawn only when cert.pem exists, so a token-created tunnel with
179
+ # no cert resumes too.
180
+ if [ -z "$CONFIG_PATH" ] || [ ! -f "$CONFIG_PATH" ]; then
181
+ resume_refused "missing-config"
160
182
  exit 0
161
183
  fi
162
184
 
163
- if [ -z "$CONFIG_PATH" ] || [ ! -f "$CONFIG_PATH" ] || [ ! -f "$CERT_PATH" ]; then
164
- log "ERROR: missing configPath or cert — cannot resume tunnel"
185
+ CRED_FILE=$(sed -n 's/^[[:space:]]*credentials-file:[[:space:]]*//p' "$CONFIG_PATH" 2>/dev/null | head -1 | tr -d '[:space:]')
186
+
187
+ if [ ! -f "$CERT_PATH" ] && { [ -z "$CRED_FILE" ] || [ ! -f "$CRED_FILE" ]; }; then
188
+ resume_refused "missing-run-credential"
165
189
  exit 0
166
190
  fi
167
191
 
@@ -184,6 +208,13 @@ LOG_OFFSET=$(wc -c < "$LOG_FILE" 2>/dev/null | awk '{print $1+0}' || echo 0)
184
208
  # once the unit is active (it does not block for the process lifetime), so there
185
209
  # is no shell child to background. --quiet suppresses systemd-run's "Running as
186
210
  # unit:" line; the trailing redirect only captures any error output it prints.
211
+ # Build the connector argv. --origincert is included only for an OAuth tunnel
212
+ # (cert.pem present); a token-created tunnel runs from its credentials-file
213
+ # alone. The array is always non-empty, so it expands safely under `set -u`.
214
+ CONNECTOR_ARGS=( "$CLOUDFLARED_BIN" --no-autoupdate )
215
+ [ -f "$CERT_PATH" ] && CONNECTOR_ARGS+=( --origincert "$CERT_PATH" )
216
+ CONNECTOR_ARGS+=( --config "$CONFIG_PATH" tunnel run )
217
+
187
218
  systemd-run \
188
219
  --user \
189
220
  --quiet \
@@ -197,7 +228,7 @@ systemd-run \
197
228
  -p "StandardOutput=append:$LOG_FILE" \
198
229
  -p "StandardError=append:$LOG_FILE" \
199
230
  -- \
200
- "$CLOUDFLARED_BIN" --no-autoupdate --origincert "$CERT_PATH" --config "$CONFIG_PATH" tunnel run \
231
+ "${CONNECTOR_ARGS[@]}" \
201
232
  >> "$LOG_FILE" 2>&1
202
233
 
203
234
  # Log spawn receipt so the operator can distinguish the supervised path from legacy nohup.
@@ -18,6 +18,6 @@
18
18
  account-level AGENTS.md from scratch (or extends an existing one).
19
19
 
20
20
  Example (populated):
21
- - **specialists:personal-assistant**: Scheduling, platform settings, messaging channels (Telegram, WhatsApp, email, Outlook), tunnel and domain setup, and browser automation. Delegate when the work is on your calendar, configuring the platform, sending or reading messages, or driving a browser.
21
+ - **specialists:personal-assistant**: Scheduling, platform settings, messaging channels (Telegram, WhatsApp, email, Outlook), and browser automation. Delegate when the work is on your calendar, configuring the platform, sending or reading messages, or driving a browser.
22
22
  - **specialists:research-assistant**: Deep research on the web, knowledge management, and writing structured summaries with citations from multiple sources. Delegate when the operator wants you to research a topic, read web pages, combine findings into a summary, or reorganise stored knowledge.
23
23
  -->
@@ -30,7 +30,7 @@ The operator's machine is the surface — paths admin gives you resolve against
30
30
  - Graph writes (memory-write, contact-create, work-create, schedule-event) — those route to `database-operator` or `personal-assistant`.
31
31
  - Ingestion of documents, transcripts, or archives into the graph — that routes to `librarian`.
32
32
  - Web research and external lookups — those route to `research-assistant`.
33
- - Messaging, scheduling, browser automation, tunnel and domain setup — those route to `personal-assistant`.
33
+ - Messaging, scheduling, browser automation — those route to `personal-assistant`.
34
34
 
35
35
  When a brief mixes coding work with one of the above (for example, "clone the repo and write a brochure from its README"), do the coding portion and hand the rest back to admin so it can dispatch the right specialist.
36
36
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: personal-assistant
3
- description: "Scheduling, platform settings, messaging channels (Telegram, WhatsApp, email, Outlook), tunnel and domain setup, and browser automation. Delegate when the work is on your calendar, configuring the platform, sending or reading messages, or driving a browser."
3
+ description: "Scheduling, platform settings, messaging channels (Telegram, WhatsApp, email, Outlook), and browser automation. Delegate when the work is on your calendar, configuring the platform, sending or reading messages, or driving a browser."
4
4
  summary: "Handles the operational tasks you'd give a personal assistant: scheduling meetings, managing your platform settings, connecting messaging channels, and completing browser-based tasks on your behalf."
5
5
  model: claude-sonnet-4-6
6
6
  tools: Skill, mcp__plugin_admin_admin__system-status, mcp__plugin_admin_admin__brand-settings, mcp__plugin_admin_admin__account-manage, mcp__plugin_admin_admin__account-update, mcp__plugin_admin_admin__logs-read, mcp__plugin_admin_admin__plugin-read, mcp__plugin_admin_admin__wifi, mcp__plugin_contacts_contacts__contact-create, mcp__plugin_contacts_contacts__contact-lookup, mcp__plugin_contacts_contacts__contact-update, mcp__plugin_contacts_contacts__contact-delete, mcp__plugin_contacts_contacts__contact-list, mcp__plugin_contacts_contacts__contact-export, mcp__plugin_contacts_contacts__contact-erase, mcp__plugin_contacts_contacts__group-create, mcp__plugin_contacts_contacts__group-manage, mcp__plugin_telegram_telegram__message, mcp__plugin_telegram_telegram__message-history, mcp__plugin_telegram_telegram__telegram-webhook-register, mcp__plugin_whatsapp_whatsapp__whatsapp-login-start, mcp__plugin_whatsapp_whatsapp__whatsapp-login-wait, mcp__plugin_whatsapp_whatsapp__whatsapp-status, mcp__plugin_whatsapp_whatsapp__whatsapp-disconnect, mcp__plugin_whatsapp_whatsapp__whatsapp-config, mcp__plugin_whatsapp_whatsapp__whatsapp-activity, mcp__plugin_whatsapp_whatsapp__whatsapp-conversations, mcp__plugin_whatsapp_whatsapp__whatsapp-messages, mcp__plugin_whatsapp_whatsapp__whatsapp-conversation-graph-state, mcp__plugin_whatsapp_whatsapp__whatsapp-group-info, mcp__plugin_email_email__email-provider-info, mcp__plugin_email_email__email-setup, mcp__plugin_email_email__email-read, mcp__plugin_email_email__email-send, mcp__plugin_email_email__email-draft, mcp__plugin_email_email__email-draft-edit, mcp__plugin_email_email__email-draft-send, mcp__plugin_email_email__email-reply, mcp__plugin_email_email__email-search, mcp__plugin_email_email__email-graph-query, mcp__plugin_email_email__email-otp-extract, mcp__plugin_email_email__email-status, mcp__plugin_email_email__email-fetch, mcp__plugin_email_email__email-ingest, mcp__plugin_outlook_outlook__outlook-account-register, mcp__plugin_outlook_outlook__outlook-mail-list, mcp__plugin_outlook_outlook__outlook-mail-search, mcp__plugin_outlook_outlook__outlook-calendar-list, mcp__plugin_outlook_outlook__outlook-calendar-event, mcp__plugin_outlook_outlook__outlook-contacts-list, mcp__plugin_outlook_outlook__outlook-mailbox-info, mcp__plugin_scheduling_scheduling__schedule-event, mcp__plugin_scheduling_scheduling__schedule-list, mcp__plugin_scheduling_scheduling__schedule-get, mcp__plugin_scheduling_scheduling__schedule-update, mcp__plugin_scheduling_scheduling__schedule-cancel, mcp__plugin_scheduling_scheduling__schedule-export-ics, mcp__plugin_scheduling_scheduling__schedule-import-ics, mcp__plugin_scheduling_scheduling__time-resolve, mcp__plugin_memory_memory__memory-search, mcp__plugin_memory_memory__profile-update, mcp__plugin_url-get_url-get__url-get, mcp__plugin_browser_browser__browser-render, mcp__plugin_browser_browser__browser-navigate, mcp__plugin_browser_browser__browser-snapshot, mcp__plugin_browser_browser__browser-click, mcp__plugin_browser_browser__browser-fill, mcp__plugin_browser_browser__browser-fill-form, mcp__plugin_browser_browser__browser-type, mcp__plugin_browser_browser__browser-press-key, mcp__plugin_browser_browser__browser-hover, mcp__plugin_browser_browser__browser-select-option, mcp__plugin_browser_browser__browser-wait-for, mcp__plugin_browser_browser__browser-handle-dialog, mcp__plugin_browser_browser__browser-evaluate, mcp__plugin_browser_browser__browser-console-messages, mcp__plugin_browser_browser__browser-tabs, mcp__plugin_browser_browser__browser-pdf-save, mcp__plugin_browser_browser__browser-screenshot, mcp__plugin_browser_browser__browser-resize
@@ -23,9 +23,10 @@ These three rules win when anything else in this prompt conflicts with them.
23
23
  Each domain has a small set of tools and, where it exists, a skill that drives the multi-step flow. Match the brief to the domain, load the skill if one is named, and run the tools the skill prescribes.
24
24
 
25
25
  - **WhatsApp setup or config:** **After routing, the first tool call MUST be `Skill connect-whatsapp` (for QR pairing and admin-phone setup) or `Skill manage-whatsapp-config` (for DM/group policies). Any channel tool call before the routed skill's content is loaded into context is a contract violation.** Load `skill-load skillName=connect-whatsapp` for QR pairing and admin-phone setup; load `skill-load skillName=manage-whatsapp-config` for DM/group policies and admin-phone management. The skills carry the per-phase flow.
26
- - **Cloudflare (tunnel, DNS, Pages hosting, D1):** **After routing, the first tool call MUST be `Skill cloudflare`. Any Bash call before the skill content is loaded into context is a contract violation.** Load `skill-load skillName=cloudflare`. The skill routes each operation class to its reference under `plugins/cloudflare/references/` (`manual-setup.md` for the tunnel, `api.md`, `hosting-sites.md`, `d1-data-capture.md`, `reset-guide.md`, `dashboard-guide.md`) and drives `cloudflared` / `wrangler` directly via Bash. There is no shell-script wrapper; PTY stdout is the only surface.
27
26
  - **Every other domain** (scheduling, Telegram, email, Outlook, contacts, browser, platform admin) runs through the tool descriptions injected into your system prompt. The rules below apply across these domains regardless of which tool is invoked.
28
27
 
28
+ Cloudflare and custom-domain setup (tunnel, DNS, Pages, D1) is **not** your work — it needs a shell you do not hold, so the admin session owns it directly. If such a brief reaches you, hand it back to admin.
29
+
29
30
  ## Cross-domain rules
30
31
 
31
32
  **Brain-first read before action.** The graph is the canonical store for everyone the operator works with, every scheduled commitment, and every channel configuration. Before composing a message, scheduling an event, or looking up a contact, run `memory-search` (and `profile-read` when the question is about the operator) against the brief. Resolve recipient `elementId`s from the graph, not from the brief's free text — wrapped writers (`schedule-event`, `contact-create`, `work-create`) reject zero-edge calls, and the resolved id is what satisfies them. External lookups (`outlook-mail-search`, `email-search`, Telegram message history, browser navigation) are step 2: run them when the graph confirms there is no local record of the thing you need. Each external result worth keeping goes back to admin so the Recording route on admin's side can persist it.
@@ -38,8 +39,6 @@ Each domain has a small set of tools and, where it exists, a skill that drives t
38
39
 
39
40
  **WhatsApp ToS.** Automated broadcast is forbidden. The platform blocks broadcast for WhatsApp regardless of channel config; do not try to design around it.
40
41
 
41
- **Cloudflare has two auth paths.** Cloudflare exposes zero MCP tools; load the `cloudflare` skill and follow its references. Tunnel auth is OAuth (`cloudflared tunnel login`) — the operator clicks the linkified URL in their own browser. Everything else (DNS, Pages, D1, Access) is the Cloudflare API, authenticated by a short-lived narrow token the agent mints from the operator's master token (see `plugins/cloudflare/references/api.md`). Do not drive the dashboard with a browser tool, do not browser-automate master-token creation, do not synthesise `cloudflared` flag combinations, and never write or echo a token. Do not edit `cert.pem`, `tunnel.state`, `config.yml`, or `alias-domains.json` outside the runbook. When a sanctioned surface fails, report with evidence (secrets redacted) and cite `plugins/cloudflare/references/reset-guide.md`. After a tunnel setup finishes, verify with `curl -I https://<hostname>`; a non-530 response means the tunnel is live.
42
-
43
42
  **Reading JavaScript-rendered pages.** When you only need to *read* a JS page and `url-get` comes back empty or as a shell, use `browser-render` with the URL. It renders the page in the device's Chromium and returns the rendered HTML plus visible text in one stateless call — it does not keep the page open.
44
43
 
45
44
  **Automating a page.** When a task needs to *act* on a page (log in, fill a form, click through a flow, drive a web app), use the persistent automation tools. Start with `browser-navigate` to open the page and keep it alive, then `browser-snapshot` to see what is on the page — it returns each interactive element as a role, a name, and a unique CSS selector. Pass those selectors verbatim to `browser-click`, `browser-fill`, `browser-fill-form`, `browser-hover`, and `browser-select-option`. Use `browser-type` / `browser-press-key` for real keyboard input (e.g. Enter to submit), `browser-wait-for` after anything that loads asynchronously, `browser-evaluate` to read page state, and `browser-console-messages` to debug. Arm `browser-handle-dialog` *before* the action that triggers an alert/confirm/prompt, because dialogs block the page. `browser-tabs` opens or switches to a fresh page so stale state from a prior task does not leak in. If an action returns `session-lost`, the page closed — call `browser-navigate` again. Element targeting is by CSS selector (always from a fresh `browser-snapshot`), not by Playwright-style refs. Report CAPTCHA or bot detection as a blocker; do not try to solve it.
@@ -3905,33 +3905,6 @@ function isActiveAgentSlug(accountDir, slug) {
3905
3905
  function getDefaultAccountId() {
3906
3906
  return resolveAccount()?.accountId ?? null;
3907
3907
  }
3908
- function resolveUserAccounts(userId) {
3909
- if (!existsSync5(ACCOUNTS_DIR)) return [];
3910
- const results = [];
3911
- const entries = readdirSync(ACCOUNTS_DIR, { withFileTypes: true });
3912
- for (const entry of entries) {
3913
- if (!entry.isDirectory()) continue;
3914
- const configPath = resolve4(ACCOUNTS_DIR, entry.name, "account.json");
3915
- if (!existsSync5(configPath)) continue;
3916
- let config;
3917
- try {
3918
- config = JSON.parse(readFileSync6(configPath, "utf-8"));
3919
- } catch {
3920
- console.error(`[session] account.json corrupt at ${configPath} \u2014 skipping`);
3921
- continue;
3922
- }
3923
- const adminEntry = config.admins?.find((a) => a.userId === userId);
3924
- if (adminEntry) {
3925
- results.push({
3926
- accountId: config.accountId,
3927
- accountDir: resolve4(ACCOUNTS_DIR, entry.name),
3928
- config,
3929
- role: adminEntry.role
3930
- });
3931
- }
3932
- }
3933
- return results;
3934
- }
3935
3908
 
3936
3909
  // app/lib/claude-agent/session-store.ts
3937
3910
  import { createHash } from "crypto";
@@ -5781,7 +5754,6 @@ export {
5781
5754
  resolveAgentConfig,
5782
5755
  isActiveAgentSlug,
5783
5756
  getDefaultAccountId,
5784
- resolveUserAccounts,
5785
5757
  fingerprintSessionKey,
5786
5758
  registerSession,
5787
5759
  validateSession,
@@ -16,7 +16,7 @@ import {
16
16
  sanitizeClientCorrId,
17
17
  vncLog,
18
18
  websockifyLog
19
- } from "./chunk-GRKG3KNN.js";
19
+ } from "./chunk-NE7G5GT7.js";
20
20
  import "./chunk-PFF6I7KP.js";
21
21
 
22
22
  // server/edge.ts
@@ -85,7 +85,6 @@ import {
85
85
  resolveCallback,
86
86
  resolveClientIp,
87
87
  resolveDefaultAgentSlug,
88
- resolveUserAccounts,
89
88
  runAdminUserSelfHeal,
90
89
  safeJson,
91
90
  sanitizeClientCorrId,
@@ -106,7 +105,7 @@ import {
106
105
  vncLog,
107
106
  walkPremiumBundles,
108
107
  writeAdminUserAndPerson
109
- } from "./chunk-GRKG3KNN.js";
108
+ } from "./chunk-NE7G5GT7.js";
110
109
  import {
111
110
  __commonJS,
112
111
  __toESM
@@ -7030,27 +7029,22 @@ function resolveTitle(body, sessionId, userTitles) {
7030
7029
  }
7031
7030
  app3.get("/", requireAdminSession, async (c) => {
7032
7031
  const accountId = process.env.ACCOUNT_ID ?? null;
7033
- const switcherUserId = (() => {
7034
- const cacheKey = c.get("cacheKey");
7035
- return cacheKey ? getUserIdForSession(cacheKey) : void 0;
7036
- })();
7037
7032
  const accounts = [];
7038
- if (switcherUserId) {
7039
- for (const a of resolveUserAccounts(switcherUserId)) {
7040
- let businessName = null;
7041
- try {
7042
- const branding = await fetchBranding(a.accountId);
7043
- businessName = branding?.name || null;
7044
- } catch {
7045
- }
7046
- accounts.push({
7047
- accountId: a.accountId,
7048
- role: a.config.role ?? "",
7049
- businessName,
7050
- isHouse: a.config.role === "house"
7051
- });
7033
+ for (const a of listValidAccounts()) {
7034
+ let businessName = null;
7035
+ try {
7036
+ const branding = await fetchBranding(a.accountId);
7037
+ businessName = branding?.name || null;
7038
+ } catch {
7052
7039
  }
7040
+ accounts.push({
7041
+ accountId: a.accountId,
7042
+ role: a.config.role ?? "",
7043
+ businessName,
7044
+ isHouse: a.config.role === "house"
7045
+ });
7053
7046
  }
7047
+ console.log(`[admin-sessions-list] accounts=${accounts.length}`);
7054
7048
  const configDir2 = claudeConfigDir();
7055
7049
  if (!configDir2) {
7056
7050
  console.error("[admin-sessions-list] CLAUDE_CONFIG_DIR not set; returning empty list");
@@ -7181,38 +7175,6 @@ function validatePin(pin, usersFilePath) {
7181
7175
  const matched = users.find((u) => u.pin === inputHash);
7182
7176
  return matched ? { kind: "match", userId: matched.userId } : { kind: "miss" };
7183
7177
  }
7184
- function scanForOrphanedAccountAdmins(users, accountsDir) {
7185
- const known = new Set(users.map((u) => u.userId));
7186
- const orphans = [];
7187
- if (!existsSync6(accountsDir)) return orphans;
7188
- let entries;
7189
- try {
7190
- entries = readdirSync5(accountsDir);
7191
- } catch {
7192
- return orphans;
7193
- }
7194
- for (const entry of entries) {
7195
- if (entry.startsWith(".")) continue;
7196
- const accountDir = join10(accountsDir, entry);
7197
- try {
7198
- if (!statSync3(accountDir).isDirectory()) continue;
7199
- } catch {
7200
- continue;
7201
- }
7202
- const accountJsonPath = join10(accountDir, "account.json");
7203
- if (!existsSync6(accountJsonPath)) continue;
7204
- try {
7205
- const config = JSON.parse(readFileSync11(accountJsonPath, "utf-8"));
7206
- const admins = config.admins ?? [];
7207
- for (const a of admins) {
7208
- if (typeof a.userId === "string" && !known.has(a.userId)) orphans.push(a.userId);
7209
- }
7210
- } catch {
7211
- continue;
7212
- }
7213
- }
7214
- return orphans;
7215
- }
7216
7178
 
7217
7179
  // app/lib/whatsapp/inbound/resolve-admin-userid.ts
7218
7180
  function classifyAdminUserId(users, admins, senderPhone) {
@@ -10919,6 +10881,9 @@ async function createAdminSession(accountId, thinkingView, userId, userName, rol
10919
10881
  sessionId: initialSessionId
10920
10882
  };
10921
10883
  }
10884
+ function operatorRoleFor(config, userId) {
10885
+ return config.admins?.find((a) => a.userId === userId)?.role ?? "admin";
10886
+ }
10922
10887
  var app11 = new Hono();
10923
10888
  app11.get("/", async (c) => {
10924
10889
  const signedSessionToken = c.req.query("session_key");
@@ -10983,27 +10948,14 @@ app11.post("/", async (c) => {
10983
10948
  return c.json({ error: "PIN not configured. Complete onboarding first." }, 503);
10984
10949
  }
10985
10950
  if (verdict.kind === "miss") {
10986
- let users = [];
10987
- try {
10988
- users = readUsersFile(USERS_FILE) ?? [];
10989
- } catch {
10990
- }
10991
- const orphanUserIds = scanForOrphanedAccountAdmins(users, ACCOUNTS_DIR);
10992
- if (orphanUserIds.length > 0) {
10993
- const prefix = orphanUserIds[0].slice(0, 8);
10994
- console.log(
10995
- `[session] PIN auth failed: no matching user; account.json admins[] contains userId=${prefix} with no users.json row \u2014 three-store divergence`
10996
- );
10997
- } else {
10998
- console.log(`[session] PIN auth failed: no matching user`);
10999
- }
10951
+ console.log(`[session] PIN auth failed: no matching user`);
11000
10952
  return c.json({ error: "Invalid PIN" }, 401);
11001
10953
  }
11002
10954
  const { userId } = verdict;
11003
- const accounts = resolveUserAccounts(userId);
10955
+ const accounts = listValidAccounts();
11004
10956
  if (accounts.length === 0) {
11005
- console.log(`[session] user has no accounts: userId=${userId}`);
11006
- return c.json({ error: "No account access for this user." }, 403);
10957
+ console.log(`[session] no accounts configured on install: userId=${userId}`);
10958
+ return c.json({ error: "No accounts are configured on this install." }, 403);
11007
10959
  }
11008
10960
  if (accounts.length > 1 && !body.accountId) {
11009
10961
  console.log(`[session] multi-account user: userId=${userId} accounts=${accounts.length}`);
@@ -11015,7 +10967,7 @@ app11.post("/", async (c) => {
11015
10967
  businessName = branding?.name || void 0;
11016
10968
  } catch {
11017
10969
  }
11018
- return { accountId: a.accountId, businessName, role: a.role };
10970
+ return { accountId: a.accountId, businessName, role: operatorRoleFor(a.config, userId) };
11019
10971
  })
11020
10972
  );
11021
10973
  return c.json({ accounts: accountList, userId });
@@ -11026,7 +10978,7 @@ app11.post("/", async (c) => {
11026
10978
  return c.json({ error: "Invalid account selection." }, 403);
11027
10979
  }
11028
10980
  const { userName, avatar } = await resolveUserIdentity(selected.accountId, userId);
11029
- const payload = await createAdminSession(selected.accountId, selected.config.thinkingView, userId, userName, selected.role, avatar);
10981
+ const payload = await createAdminSession(selected.accountId, selected.config.thinkingView, userId, userName, operatorRoleFor(selected.config, userId), avatar);
11030
10982
  return c.json(payload);
11031
10983
  });
11032
10984
  var session_default = app11;