@astrofoundry/pi-astro 0.26.1 → 0.26.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -19,7 +19,7 @@ The `security` tool is read-only. Every call is a fixed command on the guest; `[
19
19
  | `bouncers` | bouncers JSON (`name`, `last_pull`, `revoked`) |
20
20
  | `metrics` | `cscli metrics -o json` |
21
21
  | `wazuh-alerts <days> <minLevel>` | summary: count, groups by rule and agent, latest 20 (log line truncated to 200 chars) |
22
- | `wazuh-vulns <days> [agent]` | vulnerability alerts only: `open` (latest event per agent, package and CVE that is still Active), `openWithoutFixVersion`, `active` and `solved` counts, and `items` (every event, newest first) with `cve`, `severity`, `score`, `status`, `package`, installed `version`, `condition`, `references` |
22
+ | `wazuh-vulns <days> [agent]` | vulnerability alerts only: `open` (latest event per agent, package, installed version and CVE that is still Active), `openWithoutFixVersion`, `active` and `solved` counts, and `items` (every event, newest first) with `cve`, `severity`, `score`, `status`, `package`, installed `version`, `condition`, `references` |
23
23
  | `wazuh-log <lines>` | manager container log |
24
24
  | `remote-hosts` | `host/program.log` names in the archive |
25
25
  | `remote-log <host> <program> <lines>` | tail of one archived log, e.g. `remote-log frontdoor-1337 nginx-stream 200` |
@@ -41,7 +41,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
41
41
  - Fixed remote commands: `DMZ_COMMANDS`, `PULSAR_COMMANDS`, `VPS_COMMANDS`, `OBS_COMMANDS`, `BACKUP_COMMANDS`, `HOST_COMMANDS`, and `INFERENCE_COMMANDS` must match the entry scripts in `entry/remote/` and their tracked copies in the homelab repository (`02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `00-frontdoor-vps/system/`, `02-pulsar-proxmox/observability/system/`, `02-pulsar-proxmox/hermes/system/`, `04-nexus-macstudio/system/`, and `host/` of the `astronaute77/arcane` repository). Pulsar's root `authorized_keys` carries one forced key per specialist (network, backup, proxmox, inference), each pinned to its own entry script. Change both sides in the same commit and redeploy the remote script. A new privileged step on the VPS also needs its exact line in `spc-edge-frontdoor-sudoers`.
42
42
  - Repository access is `lib/repo.ts` (`repoCommand`, `RepoSpec`, `trackedFile`): one clone per specialist under `~/.specialists/work/<checkoutName>` (`homelab-edge`, `homelab-identity`; never share a checkout between specialists), a write deploy key, and an optional `writeRoot` that limits `git write`. `edge` (`deploy-nginx`) and `identity` (`dmz deploy-config`) deploy only what origin has: `trackedFile` refuses a dirty, ahead, or behind checkout. The DMZ entry keeps the previous template and restores it when render or restart fails.
43
43
  - `network` firewall policy writes are `policyWrite`: JSON body from the agent, checked for the documented required fields, sent as `POST`, `PUT`, or `DELETE` through the same pinned request; UniFi validates the rest.
44
- - The IAP tunnel is `lib/tunnel.ts`: `gcloud compute start-iap-tunnel <instance> 22 --local-host-port=localhost:<port>` (a documented flag; the hidden `--listen-on-stdin` is not used), ready when the port accepts a connection, closed after the ssh call. `gcloud auth activate-service-account` runs before every tunnel with `CLOUDSDK_CONFIG` under `~/.specialists/work/gcloud` and `CLOUDSDK_PYTHON` from the config, because the Homebrew cask ships no interpreter.
44
+ - Pi executes sibling tool calls in parallel, so two wrapper processes of the same specialist can run at once. `lib/lock.ts` (`withLock`, a directory lock under `~/.specialists/work/locks`, stale after 10 minutes) serialises shared resources; `edge` holds `edge-tunnel` around the fixed IAP port and the gcloud configuration directory. `repo.ts` checkouts are not locked yet.
45
+ - `edge` retries one SSH call through the IAP tunnel when the first connection is closed during setup (`isTransientSshFailure`: exit 255 with "closed by remote host" or a reset), except `reboot`. The IAP tunnel is `lib/tunnel.ts`: `gcloud compute start-iap-tunnel <instance> 22 --local-host-port=localhost:<port>` (a documented flag; the hidden `--listen-on-stdin` is not used), ready when the port accepts a connection, closed after the ssh call. `gcloud auth activate-service-account` runs before every tunnel with `CLOUDSDK_CONFIG` under `~/.specialists/work/gcloud` and `CLOUDSDK_PYTHON` from the config, because the Homebrew cask ships no interpreter.
45
46
  - `dns` builds every Technitium call from `TECHNITIUM_READS` and `TECHNITIUM_WRITES`; `key=value` record parameters pass through by name (`token` and `node` refused) because the API documents dozens of type-specific parameters. Writes are refused on the secondary in code: the catalog zone is the only replication path.
46
47
  - `security` parses Wazuh alert lines locally (`summariseWazuh`, `summariseVulns`) so the guest never needs `jq`; `wazuh-vulns` is a local view over the same `wazuh-alerts` dump, not a guest command.
47
48
  - TLS pinning for self-signed consoles is `lib/pinned.ts` (`pinnedRequest`); `network` and `proxmox` use it with a `*CertSha256` config value obtained once through the wrapper's `fingerprint` command.
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { readConfig } from "../lib/config.ts";
3
3
  import { ServiceError, UsageError } from "../lib/errors.ts";
4
+ import { withLock } from "../lib/lock.ts";
4
5
  import { main } from "../lib/main.ts";
5
6
  import { printJson, printRaw } from "../lib/output.ts";
6
7
  import { specialistsHome, workDir } from "../lib/paths.ts";
@@ -85,6 +86,11 @@ ${REPO_HELP}
85
86
 
86
87
  deploy-nginx refuses when the checkout is dirty or differs from origin: commit and push first.`;
87
88
 
89
+ /** The first connection through a fresh IAP tunnel is sometimes closed before sshd answers; that failure is safe to retry once. */
90
+ export function isTransientSshFailure(result: { code: number; stderr: string }): boolean {
91
+ return result.code === 255 && /closed by remote host|connection reset|connection refused|kex_exchange_identification/i.test(result.stderr);
92
+ }
93
+
88
94
  export function buildVpsRemote(args: string[]): { remote: string; stdin: boolean } {
89
95
  const [name, ...rest] = args;
90
96
  const spec = name === undefined ? undefined : VPS_COMMANDS[name];
@@ -120,7 +126,12 @@ async function activateServiceAccount(config: EdgeConfig): Promise<void> {
120
126
  }
121
127
 
122
128
  /** Opens the IAP tunnel, runs one fixed command over it, closes the tunnel. */
123
- async function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number, input?: string): Promise<{ stdout: string; stderr: string; code: number }> {
129
+ /** One tunnel at a time: the local port and the gcloud configuration directory are shared by every edge process. */
130
+ function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number, input?: string): Promise<{ stdout: string; stderr: string; code: number }> {
131
+ return withLock("edge-tunnel", () => tunnelCall(config, remote, timeoutMs, input), { timeoutMs: timeoutMs + 60_000 });
132
+ }
133
+
134
+ async function tunnelCall(config: EdgeConfig, remote: string, timeoutMs: number, input?: string): Promise<{ stdout: string; stderr: string; code: number }> {
124
135
  await activateServiceAccount(config);
125
136
  const tunnel = await openTunnel(
126
137
  config.gcloud,
@@ -138,20 +149,20 @@ async function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number,
138
149
  ],
139
150
  { env: gcloudEnv(config), port: config.iapLocalPort, readyTimeoutMs: 45_000 },
140
151
  );
152
+ const target = {
153
+ host: "127.0.0.1",
154
+ port: config.iapLocalPort,
155
+ hostKeyAlias: config.instance,
156
+ user: config.sshUser,
157
+ keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
158
+ knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
159
+ };
141
160
  try {
142
- return await sshFixed(
143
- {
144
- host: "127.0.0.1",
145
- port: config.iapLocalPort,
146
- hostKeyAlias: config.instance,
147
- user: config.sshUser,
148
- keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
149
- knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
150
- },
151
- remote,
152
- timeoutMs,
153
- input,
154
- );
161
+ const first = await sshFixed(target, remote, timeoutMs, input);
162
+ // A connection closed during setup never ran the command; a reboot is the one command not to repeat.
163
+ if (!isTransientSshFailure(first) || remote === "reboot") return first;
164
+ await new Promise((resolve) => setTimeout(resolve, 2000));
165
+ return await sshFixed(target, remote, timeoutMs, input);
155
166
  } finally {
156
167
  await tunnel.close();
157
168
  }
@@ -0,0 +1,42 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { withLock } from "./lock.ts";
6
+
7
+ describe("withLock", () => {
8
+ let dir: string;
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), "astro-lock-"));
11
+ });
12
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
13
+
14
+ it("serialises concurrent holders and releases the lock afterwards", async () => {
15
+ const order: string[] = [];
16
+ const hold = (tag: string, ms: number) =>
17
+ withLock("edge", async () => {
18
+ order.push(`${tag} start`);
19
+ await new Promise((r) => setTimeout(r, ms));
20
+ order.push(`${tag} end`);
21
+ }, { dir });
22
+ await Promise.all([hold("a", 120), hold("b", 10)]);
23
+ expect(order).toEqual(["a start", "a end", "b start", "b end"]);
24
+ expect(existsSync(join(dir, "edge.lock"))).toBe(false);
25
+ });
26
+
27
+ it("removes a stale lock and reports a busy one", async () => {
28
+ const lock = join(dir, "edge.lock");
29
+ mkdirSync(lock);
30
+ const old = new Date(Date.now() - 20 * 60_000);
31
+ utimesSync(lock, old, old);
32
+ expect(await withLock("edge", async () => "ran", { dir })).toBe("ran");
33
+ mkdirSync(lock);
34
+ await expect(withLock("edge", async () => "never", { dir, timeoutMs: 300 })).rejects.toThrow(/busy/);
35
+ expect(existsSync(lock)).toBe(true);
36
+ });
37
+
38
+ it("releases the lock when the work throws", async () => {
39
+ await expect(withLock("edge", async () => { throw new Error("boom"); }, { dir })).rejects.toThrow("boom");
40
+ expect(existsSync(join(dir, "edge.lock"))).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,49 @@
1
+ import { mkdirSync, rmSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ServiceError } from "./errors.ts";
4
+ import { workDir } from "./paths.ts";
5
+
6
+ /** A lock older than this belongs to a process that died without releasing it. */
7
+ const STALE_MS = 10 * 60_000;
8
+
9
+ function sleep(ms: number): Promise<void> {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+
13
+ /**
14
+ * Runs `fn` while holding an exclusive directory lock, so concurrent wrapper
15
+ * processes (Pi runs sibling tool calls in parallel) take turns on a shared
16
+ * resource such as a fixed tunnel port or a gcloud configuration directory.
17
+ */
18
+ export async function withLock<T>(name: string, fn: () => Promise<T>, options: { dir?: string; timeoutMs?: number; staleMs?: number } = {}): Promise<T> {
19
+ const root = options.dir ?? workDir("locks");
20
+ const lock = join(root, `${name}.lock`);
21
+ const deadline = Date.now() + (options.timeoutMs ?? 120_000);
22
+ mkdirSync(root, { recursive: true, mode: 0o700 });
23
+ for (;;) {
24
+ try {
25
+ mkdirSync(lock);
26
+ break;
27
+ } catch (err) {
28
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
29
+ }
30
+ let age: number;
31
+ try {
32
+ age = Date.now() - statSync(lock).mtimeMs;
33
+ } catch {
34
+ // The holder released it between our attempt and the check; try again at once.
35
+ continue;
36
+ }
37
+ if (age > (options.staleMs ?? STALE_MS)) {
38
+ rmSync(lock, { recursive: true, force: true });
39
+ continue;
40
+ }
41
+ if (Date.now() > deadline) throw new ServiceError(`${name} is busy: another call has held it for ${Math.round(age / 1000)} s`);
42
+ await sleep(200);
43
+ }
44
+ try {
45
+ return await fn();
46
+ } finally {
47
+ rmSync(lock, { recursive: true, force: true });
48
+ }
49
+ }
@@ -265,7 +265,7 @@ export interface VulnerabilitySummary {
265
265
  count: number;
266
266
  active: number;
267
267
  solved: number;
268
- /** Latest event per agent, package and CVE whose status is still Active: the findings that are open now. */
268
+ /** Latest event per agent, package, installed version and CVE whose status is still Active: the findings that are open now. */
269
269
  open: VulnerabilityItem[];
270
270
  /** Open findings whose condition is "Package default status": the feed knows no fixed version, so no version clears them. */
271
271
  openWithoutFixVersion: number;
@@ -381,7 +381,7 @@ export function summariseVulns(text: string, days: number, agent: string | null,
381
381
  items.sort((x, y) => y.timestamp.localeCompare(x.timestamp));
382
382
  const latest = new Map<string, VulnerabilityItem>();
383
383
  for (const item of items) {
384
- const key = `${item.agent}|${item.package}|${item.cve}`;
384
+ const key = `${item.agent}|${item.package}|${item.version}|${item.cve}`;
385
385
  if (!latest.has(key)) latest.set(key, item);
386
386
  }
387
387
  const open = [...latest.values()].filter((i) => i.status.toLowerCase() === "active");
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { refusedPrefix, command as arcaneCommand } from "./arcane/run.ts";
6
6
  import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
7
7
  import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
8
- import { buildVpsRemote, command as edgeCommand } from "./edge/run.ts";
8
+ import { buildVpsRemote, command as edgeCommand, isTransientSshFailure } from "./edge/run.ts";
9
9
  import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
10
10
  import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
11
11
  import { UsageError } from "./lib/errors.ts";
@@ -196,6 +196,9 @@ describe("edge wrapper", () => {
196
196
  expect(buildVpsRemote(["auto-upgrades"])).toEqual({ remote: "auto-upgrades", stdin: false });
197
197
  expect(buildVpsRemote(["changelog", "unbound"])).toEqual({ remote: "changelog unbound", stdin: false });
198
198
  expect(() => buildVpsRemote(["changelog", "../x"])).toThrow(/changelog/);
199
+ expect(isTransientSshFailure({ code: 255, stderr: "Connection to 127.0.0.1 closed by remote host.\r\n" })).toBe(true);
200
+ expect(isTransientSshFailure({ code: 255, stderr: "Permission denied (publickey)." })).toBe(false);
201
+ expect(isTransientSshFailure({ code: 2, stderr: "refused: shell" })).toBe(false);
199
202
  expect(() => buildVpsRemote(["package", "Lib;rm"])).toThrow(/package/);
200
203
  expect(() => buildVpsRemote(["package"])).toThrow(/1 argument/);
201
204
  expect(() => buildVpsRemote(["shell"])).toThrow(/unknown vps command/);
@@ -277,10 +280,24 @@ describe("security wrapper", () => {
277
280
  agent: { name: "frontdoor-1337" },
278
281
  data: { vulnerability: { cve: "CVE-2026-55990", severity: "Medium", status: "Active", package: { name: "libunbound8", version: "1.26.1-0+deb13u1", condition: "Package default status" } } },
279
282
  });
280
- const paired = summariseVulns([modern, solvedLater, unfixed].join("\n"), 3, null, now);
281
- expect(paired.open.map((i) => i.cve)).toEqual(["CVE-2026-55990"]);
282
- expect(paired.openWithoutFixVersion).toBe(1);
283
- expect(paired.active).toBe(2);
283
+ // After an upgrade Wazuh closes the CVE on the old version and may reopen it on the new one; the version keeps them apart.
284
+ const activeNew = JSON.stringify({
285
+ timestamp: "2026-09-20T07:07:47.889+0000",
286
+ rule: { id: "23506", level: 13, description: "CVE-2026-50252 affects libunbound8" },
287
+ agent: { name: "frontdoor-1337" },
288
+ data: { vulnerability: { cve: "CVE-2026-50252", severity: "Critical", status: "Active", package: { name: "libunbound8", version: "1.26.1-0+deb13u1", condition: "Package default status" }, score: { base: 9.3 } } },
289
+ });
290
+ const solvedOld = JSON.stringify({
291
+ timestamp: "2026-09-20T07:07:48.433+0000",
292
+ rule: { id: "23502", level: 3, description: "CVE-2026-50252 affecting libunbound8 was solved" },
293
+ agent: { name: "frontdoor-1337" },
294
+ data: { vulnerability: { cve: "CVE-2026-50252", status: "Solved", package: { name: "libunbound8", version: "1.22.0-2+deb13u3" } } },
295
+ });
296
+ const paired = summariseVulns([modern, solvedLater, unfixed, activeNew, solvedOld].join("\n"), 3, null, now);
297
+ expect(paired.open.map((i) => `${i.cve}@${i.version}`)).toEqual(["CVE-2026-50252@1.26.1-0+deb13u1", "CVE-2026-55990@1.26.1-0+deb13u1"]);
298
+ expect(paired.openWithoutFixVersion).toBe(2);
299
+ expect(paired.active).toBe(3);
300
+ expect(paired.solved).toBe(2);
284
301
  expect(summariseVulns(modern, 1, null, now + 3 * 86_400_000).count).toBe(0);
285
302
  });
286
303