agent-yes 1.300.3 → 1.300.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.
Files changed (58) hide show
  1. package/dist/{SUPPORTED_CLIS-CWJr4TJH.js → SUPPORTED_CLIS-BIg_X-jE.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-ChSO8FiS.js → SUPPORTED_CLIS-D7lOxOnw.js} +3 -3
  3. package/dist/{agentShare-NkW0b0mg.js → agentShare-BfHUZgk4.js} +4 -4
  4. package/dist/{callback-CjlaFXp1.js → callback-COR0F2eB.js} +3 -3
  5. package/dist/{callback-B8CUyag4.js → callback-yWhCmi4k.js} +3 -3
  6. package/dist/{channels-aYwLMbFV.js → channels-rhxQArqN.js} +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/{cmdPs-C8DIl8V6.js → cmdPs-B3jTE4f3.js} +3 -3
  9. package/dist/index.js +2 -2
  10. package/dist/{notifyDaemon-DWIfzEE7.js → notifyDaemon-BIo7Nkk2.js} +3 -3
  11. package/dist/{parentPingLoop-Bh3KuFcW.js → parentPingLoop-9T4Pzp6c.js} +2 -2
  12. package/dist/{remotes-B4v0Nowo.js → remotes-B4CNXwDS.js} +2 -2
  13. package/dist/{remotes-DnMQWaqQ.js → remotes-CQTEY8Gt.js} +1 -1
  14. package/dist/{rustBinary-gI_r3g9K.js → rustBinary-CJXUPUWd.js} +48 -5
  15. package/dist/rustBinary-FhGG3Yfl.js +4 -0
  16. package/dist/{schedule-DryC--yx.js → schedule-DmijwnTA.js} +4 -4
  17. package/dist/{serve-DVoYVgBo.js → serve-F5tXYO0-.js} +99 -32
  18. package/dist/{serveRust-DDQiZmMf.js → serveRust-BAawe83i.js} +16 -2
  19. package/dist/{setup--1zRHaGW.js → setup-mCb1JbTX.js} +2 -2
  20. package/dist/{share-C9oGvjXT.js → share-CLataFnI.js} +18 -12
  21. package/dist/share-nmgSIjYP.js +4 -0
  22. package/dist/{shareCmd-BI3hRWGO.js → shareCmd-aOKimJeT.js} +25 -7
  23. package/dist/{subcommands-BviFOQZE.js → subcommands-DRq0MgSm.js} +2 -2
  24. package/dist/{subcommands-UWLkF5pq.js → subcommands-Ddo2TYZs.js} +404 -77
  25. package/dist/{terminal-DviH1E3C.js → terminal-DEXrWOa9.js} +2 -2
  26. package/dist/trayApp-0RVjo43f.js +5 -0
  27. package/dist/{trayApp-PJrwfyDs.js → trayApp-BUXx7cES.js} +2 -2
  28. package/dist/{ts--JmSY117.js → ts-CRbycTni.js} +5 -5
  29. package/dist/{versionChecker-CCGSbjio.js → versionChecker-DeQEhRGB.js} +2 -2
  30. package/dist/{webrtcRemote-Cd4vTrgv.js → webrtcRemote-Cy5kB8aR.js} +115 -14
  31. package/dist/{widget-DgBDIGRQ.js → widget-DneXqnL8.js} +3 -3
  32. package/dist/{ws-DnBg8-g-.js → ws-DPw1lVTi.js} +3 -3
  33. package/dist/{ws-dc7MP054.js → ws-Dr-0Ckq7.js} +2 -2
  34. package/package.json +1 -1
  35. package/ts/remoteCache.bun.spec.ts +191 -0
  36. package/ts/remoteCache.spec.ts +191 -0
  37. package/ts/remoteCache.ts +193 -0
  38. package/ts/rustBinary.bun.spec.ts +39 -2
  39. package/ts/rustBinary.spec.ts +53 -2
  40. package/ts/rustBinary.ts +61 -3
  41. package/ts/serve.spec.ts +103 -0
  42. package/ts/serve.ts +100 -15
  43. package/ts/serveRust.spec.ts +24 -1
  44. package/ts/serveRust.ts +19 -0
  45. package/ts/share.bun.spec.ts +54 -2
  46. package/ts/share.spec.ts +54 -2
  47. package/ts/share.ts +40 -15
  48. package/ts/shareCmd.ts +33 -2
  49. package/ts/strayServe.ts +1 -0
  50. package/ts/subcommands.spec.ts +36 -3
  51. package/ts/subcommands.ts +384 -98
  52. package/ts/webrtcRemote.ts +66 -20
  53. package/ts/webrtcSignal.bun.spec.ts +120 -0
  54. package/ts/webrtcSignal.spec.ts +120 -0
  55. package/ts/webrtcSignal.ts +143 -0
  56. package/dist/rustBinary-BNmAJEBb.js +0 -4
  57. package/dist/share-Bmnd_qT0.js +0 -4
  58. package/dist/trayApp-BDIb8fOj.js +0 -5
@@ -0,0 +1,191 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtemp, readdir, readFile, rm, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import path from "path";
5
+ import {
6
+ cacheAgeLabel,
7
+ hhmm,
8
+ humanizeAge,
9
+ isStale,
10
+ jsonRemote,
11
+ noteSnapshotResult,
12
+ parseWaitFor,
13
+ readRemoteSnapshot,
14
+ remoteCachePath,
15
+ writeRemoteSnapshot,
16
+ type RemoteSnapshot,
17
+ } from "./remoteCache.ts";
18
+
19
+ const T = 1_800_000_000_000;
20
+ const rows = [{ pid: 1111, cli: "claude", status: "active" }];
21
+ const good: RemoteSnapshot = {
22
+ version: 1,
23
+ alias: "alpha",
24
+ fetchedAt: T,
25
+ records: rows,
26
+ error: null,
27
+ };
28
+
29
+ describe("noteSnapshotResult", () => {
30
+ it("a success replaces the rows, stamps fetchedAt and clears the error", () => {
31
+ const prev: RemoteSnapshot = {
32
+ ...good,
33
+ fetchedAt: T - 60_000,
34
+ records: [],
35
+ error: { message: "x", since: T - 30_000, lastTriedAt: T - 1 },
36
+ };
37
+ expect(noteSnapshotResult(prev, "alpha", { ok: true, records: rows }, T)).toEqual(good);
38
+ });
39
+
40
+ it("a failure keeps the last good rows and preserves when the streak began", () => {
41
+ const first = noteSnapshotResult(good, "alpha", { ok: false, message: "timeout" }, T + 1000);
42
+ expect(first.records).toBe(rows);
43
+ expect(first.fetchedAt).toBe(T);
44
+ expect(first.error).toEqual({ message: "timeout", since: T + 1000, lastTriedAt: T + 1000 });
45
+ const second = noteSnapshotResult(first, "alpha", { ok: false, message: "refused" }, T + 5000);
46
+ expect(second.error).toEqual({ message: "refused", since: T + 1000, lastTriedAt: T + 5000 });
47
+ });
48
+
49
+ it("a first-ever failure has no rows and starts the streak now", () => {
50
+ const s = noteSnapshotResult(null, "beta", { ok: false, message: "dns" }, T);
51
+ expect(s).toEqual({
52
+ version: 1,
53
+ alias: "beta",
54
+ fetchedAt: null,
55
+ records: [],
56
+ error: { message: "dns", since: T, lastTriedAt: T },
57
+ });
58
+ });
59
+ });
60
+
61
+ describe("isStale", () => {
62
+ it("treats no snapshot, never-fetched, old, and clock-backwards as stale", () => {
63
+ expect(isStale(null, T)).toBe(true);
64
+ expect(isStale({ ...good, fetchedAt: null }, T)).toBe(true);
65
+ expect(isStale(good, T + 30_000)).toBe(true);
66
+ expect(isStale(good, T - 1)).toBe(true);
67
+ });
68
+ it("serves fresh rows as-is", () => {
69
+ expect(isStale(good, T + 29_999)).toBe(false);
70
+ expect(isStale(good, T + 5_000, 1_000)).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("labels", () => {
75
+ it("humanizeAge keeps the table vocabulary", () => {
76
+ expect(humanizeAge(500)).toBe("0s");
77
+ expect(humanizeAge(59_000)).toBe("59s");
78
+ expect(humanizeAge(60_000)).toBe("1m");
79
+ expect(humanizeAge(3_600_000)).toBe("1h");
80
+ expect(humanizeAge(2 * 86_400_000)).toBe("2d");
81
+ });
82
+
83
+ it("hhmm is zero-padded local wall-clock", () => {
84
+ const d = new Date(2026, 0, 1, 9, 5);
85
+ expect(hhmm(d.getTime())).toBe("09:05");
86
+ });
87
+
88
+ it("cacheAgeLabel says where the rows came from and never hides an outage", () => {
89
+ expect(cacheAgeLabel(good, T + 180_000, true)).toBe("cached 3m ago, refreshing…");
90
+ expect(cacheAgeLabel(good, T + 180_000, false)).toBe("cached 3m ago");
91
+ expect(cacheAgeLabel(null, T, true)).toBe("no cache yet, refreshing…");
92
+ const since = new Date(2026, 0, 1, 14, 2).getTime();
93
+ const down: RemoteSnapshot = { ...good, error: { message: "x", since, lastTriedAt: since } };
94
+ expect(cacheAgeLabel(down, T + 720_000, true)).toBe("cached 12m ago, unreachable since 14:02");
95
+ });
96
+ });
97
+
98
+ describe("parseWaitFor", () => {
99
+ const aliases = ["alpha", "beta"];
100
+ it("expands all / local, and accepts a comma list of configured aliases", () => {
101
+ expect(parseWaitFor("all", aliases)).toEqual({ local: true, remotes: ["alpha", "beta"] });
102
+ expect(parseWaitFor("local", aliases)).toEqual({ local: true, remotes: [] });
103
+ expect(parseWaitFor("beta", aliases)).toEqual({ local: false, remotes: ["beta"] });
104
+ expect(parseWaitFor(" alpha, beta ,alpha", aliases)).toEqual({
105
+ local: false,
106
+ remotes: ["alpha", "beta"],
107
+ });
108
+ });
109
+ it("refuses an unknown alias or an empty value rather than waiting for nothing", () => {
110
+ expect(parseWaitFor("gamma", aliases)).toMatchObject({
111
+ error: expect.stringContaining("gamma"),
112
+ });
113
+ expect(parseWaitFor("", aliases)).toMatchObject({
114
+ error: expect.stringContaining("--waitFor"),
115
+ });
116
+ expect(parseWaitFor("x", [])).toMatchObject({ error: expect.stringContaining("none") });
117
+ });
118
+ });
119
+
120
+ describe("jsonRemote", () => {
121
+ it("exposes fetchedAt, stale and age so a parser can decide", () => {
122
+ expect(jsonRemote(good, T + 1000)).toEqual({
123
+ fetchedAt: T,
124
+ stale: false,
125
+ ageMs: 1000,
126
+ error: null,
127
+ records: rows,
128
+ });
129
+ expect(jsonRemote(null, T)).toEqual({
130
+ fetchedAt: null,
131
+ stale: true,
132
+ ageMs: null,
133
+ error: null,
134
+ records: [],
135
+ });
136
+ });
137
+ });
138
+
139
+ describe("snapshot files", () => {
140
+ let home: string;
141
+ const prev = process.env.AGENT_YES_HOME;
142
+ beforeEach(async () => {
143
+ home = await mkdtemp(path.join(tmpdir(), "ay-remote-cache-"));
144
+ process.env.AGENT_YES_HOME = home;
145
+ });
146
+ afterEach(async () => {
147
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
148
+ else process.env.AGENT_YES_HOME = prev;
149
+ await rm(home, { recursive: true, force: true });
150
+ });
151
+
152
+ it("round-trips, encodes an unsafe alias into the file name, and leaves no temp file", async () => {
153
+ const snap: RemoteSnapshot = { ...good, alias: "a/b c" };
154
+ await writeRemoteSnapshot(snap);
155
+ expect(remoteCachePath("a/b c")).toBe(path.join(home, "remotes", "a%2Fb%20c.cache.json"));
156
+ expect(await readRemoteSnapshot("a/b c")).toEqual(snap);
157
+ expect(await readdir(path.join(home, "remotes"))).toEqual(["a%2Fb%20c.cache.json"]);
158
+ expect(JSON.parse(await readFile(remoteCachePath("a/b c"), "utf-8")).version).toBe(1);
159
+ });
160
+
161
+ it("reads a missing, corrupt, or foreign file as a cold cache", async () => {
162
+ expect(await readRemoteSnapshot("none")).toBeNull();
163
+ await writeRemoteSnapshot(good);
164
+ await writeFile(remoteCachePath("alpha"), "{not json");
165
+ expect(await readRemoteSnapshot("alpha")).toBeNull();
166
+ await writeFile(remoteCachePath("alpha"), JSON.stringify({ version: 2, records: [] }));
167
+ expect(await readRemoteSnapshot("alpha")).toBeNull();
168
+ await writeFile(remoteCachePath("alpha"), JSON.stringify([1, 2]));
169
+ expect(await readRemoteSnapshot("alpha")).toBeNull();
170
+ });
171
+
172
+ it("tolerates a half-valid error object and a bad fetchedAt", async () => {
173
+ await writeFile(
174
+ remoteCachePath("alpha"),
175
+ JSON.stringify({ version: 1, fetchedAt: "soon", records: rows, error: { message: "x" } }),
176
+ ).catch(async () => {
177
+ await writeRemoteSnapshot(good); // ensure the dir exists, then overwrite
178
+ await writeFile(
179
+ remoteCachePath("alpha"),
180
+ JSON.stringify({ version: 1, fetchedAt: "soon", records: rows, error: { message: "x" } }),
181
+ );
182
+ });
183
+ expect(await readRemoteSnapshot("alpha")).toEqual({
184
+ version: 1,
185
+ alias: "alpha",
186
+ fetchedAt: null,
187
+ records: rows,
188
+ error: null,
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,193 @@
1
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
2
+ import path from "path";
3
+ import { agentYesHome } from "./agentYesHome.ts";
4
+
5
+ /**
6
+ * Cached-first `ay ls` across remotes.
7
+ *
8
+ * Every `ay ls` used to probe every configured remote before printing a single
9
+ * row, so one slow or unreachable machine (a WebRTC remote costs up to 25 s to
10
+ * give up on) held the whole fleet view hostage. The listing is read far more
11
+ * often than the fleet changes, so the trade is clear: print the LAST KNOWN
12
+ * rows for each remote at once, say how old they are, and refresh in the
13
+ * background so the next call is fresh. Nothing stale may ever look live —
14
+ * every cached row and section carries its age — and `--waitFor` is the
15
+ * explicit way to pay for fresh data when a decision needs it.
16
+ *
17
+ * One snapshot file per alias under `$AGENT_YES_HOME/remotes/`, beside the
18
+ * health file (ts/remoteHealth.ts) and like it derived, disposable data:
19
+ * deleting one costs a single "no cache yet" listing.
20
+ */
21
+
22
+ /** The current failure streak of a remote, kept alongside its last good rows. */
23
+ export interface RemoteCacheError {
24
+ message: string;
25
+ /** When the current run of failures began (unchanged by later failures). */
26
+ since: number;
27
+ lastTriedAt: number;
28
+ }
29
+
30
+ export interface RemoteSnapshot {
31
+ version: 1;
32
+ alias: string;
33
+ /** Time of the last SUCCESSFUL fetch; null when the remote has never answered. */
34
+ fetchedAt: number | null;
35
+ /** The `/api/ls` rows from that fetch (kept across later failures). */
36
+ records: any[];
37
+ error: RemoteCacheError | null;
38
+ }
39
+
40
+ /** Rows younger than this are served as-is without kicking a refresh. */
41
+ export const REMOTE_CACHE_FRESH_MS = 30_000;
42
+
43
+ export function remoteCachePath(alias: string): string {
44
+ return path.join(agentYesHome(), "remotes", `${encodeURIComponent(alias)}.cache.json`);
45
+ }
46
+
47
+ /** The persisted snapshot, or null for missing/corrupt — a cold cache, never an error. */
48
+ export async function readRemoteSnapshot(alias: string): Promise<RemoteSnapshot | null> {
49
+ try {
50
+ const parsed = JSON.parse(await readFile(remoteCachePath(alias), "utf-8")) as unknown;
51
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
52
+ const s = parsed as Partial<RemoteSnapshot>;
53
+ if (s.version !== 1 || !Array.isArray(s.records)) return null;
54
+ const fetchedAt =
55
+ typeof s.fetchedAt === "number" && Number.isFinite(s.fetchedAt) ? s.fetchedAt : null;
56
+ const e = s.error;
57
+ const error =
58
+ e && typeof e === "object" && typeof e.message === "string" && typeof e.since === "number"
59
+ ? { message: e.message, since: e.since, lastTriedAt: Number(e.lastTriedAt) || e.since }
60
+ : null;
61
+ return { version: 1, alias, fetchedAt, records: s.records, error };
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /** tmp + rename, like the health file: a concurrent reader never sees a torn file. */
68
+ export async function writeRemoteSnapshot(snap: RemoteSnapshot): Promise<void> {
69
+ const dest = remoteCachePath(snap.alias);
70
+ await mkdir(path.dirname(dest), { recursive: true });
71
+ const tmp = `${dest}.${process.pid}.tmp`;
72
+ await writeFile(tmp, JSON.stringify(snap));
73
+ await rename(tmp, dest);
74
+ }
75
+
76
+ export type FetchOutcome = { ok: true; records: any[] } | { ok: false; message: string };
77
+
78
+ /**
79
+ * Fold one fetch into the snapshot. Success replaces the rows and clears the
80
+ * error; failure keeps the last good rows and extends the streak, preserving
81
+ * `since` so the operator sees "unreachable since 14:02", not "since now".
82
+ */
83
+ export function noteSnapshotResult(
84
+ prev: RemoteSnapshot | null,
85
+ alias: string,
86
+ result: FetchOutcome,
87
+ now: number,
88
+ ): RemoteSnapshot {
89
+ if (result.ok) return { version: 1, alias, fetchedAt: now, records: result.records, error: null };
90
+ return {
91
+ version: 1,
92
+ alias,
93
+ fetchedAt: prev?.fetchedAt ?? null,
94
+ records: prev?.records ?? [],
95
+ error: { message: result.message, since: prev?.error?.since ?? now, lastTriedAt: now },
96
+ };
97
+ }
98
+
99
+ /** Whether the rows are old enough to warrant a refresh. Fails open to "stale". */
100
+ export function isStale(
101
+ snap: RemoteSnapshot | null,
102
+ now: number,
103
+ freshMs = REMOTE_CACHE_FRESH_MS,
104
+ ): boolean {
105
+ if (!snap || snap.fetchedAt === null) return true;
106
+ const age = now - snap.fetchedAt;
107
+ return age < 0 || age >= freshMs;
108
+ }
109
+
110
+ /** "0s", "42s", "3m", "5h", "2d" — the table's AGE column vocabulary. */
111
+ export function humanizeAge(ms: number): string {
112
+ if (ms < 1000) return "0s";
113
+ const s = Math.floor(ms / 1000);
114
+ if (s < 60) return `${s}s`;
115
+ const m = Math.floor(s / 60);
116
+ if (m < 60) return `${m}m`;
117
+ const h = Math.floor(m / 60);
118
+ if (h < 24) return `${h}h`;
119
+ const d = Math.floor(h / 24);
120
+ return `${d}d`;
121
+ }
122
+
123
+ /** Local wall-clock HH:MM, for "unreachable since 14:02". */
124
+ export function hhmm(ts: number): string {
125
+ const d = new Date(ts);
126
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
127
+ }
128
+
129
+ /**
130
+ * The per-remote tag printed beside its section: where the rows came from and
131
+ * how old they are. `refreshing` says a background refresh was just kicked.
132
+ */
133
+ export function cacheAgeLabel(
134
+ snap: RemoteSnapshot | null,
135
+ now: number,
136
+ refreshing: boolean,
137
+ ): string {
138
+ const base =
139
+ snap && snap.fetchedAt !== null
140
+ ? `cached ${humanizeAge(now - snap.fetchedAt)} ago`
141
+ : "no cache yet";
142
+ if (snap?.error) return `${base}, unreachable since ${hhmm(snap.error.since)}`;
143
+ return refreshing ? `${base}, refreshing…` : base;
144
+ }
145
+
146
+ /** What `--waitFor` asked to block on. */
147
+ export interface WaitFor {
148
+ local: boolean;
149
+ /** Configured aliases to refresh in the foreground before printing. */
150
+ remotes: string[];
151
+ }
152
+
153
+ /**
154
+ * `--waitFor=all|local|<alias>[,<alias>…]`. Unknown aliases are an error
155
+ * rather than silently waited-for-nothing: the flag exists so a caller can
156
+ * trust the rows it gets, and a typo must not become a false "fresh".
157
+ */
158
+ export function parseWaitFor(value: string, aliases: string[]): WaitFor | { error: string } {
159
+ const v = value.trim();
160
+ if (!v) return { error: "--waitFor needs a value: all, local, or an alias list" };
161
+ if (v === "all") return { local: true, remotes: [...aliases] };
162
+ if (v === "local") return { local: true, remotes: [] };
163
+ const want = v
164
+ .split(",")
165
+ .map((s) => s.trim())
166
+ .filter(Boolean);
167
+ const unknown = want.filter((a) => !aliases.includes(a));
168
+ if (unknown.length)
169
+ return {
170
+ error: `--waitFor: unknown remote ${unknown.join(", ")} (configured: ${aliases.join(", ") || "none"})`,
171
+ };
172
+ return { local: false, remotes: [...new Set(want)] };
173
+ }
174
+
175
+ /** The `--json` shape for one remote: additive, so a parser can decide on staleness. */
176
+ export function jsonRemote(
177
+ snap: RemoteSnapshot | null,
178
+ now: number,
179
+ ): {
180
+ fetchedAt: number | null;
181
+ stale: boolean;
182
+ ageMs: number | null;
183
+ error: RemoteCacheError | null;
184
+ records: any[];
185
+ } {
186
+ return {
187
+ fetchedAt: snap?.fetchedAt ?? null,
188
+ stale: isStale(snap, now),
189
+ ageMs: snap && snap.fetchedAt !== null ? now - snap.fetchedAt : null,
190
+ error: snap?.error ?? null,
191
+ records: snap?.records ?? [],
192
+ };
193
+ }
@@ -1,8 +1,8 @@
1
1
  import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { gcOldBinaryDirs } from "./rustBinary.ts";
5
+ import { chmodExtractedBinaries, ensureExecutable, gcOldBinaryDirs } from "./rustBinary.ts";
6
6
  import { _setInstalledPackageForTesting } from "./versionChecker.ts";
7
7
 
8
8
  // Startup GC for the versioned binary download cache (PERFORMANCE-EVENT
@@ -79,3 +79,40 @@ describe("gcOldBinaryDirs", () => {
79
79
  }
80
80
  });
81
81
  });
82
+
83
+ // A release bundle arrives with whatever modes the packaging pipeline kept —
84
+ // in practice none. Only the main binary used to be chmod'ed, so ayrs was left
85
+ // non-executable and the daemon died with EACCES on every supervisor restart.
86
+ describe("chmodExtractedBinaries", () => {
87
+ it("marks every extracted regular file executable and skips archives and dirs", async () => {
88
+ const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
89
+ try {
90
+ writeFileSync(path.join(dir, "agent-yes-linux-x64"), "#!/bin/sh\n", { mode: 0o644 });
91
+ writeFileSync(path.join(dir, "ayrs"), "#!/bin/sh\n", { mode: 0o644 });
92
+ writeFileSync(path.join(dir, "temp.tar.gz"), "x");
93
+ mkdirSync(path.join(dir, "sub"));
94
+ const fixed = await chmodExtractedBinaries(dir);
95
+ expect(fixed.sort()).toEqual(["agent-yes-linux-x64", "ayrs"]);
96
+ if (process.platform !== "win32") {
97
+ expect(statSync(path.join(dir, "ayrs")).mode & 0o111).toBe(0o111);
98
+ expect(statSync(path.join(dir, "temp.tar.gz")).mode & 0o111).toBe(0);
99
+ }
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
104
+ });
105
+
106
+ describe("ensureExecutable", () => {
107
+ it("repairs a present-but-not-executable binary and returns its path", () => {
108
+ const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
109
+ try {
110
+ const p = path.join(dir, "ayrs");
111
+ writeFileSync(p, "#!/bin/sh\n", { mode: 0o644 });
112
+ expect(ensureExecutable(p)).toBe(p);
113
+ if (process.platform !== "win32") expect(statSync(p).mode & 0o111).toBe(0o111);
114
+ } finally {
115
+ rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ });
118
+ });
@@ -1,8 +1,22 @@
1
1
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
3
11
  import os from "node:os";
4
12
  import path from "node:path";
5
- import { devBuildInfo, gcOldBinaryDirs, hasValidMacSignature } from "./rustBinary.ts";
13
+ import {
14
+ chmodExtractedBinaries,
15
+ devBuildInfo,
16
+ ensureExecutable,
17
+ gcOldBinaryDirs,
18
+ hasValidMacSignature,
19
+ } from "./rustBinary.ts";
6
20
  import { _setInstalledPackageForTesting } from "./versionChecker.ts";
7
21
 
8
22
  // Startup GC for the versioned binary download cache (PERFORMANCE-EVENT
@@ -135,3 +149,40 @@ describe("hasValidMacSignature", () => {
135
149
  expect(hasValidMacSignature(path.join(root, "does-not-exist"))).toBe(true);
136
150
  });
137
151
  });
152
+
153
+ // A release bundle arrives with whatever modes the packaging pipeline kept —
154
+ // in practice none. Only the main binary used to be chmod'ed, so ayrs was left
155
+ // non-executable and the daemon died with EACCES on every supervisor restart.
156
+ describe("chmodExtractedBinaries", () => {
157
+ it("marks every extracted regular file executable and skips archives and dirs", async () => {
158
+ const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
159
+ try {
160
+ writeFileSync(path.join(dir, "agent-yes-linux-x64"), "#!/bin/sh\n", { mode: 0o644 });
161
+ writeFileSync(path.join(dir, "ayrs"), "#!/bin/sh\n", { mode: 0o644 });
162
+ writeFileSync(path.join(dir, "temp.tar.gz"), "x");
163
+ mkdirSync(path.join(dir, "sub"));
164
+ const fixed = await chmodExtractedBinaries(dir);
165
+ expect(fixed.sort()).toEqual(["agent-yes-linux-x64", "ayrs"]);
166
+ if (process.platform !== "win32") {
167
+ expect(statSync(path.join(dir, "ayrs")).mode & 0o111).toBe(0o111);
168
+ expect(statSync(path.join(dir, "temp.tar.gz")).mode & 0o111).toBe(0);
169
+ }
170
+ } finally {
171
+ rmSync(dir, { recursive: true, force: true });
172
+ }
173
+ });
174
+ });
175
+
176
+ describe("ensureExecutable", () => {
177
+ it("repairs a present-but-not-executable binary and returns its path", () => {
178
+ const dir = mkdtempSync(path.join(os.tmpdir(), "ay-bin-"));
179
+ try {
180
+ const p = path.join(dir, "ayrs");
181
+ writeFileSync(p, "#!/bin/sh\n", { mode: 0o644 });
182
+ expect(ensureExecutable(p)).toBe(p);
183
+ if (process.platform !== "win32") expect(statSync(p).mode & 0o111).toBe(0o111);
184
+ } finally {
185
+ rmSync(dir, { recursive: true, force: true });
186
+ }
187
+ });
188
+ });
package/ts/rustBinary.ts CHANGED
@@ -3,7 +3,17 @@
3
3
  */
4
4
 
5
5
  import { execFileSync } from "child_process";
6
- import { existsSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from "fs";
6
+ import {
7
+ accessSync,
8
+ chmodSync,
9
+ constants as fsConstants,
10
+ existsSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ rmSync,
14
+ statSync,
15
+ unlinkSync,
16
+ } from "fs";
7
17
  import { chmod, copyFile } from "fs/promises";
8
18
  import path from "path";
9
19
  import { compareVersions, getInstalledPackage } from "./versionChecker.ts";
@@ -251,11 +261,57 @@ export function findAyrsBinary(): string | undefined {
251
261
  Bun.which("ayrs") ?? "",
252
262
  ];
253
263
  for (const p of searchPaths) {
254
- if (p && existsSync(p)) return p;
264
+ if (p && existsSync(p)) return ensureExecutable(p);
255
265
  }
256
266
  return undefined;
257
267
  }
258
268
 
269
+ /**
270
+ * Repair a binary that is present but not executable (POSIX only; Windows has
271
+ * no exec bit). A release bundle that lost its modes in transit, or a cache
272
+ * written by an older fetcher that only chmod'ed the main binary, leaves
273
+ * `ayrs` as a plain file: the daemon then dies with EACCES on every restart
274
+ * and the supervisor loops forever. Best effort — a read-only cache keeps
275
+ * failing, and the caller's spawn reports that.
276
+ */
277
+ export function ensureExecutable(p: string): string {
278
+ if (process.platform === "win32") return p;
279
+ try {
280
+ accessSync(p, fsConstants.X_OK);
281
+ } catch {
282
+ try {
283
+ chmodSync(p, 0o755);
284
+ } catch {
285
+ /* read-only cache — leave the spawn to report it */
286
+ }
287
+ }
288
+ return p;
289
+ }
290
+
291
+ /**
292
+ * Mark every regular file a release bundle extracted into `dir` executable.
293
+ * The tarball carries ayrs (and friends) next to the main binary; the modes
294
+ * they arrive with are whatever the packaging pipeline preserved, which has
295
+ * been "none" (CI artifacts drop Unix permissions). Chmod'ing the main binary
296
+ * alone (the old behaviour) left ayrs non-executable — see ensureExecutable.
297
+ */
298
+ export async function chmodExtractedBinaries(dir: string): Promise<string[]> {
299
+ const fixed: string[] = [];
300
+ for (const name of readdirSync(dir)) {
301
+ const p = path.join(dir, name);
302
+ let isFile = false;
303
+ try {
304
+ isFile = statSync(p).isFile();
305
+ } catch {
306
+ continue;
307
+ }
308
+ if (!isFile || /\.(tar\.gz|zip|json|txt|md)$/i.test(name)) continue;
309
+ await chmod(p, 0o755);
310
+ fixed.push(name);
311
+ }
312
+ return fixed;
313
+ }
314
+
259
315
  /**
260
316
  * Get GitHub release download URL for the binary
261
317
  */
@@ -351,7 +407,9 @@ export async function downloadBinary(verbose = false): Promise<string> {
351
407
  unlinkSync(tarPath);
352
408
  } catch {}
353
409
 
354
- // Make executable
410
+ // Make everything the bundle shipped executable — ayrs and the other
411
+ // helpers alongside the main binary, not just binaryPath.
412
+ await chmodExtractedBinaries(binDir);
355
413
  await chmod(binaryPath, 0o755);
356
414
  }
357
415