@astrofoundry/pi-astro 0.26.2 → 0.26.4

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.2",
3
+ "version": "0.26.4",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -23,7 +23,7 @@ Fetches `https://auth.37pla.net/.well-known/openid-configuration` through the VP
23
23
  | `bouncer` | bouncer unit status and the nftables set of blocked addresses |
24
24
  | `updates` | upgradable packages from the local apt index (not refreshed, so it can lag the mirror) and the packages that need a reboot |
25
25
  | `package <name>` | refreshes the apt index, then the installed version (`dpkg-query`) and the candidate with its origin (`apt-cache policy`) |
26
- | `changelog <name>` | first 80 lines of the candidate's Debian changelog from the Debian servers; each security upload lists the CVEs it fixes, so this is the proof that a version fixes a CVE |
26
+ | `changelog <name>` | first 80 lines of the Debian changelog: the installed package's own copy from `/usr/share/doc`, or the candidate's from the Debian servers when nothing is installed; each security upload lists the CVEs it fixes, so this is the proof that a version fixes a CVE. The servers answer 404 for many fresh security uploads; the installed copy does not have that problem |
27
27
  | `auto-upgrades` | the apt timers with their next run, the last three days of `apt-daily` and `apt-daily-upgrade` journal, the unattended-upgrades log tail, and the last dpkg upgrades |
28
28
  | `deploy-nginx` | install the tracked `nginx.conf`: `nginx -t` on the staged file, install, `nginx -t`, reload |
29
29
  | `reboot --confirm` | `systemctl reboot` |
@@ -41,6 +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
+ - 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.
44
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.
@@ -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";
@@ -61,7 +62,7 @@ export const VPS_COMMANDS: Readonly<Record<string, { args: number; help: string;
61
62
  bouncer: { args: 0, help: "crowdsec-firewall-bouncer status and the nftables block set" },
62
63
  updates: { args: 0, help: "upgradable packages (local index, not refreshed) and the packages that need a reboot" },
63
64
  package: { args: 1, help: "package <name> refresh the apt index, then installed and candidate versions of one package" },
64
- changelog: { args: 1, help: "changelog <name> Debian changelog of the candidate version, first 80 lines; it names the CVEs each upload fixes" },
65
+ changelog: { args: 1, help: "changelog <name> Debian changelog, first 80 lines: the installed package's own copy, else the candidate's from the Debian servers; it names the CVEs each upload fixes" },
65
66
  "auto-upgrades": { args: 0, help: "unattended-upgrades: apt timers, last daily runs, recent log, dpkg upgrades" },
66
67
  "deploy-nginx": { args: 0, help: "install the tracked nginx.conf from the pushed homelab checkout (nginx -t, install, reload)", stdin: true },
67
68
  reboot: { args: 0, help: "systemctl reboot (needs --confirm)" },
@@ -125,7 +126,12 @@ async function activateServiceAccount(config: EdgeConfig): Promise<void> {
125
126
  }
126
127
 
127
128
  /** Opens the IAP tunnel, runs one fixed command over it, closes the tunnel. */
128
- 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 }> {
129
135
  await activateServiceAccount(config);
130
136
  const tunnel = await openTunnel(
131
137
  config.gcloud,
@@ -45,7 +45,13 @@ case "$name" in
45
45
  changelog)
46
46
  pkg="$rest"
47
47
  printf '%s' "$pkg" | grep -Eq '^[a-z0-9][a-z0-9+.-]{0,63}$' || { echo "bad package name" >&2; exit 2; }
48
- apt-get changelog "$pkg" 2>&1 | head -n 80 ;;
48
+ if [ -r "/usr/share/doc/$pkg/changelog.Debian.gz" ]; then
49
+ echo "installed changelog (/usr/share/doc/$pkg/changelog.Debian.gz):"
50
+ zcat "/usr/share/doc/$pkg/changelog.Debian.gz" | head -n 80
51
+ else
52
+ echo "no installed changelog under /usr/share/doc/$pkg; candidate changelog from the Debian servers:"
53
+ apt-get changelog "$pkg" 2>&1 | head -n 80
54
+ fi ;;
49
55
  auto-upgrades)
50
56
  systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --all --no-pager
51
57
  echo "--- apt-daily and apt-daily-upgrade journal, 3 days:"
@@ -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
+ }