@astrofoundry/pi-astro 0.26.1 → 0.26.2

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.2",
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,7 @@ 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
+ - `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
45
  - `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
46
  - `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
47
  - 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.
@@ -85,6 +85,11 @@ ${REPO_HELP}
85
85
 
86
86
  deploy-nginx refuses when the checkout is dirty or differs from origin: commit and push first.`;
87
87
 
88
+ /** The first connection through a fresh IAP tunnel is sometimes closed before sshd answers; that failure is safe to retry once. */
89
+ export function isTransientSshFailure(result: { code: number; stderr: string }): boolean {
90
+ return result.code === 255 && /closed by remote host|connection reset|connection refused|kex_exchange_identification/i.test(result.stderr);
91
+ }
92
+
88
93
  export function buildVpsRemote(args: string[]): { remote: string; stdin: boolean } {
89
94
  const [name, ...rest] = args;
90
95
  const spec = name === undefined ? undefined : VPS_COMMANDS[name];
@@ -138,20 +143,20 @@ async function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number,
138
143
  ],
139
144
  { env: gcloudEnv(config), port: config.iapLocalPort, readyTimeoutMs: 45_000 },
140
145
  );
146
+ const target = {
147
+ host: "127.0.0.1",
148
+ port: config.iapLocalPort,
149
+ hostKeyAlias: config.instance,
150
+ user: config.sshUser,
151
+ keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
152
+ knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
153
+ };
141
154
  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
- );
155
+ const first = await sshFixed(target, remote, timeoutMs, input);
156
+ // A connection closed during setup never ran the command; a reboot is the one command not to repeat.
157
+ if (!isTransientSshFailure(first) || remote === "reboot") return first;
158
+ await new Promise((resolve) => setTimeout(resolve, 2000));
159
+ return await sshFixed(target, remote, timeoutMs, input);
155
160
  } finally {
156
161
  await tunnel.close();
157
162
  }
@@ -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