@cueloop/daemon 0.1.0-alpha.39 → 0.1.0-alpha.40
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/package.json +3 -2
- package/src/capabilities.test.ts +37 -0
- package/src/capabilities.ts +30 -0
- package/src/client.ts +7 -0
- package/src/herdr-pane.test.ts +22 -0
- package/src/herdr-pane.ts +34 -13
- package/src/herdr-split.test.ts +103 -0
- package/src/herdr-split.ts +84 -0
- package/src/server.ts +16 -1
- package/src/validate.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.40",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
"./client": "./src/client.ts",
|
|
23
23
|
"./review": "./src/review.ts",
|
|
24
24
|
"./herdr-pane": "./src/herdr-pane.ts",
|
|
25
|
+
"./herdr-split": "./src/herdr-split.ts",
|
|
25
26
|
"./share-blob": "./src/share-blob.ts"
|
|
26
27
|
},
|
|
27
28
|
"publishConfig": {
|
|
28
29
|
"access": "public"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.40",
|
|
32
33
|
"valibot": "^1.4.2"
|
|
33
34
|
},
|
|
34
35
|
"engines": {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { asDaemonRole, roleAllowsMethod } from "./capabilities";
|
|
3
|
+
|
|
4
|
+
describe("roleAllowsMethod", () => {
|
|
5
|
+
test("the owner may call every verb", () => {
|
|
6
|
+
// Assert
|
|
7
|
+
for (const method of ["session.resolve", "session.submitRevision", "session.delete"])
|
|
8
|
+
expect(roleAllowsMethod("owner", method)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a capped role may read and annotate but not escalate", () => {
|
|
12
|
+
// Assert - allowed
|
|
13
|
+
for (const method of ["session.get", "session.list", "session.wait", "session.annotate"])
|
|
14
|
+
expect(roleAllowsMethod("agent", method)).toBe(true);
|
|
15
|
+
// Assert - denied
|
|
16
|
+
for (const method of [
|
|
17
|
+
"session.resolve",
|
|
18
|
+
"session.submitRevision",
|
|
19
|
+
"session.setWorkingCopy",
|
|
20
|
+
"session.delete",
|
|
21
|
+
"session.setShareId",
|
|
22
|
+
])
|
|
23
|
+
expect(roleAllowsMethod("agent", method)).toBe(false);
|
|
24
|
+
expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("asDaemonRole", () => {
|
|
29
|
+
test("passes owner and collaborator through and caps anything unknown to agent", () => {
|
|
30
|
+
// Assert
|
|
31
|
+
expect(asDaemonRole("owner")).toBe("owner");
|
|
32
|
+
expect(asDaemonRole("collaborator")).toBe("collaborator");
|
|
33
|
+
expect(asDaemonRole("agent")).toBe("agent");
|
|
34
|
+
expect(asDaemonRole("root")).toBe("agent");
|
|
35
|
+
expect(asDaemonRole(undefined)).toBe("agent");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon role capabilities: which socket methods a connection may call. The
|
|
3
|
+
* owner (the local planner) may call everything; a collaborator or a review-side
|
|
4
|
+
* agent is capped to reading the session and adding annotations, so a bring-your-
|
|
5
|
+
* own agent literally cannot resolve, rewrite, cut, share, or delete (ADR 0009).
|
|
6
|
+
* This is the single source of truth the server dispatch enforces.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type DaemonRole = "owner" | "collaborator" | "agent";
|
|
10
|
+
|
|
11
|
+
/** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
|
|
12
|
+
const NON_OWNER_METHODS = new Set<string>([
|
|
13
|
+
"daemon.ping",
|
|
14
|
+
"daemon.hello",
|
|
15
|
+
"events.subscribe",
|
|
16
|
+
"session.get",
|
|
17
|
+
"session.list",
|
|
18
|
+
"session.wait",
|
|
19
|
+
"session.annotate",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Whether `role` may call `method`. The owner may call anything; others are capped to read + annotate. */
|
|
23
|
+
export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
|
|
24
|
+
return role === "owner" || NON_OWNER_METHODS.has(method);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse an untrusted role string, defaulting unknown values to the capped "agent". */
|
|
28
|
+
export function asDaemonRole(value: unknown): DaemonRole {
|
|
29
|
+
return value === "owner" || value === "collaborator" ? value : "agent";
|
|
30
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
WorkspaceKey,
|
|
15
15
|
} from "@cueloop/schema";
|
|
16
16
|
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
17
|
+
import type { DaemonRole } from "./capabilities";
|
|
17
18
|
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
18
19
|
import { cueloopHome, socketPath } from "./paths";
|
|
19
20
|
|
|
@@ -23,6 +24,8 @@ export interface ConnectOptions {
|
|
|
23
24
|
home?: string;
|
|
24
25
|
/** Spawn the daemon when the socket is not alive. */
|
|
25
26
|
autostart?: boolean;
|
|
27
|
+
/** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
|
|
28
|
+
role?: DaemonRole;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
@@ -65,11 +68,13 @@ export class DaemonClient implements SessionClient {
|
|
|
65
68
|
private nextId = 1;
|
|
66
69
|
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
67
70
|
private closed = false;
|
|
71
|
+
private role: DaemonRole = "owner";
|
|
68
72
|
|
|
69
73
|
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
70
74
|
const home = options.home ?? cueloopHome();
|
|
71
75
|
const path = socketPath(home);
|
|
72
76
|
const client = new DaemonClient();
|
|
77
|
+
client.role = options.role ?? "owner";
|
|
73
78
|
try {
|
|
74
79
|
await client.dial(path);
|
|
75
80
|
return client;
|
|
@@ -119,6 +124,8 @@ export class DaemonClient implements SessionClient {
|
|
|
119
124
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
120
125
|
// only to fail later, so a ping is the actual handshake.
|
|
121
126
|
await this.request("daemon.ping", {}, 2_000);
|
|
127
|
+
// Cap this connection's role for the daemon's capability gate (owner is the default).
|
|
128
|
+
if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
onEvent(listener: (event: EventFrame) => void): () => void {
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -192,6 +192,28 @@ describe("openHerdrPaneForReview", () => {
|
|
|
192
192
|
expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
test("a recall failure (stale daemon) still opens a fresh tab", async () => {
|
|
196
|
+
// Arrange - a daemon predating the herdr-tab verbs rejects the recall
|
|
197
|
+
const stub = makeStub("gated-recall-fail");
|
|
198
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
199
|
+
const persistence: HerdrTabPersistence = {
|
|
200
|
+
herdrGetTab: async () => {
|
|
201
|
+
throw new Error("unknown method herdr.getTab");
|
|
202
|
+
},
|
|
203
|
+
herdrSetTab: async () => {},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Act
|
|
207
|
+
await openHerdrPaneForReview(newSession({ id: "ses_stale" }), persistence, env);
|
|
208
|
+
|
|
209
|
+
// Assert - the recall throwing must not abort the open
|
|
210
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
211
|
+
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
212
|
+
"pane send-text w1:p2 cueloop ses_stale",
|
|
213
|
+
"pane send-keys w1:p2 enter",
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
195
217
|
test("reopens and re-records when the recorded pane is dead", async () => {
|
|
196
218
|
// Arrange
|
|
197
219
|
const stub = makeStub("gated-dead", false);
|
package/src/herdr-pane.ts
CHANGED
|
@@ -103,20 +103,41 @@ export async function openHerdrPaneForReview(
|
|
|
103
103
|
): Promise<void> {
|
|
104
104
|
const herdr = detectHerdr(env);
|
|
105
105
|
if (!herdr) return;
|
|
106
|
+
const recorded = await recallHerdrTab(persistence, session.id);
|
|
107
|
+
if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
|
|
108
|
+
focusHerdrTab(herdr.binPath, recorded.tabId);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const opened = openHerdrPane({
|
|
112
|
+
sessionId: session.id,
|
|
113
|
+
cwd: session.artifact.meta.cwd ?? process.cwd(),
|
|
114
|
+
binPath: herdr.binPath,
|
|
115
|
+
label: session.artifact.meta.title ?? session.id,
|
|
116
|
+
});
|
|
117
|
+
if (opened) await rememberHerdrTab(persistence, session.id, opened);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The recorded handle, or null on any recall failure - a stale daemon must still open a fresh tab, not abort. */
|
|
121
|
+
async function recallHerdrTab(
|
|
122
|
+
persistence: HerdrTabPersistence,
|
|
123
|
+
sessionId: string,
|
|
124
|
+
): Promise<HerdrTabHandle | null> {
|
|
106
125
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
return await persistence.herdrGetTab(sessionId);
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Record the handle for later liveness checks; a store failure loses dedup, never the already-open tab. */
|
|
133
|
+
async function rememberHerdrTab(
|
|
134
|
+
persistence: HerdrTabPersistence,
|
|
135
|
+
sessionId: string,
|
|
136
|
+
handle: HerdrTabHandle,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
try {
|
|
139
|
+
await persistence.herdrSetTab(sessionId, handle);
|
|
119
140
|
} catch {
|
|
120
|
-
//
|
|
141
|
+
// stale daemon: the tab is already shown; only the liveness-dedup handle is lost
|
|
121
142
|
}
|
|
122
143
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** launchHarnessInSplit: a stubbed herdr binary logs its argv, and the launcher splits the current pane, runs the harness in the new pane, and optionally types the plan-context seed - a no-op outside herdr. */
|
|
2
|
+
|
|
3
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { launchHarnessInSplit } from "./herdr-split";
|
|
8
|
+
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-split-"));
|
|
10
|
+
|
|
11
|
+
/** A stub herdr binary that logs argv and prints the pane-split result (pane_id of the new split). */
|
|
12
|
+
function makeStub(name: string, splitOk = true): { binPath: string; logPath: string } {
|
|
13
|
+
const logPath = join(dir, `${name}.log`);
|
|
14
|
+
const binPath = join(dir, `${name}.sh`);
|
|
15
|
+
const splitResult = splitOk
|
|
16
|
+
? `printf '{"result":{"pane":{"pane_id":"w1:p9"}}}'`
|
|
17
|
+
: `printf '{"result":{}}'`;
|
|
18
|
+
writeFileSync(
|
|
19
|
+
binPath,
|
|
20
|
+
`#!/bin/sh
|
|
21
|
+
printf '%s\\n' "$*" >> "${logPath}"
|
|
22
|
+
if [ "$1" = "pane" ] && [ "$2" = "split" ]; then ${splitResult}; fi
|
|
23
|
+
exit 0
|
|
24
|
+
`,
|
|
25
|
+
);
|
|
26
|
+
chmodSync(binPath, 0o755);
|
|
27
|
+
return { binPath, logPath };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readLines(logPath: string): string[] {
|
|
31
|
+
if (!existsSync(logPath)) return [];
|
|
32
|
+
return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("launchHarnessInSplit", () => {
|
|
36
|
+
test("splits the current pane and runs the harness in the new pane", () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
const stub = makeStub("run");
|
|
39
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo/work" }, env);
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
45
|
+
expect(launched).toBeTrue();
|
|
46
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
47
|
+
"pane split w1:p1 --direction right --ratio 0.4 --cwd /repo/work",
|
|
48
|
+
"pane send-text w1:p9 cc",
|
|
49
|
+
"pane send-keys w1:p9 enter",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("types the plan-context seed into the split after launching", () => {
|
|
54
|
+
// Arrange
|
|
55
|
+
const stub = makeStub("seeded");
|
|
56
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
57
|
+
|
|
58
|
+
// Act
|
|
59
|
+
launchHarnessInSplit({ command: "pi", cwd: "/repo", seedText: "read plan ses_1" }, env);
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
62
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
63
|
+
"pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
|
|
64
|
+
"pane send-text w1:p9 pi",
|
|
65
|
+
"pane send-keys w1:p9 enter",
|
|
66
|
+
"pane send-text w1:p9 read plan ses_1",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("no-op outside herdr - returns false and spawns nothing", () => {
|
|
71
|
+
// Arrange
|
|
72
|
+
const stub = makeStub("outside");
|
|
73
|
+
|
|
74
|
+
// Act
|
|
75
|
+
const launched = launchHarnessInSplit(
|
|
76
|
+
{ command: "cc", cwd: "/repo" },
|
|
77
|
+
{ HERDR_BIN_PATH: stub.binPath },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
81
|
+
expect(launched).toBeFalse();
|
|
82
|
+
expect(existsSync(stub.logPath)).toBeFalse();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns false when the split yields no pane id", () => {
|
|
86
|
+
// Arrange
|
|
87
|
+
const stub = makeStub("nopane", false);
|
|
88
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
89
|
+
|
|
90
|
+
// Act
|
|
91
|
+
const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo" }, env);
|
|
92
|
+
|
|
93
|
+
// Assert - the split ran but no harness was launched into a missing pane
|
|
94
|
+
expect(launched).toBeFalse();
|
|
95
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
96
|
+
"pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterAll(() => {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
103
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launches a bring-your-own review harness (claude code / pi / codex) in a herdr
|
|
3
|
+
* split beside the current pane - the Agent rail's "run it right here" spawn, so
|
|
4
|
+
* a reviewer chats with an agent about the plan without leaving the review tab.
|
|
5
|
+
* Best-effort and herdr-only: a no-op returning false outside herdr or on any
|
|
6
|
+
* spawn failure, each spawn bounded by a timeout.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
|
|
10
|
+
|
|
11
|
+
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
12
|
+
|
|
13
|
+
/** How much of the current pane the launched harness split takes (herdr --ratio). */
|
|
14
|
+
const HARNESS_SPLIT_RATIO = "0.4";
|
|
15
|
+
|
|
16
|
+
export interface LaunchHarnessInSplitOptions {
|
|
17
|
+
/** The harness launch command run in the split, e.g. "cc", "pi", "codex". */
|
|
18
|
+
command: string;
|
|
19
|
+
/** Working directory for the split - the reviewed session's cwd. */
|
|
20
|
+
cwd: string;
|
|
21
|
+
/**
|
|
22
|
+
* A plan-context briefing typed into the split after the harness command, left
|
|
23
|
+
* unsubmitted for the reviewer to send. Best-effort: on a slow harness start it
|
|
24
|
+
* may reach the shell instead, so the launch never depends on it.
|
|
25
|
+
*/
|
|
26
|
+
seedText?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split the current pane and run the harness in it. Returns true when the split
|
|
31
|
+
* opened, false on any best-effort bail-out. No-op outside herdr.
|
|
32
|
+
*/
|
|
33
|
+
export function launchHarnessInSplit(
|
|
34
|
+
options: LaunchHarnessInSplitOptions,
|
|
35
|
+
env: HerdrEnv = process.env,
|
|
36
|
+
): boolean {
|
|
37
|
+
const herdr = detectHerdr(env);
|
|
38
|
+
if (!herdr) return false;
|
|
39
|
+
try {
|
|
40
|
+
const split = Bun.spawnSync(
|
|
41
|
+
[
|
|
42
|
+
herdr.binPath,
|
|
43
|
+
"pane",
|
|
44
|
+
"split",
|
|
45
|
+
herdr.paneId,
|
|
46
|
+
"--direction",
|
|
47
|
+
"right",
|
|
48
|
+
"--ratio",
|
|
49
|
+
HARNESS_SPLIT_RATIO,
|
|
50
|
+
"--cwd",
|
|
51
|
+
options.cwd,
|
|
52
|
+
],
|
|
53
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
54
|
+
);
|
|
55
|
+
if (split.exitCode !== 0) return false;
|
|
56
|
+
const parsed = JSON.parse(split.stdout.toString()) as {
|
|
57
|
+
result?: { pane?: { pane_id?: string } };
|
|
58
|
+
};
|
|
59
|
+
const paneId = parsed.result?.pane?.pane_id;
|
|
60
|
+
if (!paneId) return false;
|
|
61
|
+
sendText(herdr.binPath, paneId, options.command);
|
|
62
|
+
sendKeys(herdr.binPath, paneId, "enter");
|
|
63
|
+
if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sendText(binPath: string, paneId: string, text: string): void {
|
|
71
|
+
Bun.spawnSync([binPath, "pane", "send-text", paneId, text], {
|
|
72
|
+
stdout: "ignore",
|
|
73
|
+
stderr: "ignore",
|
|
74
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sendKeys(binPath: string, paneId: string, keys: string): void {
|
|
79
|
+
Bun.spawnSync([binPath, "pane", "send-keys", paneId, keys], {
|
|
80
|
+
stdout: "ignore",
|
|
81
|
+
stderr: "ignore",
|
|
82
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
83
|
+
});
|
|
84
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "node:fs";
|
|
17
17
|
import { DaemonCore, type DaemonEvent } from "./api";
|
|
18
18
|
import { DaemonError } from "./errors";
|
|
19
|
+
import { roleAllowsMethod, type DaemonRole } from "./capabilities";
|
|
19
20
|
import { isKnownMethod, parseParams } from "./validate";
|
|
20
21
|
import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
|
|
21
22
|
import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
@@ -23,6 +24,8 @@ import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
|
23
24
|
interface Connection {
|
|
24
25
|
write(data: string): void;
|
|
25
26
|
subscribed: boolean;
|
|
27
|
+
/** Capability role for this connection; the owner until a daemon.hello caps it. */
|
|
28
|
+
role: DaemonRole;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface DaemonOptions {
|
|
@@ -136,7 +139,11 @@ export class DaemonServer {
|
|
|
136
139
|
socket: {
|
|
137
140
|
open: (socket) => {
|
|
138
141
|
const writer = new BackpressureWriter(socket);
|
|
139
|
-
const connection: Connection = {
|
|
142
|
+
const connection: Connection = {
|
|
143
|
+
write: (data) => writer.write(data),
|
|
144
|
+
subscribed: false,
|
|
145
|
+
role: "owner",
|
|
146
|
+
};
|
|
140
147
|
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
141
148
|
this.connections.add(connection);
|
|
142
149
|
this.scheduleIdleCheck();
|
|
@@ -219,10 +226,18 @@ export class DaemonServer {
|
|
|
219
226
|
if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
|
|
220
227
|
throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
|
|
221
228
|
}
|
|
229
|
+
// Capability gate: a capped role (a review-side agent) cannot escalate past
|
|
230
|
+
// read + annotate, whatever verb it sends.
|
|
231
|
+
if (!roleAllowsMethod(connection.role, request.method)) {
|
|
232
|
+
throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
|
|
233
|
+
}
|
|
222
234
|
const core = this.core;
|
|
223
235
|
switch (request.method) {
|
|
224
236
|
case "daemon.ping":
|
|
225
237
|
return { pid: process.pid };
|
|
238
|
+
case "daemon.hello":
|
|
239
|
+
connection.role = parseParams("daemon.hello", request.params).role;
|
|
240
|
+
return {};
|
|
226
241
|
case "daemon.shutdown":
|
|
227
242
|
setTimeout(() => {
|
|
228
243
|
this.stop();
|
package/src/validate.ts
CHANGED
|
@@ -153,6 +153,7 @@ export const Params = {
|
|
|
153
153
|
}),
|
|
154
154
|
"events.subscribe": v.object({}),
|
|
155
155
|
"daemon.ping": v.object({}),
|
|
156
|
+
"daemon.hello": v.object({ role: v.picklist(["owner", "collaborator", "agent"]) }),
|
|
156
157
|
"daemon.shutdown": v.object({}),
|
|
157
158
|
// herdr adapter scratch: the review's opened tab, kept off the session record.
|
|
158
159
|
"herdr.getTab": v.object({ id: SessionId }),
|