@rackbops/ac-agent 2.0.0-alpha.39 → 2.0.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,8 @@ small HTTPS server, paired to a console over a token, that lets it reach one dev
6
6
  one-time code exchanged for a per-machine token, #48), `repos` list/status/pull and `open` (#49,
7
7
  49a), `exec/claude` (with the job registry and its SSE log) plus `exec/gh` (#49, 49b), and the
8
8
  `context-pressure` / `subagent-cost` session-log scans (#52, 52a), and `memory-footprint` /
9
- `toolchain-inventory` (#52, 52b) are all live -- every `scan/:kind` kind #52 planned has landed.
9
+ `toolchain-inventory` (#52, 52b) are all live -- every `scan/:kind` kind #52 planned has landed --
10
+ as are the OneDrive mirror (`publish/mirror`, #53) and the tunnel-config lint (`tunnel/lint`, #416).
10
11
  Every verb also
11
12
  requires a per-plugin capability grant, pushed by the console and enforced here (#50) -- see
12
13
  **Verbs** and **Security model** below. `GET /v1/preflight` (#55) -- git/gh/node/claude executed,
@@ -18,16 +19,13 @@ chromium only resolved -- is control plane, not a verb; see **Preflight**.
18
19
  npx @rackbops/ac-agent install
19
20
  ```
20
21
 
21
- During the prerelease period, install the newest prerelease explicitly with the `next` dist-tag
22
- (`npx @rackbops/ac-agent@next install`) -- `release.yml`'s publish step tags every `-alpha.N`/
23
- `-beta.N`/`-rc.N` version `next` from here on (#269), since npm refuses a plain `npm publish` of a
24
- prerelease version with no explicit `--tag`; a stable version still publishes under `latest`. The
25
- very first publish (`2.0.0-alpha.8`, #268) was done by hand under `--tag alpha` before this existed
26
- -- and because it was the package's first version ever, npm *also* pointed `latest` at
27
- `2.0.0-alpha.8` (npm's own first-publish behaviour, not a deliberate choice here). So right now a
28
- bare `npx @rackbops/ac-agent install` (no tag) resolves to `alpha.8`, with `alpha` and `latest` both
29
- still pointing at it; `next` starts moving with the next prerelease tag, and `latest` only moves
30
- again once a real stable version is published.
22
+ Install with the plain command above, no dist-tag: since #564, every release -- prerelease included --
23
+ publishes with `--tag latest` while 2.0 is prerelease-only, so an unversioned `npx @rackbops/ac-agent`
24
+ resolves to the newest release. Two older tags are frozen: `alpha` (the very first publish,
25
+ `2.0.0-alpha.8`, #268, was done by hand under `--tag alpha`) still points at `2.0.0-alpha.8`, and
26
+ `next` (what `release.yml` gave every prerelease from #269 through `2.0.0-alpha.40`) stays at
27
+ `2.0.0-alpha.40`. Nothing moves either any more, and the maintainer removes or moves them by hand.
28
+ `latest` is expected to go back to stable-only once the first stable `2.0.0` ships.
31
29
 
32
30
  ### Upgrade with the Tooling script
33
31
 
@@ -68,7 +66,7 @@ agent.
68
66
 
69
67
  ### Linux: no special `npx` spelling needed (#278)
70
68
 
71
- The plain `npx @rackbops/ac-agent[@next] <verb>` line above is the one spelling that works on Linux
69
+ The plain `npx @rackbops/ac-agent <verb>` line above is the one spelling that works on Linux
72
70
  regardless of npm version -- confirmed on Ubuntu 26.04 with both npm 11.19 and npm 12.0.2. `#278` was
73
71
  originally filed as an npm-11-vs-12 `npx` regression (alpha.9 silently printing `npm notice run` and
74
72
  exiting 0 with no verb output on a npm 12 box), but the real cause was narrower and applied to *every*
@@ -309,6 +307,18 @@ its own live override `AC_AGENT_CLAUDE_PROJECTS_DIR` — it names the root the `
309
307
  still resolves. Unlike `repos_root`, `install` takes no flag for it and `status` does not report
310
308
  it.
311
309
 
310
+ `tunnel_config` and `tunnel_preflight` (#416; both unset by default, because most machines do not run
311
+ the tunnel) are nullable like `publish_dir`, so `agent.toml` simply omits them: `tunnel_config` is
312
+ the absolute path of the `cloudflared` config that `GET /v1/tunnel/lint` reads, `tunnel_preflight`
313
+ the absolute path of Tooling's `console_tunnel_preflight.py`. Live overrides `AC_AGENT_TUNNEL_CONFIG`
314
+ and `AC_AGENT_TUNNEL_PREFLIGHT`. **Absolute paths only**, because `tunnel_preflight` is executed and
315
+ must not resolve against whatever directory the service starts in: a relative env override throws at
316
+ load, naming the value, like every other `AC_AGENT_*` path; a relative value in the file is loaded as
317
+ written (as `publish_dir`'s is, so a typo in an optional key can never stop `serve`, `stop` or
318
+ `uninstall`) and the verb then refuses to read or run it, answering `tunnel_config is not an absolute
319
+ path` (or a `preflight-unusable` warning naming `tunnel_preflight`). A blank or non-string value in the
320
+ file is treated as unset. `install` takes no flag for either and `status` does not report them.
321
+
312
322
  ## Verbs
313
323
 
314
324
  Everything under `/v1/*` except `/v1/health` and `/v1/pair` requires pairing (see **Security
@@ -316,7 +326,7 @@ model**). The verb registry fills `GET /v1/health`'s `capabilities` field with t
316
326
  list of every registered verb's capability name — with every verb group registered, that's
317
327
  `["agent.desktop.open", "agent.exec.claude", "agent.exec.gh", "agent.git.pull", "agent.git.read",
318
328
  "agent.publish.mirror", "agent.scan.context-pressure", "agent.scan.memory-footprint",
319
- "agent.scan.subagent-cost", "agent.scan.toolchain-inventory"]`.
329
+ "agent.scan.subagent-cost", "agent.scan.toolchain-inventory", "agent.tunnel.lint"]`.
320
330
 
321
331
  **Every verb also requires two headers, checked in this order** (#50), after the bearer check
322
332
  passes: `X-AC-Plugin` (the calling plugin's id) missing or shaped wrong is `400 {ok: false, error:
@@ -520,12 +530,19 @@ running"}`.
520
530
  - **HTML**: each file's bytes are compared against what's on disk (size, then sha256) — unchanged
521
531
  bytes are never rewritten (OneDrive re-uploads every rewrite); a changed or new file is written to
522
532
  `<publish_dir>/html/<href>.tmp-<pid>` then renamed into place. A write/rename failure (a file
523
- OneDrive has locked mid-sync) counts as `failed` and the run continues with the next file.
533
+ OneDrive has locked mid-sync) counts as `failed`, is named in `html.failures` with its error's
534
+ message, and the run continues with the next file.
524
535
  - **PDF** (when `pdf: true`): one `pdf/<href>.pdf` per `wanted` report (never for an `index.html`
525
536
  page), rendered with a local headless Chromium-family browser only when missing or older than its
526
537
  html — resolved via the same search `GET /v1/preflight`'s `chromium` row uses (winPaths, then
527
538
  PATH, then a version manager's bin dir), never executed otherwise. No resolvable browser is not an
528
- error: html still mirrors, the response's `pdf.browser` is `null`.
539
+ error: html still mirrors, the response's `pdf.browser` is `null`. A render that produced no file
540
+ counts as `failed` and is named in `pdf.failures` by its `.pdf` href: `timed out after 90s` when the
541
+ browser launch timed out, `exit <code>: <the last 160 characters of its stderr>` when it exited
542
+ non-zero (just `exit <code>` when it printed nothing), else `no pdf produced within 30s`. The poll for
543
+ the file still decides success, so a browser that exits non-zero, or times out, but wrote the pdf is a
544
+ success. A report whose html is not on disk is skipped, never
545
+ named.
529
546
  - **Ownership, not "not in my docroot"** (1.x issue #230): the publish target is shared across every
530
547
  paired machine that mirrors to it, so pruning (`prune: true`) is scoped to what THIS machine's
531
548
  ledger — `<stateDir>/published-ledger.json`, machine-local, never inside the publish target itself
@@ -535,8 +552,20 @@ running"}`.
535
552
  the mirror in one run — the response's `html.veto`/`pdf.veto` names the reason; re-run with the
536
553
  same request (or a `--force`-equivalent isn't exposed here — see `publish-adopt` below for the
537
554
  one-time claim that closes an adoption gap) once satisfied it's intended.
538
- - Response `200 {ok: true, dest, html: {written, unchanged, failed, pruned, veto}, pdf: {rendered,
539
- upToDate, failed, pruned, veto, browser}}`.
555
+ - Response `200 {ok: true, dest, html: {written, unchanged, failed, pruned, veto, failures}, pdf:
556
+ {rendered, upToDate, failed, pruned, veto, browser, failures}}`. **`failures`** (added in #376) is
557
+ `[{href, reason}]` naming each report that failed to write or render (a pdf by its `.pdf` href),
558
+ capped at 50 per kind (`failed` keeps counting past it). It is always present from this agent (`[]`
559
+ when nothing failed) and absent from an older one, so a console must treat it as optional. A `reason`
560
+ is one line of at most 200 characters (whitespace collapsed), with this run's `publish_dir` replaced
561
+ by `<dest>` and the browser's temporary profile dir by `<tmp>` wherever they appear as literal text:
562
+ as configured, `path.normalize`d, with either separator, or percent-encoded as in a `file://` URL. The
563
+ response's `dest` already names the former. Only an absolute path is replaced (a relative `publish_dir`
564
+ is left alone rather than rewriting every occurrence of its name). A path that differs in case, is a
565
+ Windows short (8.3) name or is the `\\?\UNC\...` form of a UNC dest is not recognised, and neither is
566
+ any other path a browser prints on stderr (its last 160 characters are kept, trimmed and collapsed,
567
+ otherwise as printed). The agent's own log line ends its counts with ` failures: <named html>/<named
568
+ pdf>` when either is non-zero; the names stay out of the agent's log.
540
569
 
541
570
  **`ac-agent publish-adopt`** (CLI, not a verb; one-time): refuses when `publish_dir` is unset,
542
571
  otherwise claims every file currently in the mirror for this machine's ledger. Run exactly once, only
@@ -550,6 +579,103 @@ files a run both mirrors and finds already present.
550
579
  platform, and on a Windows box with neither OneDrive env var. `status` reports it under `publishDir`
551
580
  (`null` when unconfigured).
552
581
 
582
+ ### `GET /v1/tunnel/lint`
583
+
584
+ **`GET /v1/tunnel/lint`** (`agent.tunnel.lint`, #416) lints the `cloudflared` config on the machine
585
+ that runs the tunnel, so the Admin security card can show it (that card is PR 2 of #416, not yet
586
+ built). **Why an agent verb:** the console is a container that cannot read the tunnel host's config
587
+ and has no Tooling clone, so the lint runs where the config lives and the console is meant to ask each
588
+ paired machine (`host.agent.call`), the way `scans`, `repos` and `publish` already work.
589
+
590
+ The verb takes **no request input**: which file is read and which script is run come only from
591
+ `tunnel_config` / `tunnel_preflight` (or their `AC_AGENT_TUNNEL_*` overrides) on this machine. It
592
+ reads the file afresh on every call, so a result is never stale, and answers `200` with one of:
593
+
594
+ ```json
595
+ {"available": true, "engine": "builtin", "ok": false, "errors": 1, "warns": 1,
596
+ "hostnames": ["console.example.com"],
597
+ "findings": [{"id": "ingress-catch-all", "severity": "error", "message": "...", "location": "ingress[1]"}]}
598
+ {"available": false, "reason": "no tunnel_config in agent.toml"}
599
+ ```
600
+
601
+ `errors`/`warns` count every finding; `findings` is capped at 200 and `hostnames` at 200, and each
602
+ string in them is cut for both engines (messages at 500 characters, locations 200, ids 100, a hostname
603
+ 253; a cut string ends in `...`, so the longest is N + 3). **Fail-soft everywhere:** anything that
604
+ stops a lint from running is `200 {available: false, reason}`, never a non-2xx from this handler (the
605
+ grants guard in front of it still answers its own `400`/`409`/`403`), and a failing log write does not
606
+ change the answer. Reasons (each cut at 600 characters): `no tunnel_config in agent.toml` (this machine
607
+ does not run the tunnel); `tunnel_config is not an absolute path`; `cannot read tunnel_config <path>:
608
+ <why>`, where `<why>` is an OS error code such as `ENOENT`, `EACCES` or `EINVAL`, `not a regular
609
+ file`, `file is over 65536 bytes`, `timed out after 3000 ms`, or `an earlier read has not finished
610
+ (...)`; `tunnel_config <path> is not valid YAML (<code> at line <n>)` (the parser's error code and
611
+ line, never the file's text; an alias bomb is `excessive aliasing`, an alias with no anchor
612
+ `unresolved alias`) or `is not a YAML mapping`; and `tunnel lint failed: <error>` for anything
613
+ unexpected.
614
+
615
+ **The config read is bounded.** Only a regular file is read. A FIFO or a named pipe is refused
616
+ (`not a regular file`) before it is opened, because opening a pipe with no writer blocks one of libuv's
617
+ four thread-pool threads for good and a few such calls would stall every filesystem call in the agent;
618
+ a Windows device name the OS cannot stat (`CON`) fails at that stat with its own code, never reaching
619
+ an open. The read runs under a 3 s deadline. Calls that overlap a read still inside its deadline share
620
+ it; once a read has missed its deadline and is still outstanding, the next calls answer `an earlier
621
+ read has not finished` instead of starting another, so a share that is not answering can never fill
622
+ the pool. The file is read up to **64 KiB** and refused beyond that: a cloudflared config is tens of
623
+ lines, and the limit bounds parse time, which is quadratic in a flat mapping and runs on the agent's
624
+ one event loop (under a second at the limit, tens of seconds at 1 MiB).
625
+
626
+ **Two engines, one result shape** (`engine`):
627
+
628
+ - **`"builtin"`** (the default, needs no Python and no Tooling checkout): a structural lint in
629
+ TypeScript, using the preflight's own finding ids where a rule overlaps. It does **not** judge
630
+ safety (no loopback or Access checks; that is what the preflight adds), so an `ok: true` from it
631
+ means "none of these checks found a problem", not "safe". **The shared ids do not always mean the
632
+ same verdict**, so show `engine` beside a finding: the preflight reports any rule before the end
633
+ that has no hostname (a path-only rule included) as `ingress-catch-all`, where the built-in accepts
634
+ a path-only rule; and `duplicate-hostname` and `origin-url` exist only here (the preflight judges
635
+ origins by its own `origin-loopback`).
636
+
637
+ | id | severity | when |
638
+ |---|---|---|
639
+ | `named-tunnel` | error | no `tunnel:`, or no `credentials-file:` |
640
+ | `has-ingress` | error | no `ingress:` list, or an empty one |
641
+ | `ingress-catch-all` | error | the final rule is not a bare `service: http_status:404` (no `hostname`); or an entry before it is not a mapping; or a rule before it has neither a `hostname` nor a `path`, which matches everything and makes every later rule dead (a path-only rule is fine) |
642
+ | `origin-url` | error | a rule with a `hostname` whose `service` is missing or is neither a URL (`http`, `https`, `ws`, `wss`, `tcp`, `ssh`, `rdp`, `smb`, with a host) nor `http_status:<3 digits>`, `hello_world`, `bastion`, `unix:<path>` or `unix+tls:<path>`; the value is never echoed, since a service URL may carry credentials |
643
+ | `duplicate-hostname` | error | the same hostname (case-insensitive, a trailing dot ignored) **and** the same `path` twice: the later rule is dead. The same hostname with different paths is normal |
644
+ | `no-tls-verify` | warn | `noTLSVerify: true` in the top-level `originRequest` or a rule's |
645
+
646
+ The file is read as **YAML 1.1, which includes merge keys**, the dialect the preflight (PyYAML)
647
+ reads: `noTLSVerify: yes` and `on` are true, `<<: *defaults` merges, anchors and aliases resolve (an
648
+ aliased `originRequest` still warns), and the parser's alias limit refuses a bomb. Two differences
649
+ from PyYAML: the library also reads 1.1's `y` and `n` as booleans, and a duplicate key is refused
650
+ (`DUPLICATE_KEY`) where PyYAML keeps the last. `hostnames` is de-duplicated here (case-insensitive, a
651
+ trailing dot ignored); the preflight's list is passed on as it reported it.
652
+ - **`"preflight"`**, when `tunnel_preflight` is set and Python resolves: the agent runs Tooling's
653
+ `console_tunnel_preflight.py` and returns its findings (the security lint). The command is
654
+ `<python> <tunnel_preflight> --json --config <tunnel_config>`, spawned from an argument array with
655
+ no shell, in the script's own directory, with a 4 s timeout (a call that hits it and then meets a slow
656
+ config read can take the sum of the two deadlines). **Lint only: `--probe` is never passed**, so the
657
+ preflight makes no network call. Its output is **capped at 1,048,576 characters while it is
658
+ collected** (the child is killed past that; `output over 1048576 characters` in the reason), so a
659
+ runaway script cannot grow the agent's memory. Python is the first of `python3`,
660
+ `python` on PATH (`python`, `py` on Windows). Only exit codes `0` and `1` (no errors / errors found)
661
+ carry a report, and it must be JSON with `apiVersion: 1`; the agent counts `errors` and `warns` from
662
+ the findings (never the payload's own numbers), reports `ok` only when the payload says so and no
663
+ finding is an error, and passes only `id`/`severity`/`message`/`location` strings on, bounded as
664
+ above. **Anything else** (no Python, a launch failure, a timeout, output over the cap, exit `2`, junk
665
+ output, another API version, a relative `tunnel_preflight`) falls back to the built-in lint, with a
666
+ `preflight-unusable` warning as the first finding that names why, so a broken hand-off is visible and
667
+ not silently downgraded. The why is one line: an exit-`2` `error` payload is cut to its first line,
668
+ and a failed child's stderr to its exception line (or first line), because PyYAML's errors put a
669
+ snippet of the config after the first line. That first line can still quote a short token of the
670
+ config (a tag or alias name), at most 200 characters of it.
671
+
672
+ Both paths are absolute-only (see **Config and state directories**). What the verb returns is counts,
673
+ hostnames and finding text, never the file itself. The built-in lint never echoes a `service` value;
674
+ the preflight's own finding messages are passed through as the script wrote them (cut as above), and
675
+ **may quote config values, service URLs included**. The agent logs one line per call, `tunnel lint:
676
+ <engine> ok=<bool> errors=<n> warns=<n> hostnames=<n>` or `tunnel lint: unavailable (<reason>)`, with
677
+ any line break in a path flattened to a space.
678
+
553
679
  ## Health: `claudeDesktop`
554
680
 
555
681
  `GET /v1/health` carries `claudeDesktop: boolean` (#457) — whether this machine can open a