agent-yes 1.175.1 → 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-DH7pORiR.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-CLRNmdHy.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-DSN5ekb5.js → rustBinary-DZTU07SA.js} +2 -2
- package/dist/{schedule-DXGDI0Uo.js → schedule-BQtFOk0z.js} +5 -5
- package/dist/{serve-DgQjFYQC.js → serve-B5PQdiOb.js} +143 -76
- package/dist/{setup-DHVydU-j.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-DjA3FgJk.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-PerHq0yJ.js → ts-Ce7a44Rj.js} +3 -3
- package/dist/{versionChecker-CfZx87hW.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-COSdc1BZ.js +0 -8
- package/dist/subcommands-C9u1em-X.js +0 -9
package/lab/ui/sw.js
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
// fallback when offline, which is what makes the installed app launchable with no
|
|
7
7
|
// network. WebSocket signaling and cross-origin CDN scripts are not GET fetches we
|
|
8
8
|
// own, so they pass straight through.
|
|
9
|
-
const CACHE = "agent-yes-w-
|
|
9
|
+
const CACHE = "agent-yes-w-v2";
|
|
10
10
|
const SHELL = [
|
|
11
11
|
"./",
|
|
12
12
|
"./index.html",
|
|
13
13
|
"./room-client.js",
|
|
14
14
|
"./console-logic.js",
|
|
15
15
|
"./e2e.js",
|
|
16
|
+
"./qrcode.js",
|
|
16
17
|
"./manifest.webmanifest",
|
|
17
18
|
"./icon.svg",
|
|
18
19
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-yes",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.176.0",
|
|
4
4
|
"description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -45,7 +45,10 @@
|
|
|
45
45
|
"openrouter-yes": "./dist/openrouter-yes.js",
|
|
46
46
|
"orcy": "./dist/orcy.js",
|
|
47
47
|
"pi-yes": "./dist/pi-yes.js",
|
|
48
|
-
"qwen-yes": "./dist/qwen-yes.js"
|
|
48
|
+
"qwen-yes": "./dist/qwen-yes.js",
|
|
49
|
+
"bash-yes": "./dist/bash-yes.js",
|
|
50
|
+
"cmd-yes": "./dist/cmd-yes.js",
|
|
51
|
+
"powershell-yes": "./dist/powershell-yes.js"
|
|
49
52
|
},
|
|
50
53
|
"directories": {
|
|
51
54
|
"doc": "docs"
|
|
@@ -66,6 +69,7 @@
|
|
|
66
69
|
"lab/ui/index.html",
|
|
67
70
|
"lab/ui/landing.html",
|
|
68
71
|
"lab/ui/manifest.webmanifest",
|
|
72
|
+
"lab/ui/qrcode.js",
|
|
69
73
|
"lab/ui/room-client.js",
|
|
70
74
|
"lab/ui/sw.js",
|
|
71
75
|
"lab/ui/blog/**"
|
|
@@ -85,6 +89,8 @@
|
|
|
85
89
|
"build": "tsdown",
|
|
86
90
|
"check:e2e": "bun scripts/check-e2e.ts",
|
|
87
91
|
"cf": "bun scripts/cf.ts",
|
|
92
|
+
"build:rgui": "bun scripts/build-rgui.ts",
|
|
93
|
+
"dev:rgui": "bun lab/ui/rgui/dev.ts",
|
|
88
94
|
"build:rs": "bun ./scripts/build-rs.ts",
|
|
89
95
|
"postbuild": "bun ./ts/postbuild.ts",
|
|
90
96
|
"demo": "bun run build && bun link && claude-yes -- demo",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Build the /rgui page (lab/ui/rgui) — bundles main.ts + @snomiao/rgui into a
|
|
3
|
+
// single browser module, then copies index.html beside it.
|
|
4
|
+
//
|
|
5
|
+
// rgui is consumed FROM SOURCE, never the npm package, so the page tracks rgui's
|
|
6
|
+
// heavy dev. Source resolution order:
|
|
7
|
+
// 1. $RGUI_LOCAL (explicit override)
|
|
8
|
+
// 2. ~/ws/snomiao/rgui/tree/main (the local dev worktree, if present)
|
|
9
|
+
// 3. lib/rgui (the committed git submodule — CI/other machines)
|
|
10
|
+
// The chosen dir's node_modules must have d3-selection/d3-zoom; we `bun install`
|
|
11
|
+
// there once if missing (matters for the submodule / a fresh CI checkout).
|
|
12
|
+
//
|
|
13
|
+
// Usage: bun scripts/build-rgui.ts [outdir]
|
|
14
|
+
// outdir defaults to lab/ui/rgui/dist
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { cp, mkdir } from "node:fs/promises";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { $ } from "bun";
|
|
20
|
+
|
|
21
|
+
const root = path.join(import.meta.dir, "..");
|
|
22
|
+
|
|
23
|
+
function resolveRguiDir(): string {
|
|
24
|
+
const candidates = [
|
|
25
|
+
process.env.RGUI_LOCAL,
|
|
26
|
+
path.join(homedir(), "ws/snomiao/rgui/tree/main"),
|
|
27
|
+
path.join(root, "lib/rgui"),
|
|
28
|
+
].filter((d): d is string => !!d);
|
|
29
|
+
for (const d of candidates) {
|
|
30
|
+
if (existsSync(path.join(d, "src/index.ts"))) return d;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(
|
|
33
|
+
"rgui source not found — run `git submodule update --init lib/rgui` " +
|
|
34
|
+
"(or set RGUI_LOCAL to a rgui checkout).",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const RGUI_DIR = resolveRguiDir();
|
|
39
|
+
const isSubmodule = RGUI_DIR === path.join(root, "lib/rgui");
|
|
40
|
+
console.log(`[build-rgui] rgui source: ${RGUI_DIR}${isSubmodule ? " (submodule)" : " (local)"}`);
|
|
41
|
+
|
|
42
|
+
// rgui imports d3-selection/d3-zoom; ensure its deps exist (fresh submodule/CI)
|
|
43
|
+
if (!existsSync(path.join(RGUI_DIR, "node_modules/d3-selection"))) {
|
|
44
|
+
console.log("[build-rgui] installing rgui deps…");
|
|
45
|
+
await $`bun install --cwd ${RGUI_DIR}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const outdir = process.argv[2] ? path.resolve(process.argv[2]) : path.join(root, "lab/ui/rgui/dist");
|
|
49
|
+
await mkdir(outdir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const result = await Bun.build({
|
|
52
|
+
entrypoints: [path.join(root, "lab/ui/rgui/main.ts")],
|
|
53
|
+
outdir,
|
|
54
|
+
target: "browser",
|
|
55
|
+
minify: true,
|
|
56
|
+
sourcemap: "linked",
|
|
57
|
+
naming: "[name].js",
|
|
58
|
+
plugins: [
|
|
59
|
+
{
|
|
60
|
+
// alias the bare "@snomiao/rgui" specifier to the resolved source entry so
|
|
61
|
+
// d3 (rgui's own dep) resolves from RGUI_DIR/node_modules, not agent-yes's.
|
|
62
|
+
name: "rgui-source-alias",
|
|
63
|
+
setup(b) {
|
|
64
|
+
b.onResolve({ filter: /^@snomiao\/rgui$/ }, () => ({
|
|
65
|
+
path: path.join(RGUI_DIR, "src/index.ts"),
|
|
66
|
+
}));
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
for (const m of result.logs) console.error(m);
|
|
74
|
+
throw new Error("[build-rgui] bundle failed");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await cp(path.join(root, "lab/ui/rgui/index.html"), path.join(outdir, "index.html"));
|
|
78
|
+
console.log(`[build-rgui] → ${path.relative(root, outdir)}/ (index.html + main.js)`);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Lifecycle of scoped shares: mint / list / revoke / expire. The security filter
|
|
4
|
+
// (scopedFetch) is driven with the REAL resolveOne in agentShare.spec.ts; here we
|
|
5
|
+
// mock the registry and the WebRTC room so createScopedShare's bookkeeping can be
|
|
6
|
+
// exercised hermetically (no signaling server, no live agents).
|
|
7
|
+
|
|
8
|
+
const closeSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
9
|
+
let roomSeq = 0;
|
|
10
|
+
|
|
11
|
+
vi.mock("./share.ts", () => ({
|
|
12
|
+
startShare: vi.fn(async () => {
|
|
13
|
+
const close = vi.fn();
|
|
14
|
+
closeSpies.push(close);
|
|
15
|
+
const room = `room-${roomSeq++}`;
|
|
16
|
+
return { room, link: `https://agent-yes.com/w/#${room}:secret`, close };
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const records = new Map<string, Record<string, unknown>>();
|
|
21
|
+
vi.mock("./subcommands.ts", () => ({
|
|
22
|
+
listRecords: vi.fn(async () => [...records.values()]),
|
|
23
|
+
resolveOne: vi.fn(async (kw: string) => {
|
|
24
|
+
const rec = records.get(kw);
|
|
25
|
+
if (!rec) throw new Error(`no agent matches ${kw}`);
|
|
26
|
+
return rec;
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
createScopedShare,
|
|
32
|
+
listShares,
|
|
33
|
+
revokeShare,
|
|
34
|
+
revokeAllShares,
|
|
35
|
+
MAX_SHARES,
|
|
36
|
+
DEFAULT_SHARE_TTL_MS,
|
|
37
|
+
} from "./agentShare.ts";
|
|
38
|
+
|
|
39
|
+
const localFetch = async () => new Response("ok");
|
|
40
|
+
const baseOpts = { localFetch, apiToken: "tok" };
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
records.clear();
|
|
44
|
+
closeSpies.length = 0;
|
|
45
|
+
records.set("1", { pid: 1, agent_id: "aaaaaaaaaaaa", cli: "claude", cwd: "/home/u/proj" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
revokeAllShares();
|
|
50
|
+
vi.useRealTimers();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("createScopedShare", () => {
|
|
54
|
+
it("mints a view-only share with a fresh room and a cli · dir label", async () => {
|
|
55
|
+
const share = await createScopedShare({ ...baseOpts, agent: "1" });
|
|
56
|
+
expect(share.agentId).toBe("aaaaaaaaaaaa");
|
|
57
|
+
expect(share.perm).toBe("r"); // default
|
|
58
|
+
expect(share.room).toMatch(/^room-/);
|
|
59
|
+
expect(share.link).toContain(share.room);
|
|
60
|
+
expect(share.label).toBe("claude · proj");
|
|
61
|
+
expect(share.shareId).toMatch(/^s[0-9a-z]+$/);
|
|
62
|
+
expect(share.expiresAt - share.createdAt).toBe(DEFAULT_SHARE_TTL_MS);
|
|
63
|
+
expect("close" in share).toBe(false); // the close capability never leaves the host
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("labels a cwd-less agent with just the cli and honours perm/ttl overrides", async () => {
|
|
67
|
+
records.set("2", { pid: 2, agent_id: "cccccccccccc", cli: "codex", cwd: "" });
|
|
68
|
+
const share = await createScopedShare({ ...baseOpts, agent: "2", perm: "rw", ttlMs: 5000 });
|
|
69
|
+
expect(share.label).toBe("codex");
|
|
70
|
+
expect(share.perm).toBe("rw");
|
|
71
|
+
expect(share.expiresAt - share.createdAt).toBe(5000);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("refuses an agent with no stable agent_id (pid alone is reused → unsafe)", async () => {
|
|
75
|
+
records.set("3", { pid: 3, agent_id: undefined, cli: "claude", cwd: "/x" });
|
|
76
|
+
await expect(createScopedShare({ ...baseOpts, agent: "3" })).rejects.toThrow(/agent_id/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("propagates an unresolvable keyword", async () => {
|
|
80
|
+
await expect(createScopedShare({ ...baseOpts, agent: "nope" })).rejects.toThrow(/no agent/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("caps concurrent shares at MAX_SHARES", async () => {
|
|
84
|
+
for (let i = 0; i < MAX_SHARES; i++) {
|
|
85
|
+
await createScopedShare({ ...baseOpts, agent: "1" });
|
|
86
|
+
}
|
|
87
|
+
await expect(createScopedShare({ ...baseOpts, agent: "1" })).rejects.toThrow(
|
|
88
|
+
/too many active shares/,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("listShares / revokeShare / revokeAllShares", () => {
|
|
94
|
+
it("lists newest-first without exposing close", async () => {
|
|
95
|
+
vi.useFakeTimers(); // control createdAt so the sort order is deterministic
|
|
96
|
+
const a = await createScopedShare({ ...baseOpts, agent: "1" });
|
|
97
|
+
vi.advanceTimersByTime(10);
|
|
98
|
+
const b = await createScopedShare({ ...baseOpts, agent: "1" });
|
|
99
|
+
const listed = listShares();
|
|
100
|
+
expect(listed.map((s) => s.shareId)).toEqual([b.shareId, a.shareId]);
|
|
101
|
+
for (const s of listed) expect("close" in s).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("revokeShare closes the room and forgets the share; unknown id is a no-op", async () => {
|
|
105
|
+
const share = await createScopedShare({ ...baseOpts, agent: "1" });
|
|
106
|
+
expect(revokeShare(share.shareId)).toBe(true);
|
|
107
|
+
expect(closeSpies[0]).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(listShares()).toHaveLength(0);
|
|
109
|
+
expect(revokeShare(share.shareId)).toBe(false); // already gone
|
|
110
|
+
expect(revokeShare("s-unknown")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("revokeShare survives a close() that throws (room already torn down)", async () => {
|
|
114
|
+
const share = await createScopedShare({ ...baseOpts, agent: "1" });
|
|
115
|
+
closeSpies[0]!.mockImplementation(() => {
|
|
116
|
+
throw new Error("already closed");
|
|
117
|
+
});
|
|
118
|
+
expect(revokeShare(share.shareId)).toBe(true);
|
|
119
|
+
expect(listShares()).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("revokeAllShares closes every room", async () => {
|
|
123
|
+
await createScopedShare({ ...baseOpts, agent: "1" });
|
|
124
|
+
await createScopedShare({ ...baseOpts, agent: "1" });
|
|
125
|
+
revokeAllShares();
|
|
126
|
+
expect(listShares()).toHaveLength(0);
|
|
127
|
+
expect(closeSpies).toHaveLength(2);
|
|
128
|
+
for (const spy of closeSpies) expect(spy).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("a share self-revokes when its TTL elapses", async () => {
|
|
132
|
+
vi.useFakeTimers();
|
|
133
|
+
const share = await createScopedShare({ ...baseOpts, agent: "1", ttlMs: 1000 });
|
|
134
|
+
expect(listShares().map((s) => s.shareId)).toContain(share.shareId);
|
|
135
|
+
vi.advanceTimersByTime(1001);
|
|
136
|
+
expect(listShares()).toHaveLength(0);
|
|
137
|
+
expect(closeSpies[0]).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { scopedFetch } from "./agentShare.ts";
|
|
3
|
+
|
|
4
|
+
// The security core of single-agent view-only sharing: scopedFetch wraps the full
|
|
5
|
+
// `ay serve` API and must DEFAULT-DENY, exposing only reads scoped to ONE agent_id.
|
|
6
|
+
// These drive the real filter with a mock inner handler (no WebRTC / no registry).
|
|
7
|
+
|
|
8
|
+
const SHARED = "aaaaaaaaaaaa"; // the shared agent's stable id
|
|
9
|
+
const OTHER = "bbbbbbbbbbbb"; // a sibling on the same host — must never leak
|
|
10
|
+
|
|
11
|
+
function rec(pid: number, agent_id: string) {
|
|
12
|
+
return { pid, agent_id, cli: "claude", cwd: "/x", status: "running", title: "t" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A stand-in for the master apiFetch. Records what it was asked and answers a few
|
|
16
|
+
// endpoints; the scoped wrapper is what we're testing.
|
|
17
|
+
function makeInner(seen: { url?: string }) {
|
|
18
|
+
return async (req: Request): Promise<Response> => {
|
|
19
|
+
const url = new URL(req.url);
|
|
20
|
+
seen.url = req.method + " " + url.pathname + url.search;
|
|
21
|
+
const p = url.pathname;
|
|
22
|
+
if (p === "/api/ls") {
|
|
23
|
+
// The real handler honours ?keyword=; emulate that so we can assert the
|
|
24
|
+
// wrapper both narrows via keyword AND post-filters by exact agent_id.
|
|
25
|
+
const kw = url.searchParams.get("keyword");
|
|
26
|
+
const all = [rec(1, SHARED), rec(2, OTHER)];
|
|
27
|
+
const out = kw ? all.filter((r) => r.agent_id === kw || r.cwd.includes(kw)) : all;
|
|
28
|
+
return Response.json(out);
|
|
29
|
+
}
|
|
30
|
+
if (p === "/api/whoami") return Response.json({ host: "me@box" });
|
|
31
|
+
if (p === "/api/version") return new Response("1.2.3");
|
|
32
|
+
if (p === "/api/ls/subscribe") {
|
|
33
|
+
const enc = new TextEncoder();
|
|
34
|
+
const body = new ReadableStream({
|
|
35
|
+
start(ctrl) {
|
|
36
|
+
// Initial full snapshot with BOTH agents, then a delta touching each.
|
|
37
|
+
ctrl.enqueue(
|
|
38
|
+
enc.encode(
|
|
39
|
+
`data: ${JSON.stringify({ full: true, upsert: [rec(1, SHARED), rec(2, OTHER)], remove: [] })}\n\n`,
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
ctrl.enqueue(enc.encode(`: ping\n\n`));
|
|
43
|
+
ctrl.enqueue(
|
|
44
|
+
enc.encode(`data: ${JSON.stringify({ upsert: [rec(2, OTHER)], remove: [] })}\n\n`),
|
|
45
|
+
);
|
|
46
|
+
ctrl.enqueue(
|
|
47
|
+
enc.encode(`data: ${JSON.stringify({ upsert: [rec(1, SHARED)], remove: [] })}\n\n`),
|
|
48
|
+
);
|
|
49
|
+
ctrl.close();
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
|
53
|
+
}
|
|
54
|
+
// read/tail/status/size land here only if the wrapper allowed them through.
|
|
55
|
+
return new Response("inner-ok");
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const mk = (seen: { url?: string } = {}) => scopedFetch(SHARED, makeInner(seen), "r");
|
|
60
|
+
|
|
61
|
+
async function sse(res: Response): Promise<string> {
|
|
62
|
+
return await res.text();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("scopedFetch — default-deny writes", () => {
|
|
66
|
+
for (const [method, path] of [
|
|
67
|
+
["POST", "/api/send"],
|
|
68
|
+
["POST", "/api/resize/1"],
|
|
69
|
+
["POST", "/api/kill"],
|
|
70
|
+
["POST", "/api/restart"],
|
|
71
|
+
["POST", "/api/spawn"],
|
|
72
|
+
["POST", "/api/presence"],
|
|
73
|
+
["GET", "/api/presence"],
|
|
74
|
+
["POST", "/api/share"],
|
|
75
|
+
["GET", "/api/notes"],
|
|
76
|
+
["GET", "/api/spawn-config"],
|
|
77
|
+
] as const) {
|
|
78
|
+
it(`403s ${method} ${path}`, async () => {
|
|
79
|
+
const res = await mk()(new Request("http://ay.local" + path, { method }));
|
|
80
|
+
expect(res.status).toBe(403);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("scopedFetch — /api/ls is narrowed and post-filtered", () => {
|
|
86
|
+
it("injects keyword=agentId and returns ONLY the shared agent", async () => {
|
|
87
|
+
const seen: { url?: string } = {};
|
|
88
|
+
const res = await mk(seen)(new Request("http://ay.local/api/ls"));
|
|
89
|
+
expect(seen.url).toContain("keyword=" + SHARED); // narrowed server-side
|
|
90
|
+
const out = (await res.json()) as { agent_id: string }[];
|
|
91
|
+
expect(out).toHaveLength(1);
|
|
92
|
+
expect(out[0]!.agent_id).toBe(SHARED);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("post-filters even if the inner handler over-matches (fuzzy keyword)", async () => {
|
|
96
|
+
// Inner that ignores keyword and returns everyone — the wrapper must still cut
|
|
97
|
+
// it down to the shared agent by EXACT agent_id.
|
|
98
|
+
const leaky = scopedFetch(
|
|
99
|
+
SHARED,
|
|
100
|
+
async () => Response.json([rec(1, SHARED), rec(2, OTHER)]),
|
|
101
|
+
"r",
|
|
102
|
+
);
|
|
103
|
+
const out = (await (await leaky(new Request("http://ay.local/api/ls"))).json()) as {
|
|
104
|
+
agent_id: string;
|
|
105
|
+
}[];
|
|
106
|
+
expect(out.map((r) => r.agent_id)).toEqual([SHARED]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("scopedFetch — /api/ls/subscribe SSE is filtered", () => {
|
|
111
|
+
it("keeps only the shared agent across snapshot + deltas", async () => {
|
|
112
|
+
const res = await mk()(new Request("http://ay.local/api/ls/subscribe"));
|
|
113
|
+
const text = await sse(res);
|
|
114
|
+
expect(text).toContain('"full":true');
|
|
115
|
+
expect(text).toContain(SHARED);
|
|
116
|
+
expect(text).not.toContain(OTHER); // sibling never crosses the channel
|
|
117
|
+
expect(text).toContain(": ping"); // heartbeat passes through
|
|
118
|
+
// The delta that touched only OTHER must be dropped entirely.
|
|
119
|
+
const dataEvents = text.split("\n\n").filter((e) => e.startsWith("data:"));
|
|
120
|
+
// full snapshot (shared only) + the shared-agent delta = 2 data events.
|
|
121
|
+
expect(dataEvents).toHaveLength(2);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("scopedFetch — /api/ls/subscribe remove + teardown", () => {
|
|
126
|
+
// Inner whose stream announces both agents, then removes them: only the removal
|
|
127
|
+
// of a pid we actually FORWARDED may cross; a never-forwarded sibling pid is
|
|
128
|
+
// information (its existence) and must be dropped.
|
|
129
|
+
function removalInner(): (req: Request) => Promise<Response> {
|
|
130
|
+
const enc = new TextEncoder();
|
|
131
|
+
return async () =>
|
|
132
|
+
new Response(
|
|
133
|
+
new ReadableStream({
|
|
134
|
+
start(ctrl) {
|
|
135
|
+
ctrl.enqueue(
|
|
136
|
+
enc.encode(
|
|
137
|
+
`data: ${JSON.stringify({ full: true, upsert: [rec(1, SHARED), rec(2, OTHER)], remove: [] })}\n\n`,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ upsert: [], remove: [2] })}\n\n`));
|
|
141
|
+
ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ upsert: [], remove: [1] })}\n\n`));
|
|
142
|
+
ctrl.close();
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
{ headers: { "Content-Type": "text/event-stream" } },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it("forwards removes only for pids it forwarded (sibling removals stay hidden)", async () => {
|
|
150
|
+
const res = await scopedFetch(SHARED, removalInner(), "r")(
|
|
151
|
+
new Request("http://ay.local/api/ls/subscribe"),
|
|
152
|
+
);
|
|
153
|
+
const events = (await sse(res))
|
|
154
|
+
.split("\n\n")
|
|
155
|
+
.filter((e) => e.startsWith("data:"))
|
|
156
|
+
.map((e) => JSON.parse(e.slice(5)) as { upsert: unknown[]; remove: number[] });
|
|
157
|
+
// Snapshot (shared only) + the remove of OUR pid; OTHER's removal is dropped.
|
|
158
|
+
expect(events).toHaveLength(2);
|
|
159
|
+
expect(events[0]!.upsert).toHaveLength(1);
|
|
160
|
+
expect(events[1]!.remove).toEqual([1]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("cancelling the filtered stream tears down the upstream reader", async () => {
|
|
164
|
+
const res = await mk()(new Request("http://ay.local/api/ls/subscribe"));
|
|
165
|
+
await res.body!.cancel(); // viewer went away — must not leak the inner stream
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("scopedFetch — read metadata", () => {
|
|
171
|
+
it("passes /api/version through untouched", async () => {
|
|
172
|
+
const res = await mk()(new Request("http://ay.local/api/version"));
|
|
173
|
+
expect(await res.text()).toBe("1.2.3");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("advertises read-only capability on /api/whoami", async () => {
|
|
177
|
+
const res = await mk()(new Request("http://ay.local/api/whoami"));
|
|
178
|
+
const who = (await res.json()) as {
|
|
179
|
+
host: string;
|
|
180
|
+
share: { readonly: boolean; agent_id: string; perm: string };
|
|
181
|
+
};
|
|
182
|
+
expect(who.host).toBe("me@box");
|
|
183
|
+
expect(who.share.readonly).toBe(true);
|
|
184
|
+
expect(who.share.agent_id).toBe(SHARED);
|
|
185
|
+
expect(who.share.perm).toBe("r");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("403s a read of an agent that is not the shared one (unknown keyword)", async () => {
|
|
189
|
+
// resolveOne throws for an unknown keyword → targetIsAgent false → 403. Uses a
|
|
190
|
+
// keyword that cannot resolve on this machine, so it never leaks a real agent.
|
|
191
|
+
const res = await mk()(new Request("http://ay.local/api/read/zzzzzzzzzzzz-nope"));
|
|
192
|
+
expect(res.status).toBe(403);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("scopedFetch — read-write (rw / steer) share", () => {
|
|
197
|
+
const mkRw = () => scopedFetch(SHARED, makeInner({}), "rw");
|
|
198
|
+
|
|
199
|
+
it("advertises perm=rw and readonly=false on /api/whoami", async () => {
|
|
200
|
+
const res = await mkRw()(new Request("http://ay.local/api/whoami"));
|
|
201
|
+
const who = (await res.json()) as { share: { readonly: boolean; perm: string } };
|
|
202
|
+
expect(who.share.perm).toBe("rw");
|
|
203
|
+
expect(who.share.readonly).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("still DENIES control (kill / restart / spawn) — rw is steer, not control", async () => {
|
|
207
|
+
for (const path of ["/api/kill", "/api/restart", "/api/spawn"]) {
|
|
208
|
+
const res = await mkRw()(new Request("http://ay.local" + path, { method: "POST" }));
|
|
209
|
+
expect(res.status).toBe(403);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("DENIES send/resize aimed at an agent that isn't the shared one", async () => {
|
|
214
|
+
// keyword can't resolve on this machine → targetIsAgent false → 403, so an rw
|
|
215
|
+
// holder can only steer THE shared agent, never a sibling.
|
|
216
|
+
const send = await mkRw()(
|
|
217
|
+
new Request("http://ay.local/api/send", {
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: JSON.stringify({ keyword: "zzzzzzzzzzzz-nope", msg: "x" }),
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
expect(send.status).toBe(403);
|
|
223
|
+
const resize = await mkRw()(
|
|
224
|
+
new Request("http://ay.local/api/resize/zzzzzzzzzzzz-nope", { method: "POST" }),
|
|
225
|
+
);
|
|
226
|
+
expect(resize.status).toBe(403);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("still denies send on a VIEW-ONLY (r) share", async () => {
|
|
230
|
+
const res = await mk()(
|
|
231
|
+
new Request("http://ay.local/api/send", {
|
|
232
|
+
method: "POST",
|
|
233
|
+
body: JSON.stringify({ keyword: "1", msg: "x" }),
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
expect(res.status).toBe(403);
|
|
237
|
+
});
|
|
238
|
+
});
|