@openparachute/agent 0.2.2 → 0.2.3-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/transports/vault.ts +40 -22
- package/web/ui/dist/assets/index-5KEwEhfi.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
package/src/hub-jwt.test.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the agent-side hub-JWT adapter. These exercise the parts that
|
|
3
|
-
* DON'T need a live hub / JWKS endpoint: hub-origin resolution (env precedence →
|
|
4
|
-
* expose-state self-heal → loopback fallback), the audience constants, and the
|
|
5
|
-
* re-exported pure helpers (`looksLikeJwt`). Real signature/issuer/audience
|
|
6
|
-
* validation is scope-guard's own tested surface — we don't re-test it here and
|
|
7
|
-
* we never need a real JWKS.
|
|
8
|
-
*
|
|
9
|
-
* Audience constants (channel→agent rename, rule 1): the daemon now mints/
|
|
10
|
-
* validates `aud: "agent"` (`AGENT_AUDIENCE`); the pre-rename `aud: "channel"`
|
|
11
|
-
* (`CHANNEL_AUDIENCE`, deprecated) still validates during the dual-accept window
|
|
12
|
-
* via `ACCEPTED_AUDIENCES`. We assert both constants here. The dual-ACCEPT itself
|
|
13
|
-
* (a `channel`-aud token still validating) lives in `validateHubJwt`, which needs
|
|
14
|
-
* a live JWKS to exercise — that's scope-guard's tested surface, not re-tested here.
|
|
15
|
-
*
|
|
16
|
-
* The self-heal reads `<PARACHUTE_HOME>/expose-state.json`. Every case here
|
|
17
|
-
* points `PARACHUTE_HOME` at a fresh temp dir so the operator's real
|
|
18
|
-
* `~/.parachute/expose-state.json` can't leak into the loopback assertions.
|
|
19
|
-
*/
|
|
20
|
-
import { describe, test, expect, afterEach, beforeEach } from "bun:test";
|
|
21
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
-
import { tmpdir } from "node:os";
|
|
23
|
-
import { join } from "node:path";
|
|
24
|
-
import {
|
|
25
|
-
getHubOrigin,
|
|
26
|
-
AGENT_AUDIENCE,
|
|
27
|
-
CHANNEL_AUDIENCE,
|
|
28
|
-
ACCEPTED_AUDIENCES,
|
|
29
|
-
looksLikeJwt,
|
|
30
|
-
HubJwtError,
|
|
31
|
-
} from "./hub-jwt.ts";
|
|
32
|
-
|
|
33
|
-
const savedOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
34
|
-
const savedHome = process.env.PARACHUTE_HOME;
|
|
35
|
-
|
|
36
|
-
let home: string;
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
// Isolated, empty ecosystem root — no expose-state.json unless a case writes one.
|
|
40
|
-
home = mkdtempSync(join(tmpdir(), "agent-hubjwt-"));
|
|
41
|
-
process.env.PARACHUTE_HOME = home;
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
afterEach(() => {
|
|
45
|
-
if (savedOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
46
|
-
else process.env.PARACHUTE_HUB_ORIGIN = savedOrigin;
|
|
47
|
-
if (savedHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
48
|
-
else process.env.PARACHUTE_HOME = savedHome;
|
|
49
|
-
try {
|
|
50
|
-
rmSync(home, { recursive: true, force: true });
|
|
51
|
-
} catch {}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
function writeExposeState(obj: Record<string, unknown>): void {
|
|
55
|
-
writeFileSync(join(home, "expose-state.json"), JSON.stringify(obj));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
describe("getHubOrigin — env precedence", () => {
|
|
59
|
-
test("uses the env value when set", () => {
|
|
60
|
-
process.env.PARACHUTE_HUB_ORIGIN = "https://hub.example.com";
|
|
61
|
-
expect(getHubOrigin()).toBe("https://hub.example.com");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("strips a single trailing slash for a canonical form", () => {
|
|
65
|
-
process.env.PARACHUTE_HUB_ORIGIN = "https://hub.example.com/";
|
|
66
|
-
expect(getHubOrigin()).toBe("https://hub.example.com");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("env wins over expose-state (highest precedence)", () => {
|
|
70
|
-
process.env.PARACHUTE_HUB_ORIGIN = "https://env.example.com";
|
|
71
|
-
writeExposeState({ hubOrigin: "https://exposed.example.com" });
|
|
72
|
-
expect(getHubOrigin()).toBe("https://env.example.com");
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe("getHubOrigin — expose-state self-heal (agent#34)", () => {
|
|
77
|
-
test("reads expose-state.hubOrigin when env is unset", () => {
|
|
78
|
-
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
79
|
-
writeExposeState({ hubOrigin: "https://exposed.example.com" });
|
|
80
|
-
expect(getHubOrigin()).toBe("https://exposed.example.com");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("reads expose-state.hubOrigin when env is empty", () => {
|
|
84
|
-
process.env.PARACHUTE_HUB_ORIGIN = "";
|
|
85
|
-
writeExposeState({ hubOrigin: "https://exposed.example.com" });
|
|
86
|
-
expect(getHubOrigin()).toBe("https://exposed.example.com");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("synthesizes https://<canonicalFqdn> for older state files lacking hubOrigin", () => {
|
|
90
|
-
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
91
|
-
writeExposeState({ canonicalFqdn: "box.taildf9ce2.ts.net" });
|
|
92
|
-
expect(getHubOrigin()).toBe("https://box.taildf9ce2.ts.net");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("strips a trailing slash off the expose-state origin", () => {
|
|
96
|
-
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
97
|
-
writeExposeState({ hubOrigin: "https://exposed.example.com/" });
|
|
98
|
-
expect(getHubOrigin()).toBe("https://exposed.example.com");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("never self-heals to a loopback expose-state origin", () => {
|
|
102
|
-
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
103
|
-
writeExposeState({ hubOrigin: "http://127.0.0.1:1939" });
|
|
104
|
-
expect(getHubOrigin()).toBe("http://127.0.0.1:1939"); // loopback default, not a self-heal
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe("getHubOrigin — loopback fallback", () => {
|
|
109
|
-
test("falls back to loopback when env unset AND no expose-state file", () => {
|
|
110
|
-
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
111
|
-
expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("falls back to loopback when env empty AND no expose-state file", () => {
|
|
115
|
-
process.env.PARACHUTE_HUB_ORIGIN = "";
|
|
116
|
-
expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("falls back to loopback when expose-state has no usable origin", () => {
|
|
120
|
-
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
121
|
-
writeExposeState({ layer: "tailnet" }); // neither hubOrigin nor canonicalFqdn
|
|
122
|
-
expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("falls back to loopback when expose-state is malformed JSON", () => {
|
|
126
|
-
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
127
|
-
writeFileSync(join(home, "expose-state.json"), "{ not json");
|
|
128
|
-
expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("audience constants (channel→agent dual-accept, rule 1)", () => {
|
|
133
|
-
test("AGENT_AUDIENCE is the literal 'agent' (what the hub mints aud as now)", () => {
|
|
134
|
-
expect(AGENT_AUDIENCE).toBe("agent");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("CHANNEL_AUDIENCE is the deprecated legacy literal 'channel' (pre-rename tokens)", () => {
|
|
138
|
-
expect(CHANNEL_AUDIENCE).toBe("channel");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("ACCEPTED_AUDIENCES carries BOTH — new 'agent' + legacy 'channel' (the dual-accept set)", () => {
|
|
142
|
-
// The resource-server backstop: a token whose aud is neither (e.g. minted for
|
|
143
|
-
// a vault) is rejected; both transitional forms validate until live re-mint.
|
|
144
|
-
expect([...ACCEPTED_AUDIENCES]).toEqual(["agent", "channel"]);
|
|
145
|
-
expect(ACCEPTED_AUDIENCES).toContain(AGENT_AUDIENCE);
|
|
146
|
-
expect(ACCEPTED_AUDIENCES).toContain(CHANNEL_AUDIENCE);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe("re-exported helpers", () => {
|
|
151
|
-
test("looksLikeJwt recognizes the eyJ prefix", () => {
|
|
152
|
-
expect(looksLikeJwt("eyJhbGciOiJSUzI1NiJ9.payload.sig")).toBe(true);
|
|
153
|
-
expect(looksLikeJwt("opaque-shared-secret")).toBe(false);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("HubJwtError is the scope-guard error class", () => {
|
|
157
|
-
const err = new HubJwtError("issuer", "bad iss");
|
|
158
|
-
expect(err).toBeInstanceOf(Error);
|
|
159
|
-
expect(err.code).toBe("issuer");
|
|
160
|
-
});
|
|
161
|
-
});
|
package/src/jobs.test.ts
DELETED
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
-
import { validateJob, VaultJobStore, vaultTransportFor, type Job } from "./jobs.ts";
|
|
3
|
-
import { VaultTransport } from "./transports/vault.ts";
|
|
4
|
-
import type { Channel } from "./registry.ts";
|
|
5
|
-
import { TelegramTransport } from "./transports/telegram.ts";
|
|
6
|
-
|
|
7
|
-
const realFetch = globalThis.fetch;
|
|
8
|
-
afterEach(() => {
|
|
9
|
-
globalThis.fetch = realFetch;
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
function makeJob(over: Partial<Job> = {}): Job {
|
|
13
|
-
return {
|
|
14
|
-
id: "morning",
|
|
15
|
-
channel: "uni-dev",
|
|
16
|
-
message: "Run the morning weave",
|
|
17
|
-
schedule: { cron: "53 7 * * *", tz: "America/Los_Angeles" },
|
|
18
|
-
enabled: true,
|
|
19
|
-
createdAt: "2026-06-17T00:00:00.000Z",
|
|
20
|
-
...over,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe("validateJob (pure)", () => {
|
|
25
|
-
const isVault = (name: string): boolean | null => {
|
|
26
|
-
if (name === "uni-dev") return true;
|
|
27
|
-
if (name === "tele") return false;
|
|
28
|
-
return null;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
test("a well-formed vault job validates", () => {
|
|
32
|
-
expect(validateJob(makeJob(), isVault)).toEqual({ ok: true });
|
|
33
|
-
});
|
|
34
|
-
test("bad id (not a slug) rejected", () => {
|
|
35
|
-
const r = validateJob(makeJob({ id: "has spaces" }), isVault);
|
|
36
|
-
expect(r).toMatchObject({ ok: false });
|
|
37
|
-
expect((r as { error: string }).error).toMatch(/slug/);
|
|
38
|
-
});
|
|
39
|
-
test("empty message rejected", () => {
|
|
40
|
-
const r = validateJob(makeJob({ message: " " }), isVault);
|
|
41
|
-
expect((r as { error: string }).error).toMatch(/message/);
|
|
42
|
-
});
|
|
43
|
-
test("bad cron rejected with a field-naming message", () => {
|
|
44
|
-
const r = validateJob(makeJob({ schedule: { cron: "99 7 * * *" } }), isVault);
|
|
45
|
-
expect((r as { error: string }).error).toMatch(/invalid schedule.cron/);
|
|
46
|
-
});
|
|
47
|
-
test("missing schedule.cron rejected", () => {
|
|
48
|
-
const r = validateJob({ id: "x", channel: "uni-dev", message: "m" }, isVault);
|
|
49
|
-
expect((r as { error: string }).error).toMatch(/schedule.cron/);
|
|
50
|
-
});
|
|
51
|
-
test("bad tz rejected", () => {
|
|
52
|
-
const r = validateJob(makeJob({ schedule: { cron: "0 0 * * *", tz: "Mars/Olympus" } }), isVault);
|
|
53
|
-
expect((r as { error: string }).error).toMatch(/timezone/);
|
|
54
|
-
});
|
|
55
|
-
test("unknown channel rejected", () => {
|
|
56
|
-
const r = validateJob(makeJob({ channel: "ghost" }), isVault);
|
|
57
|
-
expect((r as { error: string }).error).toMatch(/unknown channel/);
|
|
58
|
-
});
|
|
59
|
-
test("non-vault channel rejected (the inject path needs a vault transport)", () => {
|
|
60
|
-
const r = validateJob(makeJob({ channel: "tele" }), isVault);
|
|
61
|
-
expect((r as { error: string }).error).toMatch(/not a vault channel/);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
/** Build a live channels map with one vault channel + (optionally) a telegram one. */
|
|
66
|
-
function channelsWithVault(): { channels: Map<string, Channel>; vault: VaultTransport } {
|
|
67
|
-
const vault = new VaultTransport({
|
|
68
|
-
vault: "default",
|
|
69
|
-
vaultUrl: "http://127.0.0.1:1940",
|
|
70
|
-
token: "write-token",
|
|
71
|
-
});
|
|
72
|
-
const channels = new Map<string, Channel>();
|
|
73
|
-
channels.set("uni-dev", {
|
|
74
|
-
name: "uni-dev",
|
|
75
|
-
transport: vault,
|
|
76
|
-
entry: { name: "uni-dev", transport: "vault", config: { vault: "default", token: "write-token" } },
|
|
77
|
-
});
|
|
78
|
-
const tele = new TelegramTransport({ token: "tg", name: "tele" });
|
|
79
|
-
channels.set("tele", {
|
|
80
|
-
name: "tele",
|
|
81
|
-
transport: tele,
|
|
82
|
-
entry: { name: "tele", transport: "telegram", config: { token: "tg" } },
|
|
83
|
-
});
|
|
84
|
-
return { channels, vault };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
describe("vaultTransportFor", () => {
|
|
88
|
-
test("resolves a vault channel to its transport; null for non-vault / unknown", () => {
|
|
89
|
-
const { channels, vault } = channelsWithVault();
|
|
90
|
-
expect(vaultTransportFor(channels, "uni-dev")).toBe(vault);
|
|
91
|
-
expect(vaultTransportFor(channels, "tele")).toBeNull();
|
|
92
|
-
expect(vaultTransportFor(channels, "ghost")).toBeNull();
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("VaultJobStore — vault-native CRUD", () => {
|
|
97
|
-
test("listAll queries each unique vault once + maps job notes to Jobs", async () => {
|
|
98
|
-
const { channels } = channelsWithVault();
|
|
99
|
-
const urls: string[] = [];
|
|
100
|
-
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
101
|
-
urls.push(String(url));
|
|
102
|
-
return new Response(
|
|
103
|
-
JSON.stringify([
|
|
104
|
-
{
|
|
105
|
-
id: "note-1",
|
|
106
|
-
content: "Run the weave",
|
|
107
|
-
metadata: {
|
|
108
|
-
channel: "uni-dev",
|
|
109
|
-
cron: "53 7 * * *",
|
|
110
|
-
tz: "America/Los_Angeles",
|
|
111
|
-
enabled: "true",
|
|
112
|
-
createdAt: "2026-06-17T00:00:00Z",
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
id: "note-2",
|
|
117
|
-
content: "Hourly ping",
|
|
118
|
-
metadata: { channel: "uni-dev", cron: "0 * * * *", enabled: "false" },
|
|
119
|
-
},
|
|
120
|
-
]),
|
|
121
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
122
|
-
);
|
|
123
|
-
}) as typeof fetch;
|
|
124
|
-
|
|
125
|
-
const store = new VaultJobStore(channels);
|
|
126
|
-
const jobs = await store.listAll();
|
|
127
|
-
// Only ONE vault transport in the map → queried exactly once (telegram is skipped).
|
|
128
|
-
expect(urls.filter((u) => u.includes("/api/notes")).length).toBe(1);
|
|
129
|
-
expect(urls[0]).toContain("tag=%23agent%2Fjob");
|
|
130
|
-
expect(jobs).toHaveLength(2);
|
|
131
|
-
expect(jobs[0]).toMatchObject({
|
|
132
|
-
id: "note-1",
|
|
133
|
-
channel: "uni-dev",
|
|
134
|
-
message: "Run the weave",
|
|
135
|
-
schedule: { cron: "53 7 * * *", tz: "America/Los_Angeles" },
|
|
136
|
-
enabled: true,
|
|
137
|
-
});
|
|
138
|
-
expect(jobs[1]!.enabled).toBe(false); // "false" string → disabled
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("listAll dedups by vault IDENTITY across SEPARATE transport instances sharing one vault (regression)", async () => {
|
|
142
|
-
// Two vault channels, each with its OWN VaultTransport INSTANCE pointing at the
|
|
143
|
-
// SAME vault "default" — the real shape (each channel constructs its own
|
|
144
|
-
// transport). Instance-identity dedup misses this; vaultKey() dedup must catch it.
|
|
145
|
-
// Caught live 2026-06-18: one job listed 3x with three channels on the default vault.
|
|
146
|
-
const cfg = { vault: "default", vaultUrl: "http://127.0.0.1:1940", token: "write-token" };
|
|
147
|
-
const channels = new Map<string, Channel>();
|
|
148
|
-
for (const name of ["uni-a", "uni-b"]) {
|
|
149
|
-
channels.set(name, {
|
|
150
|
-
name,
|
|
151
|
-
transport: new VaultTransport(cfg), // a DISTINCT instance per channel
|
|
152
|
-
entry: { name, transport: "vault", config: { vault: "default", token: "write-token" } },
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
const urls: string[] = [];
|
|
156
|
-
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
157
|
-
urls.push(String(url));
|
|
158
|
-
return new Response(
|
|
159
|
-
JSON.stringify([
|
|
160
|
-
{ id: "Channels/uni-a/jobs/j1", content: "do it", metadata: { channel: "uni-a", cron: "0 9 * * *", enabled: "true" } },
|
|
161
|
-
]),
|
|
162
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
163
|
-
);
|
|
164
|
-
}) as typeof fetch;
|
|
165
|
-
|
|
166
|
-
const store = new VaultJobStore(channels);
|
|
167
|
-
const jobs = await store.listAll();
|
|
168
|
-
// Queried the shared vault EXACTLY once (not once per channel), and the job
|
|
169
|
-
// appears EXACTLY once (not duplicated per channel that shares the vault).
|
|
170
|
-
expect(urls.filter((u) => u.includes("/api/notes")).length).toBe(1);
|
|
171
|
-
expect(jobs).toHaveLength(1);
|
|
172
|
-
expect(jobs[0]!.id).toBe("Channels/uni-a/jobs/j1");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("upsert writes a #agent/job note via the target channel's vault", async () => {
|
|
176
|
-
const { channels } = channelsWithVault();
|
|
177
|
-
const calls: { url: string; init: RequestInit }[] = [];
|
|
178
|
-
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
179
|
-
calls.push({ url: String(url), init: init ?? {} });
|
|
180
|
-
return new Response(JSON.stringify({ id: "Channels/uni-dev/jobs/morning" }), {
|
|
181
|
-
status: 201,
|
|
182
|
-
headers: { "content-type": "application/json" },
|
|
183
|
-
});
|
|
184
|
-
}) as typeof fetch;
|
|
185
|
-
|
|
186
|
-
const store = new VaultJobStore(channels);
|
|
187
|
-
const saved = await store.upsert(makeJob());
|
|
188
|
-
// `id` stays the operator slug; `noteId` is the vault note id for addressing.
|
|
189
|
-
expect(saved.id).toBe("morning");
|
|
190
|
-
expect(saved.noteId).toBe("Channels/uni-dev/jobs/morning");
|
|
191
|
-
expect(calls).toHaveLength(1);
|
|
192
|
-
const body = JSON.parse(String(calls[0]!.init.body));
|
|
193
|
-
expect(body.tags).toEqual(["#agent/job"]);
|
|
194
|
-
expect(body.path).toBe("Channels/uni-dev/jobs/morning");
|
|
195
|
-
expect(body.metadata.jobId).toBe("morning"); // slug persisted for stable display
|
|
196
|
-
expect(body.content).toBe("Run the morning weave");
|
|
197
|
-
expect(body.metadata).toMatchObject({
|
|
198
|
-
// CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
|
|
199
|
-
agent: "uni-dev",
|
|
200
|
-
cron: "53 7 * * *",
|
|
201
|
-
tz: "America/Los_Angeles",
|
|
202
|
-
enabled: "true",
|
|
203
|
-
createdAt: "2026-06-17T00:00:00.000Z",
|
|
204
|
-
});
|
|
205
|
-
expect(body.metadata.channel).toBeUndefined();
|
|
206
|
-
// nextRunAt is NEVER persisted.
|
|
207
|
-
expect(body.metadata.nextRunAt).toBeUndefined();
|
|
208
|
-
const headers = calls[0]!.init.headers as Record<string, string>;
|
|
209
|
-
expect(headers.authorization).toBe("Bearer write-token");
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("upsert to a non-vault channel throws", async () => {
|
|
213
|
-
const { channels } = channelsWithVault();
|
|
214
|
-
const store = new VaultJobStore(channels);
|
|
215
|
-
await expect(store.upsert(makeJob({ channel: "tele" }))).rejects.toThrow(/not a live vault channel/);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("remove DELETEs the note by id via the channel's vault", async () => {
|
|
219
|
-
const { channels } = channelsWithVault();
|
|
220
|
-
const calls: { url: string; method?: string }[] = [];
|
|
221
|
-
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
222
|
-
calls.push({ url: String(url), method: init?.method });
|
|
223
|
-
return new Response(null, { status: 204 });
|
|
224
|
-
}) as typeof fetch;
|
|
225
|
-
const store = new VaultJobStore(channels);
|
|
226
|
-
await store.remove("Channels/uni-dev/jobs/morning", "uni-dev");
|
|
227
|
-
expect(calls).toHaveLength(1);
|
|
228
|
-
expect(calls[0]!.method).toBe("DELETE");
|
|
229
|
-
expect(calls[0]!.url).toContain("/api/notes/");
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test("patch PATCHes bookkeeping metadata onto the note", async () => {
|
|
233
|
-
const { channels } = channelsWithVault();
|
|
234
|
-
const calls: { url: string; init: RequestInit }[] = [];
|
|
235
|
-
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
236
|
-
calls.push({ url: String(url), init: init ?? {} });
|
|
237
|
-
return new Response(null, { status: 200 });
|
|
238
|
-
}) as typeof fetch;
|
|
239
|
-
const store = new VaultJobStore(channels);
|
|
240
|
-
await store.patch("note-1", "uni-dev", { lastRunAt: "2026-06-17T11:00:00Z", lastStatus: "ok" });
|
|
241
|
-
expect(calls[0]!.init.method).toBe("PATCH");
|
|
242
|
-
const body = JSON.parse(String(calls[0]!.init.body));
|
|
243
|
-
expect(body.metadata).toEqual({ lastRunAt: "2026-06-17T11:00:00Z", lastStatus: "ok" });
|
|
244
|
-
});
|
|
245
|
-
});
|
package/src/mcp-http.test.ts
DELETED
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP MCP tests — the per-channel session registry, the wake push, write-scope
|
|
3
|
-
* enforcement on tool dispatch, and the daemon's /mcp/<channel> auth gate.
|
|
4
|
-
*
|
|
5
|
-
* Like daemon.test.ts these need NO live hub: the no-token path in requireScope
|
|
6
|
-
* short-circuits before any JWKS fetch, and the registry/push/tool tests drive
|
|
7
|
-
* the in-memory session set directly with fake servers + a fake transport.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, test, expect, afterEach, beforeAll, afterAll } from "bun:test";
|
|
10
|
-
import {
|
|
11
|
-
pushToChannel,
|
|
12
|
-
pushPermissionVerdict,
|
|
13
|
-
mcpSessionCount,
|
|
14
|
-
assertMcpSdkStreamContract,
|
|
15
|
-
_resetSessionsForTest,
|
|
16
|
-
} from "./mcp-http.ts";
|
|
17
|
-
import { createFetchHandler } from "./daemon.ts";
|
|
18
|
-
import { ClientRegistry } from "./routing.ts";
|
|
19
|
-
import { HttpUiTransport } from "./transports/http-ui.ts";
|
|
20
|
-
import type { Channel } from "./registry.ts";
|
|
21
|
-
import type { Transport, ReplyArgs } from "./transport.ts";
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Registry + wake — drive the session set directly via a registration shim.
|
|
25
|
-
//
|
|
26
|
-
// pushToChannel iterates the channel's session set and calls
|
|
27
|
-
// server.notification. We register fake sessions by reaching the same internal
|
|
28
|
-
// maps the way handleMcp's onsessioninitialized would. Since those maps are
|
|
29
|
-
// module-private, we exercise them through the public surface: register via a
|
|
30
|
-
// tiny test-only re-entry that mirrors registerSession. To keep this hermetic
|
|
31
|
-
// without exporting internals, we register by spinning handleMcp is overkill;
|
|
32
|
-
// instead we assert push reaches a registered session through a captured
|
|
33
|
-
// server.notification. We use the exported _registerForTest below.
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
import { _registerSessionForTest, _unregisterSessionForTest } from "./mcp-http.ts";
|
|
37
|
-
|
|
38
|
-
interface FakeServer {
|
|
39
|
-
notes: Array<{ method: string; params: unknown }>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function fakeSession(): { server: { notification: (n: unknown) => void }; captured: FakeServer } {
|
|
43
|
-
const captured: FakeServer = { notes: [] };
|
|
44
|
-
const server = {
|
|
45
|
-
notification(n: unknown) {
|
|
46
|
-
captured.notes.push(n as { method: string; params: unknown });
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
return { server, captured };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
afterEach(() => {
|
|
53
|
-
_resetSessionsForTest();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("per-channel MCP session registry + wake push", () => {
|
|
57
|
-
test("pushToChannel reaches a session on A and NOT one on B", () => {
|
|
58
|
-
const a = fakeSession();
|
|
59
|
-
const b = fakeSession();
|
|
60
|
-
_registerSessionForTest("A", "sid-a", a.server as never, ["agent:read", "agent:write"]);
|
|
61
|
-
_registerSessionForTest("B", "sid-b", b.server as never, ["agent:read"]);
|
|
62
|
-
|
|
63
|
-
const delivered = pushToChannel("A", "hello A", { foo: "bar" });
|
|
64
|
-
expect(delivered).toBe(1);
|
|
65
|
-
expect(a.captured.notes).toHaveLength(1);
|
|
66
|
-
expect(a.captured.notes[0]!.method).toBe("notifications/claude/agent");
|
|
67
|
-
expect((a.captured.notes[0]!.params as { content: string }).content).toBe("hello A");
|
|
68
|
-
// Source is stamped + caller meta merged.
|
|
69
|
-
expect((a.captured.notes[0]!.params as { meta: Record<string, string> }).meta).toMatchObject({
|
|
70
|
-
source: "parachute-agent",
|
|
71
|
-
foo: "bar",
|
|
72
|
-
});
|
|
73
|
-
// B got nothing.
|
|
74
|
-
expect(b.captured.notes).toHaveLength(0);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("two sessions on the same channel both get the wake", () => {
|
|
78
|
-
const a1 = fakeSession();
|
|
79
|
-
const a2 = fakeSession();
|
|
80
|
-
_registerSessionForTest("A", "sid-a1", a1.server as never, ["agent:read"]);
|
|
81
|
-
_registerSessionForTest("A", "sid-a2", a2.server as never, ["agent:read"]);
|
|
82
|
-
expect(mcpSessionCount("A")).toBe(2);
|
|
83
|
-
const delivered = pushToChannel("A", "broadcast", {});
|
|
84
|
-
expect(delivered).toBe(2);
|
|
85
|
-
expect(a1.captured.notes).toHaveLength(1);
|
|
86
|
-
expect(a2.captured.notes).toHaveLength(1);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("pushPermissionVerdict pushes the permission method", () => {
|
|
90
|
-
const a = fakeSession();
|
|
91
|
-
_registerSessionForTest("A", "sid-a", a.server as never, ["agent:read"]);
|
|
92
|
-
const delivered = pushPermissionVerdict("A", { request_id: "r1", behavior: "allow" });
|
|
93
|
-
expect(delivered).toBe(1);
|
|
94
|
-
expect(a.captured.notes[0]!.method).toBe("notifications/claude/agent/permission");
|
|
95
|
-
expect(a.captured.notes[0]!.params).toMatchObject({ request_id: "r1", behavior: "allow" });
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("push to an unknown channel delivers to nobody (0)", () => {
|
|
99
|
-
expect(pushToChannel("nope", "x", {})).toBe(0);
|
|
100
|
-
expect(pushPermissionVerdict("nope", { request_id: "r", behavior: "deny" })).toBe(0);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("a streamless session (registered, no live GET stream) is NOT counted as delivered", () => {
|
|
104
|
-
// The bug this guards: a session that POSTed `initialize` but hasn't opened (or
|
|
105
|
-
// has dropped) its standalone GET stream is registered, but the SDK silently
|
|
106
|
-
// drops any notification to it. If pushToChannel counted it, the daemon would
|
|
107
|
-
// advance the channel's delivery mark and the message would be lost.
|
|
108
|
-
const a = fakeSession();
|
|
109
|
-
_registerSessionForTest("A", "sid-streamless", a.server as never, ["agent:read"], {
|
|
110
|
-
streamless: true,
|
|
111
|
-
});
|
|
112
|
-
expect(mcpSessionCount("A")).toBe(1); // it IS registered…
|
|
113
|
-
expect(pushToChannel("A", "into the void", {})).toBe(0); // …but NOT deliverable
|
|
114
|
-
expect(a.captured.notes).toHaveLength(0); // not even attempted
|
|
115
|
-
// Permission verdicts honor the same gate.
|
|
116
|
-
expect(pushPermissionVerdict("A", { request_id: "r", behavior: "allow" })).toBe(0);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("pushToChannel counts only the live-stream sessions in a mixed set", () => {
|
|
120
|
-
const live = fakeSession();
|
|
121
|
-
const dead = fakeSession();
|
|
122
|
-
_registerSessionForTest("A", "sid-live", live.server as never, ["agent:read"]);
|
|
123
|
-
_registerSessionForTest("A", "sid-streamless", dead.server as never, ["agent:read"], {
|
|
124
|
-
streamless: true,
|
|
125
|
-
});
|
|
126
|
-
expect(mcpSessionCount("A")).toBe(2); // both registered
|
|
127
|
-
expect(pushToChannel("A", "hi", {})).toBe(1); // only the one with a live stream
|
|
128
|
-
expect(live.captured.notes).toHaveLength(1);
|
|
129
|
-
expect(dead.captured.notes).toHaveLength(0);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("the installed MCP SDK still keys the standalone GET stream as we expect (contract guard)", () => {
|
|
133
|
-
// If this fails, the SDK renamed the internal sessionHasLivePushStream reads —
|
|
134
|
-
// HTTP-MCP delivery would silently break. Catch it here, not in production.
|
|
135
|
-
expect(assertMcpSdkStreamContract()).toBe(true);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("mcpSessionCount tracks registration + reset", () => {
|
|
139
|
-
expect(mcpSessionCount("A")).toBe(0);
|
|
140
|
-
const a = fakeSession();
|
|
141
|
-
_registerSessionForTest("A", "sid-a", a.server as never, ["agent:read"]);
|
|
142
|
-
expect(mcpSessionCount("A")).toBe(1);
|
|
143
|
-
_resetSessionsForTest();
|
|
144
|
-
expect(mcpSessionCount("A")).toBe(0);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("unregister cleans up the session and drops the empty channel set (no leak)", () => {
|
|
148
|
-
_registerSessionForTest("A", "sid-a1", fakeSession().server as never, ["agent:read"]);
|
|
149
|
-
_registerSessionForTest("A", "sid-a2", fakeSession().server as never, ["agent:read"]);
|
|
150
|
-
expect(mcpSessionCount("A")).toBe(2);
|
|
151
|
-
_unregisterSessionForTest("A", "sid-a1");
|
|
152
|
-
expect(mcpSessionCount("A")).toBe(1); // the other session survives
|
|
153
|
-
_unregisterSessionForTest("A", "sid-a2");
|
|
154
|
-
expect(mcpSessionCount("A")).toBe(0); // empty set removed — no orphaned channel entry
|
|
155
|
-
// a push to the now-cleaned channel reaches nobody
|
|
156
|
-
expect(pushToChannel("A", "hi", {})).toBe(0);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// ---------------------------------------------------------------------------
|
|
161
|
-
// Tool dispatch — a reply tool call routes to the channel's transport.
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
|
|
164
|
-
describe("tool dispatch routes to the channel's transport + enforces write scope", () => {
|
|
165
|
-
function fakeTransport(): { transport: Transport; replies: ReplyArgs[] } {
|
|
166
|
-
const replies: ReplyArgs[] = [];
|
|
167
|
-
const transport: Transport = {
|
|
168
|
-
kind: "fake",
|
|
169
|
-
async start() {},
|
|
170
|
-
async stop() {},
|
|
171
|
-
async reply(args: ReplyArgs) {
|
|
172
|
-
replies.push(args);
|
|
173
|
-
return { sent: ["msg-1"] };
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
return { transport, replies };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
test("a write-scoped reply call reaches transport.reply with the channel + args", async () => {
|
|
180
|
-
const { transport, replies } = fakeTransport();
|
|
181
|
-
const { callReplyTool } = await import("./mcp-http.ts");
|
|
182
|
-
const result = await callReplyTool("A", transport, ["agent:read", "agent:write"], {
|
|
183
|
-
text: "hi there",
|
|
184
|
-
chat_id: "42",
|
|
185
|
-
});
|
|
186
|
-
expect(result.isError).toBeUndefined();
|
|
187
|
-
expect(replies).toHaveLength(1);
|
|
188
|
-
expect(replies[0]).toMatchObject({ channel: "A", text: "hi there", meta: { chat_id: "42" } });
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("a read-only token cannot call reply (write scope enforced)", async () => {
|
|
192
|
-
const { transport, replies } = fakeTransport();
|
|
193
|
-
const { callReplyTool } = await import("./mcp-http.ts");
|
|
194
|
-
const result = await callReplyTool("A", transport, ["agent:read"], { text: "blocked" });
|
|
195
|
-
expect(result.isError).toBe(true);
|
|
196
|
-
expect(replies).toHaveLength(0);
|
|
197
|
-
expect((result.content[0] as { text: string }).text).toContain("agent:write");
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("DUAL-ACCEPT: a pre-rename token with the LEGACY channel:write scope can still call reply", async () => {
|
|
201
|
-
// The write-tool gate must dual-accept (grantsScope), not raw-includes — else
|
|
202
|
-
// a pre-rename token connects + is woken but silently can't send (channel#…).
|
|
203
|
-
const { transport, replies } = fakeTransport();
|
|
204
|
-
const { callReplyTool } = await import("./mcp-http.ts");
|
|
205
|
-
const result = await callReplyTool("A", transport, ["channel:read", "channel:write"], {
|
|
206
|
-
text: "legacy-send",
|
|
207
|
-
});
|
|
208
|
-
expect(result.isError).toBeUndefined();
|
|
209
|
-
expect(replies).toHaveLength(1);
|
|
210
|
-
expect(replies[0]).toMatchObject({ channel: "A", text: "legacy-send" });
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
// Daemon auth gate — POST /mcp/<channel> with no bearer → 401 (pre-JWKS).
|
|
216
|
-
// ---------------------------------------------------------------------------
|
|
217
|
-
|
|
218
|
-
describe("daemon /mcp/<channel> auth gate", () => {
|
|
219
|
-
let server: ReturnType<typeof Bun.serve>;
|
|
220
|
-
let base: string;
|
|
221
|
-
|
|
222
|
-
beforeAll(async () => {
|
|
223
|
-
const registry = new ClientRegistry();
|
|
224
|
-
const transport = new HttpUiTransport({ channel: "ui1" });
|
|
225
|
-
await transport.start({ channel: "ui1", emit: () => {}, emitPermissionVerdict: () => {} });
|
|
226
|
-
const channels = new Map<string, Channel>([
|
|
227
|
-
["ui1", { name: "ui1", transport, entry: { name: "ui1", transport: "http-ui" } }],
|
|
228
|
-
]);
|
|
229
|
-
server = Bun.serve({
|
|
230
|
-
port: 0,
|
|
231
|
-
hostname: "127.0.0.1",
|
|
232
|
-
idleTimeout: 0,
|
|
233
|
-
fetch: createFetchHandler(channels, registry),
|
|
234
|
-
});
|
|
235
|
-
base = `http://127.0.0.1:${server.port}`;
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
afterAll(() => {
|
|
239
|
-
server.stop(true);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test("POST /mcp/ui1 with an initialize body and no bearer → 401", async () => {
|
|
243
|
-
const res = await fetch(`${base}/mcp/ui1`, {
|
|
244
|
-
method: "POST",
|
|
245
|
-
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
246
|
-
body: JSON.stringify({
|
|
247
|
-
jsonrpc: "2.0",
|
|
248
|
-
id: 1,
|
|
249
|
-
method: "initialize",
|
|
250
|
-
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "0" } },
|
|
251
|
-
}),
|
|
252
|
-
});
|
|
253
|
-
expect(res.status).toBe(401);
|
|
254
|
-
expect(((await res.json()) as { error: string }).error).toBe("unauthorized");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("POST /mcp/unknown-channel → 404 (channel miss, before auth body)", async () => {
|
|
258
|
-
const res = await fetch(`${base}/mcp/does-not-exist`, {
|
|
259
|
-
method: "POST",
|
|
260
|
-
headers: { "content-type": "application/json" },
|
|
261
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
262
|
-
});
|
|
263
|
-
expect(res.status).toBe(404);
|
|
264
|
-
});
|
|
265
|
-
});
|