@astrofoundry/pi-astro 0.26.2 → 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.2",
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"
@@ -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";
@@ -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,
@@ -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
+ }