@agentvault/claude-bridge 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/arming.d.ts +44 -14
- package/dist/bridge.d.ts +56 -3
- package/dist/config.d.ts +3 -2
- package/dist/index.js +327 -130
- package/dist/router.d.ts +14 -4
- package/dist/service/spec.d.ts.map +1 -1
- package/dist/session.d.ts +29 -0
- package/dist/worker-permission.d.ts +5 -0
- package/dist/worker-queue.d.ts +5 -0
- package/package.json +3 -3
package/dist/arming.d.ts
CHANGED
|
@@ -10,24 +10,54 @@ export declare class ArmingState {
|
|
|
10
10
|
private pending;
|
|
11
11
|
private consumed;
|
|
12
12
|
/**
|
|
13
|
-
* Replace the armed set. Use
|
|
14
|
-
*
|
|
13
|
+
* Replace the armed set. Use for the host-local launch seed (AV_ARM_ROOM) —
|
|
14
|
+
* itself a host-local action and therefore allowed to arm — and as the
|
|
15
|
+
* primitive `applyAuthoritative` below delegates to for non-shell workers.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* The B-pure posture (Task 4) treats the backend connect/live
|
|
18
|
+
* `arming_snapshot` the same way — see `applyAuthoritative`'s docstring for
|
|
19
|
+
* the current arm-from-snapshot rationale. Shell-capable (OS-isolated)
|
|
20
|
+
* workers do NOT use this path; they stay on `reconcileDisarm` (disarm-only,
|
|
21
|
+
* see below) until #20.
|
|
21
22
|
*/
|
|
22
23
|
applySnapshot(roomIds: string[]): void;
|
|
23
24
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
25
|
+
* Set the armed set to EXACTLY `intendedArmed`: arms every room in the list
|
|
26
|
+
* that isn't already armed, and disarms every currently-armed room that is
|
|
27
|
+
* absent from it. This is the AUTHORITATIVE arm-from-snapshot path (Task 4,
|
|
28
|
+
* reversing the prior disarm-only safeguard for non-shell workers).
|
|
29
|
+
*
|
|
30
|
+
* Why arming from the snapshot is now safe: `intendedArmed` is derived
|
|
31
|
+
* server-side from `devices.work_allowed`, and that flag can only be flipped
|
|
32
|
+
* by `PUT /devices/{id}/work_allowed`, which REJECTS any device-bound caller
|
|
33
|
+
* outright (C1 guard — a human account owner only; an agent, including a
|
|
34
|
+
* compromised one, cannot self-authorize). So "arm from snapshot" no longer
|
|
35
|
+
* means "a web session can arm a worker" in the old adversarial sense — it
|
|
36
|
+
* means "the verified owner's grant is applied end-to-end, live, without
|
|
37
|
+
* requiring a separate host-local approval step for every reconnect." This is
|
|
38
|
+
* the accepted B-pure posture; see #20 to harden it further with local
|
|
39
|
+
* approval for non-shell workers too. Shell-capable (OS-isolated) workers are
|
|
40
|
+
* carved out of this path entirely (C2) — see the shell-gate in bridge.ts —
|
|
41
|
+
* because a shell worker armed by a web flag is a live RCE surface even under
|
|
42
|
+
* an honest owner (a compromised owner web session, or a compromised backend,
|
|
43
|
+
* would get arbitrary code execution). Those workers stay on `reconcileDisarm`
|
|
44
|
+
* (disarm-only) until #20 delivers local approval for them as well.
|
|
45
|
+
*/
|
|
46
|
+
applyAuthoritative(intendedArmed: string[]): void;
|
|
47
|
+
/**
|
|
48
|
+
* Disarm-only reconciliation from the backend connect/live snapshot. Disarms
|
|
49
|
+
* any currently-armed room whose intent is no longer armed (i.e. NOT in
|
|
50
|
+
* `intendedArmed`) — so a disarm issued while the bridge was offline still
|
|
51
|
+
* takes effect on reconnect — but NEVER arms a room. Used for OS-isolated
|
|
52
|
+
* (shell-capable) workers ONLY (C2): a shell worker must never be armed by a
|
|
53
|
+
* web-originated flag, because a compromised owner session or backend would
|
|
54
|
+
* translate directly into host code execution. Those workers can be armed
|
|
55
|
+
* only by the host-local launch seed (`AV_ARM_ROOM`, via `applySnapshot`)
|
|
56
|
+
* until #20 delivers a local-approval path for them too. Non-shell workers
|
|
57
|
+
* use `applyAuthoritative` instead (Task 4) — see its docstring. Returns the
|
|
58
|
+
* rooms that were disarmed. In-memory arming survives a WS reconnect, so a
|
|
59
|
+
* genuinely-armed room is unaffected here; only a full process restart
|
|
60
|
+
* (which clears this state) requires re-arming.
|
|
31
61
|
*/
|
|
32
62
|
reconcileDisarm(intendedArmed: string[]): string[];
|
|
33
63
|
isArmed(roomId: string): boolean;
|
package/dist/bridge.d.ts
CHANGED
|
@@ -3,6 +3,13 @@ export interface RoomMessage {
|
|
|
3
3
|
roomId: string;
|
|
4
4
|
senderName: string;
|
|
5
5
|
plaintext: string;
|
|
6
|
+
/** True when the sender is another agent (SecureChannel derives this from the
|
|
7
|
+
* room roster). Used to decide reply expectation: a human/owner speaking in a
|
|
8
|
+
* room always expects a reply; agent-authored traffic does not (the agent may
|
|
9
|
+
* stay silent, and it prevents agent↔agent reply loops). Absent on older
|
|
10
|
+
* payloads / tests → treated as "not an agent" is NOT assumed: replyExpected is
|
|
11
|
+
* set only when we affirmatively know the sender is a human (=== false). */
|
|
12
|
+
senderIsAgent?: boolean;
|
|
6
13
|
}
|
|
7
14
|
/** Slice 2 Plan C arming events (SecureChannel re-emits these from the backend). */
|
|
8
15
|
export interface ArmRequested {
|
|
@@ -15,6 +22,12 @@ export interface DisarmEvent {
|
|
|
15
22
|
}
|
|
16
23
|
export interface ArmingSnapshot {
|
|
17
24
|
roomIds: string[];
|
|
25
|
+
/** Task 3 (P1 bridge): the device-level owner grant this snapshot carries
|
|
26
|
+
* (backend `devices.work_allowed`, Task 2). Optional so older/test payloads
|
|
27
|
+
* without it don't break — treated as `false` (fail-closed) when absent. */
|
|
28
|
+
workAllowed?: boolean;
|
|
29
|
+
/** #627: owner's "Remove all gates" grant. Absent ⇒ gates stay in place. */
|
|
30
|
+
gatesRemoved?: boolean;
|
|
18
31
|
}
|
|
19
32
|
/** Metadata SecureChannel attaches to a 1:1 `message` event. `roomId` is set only
|
|
20
33
|
* when the `message` actually originated in a room (handled by room_message), so
|
|
@@ -37,6 +50,11 @@ export interface RoomChannel {
|
|
|
37
50
|
on(ev: "arm_requested", cb: (e: ArmRequested) => void): unknown;
|
|
38
51
|
on(ev: "disarm", cb: (e: DisarmEvent) => void): unknown;
|
|
39
52
|
on(ev: "arming_snapshot", cb: (e: ArmingSnapshot) => void): unknown;
|
|
53
|
+
/** Task 3: SecureChannel's connection lifecycle signal. It never emits a
|
|
54
|
+
* bare `"close"` — a WS drop is reported as `state` moving to
|
|
55
|
+
* `"disconnected"` (or `"error"`), and a completed reconnect as `"ready"`
|
|
56
|
+
* (both the dedicated `state` value AND the separate `"ready"` event). */
|
|
57
|
+
on(ev: "state", cb: (s: string) => void): unknown;
|
|
40
58
|
sendToRoom(roomId: string, text: string): Promise<void>;
|
|
41
59
|
send(text: string): Promise<void>;
|
|
42
60
|
/** Optional so legacy/test fakes without arming support don't break wiring. */
|
|
@@ -59,9 +77,12 @@ export interface RoomSession {
|
|
|
59
77
|
/** `reply` is the immutable reply sink captured for THIS message (see
|
|
60
78
|
* ActiveTarget.snapshotReply) — the session invokes it when Claude answers.
|
|
61
79
|
* `opts.autoReplyOnText` (set for 1:1 DMs) makes the session fall back to
|
|
62
|
-
* sending plain assistant text when the model never calls the say tool (#416).
|
|
80
|
+
* sending plain assistant text when the model never calls the say tool (#416).
|
|
81
|
+
* `opts.replyExpected` requests that same fallback for a room turn (a human/owner
|
|
82
|
+
* sender) WITHOUT enabling tools — decoupled from autoReplyOnText on purpose. */
|
|
63
83
|
push(text: string, reply?: (text: string) => Promise<void>, opts?: {
|
|
64
84
|
autoReplyOnText?: boolean;
|
|
85
|
+
replyExpected?: boolean;
|
|
65
86
|
armed?: () => boolean;
|
|
66
87
|
}): void;
|
|
67
88
|
}
|
|
@@ -148,11 +169,43 @@ export declare function wireBridge(channel: RoomChannel, session: RoomSession, t
|
|
|
148
169
|
roomFilter?: string;
|
|
149
170
|
armRoom?: boolean;
|
|
150
171
|
log?: (msg: string) => void;
|
|
151
|
-
/** Slice 2 Plan C (T11): worker capability + local-approval marker dir. */
|
|
152
|
-
worker?: boolean;
|
|
172
|
+
/** Slice 2 Plan C (T11): worker capability (derived from workspaceDir) + local-approval marker dir. */
|
|
153
173
|
workspaceDir?: string;
|
|
154
174
|
dataDir?: string;
|
|
155
175
|
/** Override the approval poll interval (ms) — for tests. */
|
|
156
176
|
approvalPollMs?: number;
|
|
177
|
+
/**
|
|
178
|
+
* Task 4 (C2 shell-gate): true when this worker runs Bash unconfined
|
|
179
|
+
* (AV_WORKER_OS_ISOLATED=1 — see config.ts/index.ts, threaded from the
|
|
180
|
+
* session's `osIsolated`). When true, the `arming_snapshot` handler stays
|
|
181
|
+
* disarm-only regardless of B1/worker-capability — a shell worker is not
|
|
182
|
+
* armed by a web-originated flag (RCE risk).
|
|
183
|
+
*
|
|
184
|
+
* #627 EXCEPTION: unless the owner has removed all gates. That grant is an
|
|
185
|
+
* explicit, recorded decision to accept exactly this risk, so it lifts C2;
|
|
186
|
+
* revoking it disarms the rooms the web armed under it. See the
|
|
187
|
+
* `applyArmingSnapshot` comment block for the full rationale. Without the
|
|
188
|
+
* grant, C2 is unchanged.
|
|
189
|
+
*
|
|
190
|
+
* Falls back to reading the env directly only if the caller doesn't thread this.
|
|
191
|
+
*/
|
|
192
|
+
osIsolated?: boolean;
|
|
193
|
+
/**
|
|
194
|
+
* Task 3 (P1 bridge): out-param that receives the live `workAllowed()`
|
|
195
|
+
* getter once, synchronously, during wiring. Callers (index.ts, Task 4)
|
|
196
|
+
* capture it to thread device-level tool-capability into the router +
|
|
197
|
+
* session gate. Kept as an out-param rather than changing wireBridge's
|
|
198
|
+
* return type so every existing `const arming = wireBridge(...)` caller
|
|
199
|
+
* (index.ts, arming-authoritative.test.ts, bridge.test.ts) stays intact.
|
|
200
|
+
*/
|
|
201
|
+
onWorkAllowed?: (getter: () => boolean) => void;
|
|
202
|
+
/**
|
|
203
|
+
* #627: out-param that receives the live `gatesRemoved()` getter once,
|
|
204
|
+
* synchronously, during wiring — same shape as `onWorkAllowed` above, and
|
|
205
|
+
* for the same reason (keeps wireBridge's return type stable so existing
|
|
206
|
+
* callers are untouched). index.ts captures it and threads it into the
|
|
207
|
+
* worker session's gate.
|
|
208
|
+
*/
|
|
209
|
+
onGatesRemoved?: (getter: () => boolean) => void;
|
|
157
210
|
}): ArmingState;
|
|
158
211
|
//# sourceMappingURL=bridge.d.ts.map
|
package/dist/config.d.ts
CHANGED
|
@@ -14,8 +14,9 @@ export interface BridgeConfig {
|
|
|
14
14
|
roomFilter?: string;
|
|
15
15
|
model?: string;
|
|
16
16
|
systemPrompt?: string;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/** Per-agent filesystem sandbox (Facet-A fence). Always set: auto-defaults
|
|
18
|
+
* to a sibling of the key/data dir (H-2) unless AV_WORKSPACE_DIR overrides it. */
|
|
19
|
+
workspaceDir: string;
|
|
19
20
|
permissionMode: "auto" | "acceptEdits" | "bypassPermissions";
|
|
20
21
|
/** Slice 2: arm the pinned room (roomFilter) for worker tools on room turns.
|
|
21
22
|
* Off by default. Valid only with worker + roomFilter + workspaceDir. */
|
package/dist/index.js
CHANGED
|
@@ -10,8 +10,8 @@ var __export = (target, all) => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
// src/config.ts
|
|
13
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
14
|
-
import { join as join5 } from "node:path";
|
|
13
|
+
import { existsSync, readFileSync as readFileSync2, mkdirSync, chmodSync } from "node:fs";
|
|
14
|
+
import { join as join5, resolve as resolve2, sep, dirname } from "node:path";
|
|
15
15
|
function hasRecoverableBackup(dataDir) {
|
|
16
16
|
try {
|
|
17
17
|
const parsed = JSON.parse(readFileSync2(join5(dataDir, BACKUP_FILE), "utf-8"));
|
|
@@ -38,8 +38,22 @@ function resolveDataDir(env) {
|
|
|
38
38
|
function loadConfig(env, argv = []) {
|
|
39
39
|
const { dataDir, source: dataDirSource } = resolveDataDir(env);
|
|
40
40
|
const inviteToken = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "") || env.AV_INVITE_TOKEN || "";
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
const workspaceDir = env.AV_WORKSPACE_DIR || join5(dirname(dataDir), "workspaces", slugify2(env.AV_AGENT_NAME ?? "claude"));
|
|
42
|
+
const wsReal = resolve2(workspaceDir);
|
|
43
|
+
const ddReal = resolve2(dataDir);
|
|
44
|
+
if (wsReal === ddReal || wsReal.startsWith(ddReal + sep) || ddReal.startsWith(wsReal + sep)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`AV_WORKSPACE_DIR (${workspaceDir}) must be disjoint from the data dir (${dataDir})`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(workspaceDir, { recursive: true, mode: 448 });
|
|
51
|
+
chmodSync(workspaceDir, 448);
|
|
52
|
+
} catch (e7) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Failed to create/secure the agent workspace at ${workspaceDir} (override with AV_WORKSPACE_DIR): ${e7.message}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
43
57
|
const osIsolated = env.AV_WORKER_OS_ISOLATED === "1" || env.AV_WORKER_OS_ISOLATED === "true";
|
|
44
58
|
const PERMISSION_MODES = ["auto", "acceptEdits", "bypassPermissions"];
|
|
45
59
|
const permissionMode = env.AV_PERMISSION_MODE ?? "auto";
|
|
@@ -48,16 +62,8 @@ function loadConfig(env, argv = []) {
|
|
|
48
62
|
`invalid AV_PERMISSION_MODE: ${env.AV_PERMISSION_MODE} (expected one of ${PERMISSION_MODES.join(", ")})`
|
|
49
63
|
);
|
|
50
64
|
}
|
|
51
|
-
if (worker && !workspaceDir) {
|
|
52
|
-
throw new Error(
|
|
53
|
-
"worker mode requires AV_WORKSPACE_DIR (the project directory the agent works in)"
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
65
|
const armRoom = env.AV_ARM_ROOM === "1" || env.AV_ARM_ROOM === "true";
|
|
57
66
|
if (armRoom) {
|
|
58
|
-
if (!worker) {
|
|
59
|
-
throw new Error("AV_ARM_ROOM requires worker mode (AV_WORKER=1)");
|
|
60
|
-
}
|
|
61
67
|
if (!env.AV_ROOM_ID) {
|
|
62
68
|
throw new Error("AV_ARM_ROOM requires a pinned room (AV_ROOM_ID) \u2014 the armed collaboration room");
|
|
63
69
|
}
|
|
@@ -82,7 +88,6 @@ function loadConfig(env, argv = []) {
|
|
|
82
88
|
roomFilter: env.AV_ROOM_ID || void 0,
|
|
83
89
|
model: env.AV_CLAUDE_MODEL || void 0,
|
|
84
90
|
systemPrompt: env.AV_SYSTEM_PROMPT || void 0,
|
|
85
|
-
worker,
|
|
86
91
|
workspaceDir,
|
|
87
92
|
permissionMode,
|
|
88
93
|
armRoom,
|
|
@@ -99,7 +104,7 @@ var init_config = __esm({
|
|
|
99
104
|
});
|
|
100
105
|
|
|
101
106
|
// src/service/launcher.ts
|
|
102
|
-
import { dirname as
|
|
107
|
+
import { dirname as dirname3 } from "node:path";
|
|
103
108
|
function sq2(s10) {
|
|
104
109
|
return `'${s10.replace(/'/g, `'\\''`)}'`;
|
|
105
110
|
}
|
|
@@ -124,7 +129,7 @@ function renderLauncher(opts) {
|
|
|
124
129
|
return lines.join("\n") + "\n";
|
|
125
130
|
}
|
|
126
131
|
function writeLauncher(deps, spec) {
|
|
127
|
-
deps.mkdir(
|
|
132
|
+
deps.mkdir(dirname3(spec.launcherPath));
|
|
128
133
|
deps.writeFile(spec.launcherPath, spec.launcherScript);
|
|
129
134
|
deps.chmod(spec.launcherPath, 493);
|
|
130
135
|
}
|
|
@@ -158,7 +163,6 @@ function buildServiceSpec(cfg, opts) {
|
|
|
158
163
|
AV_API_URL: cfg.apiUrl,
|
|
159
164
|
AV_PERMISSION_MODE: cfg.permissionMode
|
|
160
165
|
};
|
|
161
|
-
if (cfg.worker) env.AV_WORKER = "1";
|
|
162
166
|
if (cfg.workspaceDir) env.AV_WORKSPACE_DIR = cfg.workspaceDir;
|
|
163
167
|
if (cfg.roomFilter) env.AV_ROOM_ID = cfg.roomFilter;
|
|
164
168
|
if (cfg.armRoom) env.AV_ARM_ROOM = "1";
|
|
@@ -171,7 +175,7 @@ function buildServiceSpec(cfg, opts) {
|
|
|
171
175
|
launcherPath,
|
|
172
176
|
launcherScript: renderLauncher({ installPath: opts.path, entrypoint: opts.entrypoint }),
|
|
173
177
|
env,
|
|
174
|
-
workingDir: cfg.
|
|
178
|
+
workingDir: cfg.workspaceDir ? cfg.workspaceDir : opts.home,
|
|
175
179
|
logOut: join7(cfg.dataDir, "logs", "bridge.log"),
|
|
176
180
|
logErr: join7(cfg.dataDir, "logs", "bridge.error.log"),
|
|
177
181
|
keepAliveUnlessCleanExit: true,
|
|
@@ -187,7 +191,7 @@ var init_spec = __esm({
|
|
|
187
191
|
});
|
|
188
192
|
|
|
189
193
|
// src/service/launchd.ts
|
|
190
|
-
import { dirname as
|
|
194
|
+
import { dirname as dirname4, join as join8 } from "node:path";
|
|
191
195
|
function esc3(s10) {
|
|
192
196
|
return s10.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
193
197
|
}
|
|
@@ -241,9 +245,9 @@ var init_launchd = __esm({
|
|
|
241
245
|
}
|
|
242
246
|
install(spec) {
|
|
243
247
|
const path2 = this.plistPath(spec.label);
|
|
244
|
-
this.deps.mkdir(
|
|
248
|
+
this.deps.mkdir(dirname4(spec.logOut));
|
|
245
249
|
writeLauncher(this.deps, spec);
|
|
246
|
-
this.deps.mkdir(
|
|
250
|
+
this.deps.mkdir(dirname4(path2));
|
|
247
251
|
this.deps.writeFile(path2, renderLaunchdPlist(spec));
|
|
248
252
|
const svc = `${this.domain()}/${spec.label}`;
|
|
249
253
|
this.deps.exec("launchctl", ["bootout", svc]);
|
|
@@ -277,7 +281,7 @@ var init_launchd = __esm({
|
|
|
277
281
|
});
|
|
278
282
|
|
|
279
283
|
// src/service/systemd.ts
|
|
280
|
-
import { dirname as
|
|
284
|
+
import { dirname as dirname5, join as join9 } from "node:path";
|
|
281
285
|
function q5(s10) {
|
|
282
286
|
const esc4 = s10.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/%/g, "%%");
|
|
283
287
|
return `"${esc4}"`;
|
|
@@ -318,9 +322,9 @@ var init_systemd = __esm({
|
|
|
318
322
|
}
|
|
319
323
|
install(spec) {
|
|
320
324
|
const path2 = this.unitPath(spec.label);
|
|
321
|
-
this.deps.mkdir(
|
|
325
|
+
this.deps.mkdir(dirname5(spec.logOut));
|
|
322
326
|
writeLauncher(this.deps, spec);
|
|
323
|
-
this.deps.mkdir(
|
|
327
|
+
this.deps.mkdir(dirname5(path2));
|
|
324
328
|
this.deps.writeFile(path2, renderSystemdUnit(spec));
|
|
325
329
|
this.deps.exec("loginctl", ["enable-linger", this.deps.user]);
|
|
326
330
|
this.deps.exec("systemctl", ["--user", "daemon-reload"]);
|
|
@@ -345,7 +349,7 @@ var init_systemd = __esm({
|
|
|
345
349
|
|
|
346
350
|
// src/service/backend.ts
|
|
347
351
|
import { spawnSync } from "node:child_process";
|
|
348
|
-
import { mkdirSync as
|
|
352
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2, rmSync as rmSync3, existsSync as existsSync4, chmodSync as chmodSync2 } from "node:fs";
|
|
349
353
|
import { userInfo } from "node:os";
|
|
350
354
|
function defaultDeps() {
|
|
351
355
|
return {
|
|
@@ -357,8 +361,8 @@ function defaultDeps() {
|
|
|
357
361
|
return { code: r7.status ?? 1, stdout: r7.stdout ?? "" };
|
|
358
362
|
},
|
|
359
363
|
writeFile: (p2, d10) => writeFileSync2(p2, d10),
|
|
360
|
-
mkdir: (p2) =>
|
|
361
|
-
chmod: (p2, m6) =>
|
|
364
|
+
mkdir: (p2) => mkdirSync4(p2, { recursive: true }),
|
|
365
|
+
chmod: (p2, m6) => chmodSync2(p2, m6),
|
|
362
366
|
rm: (p2) => rmSync3(p2, { force: true }),
|
|
363
367
|
exists: (p2) => existsSync4(p2)
|
|
364
368
|
};
|
|
@@ -589,7 +593,7 @@ var init_libsodium_sumo = __esm2({
|
|
|
589
593
|
}
|
|
590
594
|
}
|
|
591
595
|
_Module = Module;
|
|
592
|
-
Module.ready = new Promise(function(
|
|
596
|
+
Module.ready = new Promise(function(resolve32, reject) {
|
|
593
597
|
var Module2 = _Module;
|
|
594
598
|
Module2.onAbort = reject;
|
|
595
599
|
Module2.print = function(what) {
|
|
@@ -601,7 +605,7 @@ var init_libsodium_sumo = __esm2({
|
|
|
601
605
|
Module2.onRuntimeInitialized = function() {
|
|
602
606
|
try {
|
|
603
607
|
Module2._crypto_secretbox_keybytes();
|
|
604
|
-
|
|
608
|
+
resolve32();
|
|
605
609
|
} catch (err2) {
|
|
606
610
|
reject(err2);
|
|
607
611
|
}
|
|
@@ -54446,8 +54450,8 @@ var init_mutex = __esm2({
|
|
|
54446
54450
|
}
|
|
54447
54451
|
async lock() {
|
|
54448
54452
|
let releaseLock;
|
|
54449
|
-
const nextLock = new Promise((
|
|
54450
|
-
releaseLock =
|
|
54453
|
+
const nextLock = new Promise((resolve32) => {
|
|
54454
|
+
releaseLock = resolve32;
|
|
54451
54455
|
});
|
|
54452
54456
|
const previousLock = __classPrivateFieldGet(this, _Mutex_locked, "f");
|
|
54453
54457
|
__classPrivateFieldSet(this, _Mutex_locked, nextLock, "f");
|
|
@@ -65246,6 +65250,58 @@ var init_channel = __esm2({
|
|
|
65246
65250
|
}
|
|
65247
65251
|
return void 0;
|
|
65248
65252
|
}
|
|
65253
|
+
/**
|
|
65254
|
+
* True when a conversation is NOT a genuine 1:1 owner DM — i.e. it belongs to a
|
|
65255
|
+
* room OR an A2A channel — and therefore must NEVER surface to the native worker
|
|
65256
|
+
* tool-gate as a no-roomId `message` (the gate treats that as an authenticated
|
|
65257
|
+
* owner DM, tool-enabled, bypassing arming). This is the predicate every fallback
|
|
65258
|
+
* emit guard uses (MLS 1:1, DR-delivery queue, HTTP poll).
|
|
65259
|
+
*
|
|
65260
|
+
* Resolves from LOCAL persisted state — BOTH `_persisted.rooms[*].conversationIds`
|
|
65261
|
+
* and `_persisted.a2aChannels[*].conversationId` — and accepts EITHER the
|
|
65262
|
+
* conversation id or the conversation-group id, so a convGroupId-only room/A2A
|
|
65263
|
+
* payload is still caught while a genuine convGroupId-only 1:1 shared-group DM
|
|
65264
|
+
* (present in neither map) still returns false and emits.
|
|
65265
|
+
*
|
|
65266
|
+
* Some fallback guards additionally honor an AUTHORITATIVE server routing field as
|
|
65267
|
+
* a backstop for the cold-local-state window — but WHICH field each carries differs
|
|
65268
|
+
* per endpoint, so do NOT assume a blanket "backend always populates room_id/
|
|
65269
|
+
* a2a_channel_id" at a new call site:
|
|
65270
|
+
* - HTTP poll (`/devices/{id}/messages`): server `room_id` populated for room-backed
|
|
65271
|
+
* convs (conv_room_map). No `a2a_channel_id` on this endpoint → A2A caught locally.
|
|
65272
|
+
* - DR-delivery (`/dr/delivery` -> dr_delivery_service.pull_pending): server `room_id`
|
|
65273
|
+
* populated (from the conversation); NO `a2a_channel_id` (DrDeliveryQueue is
|
|
65274
|
+
* 1:1-only, never A2A). room_id is null in practice today but real for any future
|
|
65275
|
+
* room-over-DR row.
|
|
65276
|
+
* - MLS 1:1 (`_handleMessageMLS`): room/A2A are routed away UPSTREAM at the
|
|
65277
|
+
* MLS-delivery dispatcher (authoritative `room_id`/`a2a_channel_id`) before this
|
|
65278
|
+
* handler; the fields are absent on `data` here, so this predicate is the belt.
|
|
65279
|
+
* The MLS-delivery *queue* endpoint (mls_delivery_service.pull_pending) is the one that
|
|
65280
|
+
* returns all of room_id/a2a_channel_id/conversation_group_id — but that feeds the
|
|
65281
|
+
* dispatcher, not these emit guards directly.
|
|
65282
|
+
*
|
|
65283
|
+
* No fail-closed drop-unknown guard is used: that would risk dropping a legitimate
|
|
65284
|
+
* 1:1 owner DM that arrives before room/A2A hydration (a North-Star violation). The
|
|
65285
|
+
* reachable residual (truly-cold local state AND the server omitting its routing field
|
|
65286
|
+
* on a room/A2A row) is empty today because the only paths that carry room/A2A rows
|
|
65287
|
+
* (poll + MLS dispatcher) always populate the field; DR-delivery and sync are
|
|
65288
|
+
* structurally 1:1-only.
|
|
65289
|
+
*/
|
|
65290
|
+
_isNonDmConversation(convId, convGroupId) {
|
|
65291
|
+
const ids = [convId, convGroupId].filter((x22) => !!x22);
|
|
65292
|
+
if (ids.length === 0) return false;
|
|
65293
|
+
if (this._persisted?.rooms) {
|
|
65294
|
+
for (const room of Object.values(this._persisted.rooms)) {
|
|
65295
|
+
if (room.conversationIds?.some((c22) => ids.includes(c22))) return true;
|
|
65296
|
+
}
|
|
65297
|
+
}
|
|
65298
|
+
if (this._persisted?.a2aChannels) {
|
|
65299
|
+
for (const ch2 of Object.values(this._persisted.a2aChannels)) {
|
|
65300
|
+
if (ch2.conversationId && ids.includes(ch2.conversationId)) return true;
|
|
65301
|
+
}
|
|
65302
|
+
}
|
|
65303
|
+
return false;
|
|
65304
|
+
}
|
|
65249
65305
|
/**
|
|
65250
65306
|
* Get recent message history for a specific room, for LLM context injection.
|
|
65251
65307
|
* Returns the last N messages tagged with `room:{roomId}`.
|
|
@@ -65499,7 +65555,7 @@ var init_channel = __esm2({
|
|
|
65499
65555
|
*/
|
|
65500
65556
|
sendActivitySpan(spanData) {
|
|
65501
65557
|
if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
|
|
65502
|
-
const pluginVersion = true ? "0.23.
|
|
65558
|
+
const pluginVersion = true ? "0.23.7" : "0.0.0-dev";
|
|
65503
65559
|
const agentName = this.config.agentName ?? "Agent";
|
|
65504
65560
|
const resource = {
|
|
65505
65561
|
"service.name": "agentvault-agent",
|
|
@@ -65575,7 +65631,7 @@ var init_channel = __esm2({
|
|
|
65575
65631
|
* Optional timeout rejects with an Error.
|
|
65576
65632
|
*/
|
|
65577
65633
|
waitForDecision(decisionId, timeoutMs) {
|
|
65578
|
-
return new Promise((
|
|
65634
|
+
return new Promise((resolve32, reject) => {
|
|
65579
65635
|
let timer = null;
|
|
65580
65636
|
const handler = (plaintext, metadata) => {
|
|
65581
65637
|
if (metadata.messageType !== "decision_response") return;
|
|
@@ -65584,7 +65640,7 @@ var init_channel = __esm2({
|
|
|
65584
65640
|
if (parsed.decision?.decision_id === decisionId) {
|
|
65585
65641
|
if (timer) clearTimeout(timer);
|
|
65586
65642
|
this.removeListener("message", handler);
|
|
65587
|
-
|
|
65643
|
+
resolve32({
|
|
65588
65644
|
decision_id: parsed.decision.decision_id,
|
|
65589
65645
|
selected_option_id: parsed.decision.selected_option_id,
|
|
65590
65646
|
resolved_at: parsed.decision.resolved_at,
|
|
@@ -67116,7 +67172,7 @@ var init_channel = __esm2({
|
|
|
67116
67172
|
agentVersion: this.config.agentVersion ?? "0.0.0",
|
|
67117
67173
|
// __AV_VERSION__ is injected by esbuild from package.json at build time.
|
|
67118
67174
|
// Falls back to "0.0.0-dev" in non-bundled contexts (tests).
|
|
67119
|
-
pluginVersion: true ? "0.23.
|
|
67175
|
+
pluginVersion: true ? "0.23.7" : "0.0.0-dev"
|
|
67120
67176
|
});
|
|
67121
67177
|
this._telemetryReporter.startAutoFlush(3e4);
|
|
67122
67178
|
}
|
|
@@ -67366,7 +67422,11 @@ var init_channel = __esm2({
|
|
|
67366
67422
|
this.emit("disarm", { roomId: data.data?.room_id });
|
|
67367
67423
|
}
|
|
67368
67424
|
if (data.event === "arming_snapshot") {
|
|
67369
|
-
this.emit("arming_snapshot", {
|
|
67425
|
+
this.emit("arming_snapshot", {
|
|
67426
|
+
workAllowed: data.data?.work_allowed === true,
|
|
67427
|
+
gatesRemoved: data.data?.gates_removed === true,
|
|
67428
|
+
roomIds: data.data?.room_ids ?? []
|
|
67429
|
+
});
|
|
67370
67430
|
}
|
|
67371
67431
|
if (data.event === "policy_blocked") {
|
|
67372
67432
|
this.emit("policy_blocked", data.data);
|
|
@@ -67426,7 +67486,7 @@ var init_channel = __esm2({
|
|
|
67426
67486
|
agentVersion: this.config.agentVersion ?? "0.0.0",
|
|
67427
67487
|
// __AV_VERSION__ is injected by esbuild from package.json at build time.
|
|
67428
67488
|
// Falls back to "0.0.0-dev" in non-bundled contexts (tests).
|
|
67429
|
-
pluginVersion: true ? "0.23.
|
|
67489
|
+
pluginVersion: true ? "0.23.7" : "0.0.0-dev"
|
|
67430
67490
|
});
|
|
67431
67491
|
this._telemetryReporter.startAutoFlush(3e4);
|
|
67432
67492
|
}
|
|
@@ -67886,6 +67946,10 @@ var init_channel = __esm2({
|
|
|
67886
67946
|
return;
|
|
67887
67947
|
}
|
|
67888
67948
|
if (messageType === "history_catchup_response") return;
|
|
67949
|
+
if (data.room_id || data.a2a_channel_id || this._isNonDmConversation(convId, convGroupId)) {
|
|
67950
|
+
console.warn(`[SecureChannel] Dropped 1:1 MLS emit for non-DM conv ${(convId ?? convGroupId ?? "?").slice(0, 8)} (room/A2A guard)`);
|
|
67951
|
+
return;
|
|
67952
|
+
}
|
|
67889
67953
|
if (convId) {
|
|
67890
67954
|
const session = this._sessions.get(convId);
|
|
67891
67955
|
if (session && !session.activated) {
|
|
@@ -68732,7 +68796,12 @@ ${messageText}`;
|
|
|
68732
68796
|
senderName: senderLabel,
|
|
68733
68797
|
plaintext: messageText,
|
|
68734
68798
|
messageType,
|
|
68735
|
-
timestamp: data.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
68799
|
+
timestamp: data.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
68800
|
+
// Native bridge (claude-room-bridge) consumes this to decide reply
|
|
68801
|
+
// expectation: a human/owner speaking in a room always expects a reply,
|
|
68802
|
+
// an agent does not. Derived from the room roster (line ~5394) — the same
|
|
68803
|
+
// signal the OpenClaw mention-filter uses for loop prevention.
|
|
68804
|
+
senderIsAgent
|
|
68736
68805
|
});
|
|
68737
68806
|
const contextualMessage = senderIsAgent ? messageText : `[${senderLabel}]: ${messageText}`;
|
|
68738
68807
|
Promise.resolve(this.config.onMessage?.(contextualMessage, metadata)).catch((err) => {
|
|
@@ -69856,16 +69925,7 @@ ${messageText}`;
|
|
|
69856
69925
|
ackedIds.push(msg.queue_id);
|
|
69857
69926
|
continue;
|
|
69858
69927
|
}
|
|
69859
|
-
|
|
69860
|
-
if (this._persisted?.rooms) {
|
|
69861
|
-
for (const room of Object.values(this._persisted.rooms)) {
|
|
69862
|
-
if (room.conversationIds?.includes(msg.conversation_id)) {
|
|
69863
|
-
isRoom = true;
|
|
69864
|
-
break;
|
|
69865
|
-
}
|
|
69866
|
-
}
|
|
69867
|
-
}
|
|
69868
|
-
if (isRoom) {
|
|
69928
|
+
if (msg.room_id || msg.a2a_channel_id || this._isNonDmConversation(msg.conversation_id, msg.conversation_group_id)) {
|
|
69869
69929
|
ackedIds.push(msg.queue_id);
|
|
69870
69930
|
continue;
|
|
69871
69931
|
}
|
|
@@ -70508,7 +70568,7 @@ ${messageText}`;
|
|
|
70508
70568
|
if (msg.sender_device_id === this._deviceId) continue;
|
|
70509
70569
|
const session = this._sessions.get(msg.conversation_id);
|
|
70510
70570
|
if (!session) continue;
|
|
70511
|
-
if (this.
|
|
70571
|
+
if (msg.room_id || this._isNonDmConversation(msg.conversation_id)) {
|
|
70512
70572
|
this._persisted.lastMessageTimestamp = msg.created_at;
|
|
70513
70573
|
continue;
|
|
70514
70574
|
}
|
|
@@ -86826,7 +86886,7 @@ var init_protocol = __esm2({
|
|
|
86826
86886
|
return;
|
|
86827
86887
|
}
|
|
86828
86888
|
const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
|
|
86829
|
-
await new Promise((
|
|
86889
|
+
await new Promise((resolve32) => setTimeout(resolve32, pollInterval));
|
|
86830
86890
|
options?.signal?.throwIfAborted();
|
|
86831
86891
|
}
|
|
86832
86892
|
} catch (error210) {
|
|
@@ -86843,7 +86903,7 @@ var init_protocol = __esm2({
|
|
|
86843
86903
|
*/
|
|
86844
86904
|
request(request, resultSchema, options) {
|
|
86845
86905
|
const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
|
|
86846
|
-
return new Promise((
|
|
86906
|
+
return new Promise((resolve32, reject) => {
|
|
86847
86907
|
const earlyReject = (error210) => {
|
|
86848
86908
|
reject(error210);
|
|
86849
86909
|
};
|
|
@@ -86921,7 +86981,7 @@ var init_protocol = __esm2({
|
|
|
86921
86981
|
if (!parseResult.success) {
|
|
86922
86982
|
reject(parseResult.error);
|
|
86923
86983
|
} else {
|
|
86924
|
-
|
|
86984
|
+
resolve32(parseResult.data);
|
|
86925
86985
|
}
|
|
86926
86986
|
} catch (error210) {
|
|
86927
86987
|
reject(error210);
|
|
@@ -87182,12 +87242,12 @@ var init_protocol = __esm2({
|
|
|
87182
87242
|
}
|
|
87183
87243
|
} catch {
|
|
87184
87244
|
}
|
|
87185
|
-
return new Promise((
|
|
87245
|
+
return new Promise((resolve32, reject) => {
|
|
87186
87246
|
if (signal.aborted) {
|
|
87187
87247
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
87188
87248
|
return;
|
|
87189
87249
|
}
|
|
87190
|
-
const timeoutId = setTimeout(
|
|
87250
|
+
const timeoutId = setTimeout(resolve32, interval);
|
|
87191
87251
|
signal.addEventListener("abort", () => {
|
|
87192
87252
|
clearTimeout(timeoutId);
|
|
87193
87253
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
@@ -90172,7 +90232,7 @@ var require_compile = __commonJS({
|
|
|
90172
90232
|
const schOrFunc = root3.refs[ref];
|
|
90173
90233
|
if (schOrFunc)
|
|
90174
90234
|
return schOrFunc;
|
|
90175
|
-
let _sch =
|
|
90235
|
+
let _sch = resolve32.call(this, root3, ref);
|
|
90176
90236
|
if (_sch === void 0) {
|
|
90177
90237
|
const schema = (_a32 = root3.localRefs) === null || _a32 === void 0 ? void 0 : _a32[ref];
|
|
90178
90238
|
const { schemaId } = this.opts;
|
|
@@ -90199,7 +90259,7 @@ var require_compile = __commonJS({
|
|
|
90199
90259
|
function sameSchemaEnv(s1, s22) {
|
|
90200
90260
|
return s1.schema === s22.schema && s1.root === s22.root && s1.baseId === s22.baseId;
|
|
90201
90261
|
}
|
|
90202
|
-
function
|
|
90262
|
+
function resolve32(root3, ref) {
|
|
90203
90263
|
let sch;
|
|
90204
90264
|
while (typeof (sch = this.refs[ref]) == "string")
|
|
90205
90265
|
ref = sch;
|
|
@@ -90766,7 +90826,7 @@ var require_fast_uri = __commonJS({
|
|
|
90766
90826
|
}
|
|
90767
90827
|
return uri;
|
|
90768
90828
|
}
|
|
90769
|
-
function
|
|
90829
|
+
function resolve32(baseURI, relativeURI, options) {
|
|
90770
90830
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
90771
90831
|
const resolved = resolveComponent(parse32(baseURI, schemelessOptions), parse32(relativeURI, schemelessOptions), schemelessOptions, true);
|
|
90772
90832
|
schemelessOptions.skipEscape = true;
|
|
@@ -90993,7 +91053,7 @@ var require_fast_uri = __commonJS({
|
|
|
90993
91053
|
var fastUri = {
|
|
90994
91054
|
SCHEMES,
|
|
90995
91055
|
normalize,
|
|
90996
|
-
resolve:
|
|
91056
|
+
resolve: resolve32,
|
|
90997
91057
|
resolveComponent,
|
|
90998
91058
|
equal,
|
|
90999
91059
|
serialize,
|
|
@@ -95013,7 +95073,7 @@ var init_mcp = __esm2({
|
|
|
95013
95073
|
let task = createTaskResult.task;
|
|
95014
95074
|
const pollInterval = task.pollInterval ?? 5e3;
|
|
95015
95075
|
while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
|
|
95016
|
-
await new Promise((
|
|
95076
|
+
await new Promise((resolve32) => setTimeout(resolve32, pollInterval));
|
|
95017
95077
|
const updatedTask = await extra.taskStore.getTask(taskId);
|
|
95018
95078
|
if (!updatedTask) {
|
|
95019
95079
|
throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
|
|
@@ -95954,7 +96014,7 @@ var init_dist2 = __esm2({
|
|
|
95954
96014
|
});
|
|
95955
96015
|
if (!chunk) {
|
|
95956
96016
|
if (i2 === 1) {
|
|
95957
|
-
await new Promise((
|
|
96017
|
+
await new Promise((resolve32) => setTimeout(resolve32));
|
|
95958
96018
|
maxReadCount = 3;
|
|
95959
96019
|
continue;
|
|
95960
96020
|
}
|
|
@@ -96454,9 +96514,9 @@ data:
|
|
|
96454
96514
|
const initRequest = messages.find((m22) => isInitializeRequest(m22));
|
|
96455
96515
|
const clientProtocolVersion = initRequest ? initRequest.params.protocolVersion : req.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
|
|
96456
96516
|
if (this._enableJsonResponse) {
|
|
96457
|
-
return new Promise((
|
|
96517
|
+
return new Promise((resolve32) => {
|
|
96458
96518
|
this._streamMapping.set(streamId, {
|
|
96459
|
-
resolveJson:
|
|
96519
|
+
resolveJson: resolve32,
|
|
96460
96520
|
cleanup: () => {
|
|
96461
96521
|
this._streamMapping.delete(streamId);
|
|
96462
96522
|
}
|
|
@@ -97173,7 +97233,7 @@ var init_index = __esm2({
|
|
|
97173
97233
|
init_skill_invoker();
|
|
97174
97234
|
await init_skill_telemetry();
|
|
97175
97235
|
await init_policy_enforcer();
|
|
97176
|
-
VERSION = true ? "0.23.
|
|
97236
|
+
VERSION = true ? "0.23.7" : "0.0.0-dev";
|
|
97177
97237
|
}
|
|
97178
97238
|
});
|
|
97179
97239
|
await init_index();
|
|
@@ -118477,21 +118537,21 @@ function Z_($10, Q4) {
|
|
|
118477
118537
|
|
|
118478
118538
|
// src/worker-permission.ts
|
|
118479
118539
|
import { realpathSync as realpathSync2 } from "node:fs";
|
|
118480
|
-
import { resolve as
|
|
118540
|
+
import { resolve as resolve3, dirname as dirname2, basename, sep as sep2, isAbsolute } from "node:path";
|
|
118481
118541
|
var PATH_FIELDS = ["file_path", "path", "notebook_path"];
|
|
118482
118542
|
function canonical(p2) {
|
|
118483
|
-
const abs =
|
|
118543
|
+
const abs = resolve3(p2);
|
|
118484
118544
|
try {
|
|
118485
118545
|
return realpathSync2(abs);
|
|
118486
118546
|
} catch {
|
|
118487
118547
|
const suffix = [];
|
|
118488
118548
|
let dir = abs;
|
|
118489
118549
|
for (; ; ) {
|
|
118490
|
-
const parent2 =
|
|
118550
|
+
const parent2 = dirname2(dir);
|
|
118491
118551
|
suffix.unshift(basename(dir));
|
|
118492
118552
|
if (parent2 === dir) return abs;
|
|
118493
118553
|
try {
|
|
118494
|
-
return
|
|
118554
|
+
return resolve3(realpathSync2(parent2), ...suffix);
|
|
118495
118555
|
} catch {
|
|
118496
118556
|
dir = parent2;
|
|
118497
118557
|
}
|
|
@@ -118508,7 +118568,7 @@ function pathsOf(input) {
|
|
|
118508
118568
|
return out;
|
|
118509
118569
|
}
|
|
118510
118570
|
function within(canonTarget, canonRoot) {
|
|
118511
|
-
return canonTarget === canonRoot || canonTarget.startsWith(canonRoot +
|
|
118571
|
+
return canonTarget === canonRoot || canonTarget.startsWith(canonRoot + sep2);
|
|
118512
118572
|
}
|
|
118513
118573
|
function globPatternEscapes(pattern) {
|
|
118514
118574
|
if (typeof pattern !== "string" || pattern.length === 0) return true;
|
|
@@ -118521,6 +118581,12 @@ function gateDecision(toolName, input, opts) {
|
|
|
118521
118581
|
if (!opts.isToolTurn()) {
|
|
118522
118582
|
return { deny: true, reason: "tools are disabled on this turn; reply with the say tool only" };
|
|
118523
118583
|
}
|
|
118584
|
+
if (opts.gatesRemoved?.()) {
|
|
118585
|
+
return { deny: false };
|
|
118586
|
+
}
|
|
118587
|
+
if (opts.isOwnerDmTurn?.()) {
|
|
118588
|
+
return { deny: false };
|
|
118589
|
+
}
|
|
118524
118590
|
if (toolName === "Bash") {
|
|
118525
118591
|
return opts.osIsolated ? { deny: false } : { deny: true, reason: "Bash is disabled in worker mode unless the process is OS-isolated (set AV_WORKER_OS_ISOLATED=1)" };
|
|
118526
118592
|
}
|
|
@@ -132433,6 +132499,15 @@ var PersistentClaudeSession = class {
|
|
|
132433
132499
|
* disarm denies the very next tool call. wireBridge binds it to the ArmingState
|
|
132434
132500
|
* for this turn's room; an unarmed room turn's getter returns false. */
|
|
132435
132501
|
currentArmedGetter = () => false;
|
|
132502
|
+
/** Per-turn: does this turn's sender expect a reply? DECOUPLED from
|
|
132503
|
+
* `currentAutoReply` on purpose — it drives ONLY the #416 plain-text fallback
|
|
132504
|
+
* (deliver assistant text when the model never calls say), NOT the tool gate.
|
|
132505
|
+
* True for owner 1:1 DMs (defaults from autoReplyOnText) AND for a HUMAN/owner
|
|
132506
|
+
* speaking in a room (wireBridge sets replyExpected from !senderIsAgent). An
|
|
132507
|
+
* agent-authored room turn keeps this false → the agent may still stay silent,
|
|
132508
|
+
* which also prevents agent↔agent reply loops. Tool access is unaffected: it
|
|
132509
|
+
* stays gated on currentAutoReply (owner DM) OR currentArmedGetter (armed room). */
|
|
132510
|
+
currentReplyExpected = false;
|
|
132436
132511
|
saidThisTurn = false;
|
|
132437
132512
|
turnText = "";
|
|
132438
132513
|
/** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
|
|
@@ -132458,7 +132533,13 @@ var PersistentClaudeSession = class {
|
|
|
132458
132533
|
parent_tool_use_id: null,
|
|
132459
132534
|
session_id: ""
|
|
132460
132535
|
};
|
|
132461
|
-
const item = {
|
|
132536
|
+
const item = {
|
|
132537
|
+
msg,
|
|
132538
|
+
reply,
|
|
132539
|
+
autoReplyOnText: opts?.autoReplyOnText,
|
|
132540
|
+
replyExpected: opts?.replyExpected,
|
|
132541
|
+
armed: opts?.armed
|
|
132542
|
+
};
|
|
132462
132543
|
if (this.waiting) {
|
|
132463
132544
|
const w2 = this.waiting;
|
|
132464
132545
|
this.waiting = null;
|
|
@@ -132487,12 +132568,13 @@ var PersistentClaudeSession = class {
|
|
|
132487
132568
|
} else if (this.opts.ephemeral) {
|
|
132488
132569
|
return;
|
|
132489
132570
|
} else {
|
|
132490
|
-
item = await new Promise((
|
|
132491
|
-
this.waiting =
|
|
132571
|
+
item = await new Promise((resolve4) => {
|
|
132572
|
+
this.waiting = resolve4;
|
|
132492
132573
|
});
|
|
132493
132574
|
}
|
|
132494
132575
|
this.activeReply = item.reply;
|
|
132495
132576
|
this.currentAutoReply = item.autoReplyOnText ?? false;
|
|
132577
|
+
this.currentReplyExpected = item.replyExpected ?? item.autoReplyOnText ?? false;
|
|
132496
132578
|
this.currentArmedGetter = item.armed ?? (() => false);
|
|
132497
132579
|
this.saidThisTurn = false;
|
|
132498
132580
|
this.turnText = "";
|
|
@@ -132530,7 +132612,26 @@ var PersistentClaudeSession = class {
|
|
|
132530
132612
|
// D5: currentArmedGetter() is called HERE on every tool decision, so a
|
|
132531
132613
|
// mid-turn disarm denies the next call. See worker-permission.test.ts +
|
|
132532
132614
|
// session.test.ts for the covering assertions.
|
|
132533
|
-
|
|
132615
|
+
//
|
|
132616
|
+
// Task 4 (C-1/H-3) HARD BACKSTOP: device-level `workAllowed()` is a top-level
|
|
132617
|
+
// AND over BOTH signals — NO tool runs (owner DM OR armed room) when Work is
|
|
132618
|
+
// off, even if the router ever mis-routed. `?? false` is the fail-closed
|
|
132619
|
+
// default (no getter ⇒ no tools). Read LIVE per tool decision so a Work-off
|
|
132620
|
+
// flip / fail-closed reconnect window denies the very next call.
|
|
132621
|
+
isToolTurn: () => (this.opts.workAllowed?.() ?? false) && (this.currentAutoReply || this.currentArmedGetter()),
|
|
132622
|
+
// Full-access lane: a 1:1 owner DM, whose ephemeral worker is seeded with
|
|
132623
|
+
// ONLY the owner's own message, so no third party can steer the turn.
|
|
132624
|
+
// Conjoined with !currentArmedGetter() deliberately: if a room turn ever
|
|
132625
|
+
// arrived with autoReplyOnText set, it must fall through to the allowlist
|
|
132626
|
+
// rather than inherit full access. Fail-safe, not merely descriptive.
|
|
132627
|
+
// Read LIVE per tool decision, like isToolTurn.
|
|
132628
|
+
isOwnerDmTurn: () => this.currentAutoReply && !this.currentArmedGetter(),
|
|
132629
|
+
// #627: the owner removed all gates for this device. Evaluated by
|
|
132630
|
+
// gateDecision AFTER isToolTurn, so an unarmed room turn stays chat-only
|
|
132631
|
+
// — this widens what a tool turn may do, never which turns get tools.
|
|
132632
|
+
// Read LIVE per decision so a revoke (or a reconnect reset) denies the
|
|
132633
|
+
// very next call, exactly like workAllowed above.
|
|
132634
|
+
gatesRemoved: () => this.opts.gatesRemoved?.() ?? false,
|
|
132534
132635
|
// Slice 2 Plan B: emit an audit self-report for each tool decision on an
|
|
132535
132636
|
// armed-room turn (both allow and deny). room_say is excluded by the hook.
|
|
132536
132637
|
isArmedTurn: () => this.currentArmedGetter(),
|
|
@@ -132546,7 +132647,7 @@ var PersistentClaudeSession = class {
|
|
|
132546
132647
|
}
|
|
132547
132648
|
};
|
|
132548
132649
|
console.error(
|
|
132549
|
-
`[worker-gate]
|
|
132650
|
+
`[worker-gate] active \u2014 1:1 owner DM: FULL access, no allowlist, no workspace fence. Armed room: FULL access whenever "Remove all gates" is on (live, owner-controlled, off by default); otherwise allowlist, workspace=${this.opts.workspaceDir ?? "(none: file tools disabled)"}, bash=${this.opts.osIsolated ? "enabled (OS-isolated)" : "disabled"}`
|
|
132550
132651
|
);
|
|
132551
132652
|
return {
|
|
132552
132653
|
...base,
|
|
@@ -132606,7 +132707,7 @@ var PersistentClaudeSession = class {
|
|
|
132606
132707
|
}
|
|
132607
132708
|
} else if (m6.type === "result") {
|
|
132608
132709
|
const reply = this.activeReply;
|
|
132609
|
-
if (this.
|
|
132710
|
+
if (this.currentReplyExpected && !this.saidThisTurn && this.turnText.trim() && reply) {
|
|
132610
132711
|
void reply(this.turnText);
|
|
132611
132712
|
}
|
|
132612
132713
|
}
|
|
@@ -132652,6 +132753,7 @@ var WorkerQueue = class {
|
|
|
132652
132753
|
session = this.deps.makeSession(task);
|
|
132653
132754
|
session.push(task.instruction, task.reply, {
|
|
132654
132755
|
autoReplyOnText: task.autoReplyOnText,
|
|
132756
|
+
replyExpected: task.replyExpected,
|
|
132655
132757
|
armed: task.armed
|
|
132656
132758
|
});
|
|
132657
132759
|
const currentSession = session;
|
|
@@ -132682,22 +132784,26 @@ function makeRouter(deps) {
|
|
|
132682
132784
|
push(text, reply, opts) {
|
|
132683
132785
|
const replySink = reply ?? (() => {
|
|
132684
132786
|
});
|
|
132685
|
-
|
|
132686
|
-
|
|
132687
|
-
|
|
132688
|
-
|
|
132689
|
-
|
|
132690
|
-
|
|
132691
|
-
|
|
132692
|
-
|
|
132693
|
-
|
|
132694
|
-
|
|
132695
|
-
|
|
132696
|
-
|
|
132697
|
-
|
|
132698
|
-
|
|
132699
|
-
|
|
132700
|
-
|
|
132787
|
+
const isOwnerDm = opts?.autoReplyOnText === true && opts?.armed === void 0 && deps.workAllowed();
|
|
132788
|
+
const isArmedRoom = opts?.armed?.() === true && deps.workAllowed();
|
|
132789
|
+
if (isOwnerDm) {
|
|
132790
|
+
deps.queue.enqueue({
|
|
132791
|
+
instruction: text,
|
|
132792
|
+
reply: replySink,
|
|
132793
|
+
autoReplyOnText: true,
|
|
132794
|
+
replyExpected: opts?.replyExpected
|
|
132795
|
+
});
|
|
132796
|
+
return;
|
|
132797
|
+
}
|
|
132798
|
+
if (isArmedRoom) {
|
|
132799
|
+
deps.queue.enqueue({
|
|
132800
|
+
instruction: text,
|
|
132801
|
+
reply: replySink,
|
|
132802
|
+
autoReplyOnText: false,
|
|
132803
|
+
replyExpected: opts?.replyExpected,
|
|
132804
|
+
armed: opts.armed
|
|
132805
|
+
});
|
|
132806
|
+
return;
|
|
132701
132807
|
}
|
|
132702
132808
|
deps.listener.push(text, reply, opts);
|
|
132703
132809
|
}
|
|
@@ -132712,26 +132818,58 @@ var ArmingState = class {
|
|
|
132712
132818
|
consumed = /* @__PURE__ */ new Set();
|
|
132713
132819
|
// request-ids already approved (one-shot)
|
|
132714
132820
|
/**
|
|
132715
|
-
* Replace the armed set. Use
|
|
132716
|
-
*
|
|
132821
|
+
* Replace the armed set. Use for the host-local launch seed (AV_ARM_ROOM) —
|
|
132822
|
+
* itself a host-local action and therefore allowed to arm — and as the
|
|
132823
|
+
* primitive `applyAuthoritative` below delegates to for non-shell workers.
|
|
132717
132824
|
*
|
|
132718
|
-
*
|
|
132719
|
-
*
|
|
132720
|
-
*
|
|
132721
|
-
*
|
|
132722
|
-
*
|
|
132825
|
+
* The B-pure posture (Task 4) treats the backend connect/live
|
|
132826
|
+
* `arming_snapshot` the same way — see `applyAuthoritative`'s docstring for
|
|
132827
|
+
* the current arm-from-snapshot rationale. Shell-capable (OS-isolated)
|
|
132828
|
+
* workers do NOT use this path; they stay on `reconcileDisarm` (disarm-only,
|
|
132829
|
+
* see below) until #20.
|
|
132723
132830
|
*/
|
|
132724
132831
|
applySnapshot(roomIds) {
|
|
132725
132832
|
this.armed = new Set(roomIds);
|
|
132726
132833
|
}
|
|
132727
132834
|
/**
|
|
132728
|
-
*
|
|
132729
|
-
*
|
|
132730
|
-
*
|
|
132731
|
-
*
|
|
132732
|
-
*
|
|
132733
|
-
*
|
|
132734
|
-
*
|
|
132835
|
+
* Set the armed set to EXACTLY `intendedArmed`: arms every room in the list
|
|
132836
|
+
* that isn't already armed, and disarms every currently-armed room that is
|
|
132837
|
+
* absent from it. This is the AUTHORITATIVE arm-from-snapshot path (Task 4,
|
|
132838
|
+
* reversing the prior disarm-only safeguard for non-shell workers).
|
|
132839
|
+
*
|
|
132840
|
+
* Why arming from the snapshot is now safe: `intendedArmed` is derived
|
|
132841
|
+
* server-side from `devices.work_allowed`, and that flag can only be flipped
|
|
132842
|
+
* by `PUT /devices/{id}/work_allowed`, which REJECTS any device-bound caller
|
|
132843
|
+
* outright (C1 guard — a human account owner only; an agent, including a
|
|
132844
|
+
* compromised one, cannot self-authorize). So "arm from snapshot" no longer
|
|
132845
|
+
* means "a web session can arm a worker" in the old adversarial sense — it
|
|
132846
|
+
* means "the verified owner's grant is applied end-to-end, live, without
|
|
132847
|
+
* requiring a separate host-local approval step for every reconnect." This is
|
|
132848
|
+
* the accepted B-pure posture; see #20 to harden it further with local
|
|
132849
|
+
* approval for non-shell workers too. Shell-capable (OS-isolated) workers are
|
|
132850
|
+
* carved out of this path entirely (C2) — see the shell-gate in bridge.ts —
|
|
132851
|
+
* because a shell worker armed by a web flag is a live RCE surface even under
|
|
132852
|
+
* an honest owner (a compromised owner web session, or a compromised backend,
|
|
132853
|
+
* would get arbitrary code execution). Those workers stay on `reconcileDisarm`
|
|
132854
|
+
* (disarm-only) until #20 delivers local approval for them as well.
|
|
132855
|
+
*/
|
|
132856
|
+
applyAuthoritative(intendedArmed) {
|
|
132857
|
+
this.applySnapshot(intendedArmed);
|
|
132858
|
+
}
|
|
132859
|
+
/**
|
|
132860
|
+
* Disarm-only reconciliation from the backend connect/live snapshot. Disarms
|
|
132861
|
+
* any currently-armed room whose intent is no longer armed (i.e. NOT in
|
|
132862
|
+
* `intendedArmed`) — so a disarm issued while the bridge was offline still
|
|
132863
|
+
* takes effect on reconnect — but NEVER arms a room. Used for OS-isolated
|
|
132864
|
+
* (shell-capable) workers ONLY (C2): a shell worker must never be armed by a
|
|
132865
|
+
* web-originated flag, because a compromised owner session or backend would
|
|
132866
|
+
* translate directly into host code execution. Those workers can be armed
|
|
132867
|
+
* only by the host-local launch seed (`AV_ARM_ROOM`, via `applySnapshot`)
|
|
132868
|
+
* until #20 delivers a local-approval path for them too. Non-shell workers
|
|
132869
|
+
* use `applyAuthoritative` instead (Task 4) — see its docstring. Returns the
|
|
132870
|
+
* rooms that were disarmed. In-memory arming survives a WS reconnect, so a
|
|
132871
|
+
* genuinely-armed room is unaffected here; only a full process restart
|
|
132872
|
+
* (which clears this state) requires re-arming.
|
|
132735
132873
|
*/
|
|
132736
132874
|
reconcileDisarm(intendedArmed) {
|
|
132737
132875
|
const keep = new Set(intendedArmed);
|
|
@@ -132785,7 +132923,7 @@ var ArmingState = class {
|
|
|
132785
132923
|
};
|
|
132786
132924
|
|
|
132787
132925
|
// src/approve-cli.ts
|
|
132788
|
-
import { mkdirSync as
|
|
132926
|
+
import { mkdirSync as mkdirSync3, writeFileSync, readFileSync as readFileSync4, readdirSync as readdirSync2, rmSync as rmSync2, existsSync as existsSync3 } from "node:fs";
|
|
132789
132927
|
import { join as join6 } from "node:path";
|
|
132790
132928
|
var APPROVALS_SUBDIR = "arm-approvals";
|
|
132791
132929
|
var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
@@ -132807,7 +132945,7 @@ function writeApproval(dataDir, requestId, roomId) {
|
|
|
132807
132945
|
const id = sanitizeRequestId(requestId);
|
|
132808
132946
|
const room = sanitizeRoomId(roomId);
|
|
132809
132947
|
const dir = join6(dataDir, APPROVALS_SUBDIR);
|
|
132810
|
-
|
|
132948
|
+
mkdirSync3(dir, { recursive: true });
|
|
132811
132949
|
writeFileSync(join6(dir, id), room);
|
|
132812
132950
|
}
|
|
132813
132951
|
function drainApprovals(dataDir) {
|
|
@@ -132947,6 +133085,19 @@ function pollApprovalsOnce(arming, dataDir, onArmed, log = () => {
|
|
|
132947
133085
|
function wireBridge(channel, session, target, opts = {}) {
|
|
132948
133086
|
const log = opts.log ?? (() => {
|
|
132949
133087
|
});
|
|
133088
|
+
let workAllowed = false;
|
|
133089
|
+
const workAllowedGetter = () => workAllowed;
|
|
133090
|
+
opts.onWorkAllowed?.(workAllowedGetter);
|
|
133091
|
+
let gatesRemoved = false;
|
|
133092
|
+
const gatesRemovedGetter = () => gatesRemoved;
|
|
133093
|
+
opts.onGatesRemoved?.(gatesRemovedGetter);
|
|
133094
|
+
const webArmedUnderGrant = /* @__PURE__ */ new Set();
|
|
133095
|
+
channel.on("state", (s10) => {
|
|
133096
|
+
if (s10 !== "ready") {
|
|
133097
|
+
workAllowed = false;
|
|
133098
|
+
gatesRemoved = false;
|
|
133099
|
+
}
|
|
133100
|
+
});
|
|
132950
133101
|
const arming = new ArmingState();
|
|
132951
133102
|
if (opts.armRoom === true && opts.roomFilter) {
|
|
132952
133103
|
arming.applySnapshot([opts.roomFilter]);
|
|
@@ -132972,6 +133123,7 @@ function wireBridge(channel, session, target, opts = {}) {
|
|
|
132972
133123
|
target.setRoom(e7.roomId);
|
|
132973
133124
|
session.push(`[${e7.senderName}]: ${e7.plaintext}`, target.snapshotReply(channel, log), {
|
|
132974
133125
|
autoReplyOnText: false,
|
|
133126
|
+
replyExpected: e7.senderIsAgent === false,
|
|
132975
133127
|
armed: () => arming.isArmed(e7.roomId)
|
|
132976
133128
|
});
|
|
132977
133129
|
});
|
|
@@ -132981,7 +133133,8 @@ function wireBridge(channel, session, target, opts = {}) {
|
|
|
132981
133133
|
target.setDm();
|
|
132982
133134
|
session.push(text, target.snapshotReply(channel, log), { autoReplyOnText: true });
|
|
132983
133135
|
});
|
|
132984
|
-
const workerCapable = !!
|
|
133136
|
+
const workerCapable = !!opts.workspaceDir;
|
|
133137
|
+
const osIsolated = opts.osIsolated !== void 0 ? opts.osIsolated : process.env.AV_WORKER_OS_ISOLATED === "1" || process.env.AV_WORKER_OS_ISOLATED === "true";
|
|
132985
133138
|
const heartbeat = () => {
|
|
132986
133139
|
try {
|
|
132987
133140
|
channel.sendWorkerHeartbeat?.({ workerCapable, armedRooms: arming.armedRooms() });
|
|
@@ -132991,21 +133144,47 @@ function wireBridge(channel, session, target, opts = {}) {
|
|
|
132991
133144
|
};
|
|
132992
133145
|
heartbeat();
|
|
132993
133146
|
channel.on("ready", () => heartbeat());
|
|
132994
|
-
|
|
133147
|
+
const applyArmingSnapshot = (s10) => {
|
|
133148
|
+
workAllowed = s10?.workAllowed === true;
|
|
133149
|
+
gatesRemoved = s10?.gatesRemoved === true;
|
|
132995
133150
|
if (!workerCapable) {
|
|
132996
133151
|
log("arming_snapshot ignored \u2014 not worker-capable");
|
|
132997
133152
|
return;
|
|
132998
133153
|
}
|
|
133154
|
+
const roomIds = s10?.roomIds ?? [];
|
|
132999
133155
|
try {
|
|
133000
|
-
const
|
|
133001
|
-
if (
|
|
133002
|
-
|
|
133003
|
-
|
|
133156
|
+
const before = new Set(arming.armedRooms());
|
|
133157
|
+
if (osIsolated && gatesRemoved) {
|
|
133158
|
+
arming.applyAuthoritative(roomIds);
|
|
133159
|
+
for (const r7 of roomIds) if (!before.has(r7)) webArmedUnderGrant.add(r7);
|
|
133160
|
+
} else if (osIsolated) {
|
|
133161
|
+
if (webArmedUnderGrant.size > 0) {
|
|
133162
|
+
const revoked = [...webArmedUnderGrant].filter((r7) => arming.isArmed(r7));
|
|
133163
|
+
for (const r7 of revoked) arming.disarm(r7);
|
|
133164
|
+
webArmedUnderGrant.clear();
|
|
133165
|
+
if (revoked.length > 0) {
|
|
133166
|
+
log(
|
|
133167
|
+
`arming_snapshot: "Remove all gates" was revoked \u2014 disarming the rooms it had armed on this OS-isolated worker: [${revoked.map((r7) => r7.slice(0, 8)).join(", ")}]`
|
|
133168
|
+
);
|
|
133169
|
+
}
|
|
133170
|
+
}
|
|
133171
|
+
const dropped = arming.reconcileDisarm(roomIds);
|
|
133172
|
+
if (dropped.length > 0) {
|
|
133173
|
+
log(
|
|
133174
|
+
`arming_snapshot: OS-isolated worker \u2014 disarm-only until #20 (web cannot arm shell workers); disarmed: [${dropped.map((r7) => r7.slice(0, 8)).join(", ")}]`
|
|
133175
|
+
);
|
|
133176
|
+
}
|
|
133177
|
+
} else {
|
|
133178
|
+
arming.applyAuthoritative(roomIds);
|
|
133004
133179
|
}
|
|
133180
|
+
const after = arming.armedRooms();
|
|
133181
|
+
const changed = after.length !== before.size || after.some((r7) => !before.has(r7));
|
|
133182
|
+
if (changed) heartbeat();
|
|
133005
133183
|
} catch (err) {
|
|
133006
133184
|
log(`arming_snapshot handling failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
|
|
133007
133185
|
}
|
|
133008
|
-
}
|
|
133186
|
+
};
|
|
133187
|
+
channel.on("arming_snapshot", applyArmingSnapshot);
|
|
133009
133188
|
channel.on("disarm", (d10) => {
|
|
133010
133189
|
if (!workerCapable) {
|
|
133011
133190
|
log("disarm ignored \u2014 not worker-capable");
|
|
@@ -133067,22 +133246,20 @@ async function main() {
|
|
|
133067
133246
|
"[bridge] warning: passing the invite token on the command line is visible to other local users via 'ps'. Prefer: AV_INVITE_TOKEN=\u2026 npx @agentvault/claude-bridge"
|
|
133068
133247
|
);
|
|
133069
133248
|
}
|
|
133070
|
-
console.error(`[bridge] version: ${true ? "0.
|
|
133249
|
+
console.error(`[bridge] version: ${true ? "0.6.0" : "dev"}`);
|
|
133071
133250
|
console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
|
|
133072
|
-
|
|
133073
|
-
|
|
133074
|
-
|
|
133075
|
-
|
|
133076
|
-
|
|
133077
|
-
|
|
133078
|
-
|
|
133079
|
-
|
|
133080
|
-
|
|
133081
|
-
|
|
133082
|
-
|
|
133083
|
-
|
|
133084
|
-
);
|
|
133085
|
-
}
|
|
133251
|
+
console.error(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
|
|
133252
|
+
if (cfg.armRoom) {
|
|
133253
|
+
console.error(
|
|
133254
|
+
`[bridge] ARMED ROOM ${cfg.roomFilter} \u2014 worker tools are enabled on this room's turns. The enclave key dir stays fenced; the workspace dir is ADVISORY (the SDK does not confine file ops to it \u2014 they root in $HOME). Blast radius = whatever this host/environment exposes.`
|
|
133255
|
+
);
|
|
133256
|
+
console.error(
|
|
133257
|
+
"[bridge] ARMED ROOM WARNING: this room contains agents you do not control. A room peer's message can drive your agent's tools. The directory is NOT a sandbox \u2014 run this bridge in an owner-provisioned confined environment (dedicated droplet / container / OS-user) with no connected accounts or ambient credentials. See the Slice 2 hardening guide."
|
|
133258
|
+
);
|
|
133259
|
+
} else {
|
|
133260
|
+
console.error(
|
|
133261
|
+
'[bridge] 1:1 owner DMs run with FULL tool access when "Allow tools" is on for this agent (dashboard switch, default off): any command, any file this user account can reach, network, skills and subagents \u2014 equivalent to a terminal on this machine, with no per-action prompt. Rooms are NOT affected: they stay say-only unless armed (AV_ARM_ROOM=1 with a pinned AV_ROOM_ID), and an armed room keeps the confined allowlist. Each tool-enabled turn runs in a fresh session seeded with only that one message, so room text cannot leak into a DM turn.'
|
|
133262
|
+
);
|
|
133086
133263
|
}
|
|
133087
133264
|
const target = new ActiveTarget();
|
|
133088
133265
|
if (cfg.roomFilter) target.setRoom(cfg.roomFilter);
|
|
@@ -133098,6 +133275,10 @@ async function main() {
|
|
|
133098
133275
|
const c4 = channel;
|
|
133099
133276
|
return c4._deviceJwt ?? c4._persisted?.deviceJwt ?? null;
|
|
133100
133277
|
};
|
|
133278
|
+
let liveWorkAllowed = () => false;
|
|
133279
|
+
const workAllowed = () => liveWorkAllowed();
|
|
133280
|
+
let liveGatesRemoved = () => false;
|
|
133281
|
+
const gatesRemoved = () => liveGatesRemoved();
|
|
133101
133282
|
const listener = new PersistentClaudeSession({
|
|
133102
133283
|
model: cfg.model,
|
|
133103
133284
|
systemPrompt: agentSystemPrompt,
|
|
@@ -133118,6 +133299,14 @@ async function main() {
|
|
|
133118
133299
|
systemPrompt: agentSystemPrompt,
|
|
133119
133300
|
onObserve: (text) => console.error(`[worker] observed (${text.length} chars, not sent)`),
|
|
133120
133301
|
worker: true,
|
|
133302
|
+
// Task 4: the hard backstop — no tool runs on any worker turn (owner DM or
|
|
133303
|
+
// armed room) unless Work is on. Read live per tool decision.
|
|
133304
|
+
workAllowed,
|
|
133305
|
+
// #627: only the WORKER session gets this. The listener is the locked
|
|
133306
|
+
// tools:[] facet and must stay that way — a grant that widened the
|
|
133307
|
+
// always-on listener would put an unrestricted shell behind every
|
|
133308
|
+
// untrusted room message, not just tool turns.
|
|
133309
|
+
gatesRemoved,
|
|
133121
133310
|
ephemeral: true,
|
|
133122
133311
|
maxTurns: WORKER_MAX_TURNS,
|
|
133123
133312
|
workspaceDir: cfg.workspaceDir,
|
|
@@ -133133,7 +133322,7 @@ async function main() {
|
|
|
133133
133322
|
timeoutMs: WORKER_TIMEOUT_MS,
|
|
133134
133323
|
log: (m6) => console.error(m6)
|
|
133135
133324
|
});
|
|
133136
|
-
const router = makeRouter({
|
|
133325
|
+
const router = makeRouter({ workAllowed, listener, queue: workerQueue });
|
|
133137
133326
|
wireBridge(
|
|
133138
133327
|
channel,
|
|
133139
133328
|
{ push: (t7, reply, opts) => router.push(t7, reply, opts) },
|
|
@@ -133143,9 +133332,17 @@ async function main() {
|
|
|
133143
133332
|
armRoom: cfg.armRoom,
|
|
133144
133333
|
log: (m6) => console.error("[bridge] " + m6),
|
|
133145
133334
|
// Slice 2 Plan C (T11): live arm/disarm + local-approval poll + heartbeat.
|
|
133146
|
-
worker: cfg.worker,
|
|
133147
133335
|
workspaceDir: cfg.workspaceDir,
|
|
133148
|
-
dataDir: cfg.dataDir
|
|
133336
|
+
dataDir: cfg.dataDir,
|
|
133337
|
+
osIsolated: cfg.osIsolated,
|
|
133338
|
+
// Task 4: capture the live fail-closed workAllowed getter (set synchronously
|
|
133339
|
+
// during wiring) into the indirection the router + worker session read live.
|
|
133340
|
+
onWorkAllowed: (getter) => {
|
|
133341
|
+
liveWorkAllowed = getter;
|
|
133342
|
+
},
|
|
133343
|
+
onGatesRemoved: (getter) => {
|
|
133344
|
+
liveGatesRemoved = getter;
|
|
133345
|
+
}
|
|
133149
133346
|
}
|
|
133150
133347
|
);
|
|
133151
133348
|
attachLifecycle2(channel, {
|
package/dist/router.d.ts
CHANGED
|
@@ -4,12 +4,21 @@ import type { WorkerTask } from "./worker-queue.js";
|
|
|
4
4
|
export interface PushLike {
|
|
5
5
|
push(text: string, reply?: ReplySink, opts?: {
|
|
6
6
|
autoReplyOnText?: boolean;
|
|
7
|
+
replyExpected?: boolean;
|
|
7
8
|
armed?: () => boolean;
|
|
8
9
|
}): void;
|
|
9
10
|
}
|
|
10
11
|
export interface RouterDeps {
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Task 4 (C-1/H-3): live, fail-closed device-level tool capability ("Work").
|
|
14
|
+
* MUST be read live at each push (never snapshotted) so a mid-connection flip
|
|
15
|
+
* and the fail-closed reconnect window both take effect immediately. Gates
|
|
16
|
+
* BOTH tool-eligible lanes — owner-DM and armed-room: Work off → the turn
|
|
17
|
+
* falls through to the tools-absent listener (structural fail-safe), making
|
|
18
|
+
* `work_allowed` the single on/off control for tool capability. The
|
|
19
|
+
* session-level isToolTurn AND remains the hard backstop for both lanes.
|
|
20
|
+
*/
|
|
21
|
+
workAllowed: () => boolean;
|
|
13
22
|
/** Always-on locked listener (tools:[]) for untrusted/non-tool turns. */
|
|
14
23
|
listener: PushLike;
|
|
15
24
|
/** Serial queue of tool-eligible turns. */
|
|
@@ -23,8 +32,9 @@ export interface RouterDeps {
|
|
|
23
32
|
* worker via the queue; everything else stays on the locked listener. A room
|
|
24
33
|
* turn's `armed` getter is read once here at routing time to pick the lane; the
|
|
25
34
|
* same live getter is threaded to the worker task so a mid-turn disarm still
|
|
26
|
-
* denies the next tool call (Facet A D5).
|
|
27
|
-
*
|
|
35
|
+
* denies the next tool call (Facet A D5). `work_allowed` is the single control
|
|
36
|
+
* gating BOTH lanes: an armed room turn is only routed to the worker queue when
|
|
37
|
+
* workAllowed() is also true, so Work off always falls through to the listener.
|
|
28
38
|
*/
|
|
29
39
|
export declare function makeRouter(deps: RouterDeps): PushLike;
|
|
30
40
|
//# sourceMappingURL=router.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spec.d.ts","sourceRoot":"","sources":["../../src/service/spec.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGjD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,wBAAwB,EAAE,OAAO,CAAC;IAClC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,gBAAgB,GAAG,WAAW,
|
|
1
|
+
{"version":3,"file":"spec.d.ts","sourceRoot":"","sources":["../../src/service/spec.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGjD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,wBAAwB,EAAE,OAAO,CAAC;IAClC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,gBAAgB,GAAG,WAAW,CA0BvF"}
|
package/dist/session.d.ts
CHANGED
|
@@ -79,6 +79,25 @@ export interface SessionOpts {
|
|
|
79
79
|
ephemeral?: boolean;
|
|
80
80
|
/** Turn cap for a worker query() (bounds a runaway tool loop). */
|
|
81
81
|
maxTurns?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Task 4 (C-1/H-3): live, fail-closed device-level tool capability ("Work").
|
|
84
|
+
* Folded into `isToolTurn` as a top-level AND — the HARD backstop that makes
|
|
85
|
+
* the fail-safe real: NO tool runs (owner DM OR armed room) when this returns
|
|
86
|
+
* false, even if the router mis-routed. Read LIVE at each tool decision (never
|
|
87
|
+
* snapshotted). Absent getter ⇒ `?? false` ⇒ fail-closed (no tools). */
|
|
88
|
+
workAllowed?: () => boolean;
|
|
89
|
+
/**
|
|
90
|
+
* #627: live, fail-closed device-level "Remove all gates" grant. When this
|
|
91
|
+
* returns true the gate permits EVERY tool with no restrictions, in rooms
|
|
92
|
+
* and 1:1 alike — the owner's explicit decision, recorded in #627.
|
|
93
|
+
*
|
|
94
|
+
* NOT a replacement for `isToolTurn`: this is read AFTER it, so an UNARMED
|
|
95
|
+
* room turn is still chat-only. That ordering is what separates "a room you
|
|
96
|
+
* enabled" from "any room somebody added your agent to", and it costs
|
|
97
|
+
* nothing to keep. Read LIVE at each tool decision (never snapshotted);
|
|
98
|
+
* absent getter ⇒ `?? false` ⇒ gates stay in place.
|
|
99
|
+
*/
|
|
100
|
+
gatesRemoved?: () => boolean;
|
|
82
101
|
}
|
|
83
102
|
export declare class PersistentClaudeSession {
|
|
84
103
|
private opts;
|
|
@@ -107,6 +126,15 @@ export declare class PersistentClaudeSession {
|
|
|
107
126
|
* disarm denies the very next tool call. wireBridge binds it to the ArmingState
|
|
108
127
|
* for this turn's room; an unarmed room turn's getter returns false. */
|
|
109
128
|
private currentArmedGetter;
|
|
129
|
+
/** Per-turn: does this turn's sender expect a reply? DECOUPLED from
|
|
130
|
+
* `currentAutoReply` on purpose — it drives ONLY the #416 plain-text fallback
|
|
131
|
+
* (deliver assistant text when the model never calls say), NOT the tool gate.
|
|
132
|
+
* True for owner 1:1 DMs (defaults from autoReplyOnText) AND for a HUMAN/owner
|
|
133
|
+
* speaking in a room (wireBridge sets replyExpected from !senderIsAgent). An
|
|
134
|
+
* agent-authored room turn keeps this false → the agent may still stay silent,
|
|
135
|
+
* which also prevents agent↔agent reply loops. Tool access is unaffected: it
|
|
136
|
+
* stays gated on currentAutoReply (owner DM) OR currentArmedGetter (armed room). */
|
|
137
|
+
private currentReplyExpected;
|
|
110
138
|
private saidThisTurn;
|
|
111
139
|
private turnText;
|
|
112
140
|
/** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
|
|
@@ -128,6 +156,7 @@ export declare class PersistentClaudeSession {
|
|
|
128
156
|
*/
|
|
129
157
|
push(text: string, reply?: ReplySink, opts?: {
|
|
130
158
|
autoReplyOnText?: boolean;
|
|
159
|
+
replyExpected?: boolean;
|
|
131
160
|
armed?: () => boolean;
|
|
132
161
|
}): void;
|
|
133
162
|
/** Route a say-tool message to the reply bound to the in-flight message, falling
|
|
@@ -13,6 +13,9 @@ export declare function makeWorkerPreToolUseHook(opts: {
|
|
|
13
13
|
/** True when the process is attested OS-isolated, gating whether Bash is allowed at all. */
|
|
14
14
|
osIsolated: boolean;
|
|
15
15
|
isToolTurn: () => boolean;
|
|
16
|
+
/** True when this turn is a 1:1 owner DM. That lane gets full access; rooms
|
|
17
|
+
* keep the allowlist. Read live per tool call, like isToolTurn. */
|
|
18
|
+
isOwnerDmTurn?: () => boolean;
|
|
16
19
|
/** Slice 2 Plan B: true when this turn comes from an armed room. When set,
|
|
17
20
|
* each tool decision (allow AND deny) is forwarded to onToolDecision for
|
|
18
21
|
* audit self-reporting. room_say is always excluded (it is the reply
|
|
@@ -36,5 +39,7 @@ export declare function makeWorkerPermission(opts: {
|
|
|
36
39
|
/** True when the process is attested OS-isolated, gating whether Bash is allowed at all. */
|
|
37
40
|
osIsolated: boolean;
|
|
38
41
|
isToolTurn: () => boolean;
|
|
42
|
+
/** True when this turn is a 1:1 owner DM (full access lane). */
|
|
43
|
+
isOwnerDmTurn?: () => boolean;
|
|
39
44
|
}): CanUseTool;
|
|
40
45
|
//# sourceMappingURL=worker-permission.d.ts.map
|
package/dist/worker-queue.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export type WorkerTask = {
|
|
|
7
7
|
reply: ReplySink;
|
|
8
8
|
/** Owner-DM turns want a plain-text reply fallback (#416). */
|
|
9
9
|
autoReplyOnText: boolean;
|
|
10
|
+
/** A human/owner sender expects a reply: if the worker answers in plain text
|
|
11
|
+
* without calling say, deliver that text instead of dropping it. Decoupled from
|
|
12
|
+
* autoReplyOnText so it delivers on a tool-capable room turn WITHOUT enabling
|
|
13
|
+
* tools (arming governs tools). Absent/false for agent-authored turns. */
|
|
14
|
+
replyExpected?: boolean;
|
|
10
15
|
/** Armed-room turns: live getter the gate reads per tool decision (mid-turn
|
|
11
16
|
* disarm denies the next call). Absent for owner DMs. */
|
|
12
17
|
armed?: () => boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentvault/claude-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AgentVault Claude Bridge — daemon for bridging a Claude agent into secure E2E-encrypted AgentVault 1:1 direct messages and rooms.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "tsc --emitDeclarationOnly && node build.mjs",
|
|
26
|
+
"build": "npm --prefix ../plugin run build && tsc --emitDeclarationOnly && node build.mjs",
|
|
27
27
|
"start": "node dist/index.js",
|
|
28
28
|
"test": "vitest run",
|
|
29
|
-
"prepublishOnly": "
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|