agent-yes 1.290.7 → 1.291.0
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/dist/SUPPORTED_CLIS-BZP767UR.js +12 -0
- package/dist/{SUPPORTED_CLIS-hTRegnm5.js → SUPPORTED_CLIS-TiOdGi1h.js} +2 -2
- package/dist/{agentShare-C_LTEKEb.js → agentShare-D4N8StHO.js} +8 -8
- package/dist/{askCli-DR9vhdcV.js → askCli-DFsWcgpF.js} +3 -3
- package/dist/{browser-oqC3Ln2a.js → browser-wkzxw6lx.js} +3 -2
- package/dist/{callback-DAQ8wCtJ.js → callback-Bc2gnr_f.js} +3 -3
- package/dist/{callback-BEvLUUBR.js → callback-yCpK6Qpg.js} +7 -7
- package/dist/{channels-hOG3-R_3.js → channels-aYwLMbFV.js} +2 -2
- package/dist/cli.js +9 -9
- package/dist/{cmdPs-BAaLB4Pz.js → cmdPs-D5L3Eyh5.js} +7 -7
- package/dist/{cwdConflictWarn-BgXN56Jc.js → cwdConflictWarn-sQUONAye.js} +2 -2
- package/dist/{forkNested-CJ7lQQCe.js → forkNested-xtEeYpBQ.js} +3 -3
- package/dist/{globalPidIndex-DoaKEUkO.js → globalPidIndex-CRH9L9tn.js} +139 -12
- package/dist/globalPidIndex-DC93JhHQ.js +4 -0
- package/dist/index.js +5 -5
- package/dist/{messageLog-CVRSTLza.js → messageLog-Brx3w6zT.js} +2 -1
- package/dist/{notifyDaemon-Dnbe66Fl.js → notifyDaemon-CvUtRnAB.js} +7 -7
- package/dist/{parentLink-fdiuB13i.js → parentLink-BILJ32nA.js} +2 -2
- package/dist/{parentLink-bpDGuQi6.js → parentLink-CfbxykCv.js} +2 -2
- package/dist/{parentPingLoop-DWv-xJoi.js → parentPingLoop-CVOCiQp2.js} +4 -4
- package/dist/pidStore-Bpe8i_oU.js +6 -0
- package/dist/{pidStore-B73xnAeT.js → pidStore-DXWqzTSz.js} +2 -2
- package/dist/{remotes-DTXPW8U1.js → remotes-tmKwrr20.js} +3 -3
- package/dist/{remotes-C696AJzL.js → remotes-wPL0JtBs.js} +2 -2
- package/dist/{rustBinary-CZRPfa4y.js → rustBinary-C_EbyMTk.js} +2 -2
- package/dist/{rustBinary-jO6EN8LR.js → rustBinary-Clbk_K5c.js} +2 -2
- package/dist/{schedule-Hao0WqyJ.js → schedule-Ba_4wNVK.js} +7 -7
- package/dist/{serve-B2nisui8.js → serve-CJDQq_Jp.js} +46 -40
- package/dist/{setup-vCDTqyqR.js → setup-DwLAkbTC.js} +2 -2
- package/dist/share-Bmnd_qT0.js +4 -0
- package/dist/{share-DTlaFn6s.js → share-C9oGvjXT.js} +13 -4
- package/dist/spawnGate-BUEswFgo.js +5 -0
- package/dist/{spawnGate-B26UnzqP.js → spawnGate-YpHopveO.js} +2 -2
- package/dist/{subcommands-BpFdlCY0.js → subcommands-x8Pe7DrF.js} +6 -6
- package/dist/{subcommands-D6LBtVPM.js → subcommands-yWgVRM2r.js} +273 -45
- package/dist/{terminal-iGHEnc7Z.js → terminal-Dv73BTE-.js} +2 -2
- package/dist/terminal.js +1 -1
- package/dist/{todoCli-azqbjBtz.js → todoCli-DSQXAcwA.js} +3 -3
- package/dist/{todoIdentity-NEceTmEe.js → todoIdentity-CAmnkzzP.js} +2 -2
- package/dist/{trayApp-BdNlQCFx.js → trayApp-BRw8f0mT.js} +2 -2
- package/dist/trayApp-BjQ5V3WV.js +5 -0
- package/dist/{ts-oyuaLWWx.js → ts-C8PxhJZM.js} +7 -7
- package/dist/{versionChecker-CMjS88mL.js → versionChecker-CWPGASjD.js} +2 -2
- package/dist/webrtcLink-CnQf9pmu.js +73 -0
- package/dist/{webrtcRemote-L5NxhLLR.js → webrtcRemote-yvMYsij3.js} +2 -2
- package/dist/{widget-OvkqW2Z6.js → widget-DhBSNUey.js} +3 -3
- package/dist/widgets.js +1 -1
- package/dist/{ws-BeMouYAU.js → ws-CMX0pkti.js} +2 -2
- package/dist/{ws-qZy7Giub.js → ws-DZ9YAXlY.js} +7 -7
- package/lab/ui/console-logic.js +27 -4
- package/package.json +1 -1
- package/ts/crossTreeClaim.bun.spec.ts +76 -0
- package/ts/crossTreeClaim.spec.ts +76 -0
- package/ts/globalPidIndex.spec.ts +153 -0
- package/ts/globalPidIndex.ts +151 -10
- package/ts/messageLog.bun.spec.ts +33 -0
- package/ts/messageLog.spec.ts +33 -0
- package/ts/messageLog.ts +3 -0
- package/ts/rawInputDeclared.bun.spec.ts +68 -0
- package/ts/rawInputDeclared.spec.ts +68 -0
- package/ts/remoteHealth.bun.spec.ts +130 -0
- package/ts/remoteHealth.spec.ts +130 -0
- package/ts/remoteHealth.ts +149 -0
- package/ts/restartKillGuard.bun.spec.ts +40 -0
- package/ts/restartKillGuard.spec.ts +40 -0
- package/ts/senderAncestry.ts +45 -7
- package/ts/serve.ts +46 -20
- package/ts/share.bun.spec.ts +24 -2
- package/ts/share.spec.ts +24 -2
- package/ts/share.ts +37 -5
- package/ts/subcommands.ts +239 -19
- package/ts/terminal/browser.ts +5 -1
- package/ts/webrtcLink.bun.spec.ts +45 -1
- package/ts/webrtcLink.spec.ts +45 -1
- package/ts/webrtcLink.ts +63 -19
- package/dist/SUPPORTED_CLIS-CuvT3tKq.js +0 -12
- package/dist/globalPidIndex-yvWZXeWT.js +0 -4
- package/dist/pidStore-V51mbgZY.js +0 -6
- package/dist/share-BvU4I8BI.js +0 -4
- package/dist/spawnGate-C8MSRt7X.js +0 -5
- package/dist/trayApp-Br2T2Yrb.js +0 -5
- package/dist/webrtcLink-DLaJfTcB.js +0 -45
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
REMOTE_BACKOFF_BASE_MS,
|
|
4
|
+
REMOTE_BACKOFF_MAX_MS,
|
|
5
|
+
noteRemoteResult,
|
|
6
|
+
pruneRemoteHealth,
|
|
7
|
+
remoteBackoffMs,
|
|
8
|
+
shouldSkipRemote,
|
|
9
|
+
} from "./remoteHealth.ts";
|
|
10
|
+
|
|
11
|
+
describe("remoteHealth.remoteBackoffMs", () => {
|
|
12
|
+
it("doubles from the base and then holds at the cap", () => {
|
|
13
|
+
expect(remoteBackoffMs(1)).toBe(REMOTE_BACKOFF_BASE_MS);
|
|
14
|
+
expect(remoteBackoffMs(2)).toBe(REMOTE_BACKOFF_BASE_MS * 2);
|
|
15
|
+
expect(remoteBackoffMs(3)).toBe(REMOTE_BACKOFF_BASE_MS * 4);
|
|
16
|
+
expect(remoteBackoffMs(99)).toBe(REMOTE_BACKOFF_MAX_MS);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is zero for a host that has not failed — and this GUARDS the clause below", () => {
|
|
20
|
+
// A streak of 0 must never produce a wait: that is the difference between
|
|
21
|
+
// "healthy" and "failed once", and getting it wrong skips a live host.
|
|
22
|
+
expect(remoteBackoffMs(0)).toBe(0);
|
|
23
|
+
expect(remoteBackoffMs(-1)).toBe(0);
|
|
24
|
+
|
|
25
|
+
// This is also the tripwire for `shouldSkipRemote`'s `streak <= 0` clause.
|
|
26
|
+
// That clause is UNREACHABLE while this holds, which is why no test can
|
|
27
|
+
// redden it directly — but change the curve so a non-positive streak yields
|
|
28
|
+
// a non-zero wait and the clause goes LIVE, with nothing behind it, at the
|
|
29
|
+
// exact moment someone is editing the thing it guards. So the invariant is
|
|
30
|
+
// pinned HERE, where it is reachable and can fail loudly, instead.
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not overflow on an absurd streak", () => {
|
|
34
|
+
// 2 ** 5000 is Infinity; the shift clamp is what keeps this finite.
|
|
35
|
+
expect(Number.isFinite(remoteBackoffMs(5000))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("remoteHealth.shouldSkipRemote", () => {
|
|
40
|
+
const NOW = 1_000_000_000;
|
|
41
|
+
|
|
42
|
+
it("never skips a host with no record — the control that matters", () => {
|
|
43
|
+
// Wrongly skipping makes an agent silently vanish from `ay ls`; wrongly
|
|
44
|
+
// probing costs one slow list. Those are not symmetric, so the tie goes to
|
|
45
|
+
// probing, and an unknown host is always probed.
|
|
46
|
+
expect(shouldSkipRemote(undefined, NOW)).toBe(false);
|
|
47
|
+
// A cleared host carrying a stale timestamp — what `noteRemoteResult`
|
|
48
|
+
// produces on success — must also never be skipped. This asserts the
|
|
49
|
+
// BEHAVIOUR; it does not isolate the `streak <= 0` clause, which is
|
|
50
|
+
// redundant with `remoteBackoffMs(0) === 0` and cannot be reddened on its
|
|
51
|
+
// own. Said plainly rather than left looking like coverage it is not.
|
|
52
|
+
expect(shouldSkipRemote({ streak: 0, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
|
|
53
|
+
expect(shouldSkipRemote({ streak: -3, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("skips inside the backoff window and probes again once it elapses", () => {
|
|
57
|
+
const h = { streak: 1, lastFailedAt: NOW };
|
|
58
|
+
expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS - 1)).toBe(true);
|
|
59
|
+
expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("probes rather than guesses when the clock has moved backwards", () => {
|
|
63
|
+
// A backwards clock makes `elapsed` negative, which would otherwise read as
|
|
64
|
+
// "still inside the window" and skip the host until the clock caught up.
|
|
65
|
+
expect(shouldSkipRemote({ streak: 3, lastFailedAt: NOW }, NOW - 60_000)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("remoteHealth.readRemoteHealth validation", () => {
|
|
70
|
+
it("ignores an entry whose shape it cannot trust", async () => {
|
|
71
|
+
// The file is derived data in a writable path. A valid-JSON record with a
|
|
72
|
+
// plausible-looking streak must not be able to hide a live agent, so
|
|
73
|
+
// anything that is not two finite numbers is dropped rather than trusted.
|
|
74
|
+
const { mkdtemp, writeFile } = await import("fs/promises");
|
|
75
|
+
const { tmpdir } = await import("os");
|
|
76
|
+
const path = (await import("path")).default;
|
|
77
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-health-"));
|
|
78
|
+
const saved = process.env.AGENT_YES_HOME;
|
|
79
|
+
process.env.AGENT_YES_HOME = dir;
|
|
80
|
+
try {
|
|
81
|
+
await writeFile(
|
|
82
|
+
path.join(dir, "remote-health.json"),
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
good: { streak: 2, lastFailedAt: 123 },
|
|
85
|
+
missingField: { streak: 2 },
|
|
86
|
+
wrongType: { streak: "9", lastFailedAt: 1 },
|
|
87
|
+
notFinite: { streak: Number.POSITIVE_INFINITY, lastFailedAt: 1 },
|
|
88
|
+
notAnObject: 7,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
const { readRemoteHealth } = await import("./remoteHealth.ts");
|
|
92
|
+
expect(await readRemoteHealth()).toEqual({ good: { streak: 2, lastFailedAt: 123 } });
|
|
93
|
+
} finally {
|
|
94
|
+
if (saved === undefined) delete process.env.AGENT_YES_HOME;
|
|
95
|
+
else process.env.AGENT_YES_HOME = saved;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("remoteHealth.pruneRemoteHealth", () => {
|
|
101
|
+
it("drops aliases no longer configured, so the file cannot grow forever", () => {
|
|
102
|
+
const h = {
|
|
103
|
+
live: { streak: 1, lastFailedAt: 5 },
|
|
104
|
+
removed: { streak: 9, lastFailedAt: 5 },
|
|
105
|
+
};
|
|
106
|
+
expect(pruneRemoteHealth(h, ["live"])).toEqual({ live: { streak: 1, lastFailedAt: 5 } });
|
|
107
|
+
expect(pruneRemoteHealth(h, [])).toEqual({});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("remoteHealth.noteRemoteResult", () => {
|
|
112
|
+
const NOW = 1_000_000_000;
|
|
113
|
+
|
|
114
|
+
it("clears the streak completely on success", () => {
|
|
115
|
+
// A host that comes back must return to full speed immediately, not decay
|
|
116
|
+
// through the backoff it earned while it was down.
|
|
117
|
+
expect(noteRemoteResult({ streak: 9, lastFailedAt: NOW }, true, NOW + 1)).toEqual({
|
|
118
|
+
streak: 0,
|
|
119
|
+
lastFailedAt: 0,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("extends the streak on failure, from no record and from an existing one", () => {
|
|
124
|
+
expect(noteRemoteResult(undefined, false, NOW)).toEqual({ streak: 1, lastFailedAt: NOW });
|
|
125
|
+
expect(noteRemoteResult({ streak: 2, lastFailedAt: 1 }, false, NOW)).toEqual({
|
|
126
|
+
streak: 3,
|
|
127
|
+
lastFailedAt: NOW,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Backoff for remotes that are not answering.
|
|
7
|
+
*
|
|
8
|
+
* `ay ls` gathers every configured remote on every invocation. A host that has
|
|
9
|
+
* gone away costs a full `CONNECT_TIMEOUT_MS` (25s for a WebRTC spec) and
|
|
10
|
+
* returns nothing, and `Promise.allSettled` waits for all of them — so two dead
|
|
11
|
+
* entries in `remotes.yaml` made every `ay ls` on this fleet take 27s instead of
|
|
12
|
+
* 5s. Measured, then confirmed by removing only those two from a COPY of the
|
|
13
|
+
* config: 27.40s → 5.49s.
|
|
14
|
+
*
|
|
15
|
+
* Deleting the remote is the wrong remedy: those machines can come back, and a
|
|
16
|
+
* config someone must REMEMBER to re-add is a worse failure than a slow list.
|
|
17
|
+
* So the entry stays, and we stop paying for it every time.
|
|
18
|
+
*
|
|
19
|
+
* WHY EXPONENTIAL AND NOT φ: the operator's φ-backoff rule (golden ratio, no
|
|
20
|
+
* 2x) is a symval/symval convention — its AGENTS.md, its helpers. agent-yes is a
|
|
21
|
+
* general tool in snomiao/ and that rule does not reach it. This repo already
|
|
22
|
+
* has one backoff shape, `ts/autoRetry.ts` (base × 2ⁿ, capped, mirrored in
|
|
23
|
+
* rs/src/context.rs), and one repo with two backoff shapes is its own defect.
|
|
24
|
+
* So this mirrors autoRetry deliberately rather than importing a foreign
|
|
25
|
+
* convention or inventing a third.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** First skip window after a host fails. Short: a blip should barely register. */
|
|
29
|
+
export const REMOTE_BACKOFF_BASE_MS = 60_000;
|
|
30
|
+
/** Cap, mirroring autoRetry's shape: 1m, 2m, 4m, … 32m, then hold. */
|
|
31
|
+
export const REMOTE_BACKOFF_MAX_MS = 32 * 60_000;
|
|
32
|
+
|
|
33
|
+
/** One host's consecutive-failure state. Empty for a host that has never failed. */
|
|
34
|
+
export interface RemoteHealth {
|
|
35
|
+
/** Consecutive failures. Reset to 0 by any success. */
|
|
36
|
+
streak: number;
|
|
37
|
+
/** When the most recent failure was recorded. */
|
|
38
|
+
lastFailedAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How long to skip a host after `streak` consecutive failures — doubling, then
|
|
43
|
+
* capped, exactly as `autoRetryBackoffMs` does.
|
|
44
|
+
*/
|
|
45
|
+
export function remoteBackoffMs(streak: number): number {
|
|
46
|
+
if (streak <= 0) return 0;
|
|
47
|
+
const shift = Math.min(streak - 1, 20); // guard: 2**n on an absurd streak
|
|
48
|
+
return Math.min(REMOTE_BACKOFF_BASE_MS * 2 ** shift, REMOTE_BACKOFF_MAX_MS);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a host should be SKIPPED right now.
|
|
53
|
+
*
|
|
54
|
+
* Fails OPEN in every ambiguous case — no record, a zero/negative streak, a
|
|
55
|
+
* clock that has gone backwards. The cost of wrongly skipping is an agent that
|
|
56
|
+
* silently vanishes from `ay ls`; the cost of wrongly probing is one slow list.
|
|
57
|
+
* Those are not symmetric, so the tie goes to probing.
|
|
58
|
+
*/
|
|
59
|
+
export function shouldSkipRemote(health: RemoteHealth | undefined, now: number): boolean {
|
|
60
|
+
// The `streak <= 0` half is REDUNDANT today — `remoteBackoffMs` already
|
|
61
|
+
// returns 0 for a non-positive streak, so the comparison below is false
|
|
62
|
+
// anyway. Verified: no input distinguishes this function with and without it,
|
|
63
|
+
// which is why no test covers that clause and none pretends to. Kept because
|
|
64
|
+
// it states the invariant at the point a reader needs it, and it stops a
|
|
65
|
+
// future change to the backoff curve from silently making "healthy" skippable.
|
|
66
|
+
// The tripwire for that lives in the spec, on `remoteBackoffMs(0) === 0` —
|
|
67
|
+
// reachable, so it can actually fail — and says there that it guards this.
|
|
68
|
+
if (!health || health.streak <= 0) return false;
|
|
69
|
+
const elapsed = now - health.lastFailedAt;
|
|
70
|
+
if (elapsed < 0) return false; // clock moved backwards — probe rather than guess
|
|
71
|
+
return elapsed < remoteBackoffMs(health.streak);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Record an outcome. Success clears the streak entirely; failure extends it. */
|
|
75
|
+
export function noteRemoteResult(
|
|
76
|
+
health: RemoteHealth | undefined,
|
|
77
|
+
ok: boolean,
|
|
78
|
+
now: number,
|
|
79
|
+
): RemoteHealth {
|
|
80
|
+
if (ok) return { streak: 0, lastFailedAt: 0 };
|
|
81
|
+
return { streak: (health?.streak ?? 0) + 1, lastFailedAt: now };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Persistence
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Health lives beside `remotes.yaml` in the state dir, NOT inside it: that file
|
|
90
|
+
* is the operator's config and this is derived, disposable data. Deleting it
|
|
91
|
+
* costs one slow `ay ls` and nothing else.
|
|
92
|
+
*/
|
|
93
|
+
function healthPath(): string {
|
|
94
|
+
const dir = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
|
|
95
|
+
return path.join(dir, "remote-health.json");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the per-alias health map. Any error yields {} — probe rather than skip. */
|
|
99
|
+
export async function readRemoteHealth(): Promise<Record<string, RemoteHealth>> {
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(healthPath(), "utf-8");
|
|
102
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
103
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
104
|
+
// Validate each entry rather than trusting the file. This is derived data in
|
|
105
|
+
// a writable path: a hand-edited or half-migrated record with a plausible
|
|
106
|
+
// streak would otherwise skip a healthy host, and the whole point of the
|
|
107
|
+
// fail-open design is that no bad input can hide a live agent.
|
|
108
|
+
const out: Record<string, RemoteHealth> = {};
|
|
109
|
+
for (const [alias, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
110
|
+
if (!v || typeof v !== "object") continue;
|
|
111
|
+
const { streak, lastFailedAt } = v as Partial<RemoteHealth>;
|
|
112
|
+
if (typeof streak !== "number" || !Number.isFinite(streak)) continue;
|
|
113
|
+
if (typeof lastFailedAt !== "number" || !Number.isFinite(lastFailedAt)) continue;
|
|
114
|
+
out[alias] = { streak, lastFailedAt };
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
} catch {
|
|
118
|
+
return {}; // missing, unreadable, or corrupt — fail open
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Best-effort write. A health file we cannot persist must never break `ay ls`.
|
|
124
|
+
*
|
|
125
|
+
* Written to a temp file and renamed, so a concurrent reader sees either the old
|
|
126
|
+
* file or the new one and never a truncated one. Two `ay ls` processes finishing
|
|
127
|
+
* together still last-writer-wins — that is acceptable here because the loser's
|
|
128
|
+
* data is one probe's worth of a self-correcting counter, and because reading a
|
|
129
|
+
* torn file already fails open to "probe".
|
|
130
|
+
*/
|
|
131
|
+
export async function writeRemoteHealth(health: Record<string, RemoteHealth>): Promise<void> {
|
|
132
|
+
const dest = healthPath();
|
|
133
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
134
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
135
|
+
await writeFile(tmp, JSON.stringify(health, null, 2));
|
|
136
|
+
await rename(tmp, dest);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Drop entries for aliases that are no longer configured, so the file cannot
|
|
141
|
+
* grow without bound as remotes come and go. Called with the live alias set.
|
|
142
|
+
*/
|
|
143
|
+
export function pruneRemoteHealth(
|
|
144
|
+
health: Record<string, RemoteHealth>,
|
|
145
|
+
aliases: Iterable<string>,
|
|
146
|
+
): Record<string, RemoteHealth> {
|
|
147
|
+
const live = new Set(aliases);
|
|
148
|
+
return Object.fromEntries(Object.entries(health).filter(([alias]) => live.has(alias)));
|
|
149
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { pidOwnershipVerdict } from "./senderAncestry.ts";
|
|
3
|
+
|
|
4
|
+
// The verdict cmdRestart consults before SIGKILL. What matters here is not the
|
|
5
|
+
// arithmetic — that is ageMatchesRegistration's, tested with the send path —
|
|
6
|
+
// but that "cannot establish" stays a THIRD answer. Collapsing it into
|
|
7
|
+
// "reused" would read as caution and act as removal: win32 has no process
|
|
8
|
+
// table reader, so every force-kill there would refuse forever.
|
|
9
|
+
describe("restart must not SIGKILL a recycled pid", () => {
|
|
10
|
+
const now = 1_000_000_000;
|
|
11
|
+
const hourOld = now - 3600_000;
|
|
12
|
+
|
|
13
|
+
it("ours: the process is as old as the row that registered it", () => {
|
|
14
|
+
expect(pidOwnershipVerdict(3600, hourOld, now)).toBe("ours");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("reused: the process is younger than the row, so it cannot be the agent", () => {
|
|
18
|
+
// Shape of the live case: a row surviving a reboot whose pid had been
|
|
19
|
+
// handed to an unrelated system process. Nothing in the code was declining
|
|
20
|
+
// to kill it — EPERM was, and only because the owner differed. A same-user
|
|
21
|
+
// recycled pid would have been killed.
|
|
22
|
+
expect(pidOwnershipVerdict(60, now - 89 * 86400_000, now)).toBe("reused");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("unknown when there is no process table — NOT reused", () => {
|
|
26
|
+
// What `table?.get(pid)?.ageSecs` yields on win32, where readAncestryTable
|
|
27
|
+
// returns null by design, and on a transient ps failure.
|
|
28
|
+
expect(pidOwnershipVerdict(undefined, hourOld, now)).toBe("unknown");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("unknown when the row carries no registration time — NOT reused", () => {
|
|
32
|
+
expect(pidOwnershipVerdict(3600, undefined, now)).toBe("unknown");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not read ordinary registration lag as reuse", () => {
|
|
36
|
+
// A process is a moment older than the row recording it; if that read as
|
|
37
|
+
// reuse, restart would stop force-killing healthy agents.
|
|
38
|
+
expect(pidOwnershipVerdict(3595, hourOld, now)).toBe("ours");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pidOwnershipVerdict } from "./senderAncestry.ts";
|
|
3
|
+
|
|
4
|
+
// The verdict cmdRestart consults before SIGKILL. What matters here is not the
|
|
5
|
+
// arithmetic — that is ageMatchesRegistration's, tested with the send path —
|
|
6
|
+
// but that "cannot establish" stays a THIRD answer. Collapsing it into
|
|
7
|
+
// "reused" would read as caution and act as removal: win32 has no process
|
|
8
|
+
// table reader, so every force-kill there would refuse forever.
|
|
9
|
+
describe("restart must not SIGKILL a recycled pid", () => {
|
|
10
|
+
const now = 1_000_000_000;
|
|
11
|
+
const hourOld = now - 3600_000;
|
|
12
|
+
|
|
13
|
+
it("ours: the process is as old as the row that registered it", () => {
|
|
14
|
+
expect(pidOwnershipVerdict(3600, hourOld, now)).toBe("ours");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("reused: the process is younger than the row, so it cannot be the agent", () => {
|
|
18
|
+
// Shape of the live case: a row surviving a reboot whose pid had been
|
|
19
|
+
// handed to an unrelated system process. Nothing in the code was declining
|
|
20
|
+
// to kill it — EPERM was, and only because the owner differed. A same-user
|
|
21
|
+
// recycled pid would have been killed.
|
|
22
|
+
expect(pidOwnershipVerdict(60, now - 89 * 86400_000, now)).toBe("reused");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("unknown when there is no process table — NOT reused", () => {
|
|
26
|
+
// What `table?.get(pid)?.ageSecs` yields on win32, where readAncestryTable
|
|
27
|
+
// returns null by design, and on a transient ps failure.
|
|
28
|
+
expect(pidOwnershipVerdict(undefined, hourOld, now)).toBe("unknown");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("unknown when the row carries no registration time — NOT reused", () => {
|
|
32
|
+
expect(pidOwnershipVerdict(3600, undefined, now)).toBe("unknown");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not read ordinary registration lag as reuse", () => {
|
|
36
|
+
// A process is a moment older than the row recording it; if that read as
|
|
37
|
+
// reuse, restart would stop force-killing healthy agents.
|
|
38
|
+
expect(pidOwnershipVerdict(3595, hourOld, now)).toBe("ours");
|
|
39
|
+
});
|
|
40
|
+
});
|
package/ts/senderAncestry.ts
CHANGED
|
@@ -43,14 +43,28 @@ export type SenderVia =
|
|
|
43
43
|
/** No env, but an ancestor process is a registered agent. Not forgeable from
|
|
44
44
|
* inside a message: a process cannot choose its own ancestors. */
|
|
45
45
|
| "ancestry"
|
|
46
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
46
|
+
/** The claimed lane is NOT this process's ancestor, and the process is running
|
|
47
|
+
* OUTSIDE that lane's working tree. Another tree is claiming this identity.
|
|
48
|
+
*
|
|
49
|
+
* The discriminator is the working directory, not "is some other registered
|
|
50
|
+
* lane my ancestor" — that earlier rule was measured backwards on real
|
|
51
|
+
* traffic: it fired on a lane's own nested processes (same tree, benign) and
|
|
52
|
+
* stayed silent on two documents a different worktree sent under this lane's
|
|
53
|
+
* name. A lane's own detached helper runs inside the lane's tree; a different
|
|
54
|
+
* lane's process does not. */
|
|
53
55
|
| "env-uncorroborated"
|
|
56
|
+
/** The claim could not be corroborated, and nothing contradicts it either: the
|
|
57
|
+
* process is running inside the claimed lane's own tree (so it is plausibly
|
|
58
|
+
* that lane's detached helper), or the ancestry could not be read at all.
|
|
59
|
+
*
|
|
60
|
+
* This is NOT evidence of anything. A detached helper, a process re-parented
|
|
61
|
+
* to init, an `ay send` from a background job, or an unreadable process table
|
|
62
|
+
* all land here, and every one of them is honest. Distinguishing it from a
|
|
63
|
+
* real contradiction is the whole point: the first live firing of the loud
|
|
64
|
+
* marker was a legitimate long-lived lane, and a warning that fires on honest
|
|
65
|
+
* traffic is one people learn to skip — which destroys the signal for the
|
|
66
|
+
* case it was built for. Unknown has to stay expressible AS unknown. */
|
|
67
|
+
| "env-unverified"
|
|
54
68
|
/** Nothing identified the caller; only its observable facts are recorded. */
|
|
55
69
|
| "observed";
|
|
56
70
|
|
|
@@ -197,6 +211,30 @@ export async function findAgentAncestor<T>(
|
|
|
197
211
|
* Exposed so a caller can do the ancestry walk AND the pid-reuse age check off
|
|
198
212
|
* a single `ps`, instead of paying for the spawn twice.
|
|
199
213
|
*/
|
|
214
|
+
export type PidOwnership = "ours" | "reused" | "unknown";
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether the process holding a pid is still the agent that registered it.
|
|
218
|
+
*
|
|
219
|
+
* Three-way on purpose, and the third value is the point. "Cannot establish"
|
|
220
|
+
* is not "established that it is not ours": win32 has no process table reader
|
|
221
|
+
* here at all, so folding unknown into reused would not guard a destructive
|
|
222
|
+
* action on that platform — it would delete the action from it, permanently,
|
|
223
|
+
* to protect a case that has never occurred. Callers refuse on evidence
|
|
224
|
+
* ("reused") and keep their prior behaviour on "unknown".
|
|
225
|
+
*/
|
|
226
|
+
export function pidOwnershipVerdict(
|
|
227
|
+
ageSecs: number | undefined,
|
|
228
|
+
startedAt: number | undefined,
|
|
229
|
+
now = Date.now(),
|
|
230
|
+
toleranceSecs = 60,
|
|
231
|
+
): PidOwnership {
|
|
232
|
+
if (ageSecs === undefined || startedAt === undefined) return "unknown";
|
|
233
|
+
return ageMatchesRegistration(ageSecs, startedAt, now, toleranceSecs)
|
|
234
|
+
? "ours"
|
|
235
|
+
: "reused";
|
|
236
|
+
}
|
|
237
|
+
|
|
200
238
|
export async function readAncestryTable(
|
|
201
239
|
read: () => Promise<Map<number, ProcRow>> = readProcessTable,
|
|
202
240
|
): Promise<Map<number, ProcRow> | null> {
|
package/ts/serve.ts
CHANGED
|
@@ -77,6 +77,7 @@ import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
|
|
|
77
77
|
import { getInstalledPackage } from "./versionChecker.ts";
|
|
78
78
|
import { negotiateSize, sanitizeCap, type SizeCap } from "./sizeNego.ts";
|
|
79
79
|
import { listStrayServeProcesses } from "./strayServe.ts";
|
|
80
|
+
import { isWebrtcSpec, toWebrtcUrl } from "./webrtcLink.ts";
|
|
80
81
|
import {
|
|
81
82
|
getProvisionHook,
|
|
82
83
|
getProvisionRoot,
|
|
@@ -1160,19 +1161,17 @@ async function warnStrayServeProcesses(): Promise<void> {
|
|
|
1160
1161
|
);
|
|
1161
1162
|
}
|
|
1162
1163
|
|
|
1163
|
-
// An explicit
|
|
1164
|
-
// or undefined for a bare flag (which
|
|
1165
|
-
// cmdServe resolves argv.webrtc/argv.share,
|
|
1166
|
-
// (oxmgr splits the command on whitespace →
|
|
1164
|
+
// An explicit room URL passed to --webrtc/--share in the daemon's serve args,
|
|
1165
|
+
// normalized to the internal webrtc:// form — or undefined for a bare flag (which
|
|
1166
|
+
// mints a persisted room instead). Mirrors how cmdServe resolves argv.webrtc/argv.share,
|
|
1167
|
+
// but over the raw arg list install holds (oxmgr splits the command on whitespace →
|
|
1168
|
+
// `--webrtc url`; pm2/`=` → `--webrtc=url`).
|
|
1167
1169
|
function explicitWebrtcUrl(args: string[]): string | undefined {
|
|
1168
1170
|
for (let i = 0; i < args.length; i++) {
|
|
1169
1171
|
const a = args[i]!;
|
|
1170
1172
|
for (const flag of ["--webrtc", "--share"]) {
|
|
1171
|
-
|
|
1172
|
-
if (
|
|
1173
|
-
const v = a.slice(flag.length + 1);
|
|
1174
|
-
if (v.startsWith("webrtc://")) return v;
|
|
1175
|
-
}
|
|
1173
|
+
const v = a === flag ? args[i + 1] : a.startsWith(`${flag}=`) ? a.slice(flag.length + 1) : "";
|
|
1174
|
+
if (v && isWebrtcSpec(v)) return toWebrtcUrl(v) ?? undefined;
|
|
1176
1175
|
}
|
|
1177
1176
|
}
|
|
1178
1177
|
return undefined;
|
|
@@ -1630,7 +1629,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1630
1629
|
`Modes (default: --http):\n` +
|
|
1631
1630
|
` --http HTTP API + web console on --port; no WebRTC\n` +
|
|
1632
1631
|
` --webrtc [URL] Share over WebRTC (bare flag mints a room+link on\n` +
|
|
1633
|
-
` agent-yes.com, or pass
|
|
1632
|
+
` agent-yes.com, or pass a room link to join that room:\n` +
|
|
1633
|
+
` https://agent-yes.com/room/#room=<id>&s=e1.<secret>).\n` +
|
|
1634
1634
|
` Alone it needs NO port — combine with --http for both.\n` +
|
|
1635
1635
|
` The minted room persists in ~/.agent-yes/.share-room\n` +
|
|
1636
1636
|
` (stable link across restarts; delete the file to rotate).\n` +
|
|
@@ -1706,7 +1706,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1706
1706
|
.option("webrtc", {
|
|
1707
1707
|
type: "string",
|
|
1708
1708
|
description:
|
|
1709
|
-
"Share over WebRTC: bare flag mints a room+link, or pass
|
|
1709
|
+
"Share over WebRTC: bare flag mints a room+link, or pass a https://…/room/#… link to join that room. Needs no port unless combined with --http",
|
|
1710
1710
|
})
|
|
1711
1711
|
.option("share", {
|
|
1712
1712
|
type: "string",
|
|
@@ -1749,6 +1749,26 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1749
1749
|
|
|
1750
1750
|
const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
|
|
1751
1751
|
const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
|
|
1752
|
+
// Resolve --webrtc/--share's value to the internal webrtc:// form up front, so a
|
|
1753
|
+
// bad link fails before any port is bound or lock taken. A value that LOOKS like
|
|
1754
|
+
// a room link but doesn't parse must fail LOUDLY: the old check gated on the
|
|
1755
|
+
// `webrtc://` scheme alone, so every https://…/room/#… link — the shape actually
|
|
1756
|
+
// printed to operators — fell through to the mint-a-fresh-room path. `--webrtc
|
|
1757
|
+
// <someone else's link>` then served its OWN room while the operator believed it
|
|
1758
|
+
// had joined theirs. Empty string = the bare flag, which mints by design.
|
|
1759
|
+
const webrtcArg = (argv.webrtc ?? argv.share) as string | undefined;
|
|
1760
|
+
let explicitRoomUrl: string | undefined;
|
|
1761
|
+
if (typeof webrtcArg === "string" && webrtcArg !== "") {
|
|
1762
|
+
explicitRoomUrl = (isWebrtcSpec(webrtcArg) && toWebrtcUrl(webrtcArg)) || undefined;
|
|
1763
|
+
if (!explicitRoomUrl) {
|
|
1764
|
+
process.stderr.write(
|
|
1765
|
+
`ay serve: --webrtc: not a room link: ${webrtcArg}\n` +
|
|
1766
|
+
` want https://agent-yes.com/room/#room=<id>&s=e1.<secret>\n` +
|
|
1767
|
+
` or a bare --webrtc to mint a room of your own\n`,
|
|
1768
|
+
);
|
|
1769
|
+
return 1;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1752
1772
|
const fixedPort = typeof argv.port === "number";
|
|
1753
1773
|
const usePortless = wantHttp && !fixedPort;
|
|
1754
1774
|
if (argv.local === true && fixedPort) {
|
|
@@ -5062,9 +5082,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
5062
5082
|
// webrtc:// value joins an explicit one.
|
|
5063
5083
|
let closeShare: (() => void) | undefined; // closes WebRTC peers on shutdown
|
|
5064
5084
|
if (wantWebrtc) {
|
|
5065
|
-
|
|
5066
|
-
const explicitUrl =
|
|
5067
|
-
typeof webrtcVal === "string" && webrtcVal.startsWith("webrtc://") ? webrtcVal : undefined;
|
|
5085
|
+
// Validated (and normalized to webrtc://) up front, alongside wantWebrtc.
|
|
5086
|
+
const explicitUrl = explicitRoomUrl;
|
|
5068
5087
|
try {
|
|
5069
5088
|
const { startShare, loadOrCreateShareRoom } = await import("./share.ts");
|
|
5070
5089
|
const linkFile = path.join(
|
|
@@ -5073,7 +5092,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
5073
5092
|
);
|
|
5074
5093
|
// Announce the link — reused for the initial share and for any auto-rotation
|
|
5075
5094
|
// (when the signaling server rejects a stale persisted room).
|
|
5076
|
-
const announce = async (room: string, link: string, rotated: boolean) => {
|
|
5095
|
+
const announce = async (room: string, link: string, joinLink: string, rotated: boolean) => {
|
|
5077
5096
|
const lead = rotated
|
|
5078
5097
|
? "the room was rejected by signaling (stale generation) — rotated to a fresh link"
|
|
5079
5098
|
: "shared over WebRTC — open this link (the token is eaten from the URL on open)";
|
|
@@ -5081,7 +5100,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
5081
5100
|
const persistNote = explicitUrl
|
|
5082
5101
|
? "\n"
|
|
5083
5102
|
: ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`;
|
|
5084
|
-
process.stdout.write(
|
|
5103
|
+
process.stdout.write(
|
|
5104
|
+
`${wantHttp ? "\n" : ""}${lead}:\n ${link}\n` +
|
|
5105
|
+
`\nto attach ANOTHER machine to this room, open this there (or paste it into\n` +
|
|
5106
|
+
`\`ay serve --webrtc\`) — one machine per link:\n ${joinLink}\n` +
|
|
5107
|
+
persistNote,
|
|
5108
|
+
);
|
|
5085
5109
|
// Offer to open the console (default yes) on the FIRST share only —
|
|
5086
5110
|
// an auto-rotation shouldn't pop a fresh tab from under the operator.
|
|
5087
5111
|
if (!rotated) {
|
|
@@ -5092,7 +5116,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
5092
5116
|
// Non-TTY (daemon/journal/CI): the link embeds the room secret S, so never
|
|
5093
5117
|
// write it to a log stream. Stash it in a 0600 file and point there instead.
|
|
5094
5118
|
try {
|
|
5095
|
-
await writeFile(linkFile, link
|
|
5119
|
+
await writeFile(linkFile, `${link}\n${joinLink}\n`, { mode: 0o600 });
|
|
5096
5120
|
} catch {
|
|
5097
5121
|
/* best effort */
|
|
5098
5122
|
}
|
|
@@ -5106,14 +5130,16 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
5106
5130
|
// saved like the serve token), so the link is stable across restarts.
|
|
5107
5131
|
// Only the persisted path may auto-rotate (onRotate set); an explicit URL
|
|
5108
5132
|
// is the operator's choice and must not be silently changed.
|
|
5109
|
-
const { room, link, close } = await startShare({
|
|
5133
|
+
const { room, link, joinLink, close } = await startShare({
|
|
5110
5134
|
url: explicitUrl ?? (await loadOrCreateShareRoom()),
|
|
5111
5135
|
localFetch: apiFetch,
|
|
5112
5136
|
apiToken: token,
|
|
5113
|
-
onRotate: explicitUrl
|
|
5137
|
+
onRotate: explicitUrl
|
|
5138
|
+
? undefined
|
|
5139
|
+
: (info) => announce(info.room, info.link, info.joinLink, true),
|
|
5114
5140
|
});
|
|
5115
5141
|
closeShare = close;
|
|
5116
|
-
await announce(room, link, false);
|
|
5142
|
+
await announce(room, link, joinLink, false);
|
|
5117
5143
|
} catch (e) {
|
|
5118
5144
|
process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
|
|
5119
5145
|
if (!wantHttp) {
|
package/ts/share.bun.spec.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { shareLinkFromRoomUrl } from "./share.ts";
|
|
2
|
+
import { formatRoomLink, shareLinkFromRoomUrl } from "./share.ts";
|
|
3
|
+
import { parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
|
|
3
4
|
import { MARKER } from "../lab/ui/e2e.js";
|
|
4
5
|
|
|
5
6
|
const S = "a".repeat(64); // a valid 64-hex room secret
|
|
@@ -31,6 +32,27 @@ describe("shareLinkFromRoomUrl", () => {
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
it("rejects a malformed room url", () => {
|
|
34
|
-
|
|
35
|
+
// The message must name the shape operators actually hold (the /room/ link),
|
|
36
|
+
// not the internal webrtc:// form they are never shown.
|
|
37
|
+
expect(() => shareLinkFromRoomUrl("not-a-webrtc-url")).toThrow(/\/room\/#room=/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// The /room/ link is BOTH what an operator opens in a browser and what they
|
|
42
|
+
// paste into `ay serve --webrtc`. So whatever formatRoomLink prints must survive
|
|
43
|
+
// the CLI's own parser — if these two ever drift, the printed link stops working
|
|
44
|
+
// in the tool that printed it, which no other test would catch.
|
|
45
|
+
describe("formatRoomLink \u2194 the CLI parser", () => {
|
|
46
|
+
it("prints a link toWebrtcUrl normalizes back to the same room", () => {
|
|
47
|
+
const link = formatRoomLink("r1a2b3c", S, "s.agent-yes.com");
|
|
48
|
+
expect(link).toBe(`https://agent-yes.com/room/#room=r1a2b3c&s=${MARKER}${S}`);
|
|
49
|
+
expect(toWebrtcUrl(link)).toBe(`webrtc://r1a2b3c:${MARKER}${S}@s.agent-yes.com`);
|
|
50
|
+
expect(parseWebrtcLink(link)).toEqual({ room: "r1a2b3c", s: S, host: "s.agent-yes.com" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("carries a non-prod signaling host as sig= and round-trips it too", () => {
|
|
54
|
+
const link = formatRoomLink("r1a2b3c", S, "localhost:7778");
|
|
55
|
+
expect(link).toContain("sig=localhost%3A7778");
|
|
56
|
+
expect(parseWebrtcLink(link)).toMatchObject({ room: "r1a2b3c", host: "localhost:7778" });
|
|
35
57
|
});
|
|
36
58
|
});
|
package/ts/share.spec.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { shareLinkFromRoomUrl } from "./share.ts";
|
|
2
|
+
import { formatRoomLink, shareLinkFromRoomUrl } from "./share.ts";
|
|
3
|
+
import { parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
|
|
3
4
|
import { MARKER } from "../lab/ui/e2e.js";
|
|
4
5
|
|
|
5
6
|
const S = "a".repeat(64); // a valid 64-hex room secret
|
|
@@ -31,6 +32,27 @@ describe("shareLinkFromRoomUrl", () => {
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
it("rejects a malformed room url", () => {
|
|
34
|
-
|
|
35
|
+
// The message must name the shape operators actually hold (the /room/ link),
|
|
36
|
+
// not the internal webrtc:// form they are never shown.
|
|
37
|
+
expect(() => shareLinkFromRoomUrl("not-a-webrtc-url")).toThrow(/\/room\/#room=/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// The /room/ link is BOTH what an operator opens in a browser and what they
|
|
42
|
+
// paste into `ay serve --webrtc`. So whatever formatRoomLink prints must survive
|
|
43
|
+
// the CLI's own parser — if these two ever drift, the printed link stops working
|
|
44
|
+
// in the tool that printed it, which no other test would catch.
|
|
45
|
+
describe("formatRoomLink \u2194 the CLI parser", () => {
|
|
46
|
+
it("prints a link toWebrtcUrl normalizes back to the same room", () => {
|
|
47
|
+
const link = formatRoomLink("r1a2b3c", S, "s.agent-yes.com");
|
|
48
|
+
expect(link).toBe(`https://agent-yes.com/room/#room=r1a2b3c&s=${MARKER}${S}`);
|
|
49
|
+
expect(toWebrtcUrl(link)).toBe(`webrtc://r1a2b3c:${MARKER}${S}@s.agent-yes.com`);
|
|
50
|
+
expect(parseWebrtcLink(link)).toEqual({ room: "r1a2b3c", s: S, host: "s.agent-yes.com" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("carries a non-prod signaling host as sig= and round-trips it too", () => {
|
|
54
|
+
const link = formatRoomLink("r1a2b3c", S, "localhost:7778");
|
|
55
|
+
expect(link).toContain("sig=localhost%3A7778");
|
|
56
|
+
expect(parseWebrtcLink(link)).toMatchObject({ room: "r1a2b3c", host: "localhost:7778" });
|
|
35
57
|
});
|
|
36
58
|
});
|