agent-yes 1.175.0 → 1.176.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/agent-yes.config.schema.json +2 -2
- package/default.config.yaml +45 -0
- package/dist/SUPPORTED_CLIS-BC4fOfBC.js +8 -0
- package/dist/{SUPPORTED_CLIS-DOs4OKwx.js → SUPPORTED_CLIS-TMmgX_p6.js} +2 -2
- package/dist/agentShare-DRfghQz8.js +231 -0
- package/dist/bash-yes.js +10 -0
- package/dist/cli.js +8 -8
- package/dist/cmd-yes.js +10 -0
- package/dist/{e2e-jb0Hp43q.js → e2e-BeKjLhmO.js} +1 -1
- package/dist/{forkNested-DhJxa4q4.js → forkNested-C8q7E6mp.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-EQPDGq-K.js → notifyDaemon-C08fJ2Ai.js} +5 -5
- package/dist/{openBrowser-ChR4llYa.js → openBrowser-BO5RVYwL.js} +1 -1
- package/dist/powershell-yes.js +10 -0
- package/dist/{remotes-BQMr4_En.js → remotes-BIHNxn1a.js} +3 -3
- package/dist/{remotes-CC-4GuJb.js → remotes-DsTnQhyx.js} +3 -3
- package/dist/{rustBinary-B5pGAxJj.js → rustBinary-DZTU07SA.js} +2 -2
- package/dist/{schedule-2gVJxKiN.js → schedule-BQtFOk0z.js} +5 -5
- package/dist/{serve-3WOXuTLt.js → serve-B5PQdiOb.js} +143 -76
- package/dist/{setup-CJQPRwIb.js → setup-BCqFNJoB.js} +3 -3
- package/dist/{share-QregR8a_.js → share-29YbzpbU.js} +6 -6
- package/dist/share-nvqy64A2.js +4 -0
- package/dist/{spawnGate-BFhva-2F.js → spawnGate-C5nBiUZQ.js} +1 -1
- package/dist/{spawnGate-IJDByl2U.js → spawnGate-Clp9xBPX.js} +2 -2
- package/dist/{subcommands-CpA7qj-z.js → subcommands-BbSJ_nTU.js} +736 -714
- package/dist/subcommands-BokdtqqC.js +9 -0
- package/dist/{tray-DXr7iK3E.js → tray-DKwVRBwr.js} +1 -1
- package/dist/{ts-DRUpMU-r.js → ts-Ce7a44Rj.js} +3 -3
- package/dist/{versionChecker-0OpVlsRu.js → versionChecker-C7C7QAPu.js} +2 -2
- package/dist/{webrtcLink-B7REGtK2.js → webrtcLink-BG0Xc4-W.js} +2 -2
- package/dist/{webrtcRemote-Bx-eD_0I.js → webrtcRemote-BLEbZnbY.js} +3 -3
- package/dist/{workspaceConfig-BgqK-31W.js → workspaceConfig-DKd6UvVm.js} +1 -1
- package/lab/ui/index.html +495 -291
- package/lab/ui/qrcode.js +2297 -0
- package/lab/ui/sw.js +2 -1
- package/package.json +8 -2
- package/scripts/build-rgui.ts +78 -0
- package/ts/agentShare.lifecycle.spec.ts +139 -0
- package/ts/agentShare.spec.ts +238 -0
- package/ts/agentShare.ts +330 -0
- package/ts/forkNested.spec.ts +39 -2
- package/ts/index.ts +5 -1
- package/ts/serve.ts +119 -5
- package/ts/share.ts +10 -3
- package/ts/subcommands.ts +29 -0
- package/dist/SUPPORTED_CLIS-7XJNuNWe.js +0 -8
- package/dist/subcommands-CkrKWSp8.js +0 -9
package/ts/agentShare.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Single-agent, view-only shares (Option X from docs/agent-sharing.md).
|
|
2
|
+
//
|
|
3
|
+
// A scoped share stands up its OWN e2ee WebRTC room (via startShare, minted
|
|
4
|
+
// fresh — never the persisted master fleet room) that exposes exactly ONE agent,
|
|
5
|
+
// read-only. The room's host bridge wraps the full `ay serve` API handler in a
|
|
6
|
+
// `scopedFetch` that DEFAULT-DENIES and permits only read paths, each verified to
|
|
7
|
+
// resolve to the shared `agent_id` (the stable per-process id, not the reusable
|
|
8
|
+
// pid). Read-only is enforced here on the host — the browser hiding controls is
|
|
9
|
+
// only UX (design principle #1: host-enforced capability, not client-side hiding).
|
|
10
|
+
//
|
|
11
|
+
// Link format: agent-yes.com/w/#room:grantSecret — the pid is NOT in the URL; the
|
|
12
|
+
// scope lives in this host-side share record. Shares are ephemeral (no disk
|
|
13
|
+
// persistence): a daemon restart drops them, and a restarted agent mints a fresh
|
|
14
|
+
// agent_id, so the holder re-shares (a deliberate NON-GOAL per the design).
|
|
15
|
+
import { listRecords, resolveOne, type CommonOpts } from "./subcommands.ts";
|
|
16
|
+
import { startShare } from "./share.ts";
|
|
17
|
+
|
|
18
|
+
export type SharePerm = "r" | "rw";
|
|
19
|
+
|
|
20
|
+
export interface ScopedShare {
|
|
21
|
+
shareId: string;
|
|
22
|
+
agentId: string;
|
|
23
|
+
perm: SharePerm;
|
|
24
|
+
room: string;
|
|
25
|
+
link: string;
|
|
26
|
+
label: string; // human hint: cli · cwd basename
|
|
27
|
+
createdAt: number;
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
close: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Bound concurrent shares: each is its own signaling WS + WebRTC host peer with a
|
|
33
|
+
// process.exit self-heal on repeated native peer-setup failure (see ts/share.ts),
|
|
34
|
+
// so a fleet of rooms multiplies that risk and the signaling-DO cost. A handful is
|
|
35
|
+
// plenty for the intended "show someone this one agent" use.
|
|
36
|
+
export const MAX_SHARES = 8;
|
|
37
|
+
export const DEFAULT_SHARE_TTL_MS = 24 * 60 * 60 * 1000; // 24h — design "short expiration"
|
|
38
|
+
|
|
39
|
+
const shares = new Map<string, ScopedShare>();
|
|
40
|
+
|
|
41
|
+
function lsOpts(all = false): CommonOpts {
|
|
42
|
+
return { all, active: false, json: true, latest: true, cwdScope: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listShares(): Omit<ScopedShare, "close">[] {
|
|
46
|
+
return [...shares.values()]
|
|
47
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
48
|
+
.map(({ close: _close, ...s }) => s);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function revokeShare(shareId: string): boolean {
|
|
52
|
+
const s = shares.get(shareId);
|
|
53
|
+
if (!s) return false;
|
|
54
|
+
try {
|
|
55
|
+
s.close();
|
|
56
|
+
} catch {
|
|
57
|
+
/* already closed */
|
|
58
|
+
}
|
|
59
|
+
shares.delete(shareId);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function revokeAllShares(): void {
|
|
64
|
+
for (const id of [...shares.keys()]) revokeShare(id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolve a keyword to a live agent and mint a fresh view-only share room for it. */
|
|
68
|
+
export async function createScopedShare(opts: {
|
|
69
|
+
agent: string; // any keyword: pid, agent_id, cwd/cli/prompt substring
|
|
70
|
+
perm?: SharePerm;
|
|
71
|
+
localFetch: (req: Request) => Promise<Response>;
|
|
72
|
+
apiToken: string;
|
|
73
|
+
sighost?: string;
|
|
74
|
+
ttlMs?: number;
|
|
75
|
+
}): Promise<Omit<ScopedShare, "close">> {
|
|
76
|
+
if (shares.size >= MAX_SHARES) {
|
|
77
|
+
throw new Error(`too many active shares (max ${MAX_SHARES}) — revoke one first`);
|
|
78
|
+
}
|
|
79
|
+
const record = await resolveOne(opts.agent, lsOpts(true)); // throws if none match
|
|
80
|
+
const agentId = record.agent_id;
|
|
81
|
+
if (!agentId) {
|
|
82
|
+
// Only agents registered with a stable id can be scoped safely; pid alone is
|
|
83
|
+
// reused and unsafe as the security key.
|
|
84
|
+
throw new Error(`agent ${record.pid} has no stable agent_id — cannot share (restart it)`);
|
|
85
|
+
}
|
|
86
|
+
const label = `${record.cli}${record.cwd ? " · " + record.cwd.split("/").filter(Boolean).pop() : ""}`;
|
|
87
|
+
|
|
88
|
+
const scoped = scopedFetch(agentId, opts.localFetch, opts.perm ?? "r");
|
|
89
|
+
const { room, link, close } = await startShare({
|
|
90
|
+
localFetch: scoped,
|
|
91
|
+
apiToken: opts.apiToken,
|
|
92
|
+
sighost: opts.sighost,
|
|
93
|
+
// no `url` → startShare mints a fresh, unpersisted room (never the master room)
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const shareId = "s" + Math.abs(hashStr(room + agentId + link)).toString(36);
|
|
97
|
+
const createdAt = Date.now();
|
|
98
|
+
const ttl = opts.ttlMs ?? DEFAULT_SHARE_TTL_MS;
|
|
99
|
+
const expiresAt = createdAt + ttl;
|
|
100
|
+
|
|
101
|
+
const stop = () => close();
|
|
102
|
+
const expiryTimer = setTimeout(() => revokeShare(shareId), ttl);
|
|
103
|
+
expiryTimer.unref?.();
|
|
104
|
+
|
|
105
|
+
const share: ScopedShare = {
|
|
106
|
+
shareId,
|
|
107
|
+
agentId,
|
|
108
|
+
perm: opts.perm ?? "r",
|
|
109
|
+
room,
|
|
110
|
+
link,
|
|
111
|
+
label,
|
|
112
|
+
createdAt,
|
|
113
|
+
expiresAt,
|
|
114
|
+
close: () => {
|
|
115
|
+
clearTimeout(expiryTimer);
|
|
116
|
+
stop();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
shares.set(shareId, share);
|
|
120
|
+
const { close: _c, ...pub } = share;
|
|
121
|
+
return pub;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hashStr(s: string): number {
|
|
125
|
+
let h = 0;
|
|
126
|
+
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
127
|
+
return h;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function forbidden(msg = "read-only share"): Response {
|
|
131
|
+
return new Response(msg, { status: 403 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Wrap the master API handler so a scoped viewer can ONLY read, and only THIS
|
|
135
|
+
// agent. Default-deny: an endpoint not explicitly allowed below is 403.
|
|
136
|
+
export function scopedFetch(
|
|
137
|
+
agentId: string,
|
|
138
|
+
inner: (req: Request) => Promise<Response>,
|
|
139
|
+
perm: SharePerm = "r",
|
|
140
|
+
): (req: Request) => Promise<Response> {
|
|
141
|
+
return async (req: Request): Promise<Response> => {
|
|
142
|
+
const url = new URL(req.url);
|
|
143
|
+
const p = url.pathname;
|
|
144
|
+
const method = req.method;
|
|
145
|
+
|
|
146
|
+
// Static host metadata, no agent data.
|
|
147
|
+
if (method === "GET" && p === "/api/version") return inner(req);
|
|
148
|
+
if (method === "GET" && p === "/api/whoami")
|
|
149
|
+
return withShareFlag(await inner(req), agentId, perm);
|
|
150
|
+
|
|
151
|
+
// Agent list: force keyword=agentId (cheap server-side narrowing) then
|
|
152
|
+
// post-filter by EXACT agent_id — matchKeyword can fuzzy-match a sibling whose
|
|
153
|
+
// cwd/prompt contains the hex, so the keyword hint is not a boundary on its own.
|
|
154
|
+
if (method === "GET" && (p === "/api/ls" || p === "/api/ls/subscribe")) {
|
|
155
|
+
const scopedUrl = new URL(url);
|
|
156
|
+
scopedUrl.searchParams.set("keyword", agentId);
|
|
157
|
+
scopedUrl.searchParams.delete("all"); // never widen to other/exited agents
|
|
158
|
+
const scopedReq = new Request(scopedUrl.toString(), req);
|
|
159
|
+
const res = await inner(scopedReq);
|
|
160
|
+
return p === "/api/ls" ? filterLsJson(res, agentId) : filterLsSse(res, agentId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Per-agent reads — verify the target resolves to OUR agent_id (403 otherwise).
|
|
164
|
+
const m =
|
|
165
|
+
/^\/api\/(read|tail|status|size)\/(.+)$/.exec(p) && method === "GET"
|
|
166
|
+
? /^\/api\/(read|tail|status|size)\/(.+)$/.exec(p)
|
|
167
|
+
: null;
|
|
168
|
+
if (m) {
|
|
169
|
+
const kw = decodeURIComponent(m[2]!);
|
|
170
|
+
if (!(await targetIsAgent(kw, agentId))) return forbidden("agent not shared");
|
|
171
|
+
return inner(req);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Steer (rw): a read-WRITE share may drive THIS agent — send input, resize its
|
|
175
|
+
// PTY, and self-report presence. It still may NOT control it (kill/restart/
|
|
176
|
+
// spawn are machine-admin, excluded from a per-agent share) nor touch any other
|
|
177
|
+
// agent. Treat `send` as command-injection-equivalent — the warning shown when
|
|
178
|
+
// minting an rw link is the real safety gate (docs/agent-sharing.md).
|
|
179
|
+
if (perm.includes("w")) {
|
|
180
|
+
// POST /api/send body {keyword, msg, code} — verify the target is us.
|
|
181
|
+
if (method === "POST" && p === "/api/send") {
|
|
182
|
+
let kw: unknown;
|
|
183
|
+
try {
|
|
184
|
+
kw = JSON.parse(await req.clone().text())?.keyword;
|
|
185
|
+
} catch {
|
|
186
|
+
return forbidden("bad body");
|
|
187
|
+
}
|
|
188
|
+
if (kw == null || !(await targetIsAgent(String(kw), agentId)))
|
|
189
|
+
return forbidden("agent not shared");
|
|
190
|
+
return inner(req);
|
|
191
|
+
}
|
|
192
|
+
// POST /api/resize/:kw — verify the target is us.
|
|
193
|
+
const rz = method === "POST" ? /^\/api\/resize\/(.+)$/.exec(p) : null;
|
|
194
|
+
if (rz) {
|
|
195
|
+
const kw = decodeURIComponent(rz[1]!);
|
|
196
|
+
if (!(await targetIsAgent(kw, agentId))) return forbidden("agent not shared");
|
|
197
|
+
return inner(req);
|
|
198
|
+
}
|
|
199
|
+
// POST /api/presence — cosmetic "who's watching" self-report, no agent control.
|
|
200
|
+
if (method === "POST" && p === "/api/presence") return inner(req);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Everything else (kill, restart, spawn, notes, spawn-config, share*, …, and —
|
|
204
|
+
// for a view-only share — send/resize/presence too) is denied.
|
|
205
|
+
return forbidden();
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function targetIsAgent(keyword: string, agentId: string): Promise<boolean> {
|
|
210
|
+
try {
|
|
211
|
+
const rec = await resolveOne(keyword, lsOpts(true));
|
|
212
|
+
return rec.agent_id === agentId;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Add a self-describing read-only capability to /api/whoami so the viewer console
|
|
219
|
+
// can enter read-only UI. The host stays the real boundary (writes are 403'd
|
|
220
|
+
// regardless); this is a UX hint the browser can trust because it comes from the
|
|
221
|
+
// host over the same e2ee channel.
|
|
222
|
+
async function withShareFlag(res: Response, agentId: string, perm: SharePerm): Promise<Response> {
|
|
223
|
+
if (!res.ok) return res;
|
|
224
|
+
let body: unknown;
|
|
225
|
+
try {
|
|
226
|
+
body = await res.json();
|
|
227
|
+
} catch {
|
|
228
|
+
return res;
|
|
229
|
+
}
|
|
230
|
+
const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
231
|
+
return Response.json({ ...obj, share: { perm, agent_id: agentId, readonly: perm === "r" } });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function filterLsJson(res: Response, agentId: string): Promise<Response> {
|
|
235
|
+
if (!res.ok) return res;
|
|
236
|
+
let arr: unknown;
|
|
237
|
+
try {
|
|
238
|
+
arr = await res.json();
|
|
239
|
+
} catch {
|
|
240
|
+
return res;
|
|
241
|
+
}
|
|
242
|
+
const kept = Array.isArray(arr)
|
|
243
|
+
? arr.filter(
|
|
244
|
+
(r) => r && typeof r === "object" && (r as { agent_id?: string }).agent_id === agentId,
|
|
245
|
+
)
|
|
246
|
+
: [];
|
|
247
|
+
return Response.json(kept, { status: res.status });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Filter the /api/ls/subscribe SSE so only the shared agent's deltas cross the
|
|
251
|
+
// channel. Each event is `{full?, upsert:[records], remove:[pids]}`; keep only
|
|
252
|
+
// upserts whose agent_id matches, and only removes for pids we actually forwarded.
|
|
253
|
+
function filterLsSse(res: Response, agentId: string): Response {
|
|
254
|
+
if (!res.ok || !res.body) return res;
|
|
255
|
+
const reader = res.body.getReader();
|
|
256
|
+
const dec = new TextDecoder();
|
|
257
|
+
const enc = new TextEncoder();
|
|
258
|
+
const forwarded = new Set<number>();
|
|
259
|
+
let buf = "";
|
|
260
|
+
|
|
261
|
+
const stream = new ReadableStream({
|
|
262
|
+
async pull(ctrl) {
|
|
263
|
+
for (;;) {
|
|
264
|
+
const { done, value } = await reader.read();
|
|
265
|
+
if (done) {
|
|
266
|
+
ctrl.close();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
buf += dec.decode(value, { stream: true });
|
|
270
|
+
// Emit each complete SSE event (blank-line separated).
|
|
271
|
+
let sep: number;
|
|
272
|
+
let emittedSomething = false;
|
|
273
|
+
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
|
274
|
+
const rawEvent = buf.slice(0, sep);
|
|
275
|
+
buf = buf.slice(sep + 2);
|
|
276
|
+
const out = transformEvent(rawEvent, agentId, forwarded);
|
|
277
|
+
if (out !== null) {
|
|
278
|
+
ctrl.enqueue(enc.encode(out + "\n\n"));
|
|
279
|
+
emittedSomething = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (emittedSomething) return; // yield to the consumer; resume on next pull
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
cancel() {
|
|
286
|
+
reader.cancel().catch(() => {});
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
return new Response(stream, {
|
|
290
|
+
status: res.status,
|
|
291
|
+
headers: {
|
|
292
|
+
"Content-Type": "text/event-stream",
|
|
293
|
+
"Cache-Control": "no-cache",
|
|
294
|
+
Connection: "keep-alive",
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Returns the rewritten SSE event text, or null to drop it entirely. Passes
|
|
300
|
+
// through comment lines (": ping" heartbeats) and non-data frames unchanged.
|
|
301
|
+
function transformEvent(rawEvent: string, agentId: string, forwarded: Set<number>): string | null {
|
|
302
|
+
const trimmed = rawEvent.replace(/\r/g, "");
|
|
303
|
+
if (!trimmed.trim()) return null;
|
|
304
|
+
if (!/^data:/m.test(trimmed)) return trimmed; // comments/heartbeats pass through
|
|
305
|
+
// Reassemble the `data:` payload (SSE allows multiple data: lines per event).
|
|
306
|
+
const dataLines = trimmed
|
|
307
|
+
.split("\n")
|
|
308
|
+
.filter((l) => l.startsWith("data:"))
|
|
309
|
+
.map((l) => l.slice(5).replace(/^ /, ""));
|
|
310
|
+
const payload = dataLines.join("\n");
|
|
311
|
+
let obj: {
|
|
312
|
+
full?: boolean;
|
|
313
|
+
upsert?: { pid: number; agent_id?: string }[];
|
|
314
|
+
remove?: number[];
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
obj = JSON.parse(payload);
|
|
318
|
+
} catch {
|
|
319
|
+
return null; // malformed — drop rather than leak
|
|
320
|
+
}
|
|
321
|
+
const upsert = (obj.upsert ?? []).filter((r) => r && r.agent_id === agentId);
|
|
322
|
+
for (const r of upsert) forwarded.add(r.pid);
|
|
323
|
+
const remove = (obj.remove ?? []).filter((pid) => forwarded.has(pid));
|
|
324
|
+
for (const pid of remove) forwarded.delete(pid);
|
|
325
|
+
// On the first snapshot always emit (even if empty) so the viewer knows it's
|
|
326
|
+
// connected; later ticks only when something relevant changed.
|
|
327
|
+
if (!obj.full && upsert.length === 0 && remove.length === 0) return null;
|
|
328
|
+
const next = obj.full ? { full: true, upsert, remove } : { upsert, remove };
|
|
329
|
+
return "data: " + JSON.stringify(next);
|
|
330
|
+
}
|
package/ts/forkNested.spec.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { buildSpawnTutorial, shouldForkNested, waitForFifo } from "./forkNested";
|
|
3
6
|
|
|
4
7
|
describe("shouldForkNested", () => {
|
|
5
8
|
it("forks when nested (AGENT_YES_PID set) and stdout is not a TTY", () => {
|
|
@@ -21,6 +24,40 @@ describe("shouldForkNested", () => {
|
|
|
21
24
|
});
|
|
22
25
|
});
|
|
23
26
|
|
|
27
|
+
describe("waitForFifo", () => {
|
|
28
|
+
let home: string;
|
|
29
|
+
let prevHome: string | undefined;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
home = mkdtempSync(path.join(tmpdir(), "ay-forknested-"));
|
|
33
|
+
mkdirSync(path.join(home, "fifo"), { recursive: true });
|
|
34
|
+
prevHome = process.env.AGENT_YES_HOME;
|
|
35
|
+
process.env.AGENT_YES_HOME = home;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
if (prevHome === undefined) delete process.env.AGENT_YES_HOME;
|
|
40
|
+
else process.env.AGENT_YES_HOME = prevHome;
|
|
41
|
+
rmSync(home, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("resolves true as soon as the wrapper's stdin FIFO is registered", async () => {
|
|
45
|
+
// Register the endpoint a poll-tick after the wait starts, like a real spawn.
|
|
46
|
+
setTimeout(() => writeFileSync(path.join(home, "fifo", "111.stdin"), ""), 60);
|
|
47
|
+
await expect(waitForFifo(111, 2000)).resolves.toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("times out false when the child never registers", async () => {
|
|
51
|
+
await expect(waitForFifo(222, 120)).resolves.toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("fails fast when aborted() reports the child already died", async () => {
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
await expect(waitForFifo(333, 5000, () => true)).resolves.toBe(false);
|
|
57
|
+
expect(Date.now() - start).toBeLessThan(1000); // no full-timeout wait
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
24
61
|
describe("buildSpawnTutorial", () => {
|
|
25
62
|
it("names the cli + pid and lists the drive commands with that pid", () => {
|
|
26
63
|
const out = buildSpawnTutorial("claude", 4242);
|
package/ts/index.ts
CHANGED
|
@@ -65,7 +65,7 @@ export type AgentCliConfig = {
|
|
|
65
65
|
working?: RegExp[]; // regex matcher for working status
|
|
66
66
|
updateAvailable?: RegExp[]; // regex matcher for update available banners
|
|
67
67
|
exitCommands?: string[]; // commands to exit the cli gracefully
|
|
68
|
-
promptArg?: (string & {}) | "first-arg" | "last-arg"; // argument name to pass the prompt, e.g. --prompt,
|
|
68
|
+
promptArg?: (string & {}) | "first-arg" | "last-arg" | "typed"; // argument name to pass the prompt, e.g. --prompt, first-arg for positional arg, or "typed" to type it into an interactive session after ready (shells)
|
|
69
69
|
|
|
70
70
|
// handle special format
|
|
71
71
|
noEOL?: boolean; // if true, do not split lines by \n when handling inputs, e.g. for codex, which uses cursor-move csi code instead of \n to move lines
|
|
@@ -361,6 +361,10 @@ export default async function agentYes({
|
|
|
361
361
|
} else if (cliConf.promptArg.startsWith("--")) {
|
|
362
362
|
cliArgs = [cliConf.promptArg, prompt, ...cliArgs];
|
|
363
363
|
prompt = undefined; // clear prompt to avoid sending later
|
|
364
|
+
} else if (cliConf.promptArg === "typed") {
|
|
365
|
+
// Shell mode (bash/cmd/powershell): don't pass an argv (that would
|
|
366
|
+
// run-and-exit). Leave `prompt` set so it's typed into the interactive
|
|
367
|
+
// session at onStart, keeping the shell alive afterwards.
|
|
364
368
|
} else {
|
|
365
369
|
logger.warn(`Unknown promptArg format: ${cliConf.promptArg}`);
|
|
366
370
|
}
|
package/ts/serve.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
listRecords,
|
|
17
17
|
readNotes,
|
|
18
18
|
readPtysize,
|
|
19
|
+
recentReadEdges,
|
|
19
20
|
renderRawLog,
|
|
20
21
|
resolveOne,
|
|
21
22
|
snapshotStatus,
|
|
@@ -1118,6 +1119,16 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1118
1119
|
const oscTitleRe = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
1119
1120
|
let title: string | null = null;
|
|
1120
1121
|
for (let m; (m = oscTitleRe.exec(text)); ) if (m[1]!.trim()) title = m[1]!.trim();
|
|
1122
|
+
// Defense-in-depth: this title is remote-controlled text (an agent sets its
|
|
1123
|
+
// own terminal title) that the web console renders. The console escapes it,
|
|
1124
|
+
// but strip C0/C1 control bytes and cap the length at the SOURCE too, so a
|
|
1125
|
+
// hostile title can't smuggle control characters into any current/future
|
|
1126
|
+
// sink. Quotes are left intact (legitimate titles contain them) and handled
|
|
1127
|
+
// by the console's HTML escaper.
|
|
1128
|
+
if (title) {
|
|
1129
|
+
// eslint-disable-next-line no-control-regex
|
|
1130
|
+
title = title.replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, 256).trim() || null;
|
|
1131
|
+
}
|
|
1121
1132
|
titleCache.set(logFile, { size, mtimeMs, title });
|
|
1122
1133
|
return title;
|
|
1123
1134
|
} finally {
|
|
@@ -1578,6 +1589,17 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1578
1589
|
});
|
|
1579
1590
|
}
|
|
1580
1591
|
|
|
1592
|
+
// GET /api/edges — recent inter-agent relationship edges for the /rgui wire
|
|
1593
|
+
// view. Currently the read/tail edges (agent `by` read agent `target` within
|
|
1594
|
+
// the last minute, from ~/.agent-yes/reads.jsonl). Directional, ephemeral.
|
|
1595
|
+
if (req.method === "GET" && p === "/api/edges") {
|
|
1596
|
+
try {
|
|
1597
|
+
return Response.json({ reads: await recentReadEdges() });
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
return new Response((e as Error).message, { status: 500 });
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1581
1603
|
// GET /api/whoami — this host's device label (user@host), so a remote
|
|
1582
1604
|
// console can tag each agent with the machine it came from. Unlike codehost,
|
|
1583
1605
|
// `ay serve --share` carries no per-agent device id; the viewer fetches this
|
|
@@ -1697,6 +1719,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1697
1719
|
return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
|
|
1698
1720
|
const logPath = record.log_file;
|
|
1699
1721
|
|
|
1722
|
+
// Assigned inside start(); called by BOTH the stream's cancel() and the
|
|
1723
|
+
// req.signal abort listener, so client-disconnect teardown can't leak.
|
|
1724
|
+
let cleanup = () => {};
|
|
1700
1725
|
const stream = new ReadableStream({
|
|
1701
1726
|
async start(ctrl) {
|
|
1702
1727
|
const enc = new TextEncoder();
|
|
@@ -1769,9 +1794,21 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1769
1794
|
} catch {
|
|
1770
1795
|
/* fs.watch unsupported — the fallback poll below still works */
|
|
1771
1796
|
}
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1797
|
+
// When fs.watch is live it already gives instant echo, so the poll is
|
|
1798
|
+
// only a safety net → 500ms. Without a watcher it IS the primary path
|
|
1799
|
+
// → keep it at 60ms for low typing-echo latency. This matters when many
|
|
1800
|
+
// /api/tail streams are open at once (the /rgui viewer opens one per
|
|
1801
|
+
// node): N × a 60ms poll each was needless load on the event loop.
|
|
1802
|
+
const poller = setInterval(() => void flush(), watcher ? 500 : 60);
|
|
1803
|
+
|
|
1804
|
+
// Tear down on client disconnect via BOTH the req.signal 'abort'
|
|
1805
|
+
// listener and the stream's cancel() — whichever the runtime fires
|
|
1806
|
+
// (Bun uses abort here; other runtimes/paths may only cancel). `closed`
|
|
1807
|
+
// makes it idempotent so it runs exactly once and never leaks the
|
|
1808
|
+
// heartbeat/poller/fs-watcher/fd, however many /api/tail streams churn
|
|
1809
|
+
// (the /rgui viewer opens+drops one per node while panning/zooming).
|
|
1810
|
+
cleanup = () => {
|
|
1811
|
+
if (closed) return;
|
|
1775
1812
|
closed = true;
|
|
1776
1813
|
clearInterval(heartbeat);
|
|
1777
1814
|
clearInterval(poller);
|
|
@@ -1786,7 +1823,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1786
1823
|
} catch {
|
|
1787
1824
|
/* already closed */
|
|
1788
1825
|
}
|
|
1789
|
-
}
|
|
1826
|
+
};
|
|
1827
|
+
req.signal.addEventListener("abort", cleanup);
|
|
1828
|
+
},
|
|
1829
|
+
cancel() {
|
|
1830
|
+
cleanup();
|
|
1790
1831
|
},
|
|
1791
1832
|
});
|
|
1792
1833
|
|
|
@@ -2334,6 +2375,54 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2334
2375
|
}
|
|
2335
2376
|
}
|
|
2336
2377
|
|
|
2378
|
+
// ---- Single-agent view-only shares (docs/agent-sharing.md, Option X) ------
|
|
2379
|
+
// Mint / list / revoke scoped share rooms. Reachable by whoever already holds
|
|
2380
|
+
// full control of this host (the local token or the master fleet room) — a
|
|
2381
|
+
// scoped viewer can't reach these (its scopedFetch 403s /api/share*).
|
|
2382
|
+
|
|
2383
|
+
// POST /api/share body {agent, perm?} → mint a fresh view-only room for ONE
|
|
2384
|
+
// agent and return its share link.
|
|
2385
|
+
if (req.method === "POST" && p === "/api/share") {
|
|
2386
|
+
let body: { agent?: string; perm?: "r" | "rw" };
|
|
2387
|
+
try {
|
|
2388
|
+
body = (await req.json()) as typeof body;
|
|
2389
|
+
} catch {
|
|
2390
|
+
return new Response("invalid JSON body", { status: 400 });
|
|
2391
|
+
}
|
|
2392
|
+
if (!body.agent) return new Response("agent required", { status: 400 });
|
|
2393
|
+
const perm = body.perm ?? "r";
|
|
2394
|
+
if (perm !== "r" && perm !== "rw")
|
|
2395
|
+
return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
|
|
2396
|
+
try {
|
|
2397
|
+
const { createScopedShare } = await import("./agentShare.ts");
|
|
2398
|
+
const share = await createScopedShare({
|
|
2399
|
+
agent: body.agent,
|
|
2400
|
+
perm,
|
|
2401
|
+
localFetch: apiFetch,
|
|
2402
|
+
apiToken: token,
|
|
2403
|
+
});
|
|
2404
|
+
return Response.json(share);
|
|
2405
|
+
} catch (e) {
|
|
2406
|
+
const msg = (e as Error).message;
|
|
2407
|
+
const status = /too many active shares/.test(msg) ? 409 : /no agent matched|no stable/.test(msg) ? 404 : 500;
|
|
2408
|
+
return new Response(msg, { status });
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// GET /api/shares → active scoped shares (for the manage/revoke UI).
|
|
2413
|
+
if (req.method === "GET" && p === "/api/shares") {
|
|
2414
|
+
const { listShares } = await import("./agentShare.ts");
|
|
2415
|
+
return Response.json(listShares());
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// DELETE /api/share/:shareId → revoke (close the room).
|
|
2419
|
+
const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
|
|
2420
|
+
if (req.method === "DELETE" && revokeM) {
|
|
2421
|
+
const { revokeShare } = await import("./agentShare.ts");
|
|
2422
|
+
const ok = revokeShare(decodeURIComponent(revokeM[1]!));
|
|
2423
|
+
return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2337
2426
|
return new Response("Not Found", { status: 404 });
|
|
2338
2427
|
};
|
|
2339
2428
|
|
|
@@ -2342,10 +2431,31 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2342
2431
|
// (the page holds no secrets); the page carries the token via the #k= link
|
|
2343
2432
|
// and sends it on every /api call.
|
|
2344
2433
|
const uiDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "lab", "ui");
|
|
2434
|
+
// Defense-in-depth CSP for the console document served by --http (mirrors the
|
|
2435
|
+
// one in lab/ui/cf/worker.ts — keep them in sync). The console renders remote
|
|
2436
|
+
// host-supplied agent metadata, so we constrain where an injection could send
|
|
2437
|
+
// data even though output is escaped. connect-src allows any wss: so custom
|
|
2438
|
+
// signaling hosts still work; 'self' covers same-origin /api + EventSource.
|
|
2439
|
+
const CONSOLE_CSP = [
|
|
2440
|
+
"default-src 'self'",
|
|
2441
|
+
"base-uri 'none'",
|
|
2442
|
+
"object-src 'none'",
|
|
2443
|
+
"frame-ancestors 'none'",
|
|
2444
|
+
"form-action 'self'",
|
|
2445
|
+
"img-src 'self' data:",
|
|
2446
|
+
"font-src 'self' data:",
|
|
2447
|
+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
|
2448
|
+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
|
2449
|
+
"connect-src 'self' https://s.agent-yes.com https://agent-yes.com wss:",
|
|
2450
|
+
"worker-src 'self'",
|
|
2451
|
+
"manifest-src 'self'",
|
|
2452
|
+
].join("; ");
|
|
2345
2453
|
const serveUiFile = async (name: string, type: string): Promise<Response> => {
|
|
2346
2454
|
try {
|
|
2347
2455
|
const buf = await readFile(path.join(uiDir, name));
|
|
2348
|
-
|
|
2456
|
+
const headers: Record<string, string> = { "Content-Type": type };
|
|
2457
|
+
if (type.includes("text/html")) headers["Content-Security-Policy"] = CONSOLE_CSP;
|
|
2458
|
+
return new Response(buf, { headers });
|
|
2349
2459
|
} catch {
|
|
2350
2460
|
return new Response("UI assets not found in this install — use the /api endpoints", {
|
|
2351
2461
|
status: 404,
|
|
@@ -2362,6 +2472,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2362
2472
|
return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
|
|
2363
2473
|
if (req.method === "GET" && p === "/e2e.js")
|
|
2364
2474
|
return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
|
|
2475
|
+
if (req.method === "GET" && p === "/qrcode.js")
|
|
2476
|
+
return serveUiFile("qrcode.js", "text/javascript; charset=utf-8");
|
|
2365
2477
|
if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
|
|
2366
2478
|
return apiFetch(req);
|
|
2367
2479
|
};
|
|
@@ -2534,6 +2646,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2534
2646
|
const shutdown = (resolve: () => void) => {
|
|
2535
2647
|
if (heartbeat) clearInterval(heartbeat);
|
|
2536
2648
|
closeShare?.();
|
|
2649
|
+
// Close any scoped single-agent share rooms so viewers get an immediate drop.
|
|
2650
|
+
void import("./agentShare.ts").then((m) => m.revokeAllShares()).catch(() => {});
|
|
2537
2651
|
server?.stop();
|
|
2538
2652
|
resolve();
|
|
2539
2653
|
};
|
package/ts/share.ts
CHANGED
|
@@ -77,6 +77,13 @@ const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
|
77
77
|
// TURN) to enable; without them we use STUN only, exactly as before. Cached
|
|
78
78
|
// until just before expiry; STUN-only fallback on any error so sharing never
|
|
79
79
|
// breaks because TURN is misconfigured or unreachable.
|
|
80
|
+
//
|
|
81
|
+
// Exposure note: these short-lived (1h) relay credentials are handed to every
|
|
82
|
+
// browser peer that joins a room (they ride in the offer, see startPeer), so any
|
|
83
|
+
// holder of a valid share link can use them to relay for the credential's
|
|
84
|
+
// lifetime. That is acceptable — joining a room already requires the share
|
|
85
|
+
// secret — but it means a share link also grants ~1h of Cloudflare TURN relay
|
|
86
|
+
// capacity. Keep the TTL short; do not widen it.
|
|
80
87
|
let iceCache: { servers: IceServer[]; exp: number } | null = null;
|
|
81
88
|
async function getIceServers(): Promise<IceServer[]> {
|
|
82
89
|
const keyId = process.env.CF_TURN_KEY_ID;
|
|
@@ -146,7 +153,7 @@ export async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST): Promise<
|
|
|
146
153
|
} catch {
|
|
147
154
|
/* not yet minted */
|
|
148
155
|
}
|
|
149
|
-
const room = "r" + randomBytes(
|
|
156
|
+
const room = "r" + randomBytes(6).toString("hex");
|
|
150
157
|
const s = randomBytes(32).toString("hex");
|
|
151
158
|
const url = `webrtc://${room}:${MARKER}${s}@${sighost}`;
|
|
152
159
|
await mkdir(path.dirname(shareRoomPath()), { recursive: true });
|
|
@@ -284,7 +291,7 @@ export async function startShare(
|
|
|
284
291
|
const initial = opts.url
|
|
285
292
|
? parseShareUrl(opts.url)
|
|
286
293
|
: {
|
|
287
|
-
room: "r" + randomBytes(
|
|
294
|
+
room: "r" + randomBytes(6).toString("hex"),
|
|
288
295
|
token: `${MARKER}${randomBytes(32).toString("hex")}`,
|
|
289
296
|
host: sighost,
|
|
290
297
|
};
|
|
@@ -322,7 +329,7 @@ export async function startShare(
|
|
|
322
329
|
const rotate = async (): Promise<boolean> => {
|
|
323
330
|
if (!opts.onRotate || closed || rotateCount >= 5) return false;
|
|
324
331
|
rotateCount++;
|
|
325
|
-
room = "r" + randomBytes(
|
|
332
|
+
room = "r" + randomBytes(6).toString("hex");
|
|
326
333
|
token = `${MARKER}${randomBytes(32).toString("hex")}`;
|
|
327
334
|
S = parseSecret(token).s;
|
|
328
335
|
authToken = await deriveAuthToken(S, room, host);
|