@corenel/harness 0.9.0 → 0.11.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/approvals/decisionLog.d.ts +21 -0
- package/dist/approvals/decisionLog.d.ts.map +1 -0
- package/dist/approvals/decisionLog.js +129 -0
- package/dist/approvals/decisionLog.js.map +1 -0
- package/dist/approvals/hooks.d.ts +18 -0
- package/dist/approvals/hooks.d.ts.map +1 -0
- package/dist/approvals/hooks.js +34 -0
- package/dist/approvals/hooks.js.map +1 -0
- package/dist/approvals/index.d.ts +10 -0
- package/dist/approvals/index.d.ts.map +1 -0
- package/dist/approvals/index.js +10 -0
- package/dist/approvals/index.js.map +1 -0
- package/dist/approvals/service.d.ts +46 -0
- package/dist/approvals/service.d.ts.map +1 -0
- package/dist/approvals/service.js +181 -0
- package/dist/approvals/service.js.map +1 -0
- package/dist/approvals/settings.d.ts +20 -0
- package/dist/approvals/settings.d.ts.map +1 -0
- package/dist/approvals/settings.js +32 -0
- package/dist/approvals/settings.js.map +1 -0
- package/dist/approvals/shellTools.d.ts +10 -0
- package/dist/approvals/shellTools.d.ts.map +1 -0
- package/dist/approvals/shellTools.js +17 -0
- package/dist/approvals/shellTools.js.map +1 -0
- package/dist/approvals/standingStore.d.ts +15 -0
- package/dist/approvals/standingStore.d.ts.map +1 -0
- package/dist/approvals/standingStore.js +50 -0
- package/dist/approvals/standingStore.js.map +1 -0
- package/dist/approvals/suggest.d.ts +15 -0
- package/dist/approvals/suggest.d.ts.map +1 -0
- package/dist/approvals/suggest.js +23 -0
- package/dist/approvals/suggest.js.map +1 -0
- package/dist/approvals/summarize.d.ts +7 -0
- package/dist/approvals/summarize.d.ts.map +1 -0
- package/dist/approvals/summarize.js +70 -0
- package/dist/approvals/summarize.js.map +1 -0
- package/dist/approvals/testTextStore.d.ts +6 -0
- package/dist/approvals/testTextStore.d.ts.map +1 -0
- package/dist/approvals/testTextStore.js +11 -0
- package/dist/approvals/testTextStore.js.map +1 -0
- package/dist/approvals/textStore.d.ts +9 -0
- package/dist/approvals/textStore.d.ts.map +1 -0
- package/dist/approvals/textStore.js +2 -0
- package/dist/approvals/textStore.js.map +1 -0
- package/dist/audit/canonical.d.ts +5 -0
- package/dist/audit/canonical.d.ts.map +1 -0
- package/dist/audit/canonical.js +15 -0
- package/dist/audit/canonical.js.map +1 -0
- package/dist/audit/ids.d.ts +3 -0
- package/dist/audit/ids.d.ts.map +1 -0
- package/dist/audit/ids.js +11 -0
- package/dist/audit/ids.js.map +1 -0
- package/dist/audit/index.d.ts +7 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +7 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/log.d.ts +64 -0
- package/dist/audit/log.d.ts.map +1 -0
- package/dist/audit/log.js +457 -0
- package/dist/audit/log.js.map +1 -0
- package/dist/audit/recorder.d.ts +31 -0
- package/dist/audit/recorder.d.ts.map +1 -0
- package/dist/audit/recorder.js +185 -0
- package/dist/audit/recorder.js.map +1 -0
- package/dist/audit/runAudit.d.ts +38 -0
- package/dist/audit/runAudit.d.ts.map +1 -0
- package/dist/audit/runAudit.js +99 -0
- package/dist/audit/runAudit.js.map +1 -0
- package/dist/audit/verify.d.ts +14 -0
- package/dist/audit/verify.d.ts.map +1 -0
- package/dist/audit/verify.js +47 -0
- package/dist/audit/verify.js.map +1 -0
- package/dist/conversation.d.ts +31 -0
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +33 -0
- package/dist/conversation.js.map +1 -1
- package/dist/core/hash.d.ts +2 -0
- package/dist/core/hash.d.ts.map +1 -0
- package/dist/core/hash.js +9 -0
- package/dist/core/hash.js.map +1 -0
- package/dist/core/loop.d.ts +18 -0
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +61 -5
- package/dist/core/loop.js.map +1 -1
- package/dist/core/spawn.d.ts +59 -4
- package/dist/core/spawn.d.ts.map +1 -1
- package/dist/core/spawn.js +83 -5
- package/dist/core/spawn.js.map +1 -1
- package/dist/core/turnVerify.d.ts +25 -0
- package/dist/core/turnVerify.d.ts.map +1 -0
- package/dist/core/turnVerify.js +177 -0
- package/dist/core/turnVerify.js.map +1 -0
- package/dist/dag/executeNode.d.ts +7 -0
- package/dist/dag/executeNode.d.ts.map +1 -1
- package/dist/dag/executeNode.js +3 -0
- package/dist/dag/executeNode.js.map +1 -1
- package/dist/guardrail/guardrail.d.ts +7 -0
- package/dist/guardrail/guardrail.d.ts.map +1 -1
- package/dist/guardrail/guardrail.js +8 -0
- package/dist/guardrail/guardrail.js.map +1 -1
- package/dist/guardrail/index.d.ts +2 -2
- package/dist/guardrail/index.d.ts.map +1 -1
- package/dist/guardrail/index.js +2 -2
- package/dist/guardrail/index.js.map +1 -1
- package/dist/guardrail/permission-service.d.ts +37 -6
- package/dist/guardrail/permission-service.d.ts.map +1 -1
- package/dist/guardrail/permission-service.js +111 -11
- package/dist/guardrail/permission-service.js.map +1 -1
- package/dist/guardrail/serialize.d.ts.map +1 -1
- package/dist/guardrail/serialize.js +2 -0
- package/dist/guardrail/serialize.js.map +1 -1
- package/dist/guardrail/validate.d.ts.map +1 -1
- package/dist/guardrail/validate.js +3 -1
- package/dist/guardrail/validate.js.map +1 -1
- package/dist/install/lockfile.d.ts +13 -5
- package/dist/install/lockfile.d.ts.map +1 -1
- package/dist/install/lockfile.js +31 -2
- package/dist/install/lockfile.js.map +1 -1
- package/dist/orchestrator/agentSearch.d.ts +15 -0
- package/dist/orchestrator/agentSearch.d.ts.map +1 -0
- package/dist/orchestrator/agentSearch.js +102 -0
- package/dist/orchestrator/agentSearch.js.map +1 -0
- package/dist/orchestrator/agentTags.d.ts +13 -0
- package/dist/orchestrator/agentTags.d.ts.map +1 -0
- package/dist/orchestrator/agentTags.js +48 -0
- package/dist/orchestrator/agentTags.js.map +1 -0
- package/dist/orchestrator/branch.d.ts +9 -0
- package/dist/orchestrator/branch.d.ts.map +1 -0
- package/dist/orchestrator/branch.js +26 -0
- package/dist/orchestrator/branch.js.map +1 -0
- package/dist/orchestrator/checks.d.ts +16 -0
- package/dist/orchestrator/checks.d.ts.map +1 -0
- package/dist/orchestrator/checks.js +66 -0
- package/dist/orchestrator/checks.js.map +1 -0
- package/dist/orchestrator/planDoc.d.ts +15 -0
- package/dist/orchestrator/planDoc.d.ts.map +1 -0
- package/dist/orchestrator/planDoc.js +59 -0
- package/dist/orchestrator/planDoc.js.map +1 -0
- package/dist/orchestrator/record.d.ts +103 -0
- package/dist/orchestrator/record.d.ts.map +1 -0
- package/dist/orchestrator/record.js +168 -0
- package/dist/orchestrator/record.js.map +1 -0
- package/dist/orchestrator/sidecarTools.d.ts +7 -0
- package/dist/orchestrator/sidecarTools.d.ts.map +1 -0
- package/dist/orchestrator/sidecarTools.js +141 -0
- package/dist/orchestrator/sidecarTools.js.map +1 -0
- package/dist/prompts/config.d.ts.map +1 -1
- package/dist/prompts/config.js +11 -0
- package/dist/prompts/config.js.map +1 -1
- package/dist/prompts/index.d.ts +0 -6
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +10 -17
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
- package/dist/prompts/library/defaults.generated.js +29 -12
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +1 -0
- package/dist/redact.js.map +1 -1
- package/dist/rooms/findings.d.ts +14 -0
- package/dist/rooms/findings.d.ts.map +1 -1
- package/dist/rooms/findings.js +37 -0
- package/dist/rooms/findings.js.map +1 -1
- package/dist/sessions/blobGc.d.ts.map +1 -1
- package/dist/sessions/blobGc.js +2 -5
- package/dist/sessions/blobGc.js.map +1 -1
- package/dist/sessions/childSessions.d.ts +12 -0
- package/dist/sessions/childSessions.d.ts.map +1 -0
- package/dist/sessions/childSessions.js +43 -0
- package/dist/sessions/childSessions.js.map +1 -0
- package/dist/sessions/fileRecallStore.d.ts.map +1 -1
- package/dist/sessions/fileRecallStore.js +1 -3
- package/dist/sessions/fileRecallStore.js.map +1 -1
- package/dist/sessions/fileSessionStore.d.ts +13 -8
- package/dist/sessions/fileSessionStore.d.ts.map +1 -1
- package/dist/sessions/fileSessionStore.js +53 -17
- package/dist/sessions/fileSessionStore.js.map +1 -1
- package/dist/sessions/inflight.js +0 -0
- package/dist/sessions/inflight.js.map +1 -1
- package/dist/sessions/jsonl.d.ts +9 -0
- package/dist/sessions/jsonl.d.ts.map +1 -1
- package/dist/sessions/jsonl.js +11 -0
- package/dist/sessions/jsonl.js.map +1 -1
- package/dist/sessions/testFs.d.ts.map +1 -1
- package/dist/sessions/testFs.js +1 -1
- package/dist/sessions/testFs.js.map +1 -1
- package/dist/sidecar/host.d.ts +178 -1
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +363 -3
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/identity.d.ts +44 -0
- package/dist/sidecar/identity.d.ts.map +1 -0
- package/dist/sidecar/identity.js +45 -0
- package/dist/sidecar/identity.js.map +1 -0
- package/dist/sidecar/server.d.ts +273 -6
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +1009 -40
- package/dist/sidecar/server.js.map +1 -1
- package/dist/skillSource.js +1 -1
- package/dist/skillSource.js.map +1 -1
- package/dist/skills.d.ts +23 -7
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +62 -37
- package/dist/skills.js.map +1 -1
- package/dist/tools/callAgent.d.ts +29 -12
- package/dist/tools/callAgent.d.ts.map +1 -1
- package/dist/tools/callAgent.js +120 -14
- package/dist/tools/callAgent.js.map +1 -1
- package/dist/tools/crewList.d.ts +33 -0
- package/dist/tools/crewList.d.ts.map +1 -0
- package/dist/tools/crewList.js +66 -0
- package/dist/tools/crewList.js.map +1 -0
- package/dist/tools/files.d.ts.map +1 -1
- package/dist/tools/files.js +11 -5
- package/dist/tools/files.js.map +1 -1
- package/dist/tools/findAgent.d.ts +23 -0
- package/dist/tools/findAgent.d.ts.map +1 -0
- package/dist/tools/findAgent.js +58 -0
- package/dist/tools/findAgent.js.map +1 -0
- package/dist/tools/namespaces.d.ts.map +1 -1
- package/dist/tools/namespaces.js +7 -0
- package/dist/tools/namespaces.js.map +1 -1
- package/dist/tools/orchestrate.d.ts +4 -0
- package/dist/tools/orchestrate.d.ts.map +1 -0
- package/dist/tools/orchestrate.js +38 -0
- package/dist/tools/orchestrate.js.map +1 -0
- package/dist/tools/orchestration.d.ts +24 -0
- package/dist/tools/orchestration.d.ts.map +1 -0
- package/dist/tools/orchestration.js +173 -0
- package/dist/tools/orchestration.js.map +1 -0
- package/dist/tools/orchestrationSet.d.ts +6 -0
- package/dist/tools/orchestrationSet.d.ts.map +1 -0
- package/dist/tools/orchestrationSet.js +12 -0
- package/dist/tools/orchestrationSet.js.map +1 -0
- package/dist/tools/roomTools.d.ts +6 -0
- package/dist/tools/roomTools.d.ts.map +1 -1
- package/dist/tools/roomTools.js +26 -2
- package/dist/tools/roomTools.js.map +1 -1
- package/dist/tools/skillTool.d.ts +15 -0
- package/dist/tools/skillTool.d.ts.map +1 -0
- package/dist/tools/skillTool.js +39 -0
- package/dist/tools/skillTool.js.map +1 -0
- package/dist/tools/urlGuard.d.ts +7 -0
- package/dist/tools/urlGuard.d.ts.map +1 -1
- package/dist/tools/urlGuard.js +54 -18
- package/dist/tools/urlGuard.js.map +1 -1
- package/package.json +8 -3
package/dist/sidecar/server.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_STATE_DIR, RUN_TASK_FEATURE, CREW_DEPLOY_FEATURE } from '@corenel/protocol';
|
|
2
|
+
import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, } from '@corenel/protocol';
|
|
3
|
+
import { pairingTokenHash } from './identity.js';
|
|
4
|
+
const NO_CHECKS = 'this sidecar does not run checks (start it with --allow-shell)';
|
|
5
|
+
/** Default pre-pairing deadline: a real client sends `hello` immediately. */
|
|
6
|
+
export const HELLO_DEADLINE_MS = 10000;
|
|
7
|
+
/** Denied hellos one connection may send before it is closed: a client with a
|
|
8
|
+
* stale token needs one, not an unbounded guessing loop. */
|
|
9
|
+
export const MAX_FAILED_HELLOS = 3;
|
|
2
10
|
/** How long the welcome waits on `clientDirName` before going out without it. A
|
|
3
11
|
* manifest write is a few milliseconds; anything near this is a wedged disk, and
|
|
4
12
|
* a client left with no handshake at all is worse than one that has to fall back
|
|
5
13
|
* to deducing its own directory. */
|
|
6
14
|
const DIR_NAME_TIMEOUT_MS = 3000;
|
|
15
|
+
/** How long pairing waits on the identity verifier before going out
|
|
16
|
+
* UNVERIFIED. The same bound as `DIR_NAME_TIMEOUT_MS`, and run concurrently
|
|
17
|
+
* with it, so a verifier that hangs never makes the handshake slower than a
|
|
18
|
+
* wedged dirName lookup already could. A timeout never refuses the connection:
|
|
19
|
+
* it only withholds the widened trust a verified id would earn. */
|
|
20
|
+
export const IDENTITY_TIMEOUT_MS = 3000;
|
|
7
21
|
/** True for anything with a callable `.then` -- covers native Promises and any
|
|
8
22
|
* other thenable a caller's `onClient` might return, without assuming it's a
|
|
9
23
|
* real Promise instance (the declared type is `() => void`; an async function
|
|
@@ -24,6 +38,19 @@ const MAX_IDENTITY_FIELD_LEN = 200;
|
|
|
24
38
|
* daemon produces -- it only stops an unbounded list from becoming unbounded
|
|
25
39
|
* work at a point where the connection has just authenticated. */
|
|
26
40
|
const MAX_CLAIMED_ACCOUNTS = 8;
|
|
41
|
+
/** Bounds on a hello's client `capabilities`. A list outside them is carried
|
|
42
|
+
* as NOTHING (not truncated): it is informational only, so the fail-safe
|
|
43
|
+
* reading of a malformed list is "an older app". */
|
|
44
|
+
const MAX_CAPABILITIES = 16;
|
|
45
|
+
const MAX_CAPABILITY_LEN = 64;
|
|
46
|
+
/** The client capabilities a hello announced, or [] when absent or malformed
|
|
47
|
+
* (not an array, too long, or holding anything but short strings). */
|
|
48
|
+
function announcedCapabilities(v) {
|
|
49
|
+
if (!Array.isArray(v) || v.length > MAX_CAPABILITIES)
|
|
50
|
+
return [];
|
|
51
|
+
const list = v;
|
|
52
|
+
return list.every((c) => typeof c === 'string' && c.length <= MAX_CAPABILITY_LEN) ? list : [];
|
|
53
|
+
}
|
|
27
54
|
/** Base64-encode bytes without a Node Buffer (btoa is in the browser + Node 18+),
|
|
28
55
|
* so this module stays free of node:* types for the shared harness build. */
|
|
29
56
|
function bytesToB64(bytes) {
|
|
@@ -32,6 +59,16 @@ function bytesToB64(bytes) {
|
|
|
32
59
|
bin += String.fromCharCode(bytes[i]);
|
|
33
60
|
return btoa(bin);
|
|
34
61
|
}
|
|
62
|
+
/** A fresh, single-use identity-challenge nonce: 32 random bytes, base64url
|
|
63
|
+
* (43 characters, no padding). `crypto.getRandomValues` — not node:crypto's
|
|
64
|
+
* `randomBytes` — for the same host-agnostic reason as `bytesToB64` above:
|
|
65
|
+
* it is a Web Crypto global in both the browser and Node 18+, so this module
|
|
66
|
+
* stays free of node:* imports. */
|
|
67
|
+
function makeNonce() {
|
|
68
|
+
const bytes = new Uint8Array(32);
|
|
69
|
+
crypto.getRandomValues(bytes);
|
|
70
|
+
return bytesToB64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
71
|
+
}
|
|
35
72
|
/** Constant-time string compare for the pairing token — avoids leaking it via the
|
|
36
73
|
* early-exit timing of `!==`. Pure JS so this stays host-agnostic (no node:crypto in
|
|
37
74
|
* a possibly-bundled module). The token is a fixed-length hex string, so comparing
|
|
@@ -44,6 +81,39 @@ function safeEqual(a, b) {
|
|
|
44
81
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
45
82
|
return diff === 0;
|
|
46
83
|
}
|
|
84
|
+
/** Longest verdict reason the harness will put on a welcome or in a log. */
|
|
85
|
+
const MAX_REASON_LEN = 80;
|
|
86
|
+
/** A verifier-supplied reason (or error message), made safe to echo on the
|
|
87
|
+
* welcome and write to the log WHATEVER the verifier put in it: only
|
|
88
|
+
* `[A-Za-z0-9 .,:_()-]` survives (no newlines, quotes, braces or log-forging
|
|
89
|
+
* control text), whitespace runs collapse, and the result is capped at
|
|
90
|
+
* MAX_REASON_LEN. Nothing left means 'rejected'. The harness does this itself
|
|
91
|
+
* rather than trusting every verifier to keep its reasons short and clean. */
|
|
92
|
+
function sanitizeReason(raw) {
|
|
93
|
+
const kept = raw.replace(/[^A-Za-z0-9 .,:_()-]/g, '').replace(/ {2,}/g, ' ').trim().slice(0, MAX_REASON_LEN).trim();
|
|
94
|
+
return kept || 'rejected';
|
|
95
|
+
}
|
|
96
|
+
/** A verifier's answer, re-checked before it can bind anything. The port is
|
|
97
|
+
* daemon code, but a verdict that claims success without a usable user id must
|
|
98
|
+
* fail CLOSED (unverified), never bind an empty, oversized or non-string id. A
|
|
99
|
+
* failure with no usable reason still reads as a failure. */
|
|
100
|
+
function normalizeVerdict(got) {
|
|
101
|
+
if (typeof got === 'object' && got !== null) {
|
|
102
|
+
const r = got;
|
|
103
|
+
if (r.ok === true) {
|
|
104
|
+
const userId = r.identity?.userId;
|
|
105
|
+
const issuer = r.identity?.issuer;
|
|
106
|
+
if (typeof userId === 'string' && userId && userId.length <= MAX_IDENTITY_FIELD_LEN) {
|
|
107
|
+
return { ok: true, identity: { userId, issuer: typeof issuer === 'string' ? issuer : '' } };
|
|
108
|
+
}
|
|
109
|
+
return { ok: false, reason: 'malformed verdict' };
|
|
110
|
+
}
|
|
111
|
+
if (r.ok === false) {
|
|
112
|
+
return { ok: false, reason: typeof r.reason === 'string' ? sanitizeReason(r.reason) : 'rejected' };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ok: false, reason: 'malformed verdict' };
|
|
116
|
+
}
|
|
47
117
|
export class SidecarServer {
|
|
48
118
|
constructor(opts) {
|
|
49
119
|
Object.defineProperty(this, "ch", {
|
|
@@ -70,6 +140,19 @@ export class SidecarServer {
|
|
|
70
140
|
writable: true,
|
|
71
141
|
value: false
|
|
72
142
|
});
|
|
143
|
+
/** Denied hellos on this connection (see MAX_FAILED_HELLOS). */
|
|
144
|
+
Object.defineProperty(this, "failedHellos", {
|
|
145
|
+
enumerable: true,
|
|
146
|
+
configurable: true,
|
|
147
|
+
writable: true,
|
|
148
|
+
value: 0
|
|
149
|
+
});
|
|
150
|
+
Object.defineProperty(this, "clearHelloDeadline", {
|
|
151
|
+
enumerable: true,
|
|
152
|
+
configurable: true,
|
|
153
|
+
writable: true,
|
|
154
|
+
value: () => { }
|
|
155
|
+
});
|
|
73
156
|
Object.defineProperty(this, "hostSeq", {
|
|
74
157
|
enumerable: true,
|
|
75
158
|
configurable: true,
|
|
@@ -89,7 +172,7 @@ export class SidecarServer {
|
|
|
89
172
|
enumerable: true,
|
|
90
173
|
configurable: true,
|
|
91
174
|
writable: true,
|
|
92
|
-
value:
|
|
175
|
+
value: DEFAULT_STATE_DIR
|
|
93
176
|
});
|
|
94
177
|
/** The authenticated connection's `clientId`, retained here (from `hello`) so
|
|
95
178
|
* later `host-sync-*` messages can be attributed to their owner without
|
|
@@ -112,6 +195,73 @@ export class SidecarServer {
|
|
|
112
195
|
writable: true,
|
|
113
196
|
value: void 0
|
|
114
197
|
});
|
|
198
|
+
/** The connection's claimed accounts from the same `hello`, kept beside
|
|
199
|
+
* `clientId` so web push can be filed under both. Bounded/typeof-filtered
|
|
200
|
+
* the same way `clientId` is -- see the `accounts` local in `onHello`. */
|
|
201
|
+
Object.defineProperty(this, "accounts", {
|
|
202
|
+
enumerable: true,
|
|
203
|
+
configurable: true,
|
|
204
|
+
writable: true,
|
|
205
|
+
value: []
|
|
206
|
+
});
|
|
207
|
+
/** The VERIFIED user id (the identity token's `sub`), set at most once, in
|
|
208
|
+
* `onHello`, after the pairing token passed and the verifier answered. It is
|
|
209
|
+
* never re-assigned: no message re-authenticates, so a hijacked socket cannot
|
|
210
|
+
* swap whose connection this is. Undefined = unverified, which is every
|
|
211
|
+
* connection on a daemon with no `identity` verifier. The token itself is
|
|
212
|
+
* never kept. */
|
|
213
|
+
Object.defineProperty(this, "verifiedUserId", {
|
|
214
|
+
enumerable: true,
|
|
215
|
+
configurable: true,
|
|
216
|
+
writable: true,
|
|
217
|
+
value: undefined
|
|
218
|
+
});
|
|
219
|
+
/** The verdict echoed on the welcome, when a verifier is wired. Dead by the
|
|
220
|
+
* time a welcome is ever sent -- `onHello`'s identity block always
|
|
221
|
+
* overwrites it with a real reason ('not requested', 'no daemon id', or the
|
|
222
|
+
* challenge/assert outcome) before `sendWelcome` reads it -- but given a
|
|
223
|
+
* value anyway rather than left to a type assertion, matching every other
|
|
224
|
+
* field on this class. */
|
|
225
|
+
Object.defineProperty(this, "identityResult", {
|
|
226
|
+
enumerable: true,
|
|
227
|
+
configurable: true,
|
|
228
|
+
writable: true,
|
|
229
|
+
value: { verified: false, reason: 'not requested' }
|
|
230
|
+
});
|
|
231
|
+
/** Resolves the promise `onHello` is awaiting on `identity-assert`. Cleared
|
|
232
|
+
* (set to `undefined`) the moment it is used, which is what makes a SECOND
|
|
233
|
+
* `identity-assert` on the same connection a no-op: with no resolver left,
|
|
234
|
+
* the handler in `onMessage` has nothing to call and the message is simply
|
|
235
|
+
* ignored. Also resolved (to a timeout-shaped answer) from the close
|
|
236
|
+
* handler, so a connection that drops while a verdict is pending does not
|
|
237
|
+
* leave `onHello` waiting on a promise nothing will ever settle until the
|
|
238
|
+
* unrelated 3s timer fires. */
|
|
239
|
+
Object.defineProperty(this, "identityAssertResolve", {
|
|
240
|
+
enumerable: true,
|
|
241
|
+
configurable: true,
|
|
242
|
+
writable: true,
|
|
243
|
+
value: void 0
|
|
244
|
+
});
|
|
245
|
+
/** Set when the channel closes. `onHello` awaits (identity, dirName) before
|
|
246
|
+
* attaching to attendance and sending the welcome; a connection that dropped
|
|
247
|
+
* during that wait must not be attached afterwards, or the registry would
|
|
248
|
+
* hold a dead client that nothing ever detaches. */
|
|
249
|
+
Object.defineProperty(this, "closed", {
|
|
250
|
+
enumerable: true,
|
|
251
|
+
configurable: true,
|
|
252
|
+
writable: true,
|
|
253
|
+
value: false
|
|
254
|
+
});
|
|
255
|
+
/** This connection's attended-client object, retained so an answer it sends
|
|
256
|
+
* can be attributed to it. Typed `unknown` because its only consumer is the
|
|
257
|
+
* registry's own identity lookup, which takes `unknown` for the
|
|
258
|
+
* dependency-direction reason on `SidecarAttendanceRegistry.answer`. */
|
|
259
|
+
Object.defineProperty(this, "attended", {
|
|
260
|
+
enumerable: true,
|
|
261
|
+
configurable: true,
|
|
262
|
+
writable: true,
|
|
263
|
+
value: void 0
|
|
264
|
+
});
|
|
115
265
|
/** Detach function for this connection's attendance attachment, set on a
|
|
116
266
|
* successful `hello` when `opts.attendance` is wired. Called once, on channel
|
|
117
267
|
* close, so a dead connection stops receiving/counting toward asks. */
|
|
@@ -259,6 +409,15 @@ export class SidecarServer {
|
|
|
259
409
|
for (const t of opts.tools)
|
|
260
410
|
this.tools.set(t.name, t);
|
|
261
411
|
this.ch.onMessage((m) => this.onMessage(m));
|
|
412
|
+
const deadline = setTimeout(() => {
|
|
413
|
+
if (this.authed || this.closed)
|
|
414
|
+
return;
|
|
415
|
+
this.opts.log?.('closing a connection that did not pair in time');
|
|
416
|
+
this.ch.close();
|
|
417
|
+
}, opts.helloDeadlineMs ?? HELLO_DEADLINE_MS);
|
|
418
|
+
// Never keep a node process alive for this timer alone.
|
|
419
|
+
deadline.unref?.();
|
|
420
|
+
this.clearHelloDeadline = () => clearTimeout(deadline);
|
|
262
421
|
// Every connected server subscribes, which is what makes a root added by one
|
|
263
422
|
// paired client reach ALL of them: two browsers on one daemon must never
|
|
264
423
|
// disagree about what is reachable. The payload is the COMPLETE set.
|
|
@@ -285,6 +444,14 @@ export class SidecarServer {
|
|
|
285
444
|
this.ch.send({ t: 'room-progress', room });
|
|
286
445
|
});
|
|
287
446
|
this.ch.onClose?.(() => {
|
|
447
|
+
this.closed = true;
|
|
448
|
+
this.clearHelloDeadline();
|
|
449
|
+
// A connection that drops while `onHello` is awaiting `identity-assert`
|
|
450
|
+
// must not leave that promise pending until the unrelated 3s timer
|
|
451
|
+
// fires -- settle it now, exactly like a timeout, so onHello observes
|
|
452
|
+
// `this.closed` on its very next line and attaches/sends nothing.
|
|
453
|
+
this.identityAssertResolve?.({ kind: 'timeout' });
|
|
454
|
+
this.identityAssertResolve = undefined;
|
|
288
455
|
this.attendedDetach?.();
|
|
289
456
|
this.attendedDetach = undefined;
|
|
290
457
|
// Leave the live set, or this directory reads as connected for ever and
|
|
@@ -314,10 +481,33 @@ export class SidecarServer {
|
|
|
314
481
|
onMessage(m) {
|
|
315
482
|
switch (m.t) {
|
|
316
483
|
case 'hello':
|
|
484
|
+
/* Identity is fixed at first hello; a second must not re-own the
|
|
485
|
+
* connection. `authed` is set SYNCHRONOUSLY inside onHello, before its
|
|
486
|
+
* first await, so this also refuses a second hello that arrives while
|
|
487
|
+
* the first is still waiting on the identity verifier. */
|
|
317
488
|
if (this.authed)
|
|
318
|
-
return;
|
|
319
|
-
|
|
489
|
+
return;
|
|
490
|
+
/* onHello is async now, so a throw after its awaits (attendance.attach,
|
|
491
|
+
* sendWelcome, hosts.attach) would be an UNHANDLED rejection -- which
|
|
492
|
+
* takes the whole daemon down, not just this connection. Log it. */
|
|
493
|
+
void this.onHello(m).catch((err) => this.opts.log?.(`hello failed: ${errMessage(err)}`));
|
|
320
494
|
return;
|
|
495
|
+
case 'identity-assert': {
|
|
496
|
+
// Only the FIRST identity-assert on a connection counts -- the
|
|
497
|
+
// resolver is cleared the instant it is used (right here), so a
|
|
498
|
+
// second (or unsolicited, or late-after-timeout) one finds nothing to
|
|
499
|
+
// call and is silently ignored, which is also what consumes the
|
|
500
|
+
// single-use nonce: nothing after this point can still redeem it.
|
|
501
|
+
const resolve = this.identityAssertResolve;
|
|
502
|
+
this.identityAssertResolve = undefined;
|
|
503
|
+
if (!resolve)
|
|
504
|
+
return;
|
|
505
|
+
if (typeof m.assertion === 'string' && m.assertion)
|
|
506
|
+
resolve({ kind: 'assertion', value: m.assertion });
|
|
507
|
+
else
|
|
508
|
+
resolve({ kind: 'none' });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
321
511
|
case 'invoke':
|
|
322
512
|
if (!this.authed) {
|
|
323
513
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
@@ -358,6 +548,13 @@ export class SidecarServer {
|
|
|
358
548
|
return;
|
|
359
549
|
this.onSubscribe(m);
|
|
360
550
|
return;
|
|
551
|
+
case 'cancel-run':
|
|
552
|
+
if (!this.authed) {
|
|
553
|
+
this.ch.send({ t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'unsupported' });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
this.onCancelRun(m);
|
|
557
|
+
return;
|
|
361
558
|
case 'acp-start':
|
|
362
559
|
if (!this.authed) {
|
|
363
560
|
this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
|
|
@@ -387,7 +584,7 @@ export class SidecarServer {
|
|
|
387
584
|
// answer is dropped silently rather than forwarded into the registry.
|
|
388
585
|
if (!this.authed)
|
|
389
586
|
return;
|
|
390
|
-
this.opts.attendance?.answer(m.id, m.decision);
|
|
587
|
+
this.opts.attendance?.answer(m.id, m.decision, this.attended);
|
|
391
588
|
return;
|
|
392
589
|
case 'roots-browse':
|
|
393
590
|
// Auth-gated like every other request: directory enumeration must never
|
|
@@ -532,6 +729,66 @@ export class SidecarServer {
|
|
|
532
729
|
}
|
|
533
730
|
void this.onAgentPauseSet(m);
|
|
534
731
|
return;
|
|
732
|
+
case 'crew-deployments':
|
|
733
|
+
case 'crew-deploy':
|
|
734
|
+
case 'crew-undeploy':
|
|
735
|
+
case 'crew-pause':
|
|
736
|
+
case 'crew-resume':
|
|
737
|
+
case 'crew-stop':
|
|
738
|
+
if (!this.authed) {
|
|
739
|
+
this.ch.send({ t: 'crew-deploy-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
void this.onCrewDeploy(m);
|
|
743
|
+
return;
|
|
744
|
+
case 'approvals-accept':
|
|
745
|
+
if (!this.authed) {
|
|
746
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
void this.onApprovalsAccept(m);
|
|
750
|
+
return;
|
|
751
|
+
case 'approvals-list':
|
|
752
|
+
if (!this.authed) {
|
|
753
|
+
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
void this.onApprovalsList(m);
|
|
757
|
+
return;
|
|
758
|
+
case 'approvals-revoke':
|
|
759
|
+
if (!this.authed) {
|
|
760
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
void this.onApprovalsRevoke(m);
|
|
764
|
+
return;
|
|
765
|
+
case 'checks-get':
|
|
766
|
+
if (!this.authed) {
|
|
767
|
+
this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
void this.onChecksGet(m);
|
|
771
|
+
return;
|
|
772
|
+
case 'checks-pin':
|
|
773
|
+
if (!this.authed) {
|
|
774
|
+
this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
void this.onChecksPin(m);
|
|
778
|
+
return;
|
|
779
|
+
case 'workspace-release':
|
|
780
|
+
if (!this.authed) {
|
|
781
|
+
this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
void this.onWorkspaceRelease(m);
|
|
785
|
+
return;
|
|
786
|
+
case 'push-subscribe':
|
|
787
|
+
void this.onPushSubscribe(m);
|
|
788
|
+
return;
|
|
789
|
+
case 'push-unsubscribe':
|
|
790
|
+
void this.onPushUnsubscribe(m);
|
|
791
|
+
return;
|
|
535
792
|
case 'hosts-list':
|
|
536
793
|
// `ok: false`, not an empty estate: telling an unauthenticated caller
|
|
537
794
|
// "this daemon holds no synced hosts" is a claim it has not earned --
|
|
@@ -657,25 +914,43 @@ export class SidecarServer {
|
|
|
657
914
|
return;
|
|
658
915
|
}
|
|
659
916
|
}
|
|
660
|
-
|
|
917
|
+
/** Close a connection that keeps failing to pair. */
|
|
918
|
+
countFailedHello() {
|
|
919
|
+
this.failedHellos++;
|
|
920
|
+
if (this.failedHellos >= MAX_FAILED_HELLOS) {
|
|
921
|
+
this.opts.log?.(`closing a connection after ${this.failedHellos} denied hellos`);
|
|
922
|
+
this.ch.close();
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async onHello(m) {
|
|
661
926
|
if (m.protocol !== SIDECAR_PROTOCOL) {
|
|
662
927
|
this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
|
|
663
928
|
this.ch.send({ t: 'denied', reason: `protocol ${m.protocol} unsupported` });
|
|
929
|
+
this.countFailedHello();
|
|
664
930
|
return;
|
|
665
931
|
}
|
|
666
932
|
if (!safeEqual(m.token ?? '', this.opts.token)) {
|
|
667
933
|
this.opts.log?.('denied: invalid token');
|
|
668
934
|
this.ch.send({ t: 'denied', reason: 'invalid token' });
|
|
935
|
+
this.countFailedHello();
|
|
669
936
|
return;
|
|
670
937
|
}
|
|
671
938
|
this.authed = true;
|
|
672
|
-
this.
|
|
939
|
+
this.clearHelloDeadline();
|
|
940
|
+
this.hostStateDir = m.stateDir ?? DEFAULT_STATE_DIR;
|
|
673
941
|
// Runtime-validate identity, even though the declared type is `string` --
|
|
674
942
|
// the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
|
|
675
943
|
// nothing actually guaranteed that. A non-string `clientId` (e.g. a number
|
|
676
944
|
// or object) must never reach `onClient`: downstream consumers use it as an
|
|
677
945
|
// object key (a daemon's hosts manifest) or feed it to a directory-name
|
|
678
946
|
// sanitizer that assumes a string.
|
|
947
|
+
/* Declared OUT here, not inside the identity block that fills it, because
|
|
948
|
+
* the attendance attach below needs the same bounded list -- and must get
|
|
949
|
+
* the ALREADY-BOUNDED one rather than re-reading `m.accounts`, which would
|
|
950
|
+
* reintroduce the unbounded walk the block below exists to prevent. A
|
|
951
|
+
* connection that carried no usable clientId leaves this empty, which is
|
|
952
|
+
* the right claim for a peer that identified itself as nobody. */
|
|
953
|
+
let accounts = [];
|
|
679
954
|
if (typeof m.clientId === 'string' && m.clientId) {
|
|
680
955
|
const wireId = m.clientId.slice(0, MAX_IDENTITY_FIELD_LEN);
|
|
681
956
|
/* THE ONE PLACE IDENTITY IS DECIDED, and it has to be here.
|
|
@@ -709,7 +984,7 @@ export class SidecarServer {
|
|
|
709
984
|
* `<account><spaces>` behind, which a normalizing comparison then trims
|
|
710
985
|
* into a match nobody claimed. */
|
|
711
986
|
const claimed = Array.isArray(m.accounts) ? m.accounts : [];
|
|
712
|
-
|
|
987
|
+
accounts = claimed.length > MAX_CLAIMED_ACCOUNTS
|
|
713
988
|
? []
|
|
714
989
|
: claimed.filter((a) => typeof a === 'string' && a.length <= MAX_IDENTITY_FIELD_LEN);
|
|
715
990
|
let clientId = wireId;
|
|
@@ -728,6 +1003,7 @@ export class SidecarServer {
|
|
|
728
1003
|
}
|
|
729
1004
|
}
|
|
730
1005
|
this.clientId = clientId;
|
|
1006
|
+
this.accounts = accounts;
|
|
731
1007
|
// The FRIENDLY label, which falls back to the WIRE id and never to the
|
|
732
1008
|
// mapped key: the name is what seeds a directory name and what an
|
|
733
1009
|
// operator reads in `hosts list`, and an `acct:<digest>` there would name
|
|
@@ -756,40 +1032,219 @@ export class SidecarServer {
|
|
|
756
1032
|
this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
|
|
757
1033
|
}
|
|
758
1034
|
}
|
|
1035
|
+
/* THE WAITS, started together. The welcome carries the synced directory
|
|
1036
|
+
* name this daemon resolved for this connection, and the client has no
|
|
1037
|
+
* other way to learn it -- it could previously only deduce it, and got null
|
|
1038
|
+
* whenever no workspace was open or two hosts held the same workspace id,
|
|
1039
|
+
* leaving a management surface unable to say which registrations were its
|
|
1040
|
+
* own.
|
|
1041
|
+
*
|
|
1042
|
+
* The dirName lookup is STARTED here, before the identity verdict is
|
|
1043
|
+
* awaited, so the two bounded waits (DIR_NAME_TIMEOUT_MS,
|
|
1044
|
+
* IDENTITY_TIMEOUT_MS) overlap: the worst case stays one bound, not two.
|
|
1045
|
+
* Both only ever resolve (every failure is null / unverified), so starting
|
|
1046
|
+
* one and then awaiting the other cannot leave a rejection unhandled.
|
|
1047
|
+
*
|
|
1048
|
+
* Bounded, and every dirName failure ends the same way as an older daemon:
|
|
1049
|
+
* no `dirName` on the welcome. A handshake that never arrives would be a
|
|
1050
|
+
* far worse trade than a client that falls back to deducing. With neither
|
|
1051
|
+
* a resolver nor a verifier nothing is awaited at all, and the welcome
|
|
1052
|
+
* goes out in the same tick as today. */
|
|
1053
|
+
const dirIdentity = this.clientId === undefined
|
|
1054
|
+
? undefined
|
|
1055
|
+
: { clientId: this.clientId, name: this.clientName ?? this.clientId };
|
|
1056
|
+
const dirNamePending = this.opts.clientDirName && dirIdentity
|
|
1057
|
+
? this.resolveDirName(this.opts.clientDirName, dirIdentity)
|
|
1058
|
+
: undefined;
|
|
1059
|
+
/* IDENTITY, decided BEFORE attendance or push can see this connection.
|
|
1060
|
+
* Reached only past the pairing-token check above -- the challenge is
|
|
1061
|
+
* never sent for a bad pairing token. Whatever the verdict, the
|
|
1062
|
+
* connection pairs: unverified only withholds the widened trust a verified
|
|
1063
|
+
* id earns. `verifiedUserId` is assigned here and nowhere else, once. The
|
|
1064
|
+
* assertion is never logged; only the reason is. The Clerk token itself
|
|
1065
|
+
* never reaches this class -- this exchange only ever sees a corenel-api
|
|
1066
|
+
* pairing assertion (spec amendment 2026-09-25b). */
|
|
1067
|
+
if (this.opts.identity) {
|
|
1068
|
+
if (!m.wantsIdentity) {
|
|
1069
|
+
this.identityResult = { verified: false, reason: 'not requested' };
|
|
1070
|
+
}
|
|
1071
|
+
else if (!this.opts.info?.id) {
|
|
1072
|
+
this.identityResult = { verified: false, reason: 'no daemon id' };
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
const sidecarId = this.opts.info.id;
|
|
1076
|
+
/* The hash of the pairing token THIS connection presented (it passed
|
|
1077
|
+
* the check above). The assertion's `cnf.tkh` must equal it: that
|
|
1078
|
+
* binds the assertion to the pairing the browser actually made, so a
|
|
1079
|
+
* daemon that holds our token cannot relay our challenge through a
|
|
1080
|
+
* browser paired with IT (that browser hashed its own, different,
|
|
1081
|
+
* token). STARTED here but not awaited: the digest is async, and the
|
|
1082
|
+
* challenge, the resolver and the bound timer must all be in place
|
|
1083
|
+
* synchronously -- it is awaited only once an assertion is in hand
|
|
1084
|
+
* (see verdictFromAssertion), inside the same bound. A digest that
|
|
1085
|
+
* rejects pairs unverified ('error'), never refused. */
|
|
1086
|
+
const tokenHash = pairingTokenHash(m.token ?? '').catch(() => null);
|
|
1087
|
+
const nonce = makeNonce();
|
|
1088
|
+
this.ch.send({ t: 'identity-challenge', nonce, sidecarId });
|
|
1089
|
+
const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, tokenHash);
|
|
1090
|
+
if (verdict.ok)
|
|
1091
|
+
this.verifiedUserId = verdict.identity.userId;
|
|
1092
|
+
this.identityResult = verdict.ok ? { verified: true } : { verified: false, reason: verdict.reason };
|
|
1093
|
+
}
|
|
1094
|
+
this.opts.log?.(`identity: ${this.identityResult.verified ? 'verified' : `unverified (${this.identityResult.reason})`}`);
|
|
1095
|
+
}
|
|
1096
|
+
/* OLDER APPS (crew deploy). A client that does not announce it can deploy
|
|
1097
|
+
* has agents that sync here but never fire until deployed, and nothing on
|
|
1098
|
+
* its screen can say so -- the daemon says it, once per client (the port
|
|
1099
|
+
* de-duplicates). The list is only ever informational: it grants nothing.
|
|
1100
|
+
* A malformed one counts as absent. Best-effort: never breaks a handshake. */
|
|
1101
|
+
if (this.opts.crewDeploy && !announcedCapabilities(m.capabilities).includes(CREW_DEPLOY_FEATURE)) {
|
|
1102
|
+
try {
|
|
1103
|
+
this.opts.crewDeploy.noteOlderClient(this.clientId ?? '', this.clientName ?? 'unknown app');
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
this.opts.log?.(`noteOlderClient threw: ${errMessage(err)}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
// Dropped while verifying: attaching now would leave a dead client in the
|
|
1110
|
+
// registry that the (already-run) close handler can never detach.
|
|
1111
|
+
if (this.closed)
|
|
1112
|
+
return;
|
|
759
1113
|
if (this.opts.attendance) {
|
|
760
|
-
|
|
1114
|
+
const attended = {
|
|
761
1115
|
send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
|
|
762
|
-
resolved: (id, decision, by) => this.ch.send({ t: 'permission-resolved', id, decision, by }),
|
|
1116
|
+
resolved: (id, decision, by, byClientId) => this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) }),
|
|
763
1117
|
runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
|
|
764
1118
|
registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
|
|
1119
|
+
approvalSuggestion: (s) => this.ch.send({ t: 'approvals-suggest', ...s }),
|
|
1120
|
+
};
|
|
1121
|
+
// Retained so `permission-answer` can name THIS connection as the
|
|
1122
|
+
// answerer -- the registry identifies an answerer by the object it
|
|
1123
|
+
// attached, not by anything the answer message claims.
|
|
1124
|
+
this.attended = attended;
|
|
1125
|
+
/* The identity is what routes an ask under `--mode team`. `clientId` is
|
|
1126
|
+
* the key this connection was FILED under (mapped through `clientKey`
|
|
1127
|
+
* where the daemon supplies one), i.e. the same value its RunIdentity
|
|
1128
|
+
* carries -- which is what makes a run's owner and its ask's recipient
|
|
1129
|
+
* the same connection. `accounts` are CLAIMED, carried for display, and
|
|
1130
|
+
* widen nothing; only `verifiedUserId` (proven at pairing) widens a
|
|
1131
|
+
* delivery to the same user's other devices. See the registry's
|
|
1132
|
+
* `recipients`. */
|
|
1133
|
+
this.attendedDetach = this.opts.attendance.attach(attended, {
|
|
1134
|
+
clientId: this.clientId ?? '',
|
|
1135
|
+
accounts,
|
|
1136
|
+
...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
765
1137
|
});
|
|
766
1138
|
}
|
|
767
1139
|
this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
* worse trade than a client that falls back to deducing. */
|
|
777
|
-
const identity = this.clientId === undefined
|
|
778
|
-
? undefined
|
|
779
|
-
: { clientId: this.clientId, name: this.clientName ?? this.clientId };
|
|
780
|
-
if (this.opts.clientDirName && identity) {
|
|
781
|
-
void this.resolveDirName(this.opts.clientDirName, identity).then((dirName) => {
|
|
782
|
-
this.sendWelcome(dirName);
|
|
783
|
-
});
|
|
1140
|
+
if (!dirNamePending) {
|
|
1141
|
+
this.sendWelcome(null);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const dirName = await dirNamePending;
|
|
1145
|
+
// Same reasoning as above: a dropped connection must not attach to the
|
|
1146
|
+
// hosts live set, which only a close handler that already ran would undo.
|
|
1147
|
+
if (this.closed)
|
|
784
1148
|
return;
|
|
1149
|
+
/* CREW DEPLOY OWNERSHIP: a verified connection filed as a synced folder
|
|
1150
|
+
* binds that folder to its user (first one wins; the daemon never
|
|
1151
|
+
* rebinds). Before the welcome, so nothing this client sends can race it.
|
|
1152
|
+
* Best-effort: never breaks a handshake. */
|
|
1153
|
+
if (dirName && this.verifiedUserId && this.opts.crewDeploy?.noteVerifiedDir) {
|
|
1154
|
+
try {
|
|
1155
|
+
this.opts.crewDeploy.noteVerifiedDir(dirName, this.verifiedUserId);
|
|
1156
|
+
}
|
|
1157
|
+
catch (err) {
|
|
1158
|
+
this.opts.log?.(`noteVerifiedDir threw: ${errMessage(err)}`);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
this.sendWelcome(dirName);
|
|
1162
|
+
}
|
|
1163
|
+
/** Waits for this connection's `identity-assert`, with NO timeout of its
|
|
1164
|
+
* own -- the overall bound is `awaitAndVerifyIdentity`'s, covering this wait
|
|
1165
|
+
* AND the verify call together, so the whole leg is bounded at
|
|
1166
|
+
* IDENTITY_TIMEOUT_MS from the challenge, not up to double that. Also
|
|
1167
|
+
* settled (to a timeout-shaped answer) from the close handler, so a
|
|
1168
|
+
* dropped connection cannot leave this pending forever. */
|
|
1169
|
+
awaitIdentityAssertion() {
|
|
1170
|
+
return new Promise((resolve) => {
|
|
1171
|
+
this.identityAssertResolve = resolve;
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
/** Turns an `identity-assert` answer into a verdict: 'no assertion' or
|
|
1175
|
+
* 'timed out' unverified for the non-assertion answers, 'assertion too
|
|
1176
|
+
* long' WITHOUT calling the verifier for an oversized one, and otherwise
|
|
1177
|
+
* the verifier's own verdict (never thrown -- a throw or rejection becomes
|
|
1178
|
+
* an unverified 'error'). The expectation is built here, once the
|
|
1179
|
+
* pairing-token hash has settled; a hash that could not be computed is an
|
|
1180
|
+
* unverified 'error' without calling the verifier. */
|
|
1181
|
+
async verdictFromAssertion(answer, audience, nonce, tokenHashPending) {
|
|
1182
|
+
if (answer.kind === 'none')
|
|
1183
|
+
return { ok: false, reason: 'no assertion' };
|
|
1184
|
+
if (answer.kind === 'timeout')
|
|
1185
|
+
return { ok: false, reason: 'timed out' };
|
|
1186
|
+
const assertion = answer.value;
|
|
1187
|
+
if (assertion.length > MAX_ASSERTION_LEN)
|
|
1188
|
+
return { ok: false, reason: 'assertion too long' };
|
|
1189
|
+
const v = this.opts.identity;
|
|
1190
|
+
if (!v)
|
|
1191
|
+
return { ok: false, reason: 'no verifier' };
|
|
1192
|
+
const tokenHash = await tokenHashPending;
|
|
1193
|
+
if (tokenHash === null)
|
|
1194
|
+
return { ok: false, reason: 'error' };
|
|
1195
|
+
const expect = { audience, nonce, tokenHash };
|
|
1196
|
+
try {
|
|
1197
|
+
const verdict = v.explain
|
|
1198
|
+
? await v.explain(assertion, expect)
|
|
1199
|
+
: await v.verify(assertion, expect).then((id) => (id ? { ok: true, identity: id } : { ok: false, reason: 'rejected' }));
|
|
1200
|
+
return normalizeVerdict(verdict);
|
|
1201
|
+
}
|
|
1202
|
+
catch (err) {
|
|
1203
|
+
this.opts.log?.(`identity verification failed: error (${sanitizeReason(errMessage(err))})`);
|
|
1204
|
+
return { ok: false, reason: 'error' };
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/** The identity verdict for this connection's challenge/assert exchange,
|
|
1208
|
+
* bounded by IDENTITY_TIMEOUT_MS FROM THE CHALLENGE -- covering both the
|
|
1209
|
+
* wait for `identity-assert` and the verify call, in one race, so a slow
|
|
1210
|
+
* verifier cannot add a second timeout's worth of wait on top of a slow
|
|
1211
|
+
* browser. Every failure mode (no assertion, an oversized one, a throw, a
|
|
1212
|
+
* rejection, a malformed answer, a timeout) is an UNVERIFIED verdict with a
|
|
1213
|
+
* reason -- never a refusal. The assertion is passed to the verifier and
|
|
1214
|
+
* nowhere else. */
|
|
1215
|
+
async awaitAndVerifyIdentity(audience, nonce, tokenHash) {
|
|
1216
|
+
let timer;
|
|
1217
|
+
try {
|
|
1218
|
+
const timed = new Promise((res) => {
|
|
1219
|
+
timer = setTimeout(() => res({ ok: false, reason: 'timed out' }), IDENTITY_TIMEOUT_MS);
|
|
1220
|
+
});
|
|
1221
|
+
const answered = this.awaitIdentityAssertion().then((answer) => this.verdictFromAssertion(answer, audience, nonce, tokenHash));
|
|
1222
|
+
const got = await Promise.race([answered, timed]);
|
|
1223
|
+
return normalizeVerdict(got);
|
|
1224
|
+
}
|
|
1225
|
+
finally {
|
|
1226
|
+
if (timer !== undefined)
|
|
1227
|
+
clearTimeout(timer);
|
|
1228
|
+
/* Whichever side of the race won, nothing may still be waiting on
|
|
1229
|
+
* `identity-assert` once this settles. If `answered` won, the message
|
|
1230
|
+
* handler already cleared this itself. If `timed` won (the timeout),
|
|
1231
|
+
* NOTHING has cleared it yet -- `awaitIdentityAssertion`'s promise is
|
|
1232
|
+
* still pending, just no longer awaited by anyone. Leaving the
|
|
1233
|
+
* resolver live would let a LATE `identity-assert` (arriving after the
|
|
1234
|
+
* bound this method already returned past) still call it, running
|
|
1235
|
+
* `verdictFromAssertion` -- and therefore the verifier, and possibly a
|
|
1236
|
+
* JWKS fetch -- for a result nothing reads, while the message handler's
|
|
1237
|
+
* own comment claims a late message is ignored. Clearing here is what
|
|
1238
|
+
* makes that claim true. */
|
|
1239
|
+
this.identityAssertResolve = undefined;
|
|
785
1240
|
}
|
|
786
|
-
this.sendWelcome(null);
|
|
787
1241
|
}
|
|
788
1242
|
/** Resolve the connection's synced directory name, or null for every failure
|
|
789
1243
|
* mode there is (throw, rejection, non-string, timeout). */
|
|
790
1244
|
async resolveDirName(resolve, identity) {
|
|
1245
|
+
let timer;
|
|
791
1246
|
try {
|
|
792
|
-
const timed = new Promise((res) => setTimeout(() => res(null), DIR_NAME_TIMEOUT_MS));
|
|
1247
|
+
const timed = new Promise((res) => { timer = setTimeout(() => res(null), DIR_NAME_TIMEOUT_MS); });
|
|
793
1248
|
const got = await Promise.race([Promise.resolve(resolve(identity)), timed]);
|
|
794
1249
|
return typeof got === 'string' && got ? got : null;
|
|
795
1250
|
}
|
|
@@ -797,6 +1252,10 @@ export class SidecarServer {
|
|
|
797
1252
|
this.opts.log?.(`clientDirName failed: ${errMessage(err)}`);
|
|
798
1253
|
return null;
|
|
799
1254
|
}
|
|
1255
|
+
finally {
|
|
1256
|
+
if (timer !== undefined)
|
|
1257
|
+
clearTimeout(timer);
|
|
1258
|
+
}
|
|
800
1259
|
}
|
|
801
1260
|
/** Whether the identity rule can produce an account key right now. A
|
|
802
1261
|
* predicate that throws answers NO: an advertisement is a promise to the
|
|
@@ -843,6 +1302,11 @@ export class SidecarServer {
|
|
|
843
1302
|
// default, whose scheduled runs would otherwise fail one by one with
|
|
844
1303
|
// nothing on screen to say why.
|
|
845
1304
|
...(this.opts.runner?.hasDefaultModel ? ['default-model'] : []),
|
|
1305
|
+
// A client offers Stop on a watched daemon run only where it would work.
|
|
1306
|
+
...(this.opts.runner?.cancel ? ['run-cancel'] : []),
|
|
1307
|
+
// A client starts a task only where the daemon will honour it: an older
|
|
1308
|
+
// runner ignores `task`, and a dropped task must never look like a start.
|
|
1309
|
+
...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
|
|
846
1310
|
...(this.opts.acp ? ['acp'] : []),
|
|
847
1311
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
848
1312
|
...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
|
|
@@ -863,7 +1327,22 @@ export class SidecarServer {
|
|
|
863
1327
|
// behind it, so a client offers "Pause" only where pausing would actually
|
|
864
1328
|
// stop the agent -- the alternative is a button that reports success on a
|
|
865
1329
|
// daemon that keeps firing.
|
|
866
|
-
|
|
1330
|
+
// A deploy list holds pauses too (it answers the agent-pause-* messages
|
|
1331
|
+
// through the deploy core), so it earns the flag on its own.
|
|
1332
|
+
...(this.opts.pauses || this.opts.crewDeploy ? ['agent-pause'] : []),
|
|
1333
|
+
// Whether this daemon keeps a deploy list (crew deploy per machine).
|
|
1334
|
+
// Only with a real port behind it, so a client offers Deploy and
|
|
1335
|
+
// Undeploy only where the daemon would record them -- anywhere else a
|
|
1336
|
+
// client shows the old read-only view.
|
|
1337
|
+
...(this.opts.crewDeploy ? [CREW_DEPLOY_FEATURE] : []),
|
|
1338
|
+
// Whether this daemon keeps standing approvals (plan 3). Only with a real
|
|
1339
|
+
// service behind it, so a client shows the "This machine" group only
|
|
1340
|
+
// where there is something to list and revoke.
|
|
1341
|
+
...(this.opts.approvals ? ['approvals'] : []),
|
|
1342
|
+
// Whether this daemon runs confirmed checks and holds the orchestrator's
|
|
1343
|
+
// workspace lock. Only with a real service behind it (--allow-shell) that
|
|
1344
|
+
// is not switched off right now (see SidecarChecks.unavailable).
|
|
1345
|
+
...(this.checksRefusal() === null ? ['checks'] : []),
|
|
867
1346
|
// Whether this daemon can report and reclaim its synced host directories.
|
|
868
1347
|
// Advertised only with a real manifest behind it, so a client never
|
|
869
1348
|
// offers a destructive control to a daemon that would refuse it.
|
|
@@ -886,11 +1365,20 @@ export class SidecarServer {
|
|
|
886
1365
|
// why it must be true at the moment it is said, not merely true of how
|
|
887
1366
|
// the daemon was built. See `clientKeyActive`.
|
|
888
1367
|
...(this.opts.clientKey && this.clientKeyIsActive() ? ['account-identity'] : []),
|
|
1368
|
+
// Whether this daemon can send web push. Advertised only with a real
|
|
1369
|
+
// notifier behind it, so a client offers "notify me" only where a
|
|
1370
|
+
// subscription would actually be reached.
|
|
1371
|
+
...(this.opts.push ? ['web-push'] : []),
|
|
1372
|
+
// Whether this daemon verifies the browser's identity token. Advertised
|
|
1373
|
+
// only with a real verifier behind it; the verdict itself is `identity`.
|
|
1374
|
+
...(this.opts.identity ? ['verified-identity'] : []),
|
|
889
1375
|
];
|
|
890
1376
|
this.ch.send({
|
|
891
1377
|
t: 'welcome', tools, features,
|
|
892
1378
|
...(this.opts.info ? { info: this.opts.info } : {}),
|
|
893
1379
|
...(dirName ? { dirName } : {}),
|
|
1380
|
+
...(this.opts.push ? { vapidPublicKey: this.opts.push.publicKey } : {}),
|
|
1381
|
+
...(this.opts.identity ? { identity: this.identityResult } : {}),
|
|
894
1382
|
});
|
|
895
1383
|
}
|
|
896
1384
|
/** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
|
|
@@ -909,7 +1397,20 @@ export class SidecarServer {
|
|
|
909
1397
|
throw new Error('http(s) URLs only');
|
|
910
1398
|
if (!isLoopbackHost(u.hostname))
|
|
911
1399
|
throw new Error(`the sidecar proxy only reaches loopback hosts, not "${u.hostname}"`);
|
|
912
|
-
|
|
1400
|
+
// A NAME (only `localhost` passes the check above) is resolved and every
|
|
1401
|
+
// address re-checked, so a hosts-file or resolver entry cannot point it
|
|
1402
|
+
// off the machine. IP literals need no lookup.
|
|
1403
|
+
const bare = u.hostname.replace(/^\[/, '').replace(/\]$/, '');
|
|
1404
|
+
if (!isLoopbackAddress(bare) && this.opts.resolveHost) {
|
|
1405
|
+
const addrs = await this.opts.resolveHost(bare);
|
|
1406
|
+
if (addrs.length === 0 || addrs.some((a) => !isLoopbackAddress(a))) {
|
|
1407
|
+
throw new Error(`"${u.hostname}" resolves to a non-loopback address; the sidecar proxy only reaches loopback hosts`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
// redirect:'manual' -- a loopback service with an open redirect must not
|
|
1411
|
+
// bounce this request to a metadata or LAN host. The 3xx (with its
|
|
1412
|
+
// Location) goes back to the caller, which can decide what to do.
|
|
1413
|
+
const res = await fetch(m.url, { method: m.method, headers: m.headers, body: m.body, redirect: 'manual' });
|
|
913
1414
|
const headers = {};
|
|
914
1415
|
res.headers.forEach((v, k) => { headers[k] = v; });
|
|
915
1416
|
this.ch.send({ t: 'http-res', id: m.id, status: res.status, statusText: res.statusText, headers });
|
|
@@ -952,7 +1453,11 @@ export class SidecarServer {
|
|
|
952
1453
|
* which URLs are refused all live on the daemon side, where the
|
|
953
1454
|
* operator's declarations are. */
|
|
954
1455
|
...(m.repo ? { repo: m.repo } : {}),
|
|
955
|
-
|
|
1456
|
+
// From the AUTHENTICATED connection, never from this message: a
|
|
1457
|
+
// client naming an owner could label a session as someone
|
|
1458
|
+
// else's. `m.owner` is ignored; a connection that sent no
|
|
1459
|
+
// clientId files the session under nobody.
|
|
1460
|
+
...(this.clientId ? { owner: this.clientId } : {}),
|
|
956
1461
|
...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
|
|
957
1462
|
// From the HANDSHAKE, never from this message: the dot-dir is a
|
|
958
1463
|
// property of the client's workspace, and taking it from the
|
|
@@ -1181,7 +1686,12 @@ export class SidecarServer {
|
|
|
1181
1686
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
|
|
1182
1687
|
}
|
|
1183
1688
|
catch (e) {
|
|
1184
|
-
|
|
1689
|
+
// A read that finds nothing there (ENOENT) answered the question -- the
|
|
1690
|
+
// crew page probes every project for a crew folder most do not have.
|
|
1691
|
+
// Every other failure, and any from a tool that changes things, is logged.
|
|
1692
|
+
const notThere = tool.mutates !== true && e?.code === 'ENOENT';
|
|
1693
|
+
if (!notThere)
|
|
1694
|
+
this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
|
|
1185
1695
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
|
|
1186
1696
|
}
|
|
1187
1697
|
}
|
|
@@ -1212,6 +1722,10 @@ export class SidecarServer {
|
|
|
1212
1722
|
* user has -- the client does -- and the ACP client drops them if the
|
|
1213
1723
|
* agent never advertised mcpCapabilities.http. */
|
|
1214
1724
|
...(m.mcpServers?.length ? { mcpServers: m.mcpServers } : {}),
|
|
1725
|
+
/* The connection's own filed key -- the same value it attached to the
|
|
1726
|
+
* registry under -- so the proxied agent's asks reach whoever started
|
|
1727
|
+
* it under `--mode team`, and not their teammates. Never from `m`. */
|
|
1728
|
+
...(this.clientId ? { owner: this.clientId } : {}),
|
|
1215
1729
|
});
|
|
1216
1730
|
this.ch.send({ t: 'acp-started', id: m.id, ...started });
|
|
1217
1731
|
}
|
|
@@ -1302,6 +1816,19 @@ export class SidecarServer {
|
|
|
1302
1816
|
detachOnce();
|
|
1303
1817
|
}
|
|
1304
1818
|
}
|
|
1819
|
+
/** Stop a run, whoever started it. Same trust as `run`: a connection that may
|
|
1820
|
+
* start runs on this daemon may stop them. */
|
|
1821
|
+
onCancelRun(m) {
|
|
1822
|
+
const cancel = this.opts.runner?.cancel;
|
|
1823
|
+
if (!cancel) {
|
|
1824
|
+
this.ch.send({ t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'unsupported' });
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const out = cancel.call(this.opts.runner, m.runId);
|
|
1828
|
+
this.ch.send(out === 'cancelled'
|
|
1829
|
+
? { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: true }
|
|
1830
|
+
: { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'no-such-run' });
|
|
1831
|
+
}
|
|
1305
1832
|
/**
|
|
1306
1833
|
* What this caller's agent has actually done.
|
|
1307
1834
|
*
|
|
@@ -1364,6 +1891,14 @@ export class SidecarServer {
|
|
|
1364
1891
|
* says so rather than running the work in its own process anyway — silently
|
|
1365
1892
|
* downgrading is the failure IsolationLevel exists to prevent, and it would
|
|
1366
1893
|
* be invisible to the client that asked. */
|
|
1894
|
+
// A task is its own run with its own record and autonomy ceiling; the
|
|
1895
|
+
// session layer knows neither, so it is refused rather than dropped. So is
|
|
1896
|
+
// an autonomy on its own: the session path would ignore it, and a ceiling
|
|
1897
|
+
// the client asked for must never be silently dropped.
|
|
1898
|
+
if ((m.task || m.autonomy) && m.sessionId !== undefined) {
|
|
1899
|
+
this.ch.send({ t: 'run-error', id: m.id, error: 'A task starts its own run; it cannot run inside an existing session.' });
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1367
1902
|
if (m.sessionId !== undefined) {
|
|
1368
1903
|
const sessions = this.opts.sessions;
|
|
1369
1904
|
if (!sessions?.run) {
|
|
@@ -1386,7 +1921,10 @@ export class SidecarServer {
|
|
|
1386
1921
|
// would grant itself mutating tools inside a sandbox. Deliberately
|
|
1387
1922
|
// absent -- see session/guestRunner.ts.
|
|
1388
1923
|
this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, { keepTranscript: m.keepTranscript === true })
|
|
1389
|
-
: (agent, input, onEvent, identity) =>
|
|
1924
|
+
: (agent, input, onEvent, identity) =>
|
|
1925
|
+
// Task and autonomy are forwarded as asked; the runner, not this
|
|
1926
|
+
// class, decides whether the agent may run that way.
|
|
1927
|
+
this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, { task: m.task, autonomy: m.autonomy });
|
|
1390
1928
|
let runId;
|
|
1391
1929
|
const pending = [];
|
|
1392
1930
|
const onEvent = (event) => {
|
|
@@ -1542,6 +2080,17 @@ export class SidecarServer {
|
|
|
1542
2080
|
}
|
|
1543
2081
|
}
|
|
1544
2082
|
onAgentPauseList(m) {
|
|
2083
|
+
const deploy = this.opts.crewDeploy;
|
|
2084
|
+
if (deploy) {
|
|
2085
|
+
// A pause is a deploy entry now: the list comes from the deploy list.
|
|
2086
|
+
try {
|
|
2087
|
+
this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: true, pauses: deploy.legacyPauses() });
|
|
2088
|
+
}
|
|
2089
|
+
catch (err) {
|
|
2090
|
+
this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2091
|
+
}
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
1545
2094
|
if (!this.opts.pauses) {
|
|
1546
2095
|
// An honest empty answer for a daemon without the capability: it is
|
|
1547
2096
|
// holding no pauses, which is true. The feature gate is what tells a
|
|
@@ -1557,7 +2106,7 @@ export class SidecarServer {
|
|
|
1557
2106
|
}
|
|
1558
2107
|
}
|
|
1559
2108
|
async onAgentPauseSet(m) {
|
|
1560
|
-
if (!this.opts.pauses) {
|
|
2109
|
+
if (!this.opts.pauses && !this.opts.crewDeploy) {
|
|
1561
2110
|
// NOT an empty success. Accepting a pause this daemon cannot hold would
|
|
1562
2111
|
// leave the operator believing an agent had been stopped while it kept
|
|
1563
2112
|
// firing on the hour -- the exact failure the feature exists to end.
|
|
@@ -1571,6 +2120,23 @@ export class SidecarServer {
|
|
|
1571
2120
|
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: 'owner must be a string and agent a non-empty string' });
|
|
1572
2121
|
return;
|
|
1573
2122
|
}
|
|
2123
|
+
const deploy = this.opts.crewDeploy;
|
|
2124
|
+
if (deploy) {
|
|
2125
|
+
// An older app's pause goes through the deploy core, recorded as THIS
|
|
2126
|
+
// connection (the core adds who and why; an older app gives no reason).
|
|
2127
|
+
try {
|
|
2128
|
+
const out = await deploy.pauseByOwnerKey(this.deployCaller(), m.owner, m.agent, m.paused === true);
|
|
2129
|
+
this.ch.send(out.ok
|
|
2130
|
+
? { t: 'agent-pause-set-result', id: m.id, ok: true, pauses: out.pauses }
|
|
2131
|
+
: { t: 'agent-pause-set-result', id: m.id, ok: false, error: out.error });
|
|
2132
|
+
}
|
|
2133
|
+
catch (err) {
|
|
2134
|
+
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2135
|
+
}
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
if (!this.opts.pauses)
|
|
2139
|
+
return;
|
|
1574
2140
|
try {
|
|
1575
2141
|
const pauses = await this.opts.pauses.set(m.owner, m.agent, m.paused === true);
|
|
1576
2142
|
// Republish before replying: the caller's own row and every other
|
|
@@ -1583,13 +2149,304 @@ export class SidecarServer {
|
|
|
1583
2149
|
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1584
2150
|
}
|
|
1585
2151
|
}
|
|
2152
|
+
/** The caller for a deploy action: THIS connection, never a message field.
|
|
2153
|
+
* `dirName` is the directory the daemon resolved for this connection and
|
|
2154
|
+
* `verifiedUserId` the id it proved at pairing; both are absent until (and
|
|
2155
|
+
* unless) the daemon established them, which only ever narrows what the
|
|
2156
|
+
* core permits. */
|
|
2157
|
+
deployCaller() {
|
|
2158
|
+
return {
|
|
2159
|
+
kind: 'client',
|
|
2160
|
+
name: this.clientName ?? this.clientId ?? 'unknown app',
|
|
2161
|
+
...(this.dirName !== undefined ? { dirName: this.dirName } : {}),
|
|
2162
|
+
...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
async onCrewDeploy(m) {
|
|
2166
|
+
const port = this.opts.crewDeploy;
|
|
2167
|
+
const answer = (reply) => this.ch.send({ t: 'crew-deploy-result', id: m.id, reply });
|
|
2168
|
+
if (!port) {
|
|
2169
|
+
answer({ ok: false, error: 'this daemon does not keep a deploy list' });
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
// Runtime-typed: the message arrived via JSON.parse(...) as SidecarMsg.
|
|
2173
|
+
// The ref goes on as `unknown` (the core validates it); a reason or run id
|
|
2174
|
+
// that is not a string is dropped, never coerced.
|
|
2175
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
2176
|
+
try {
|
|
2177
|
+
switch (m.t) {
|
|
2178
|
+
case 'crew-deployments':
|
|
2179
|
+
answer(await port.deployments());
|
|
2180
|
+
return;
|
|
2181
|
+
case 'crew-deploy':
|
|
2182
|
+
answer(await port.deploy(this.deployCaller(), m.ref, str(m.reason)));
|
|
2183
|
+
return;
|
|
2184
|
+
case 'crew-undeploy':
|
|
2185
|
+
answer(await port.undeploy(this.deployCaller(), m.ref, str(m.reason)));
|
|
2186
|
+
return;
|
|
2187
|
+
case 'crew-pause':
|
|
2188
|
+
answer(await port.pause(this.deployCaller(), m.ref, str(m.reason) ?? ''));
|
|
2189
|
+
return;
|
|
2190
|
+
case 'crew-resume':
|
|
2191
|
+
answer(await port.resume(this.deployCaller(), m.ref, str(m.reason)));
|
|
2192
|
+
return;
|
|
2193
|
+
case 'crew-stop':
|
|
2194
|
+
answer(await port.stop(this.deployCaller(), m.ref, str(m.runId) ?? '', str(m.reason) ?? ''));
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
catch (err) {
|
|
2199
|
+
answer({ ok: false, error: errMessage(err) });
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
async onApprovalsAccept(m) {
|
|
2203
|
+
const port = this.opts.approvals;
|
|
2204
|
+
if (!port) {
|
|
2205
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
// Runtime-validated: the message arrived via JSON.parse(...) as SidecarMsg.
|
|
2209
|
+
if (typeof m.suggestionId !== 'string' || !m.suggestionId || (m.choice !== 'global' && m.choice !== 'not-now' && m.choice !== 'never')) {
|
|
2210
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'suggestionId must be a non-empty string and choice one of global, not-now, never' });
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
// A connection that never attached to attendance was never offered
|
|
2214
|
+
// anything, and has no identity to accept with.
|
|
2215
|
+
if (this.attended === undefined) {
|
|
2216
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'this connection was not offered that suggestion' });
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
// `this.attended` -- the object THIS connection attached -- is the only
|
|
2221
|
+
// accepter identity; the daemon compares it with the client it offered
|
|
2222
|
+
// to. Nothing in the message can name another connection.
|
|
2223
|
+
await port.accept(this.attended, m.suggestionId, m.choice);
|
|
2224
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: true });
|
|
2225
|
+
}
|
|
2226
|
+
catch (err) {
|
|
2227
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
async onApprovalsList(m) {
|
|
2231
|
+
const port = this.opts.approvals;
|
|
2232
|
+
if (!port) {
|
|
2233
|
+
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
try {
|
|
2237
|
+
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: true, state: await port.state() });
|
|
2238
|
+
}
|
|
2239
|
+
catch (err) {
|
|
2240
|
+
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
async onApprovalsRevoke(m) {
|
|
2244
|
+
const port = this.opts.approvals;
|
|
2245
|
+
if (!port) {
|
|
2246
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (typeof m.tool !== 'string' || !m.tool) {
|
|
2250
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'tool must be a non-empty string' });
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
try {
|
|
2254
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: true, state: await port.revoke(m.tool) });
|
|
2255
|
+
}
|
|
2256
|
+
catch (err) {
|
|
2257
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
/** Why the checks port cannot answer right now, or null when it can. */
|
|
2261
|
+
checksRefusal() {
|
|
2262
|
+
if (!this.opts.checks)
|
|
2263
|
+
return NO_CHECKS;
|
|
2264
|
+
try {
|
|
2265
|
+
return this.opts.checks.unavailable?.() ?? null;
|
|
2266
|
+
}
|
|
2267
|
+
catch (err) {
|
|
2268
|
+
// A guard that cannot answer is a guard that says no.
|
|
2269
|
+
return errMessage(err);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
async onChecksGet(m) {
|
|
2273
|
+
const refusal = this.checksRefusal();
|
|
2274
|
+
if (refusal !== null || !this.opts.checks) {
|
|
2275
|
+
this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: refusal ?? NO_CHECKS });
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
// Runtime-validate: the message arrived via `JSON.parse(...) as SidecarMsg`.
|
|
2279
|
+
if (typeof m.root !== 'string' || typeof m.stateDir !== 'string') {
|
|
2280
|
+
this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: 'root and stateDir must be strings' });
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
try {
|
|
2284
|
+
this.ch.send({ t: 'checks-get-result', id: m.id, ok: true, state: await this.opts.checks.get(m.root, m.stateDir) });
|
|
2285
|
+
}
|
|
2286
|
+
catch (err) {
|
|
2287
|
+
this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
async onChecksPin(m) {
|
|
2291
|
+
const refusal = this.checksRefusal();
|
|
2292
|
+
if (refusal !== null || !this.opts.checks) {
|
|
2293
|
+
this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: refusal ?? NO_CHECKS });
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
if (typeof m.root !== 'string' || typeof m.stateDir !== 'string' || typeof m.content !== 'string') {
|
|
2297
|
+
this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: 'root, stateDir and content must be strings' });
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
try {
|
|
2301
|
+
this.ch.send({ t: 'checks-pin-result', id: m.id, ok: true, state: await this.opts.checks.pin(m.root, m.stateDir, m.content) });
|
|
2302
|
+
}
|
|
2303
|
+
catch (err) {
|
|
2304
|
+
this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
async onWorkspaceRelease(m) {
|
|
2308
|
+
// Deliberately NOT gated on checksRefusal(): releasing only removes a run's
|
|
2309
|
+
// own lock, and a run that started before the port switched off must still
|
|
2310
|
+
// hand its lock back.
|
|
2311
|
+
if (!this.opts.checks) {
|
|
2312
|
+
this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: NO_CHECKS });
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (typeof m.root !== 'string' || typeof m.stateDir !== 'string' || typeof m.runId !== 'string') {
|
|
2316
|
+
this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: 'root, stateDir and runId must be strings' });
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
try {
|
|
2320
|
+
const released = await this.opts.checks.release({ root: m.root, stateDir: m.stateDir, runId: m.runId });
|
|
2321
|
+
this.ch.send({ t: 'workspace-release-result', id: m.id, ok: true, released });
|
|
2322
|
+
}
|
|
2323
|
+
catch (err) {
|
|
2324
|
+
this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
/** The identity a push subscription/unsubscription is filed under -- ALWAYS
|
|
2328
|
+
* this connection's own authenticated `clientId`/`accounts`, never anything
|
|
2329
|
+
* the message itself carries. Auth-gated first, same as every other
|
|
2330
|
+
* handler; a connection with no `clientId` (an older client, or one that
|
|
2331
|
+
* never claimed an identity) is refused rather than filed under nothing. */
|
|
2332
|
+
pushIdentity() {
|
|
2333
|
+
if (!this.authed)
|
|
2334
|
+
return { error: 'not authenticated' };
|
|
2335
|
+
if (!this.clientId)
|
|
2336
|
+
return { error: 'this connection carries no client identity, so there is nobody to notify' };
|
|
2337
|
+
return {
|
|
2338
|
+
clientId: this.clientId,
|
|
2339
|
+
accounts: this.accounts,
|
|
2340
|
+
...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
async onPushSubscribe(m) {
|
|
2344
|
+
const who = this.pushIdentity();
|
|
2345
|
+
if ('error' in who) {
|
|
2346
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: who.error });
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (!this.opts.push) {
|
|
2350
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'this daemon does not send web push' });
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
if (!isWebPushSubscription(m.subscription)) {
|
|
2354
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'malformed push subscription' });
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
try {
|
|
2358
|
+
await this.opts.push.subscribe(who, m.subscription);
|
|
2359
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: true });
|
|
2360
|
+
}
|
|
2361
|
+
catch (err) {
|
|
2362
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
async onPushUnsubscribe(m) {
|
|
2366
|
+
const who = this.pushIdentity();
|
|
2367
|
+
if ('error' in who) {
|
|
2368
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: who.error });
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
if (!this.opts.push) {
|
|
2372
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'this daemon does not send web push' });
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
if (typeof m.endpoint !== 'string') {
|
|
2376
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'malformed endpoint' });
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
try {
|
|
2380
|
+
await this.opts.push.unsubscribe(who, m.endpoint);
|
|
2381
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: true });
|
|
2382
|
+
}
|
|
2383
|
+
catch (err) {
|
|
2384
|
+
this.ch.send({ t: 'push-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
1586
2387
|
async onRoomOpen(m) {
|
|
1587
2388
|
if (!this.opts.rooms) {
|
|
1588
2389
|
this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: ['this sidecar does not host rooms'] });
|
|
1589
2390
|
return;
|
|
1590
2391
|
}
|
|
2392
|
+
/* Ownership must be OBSERVED, not claimed. `m.req.participants` crossed
|
|
2393
|
+
* the wire from a caller this connection has not otherwise proven
|
|
2394
|
+
* anything about beyond its `hello` token -- every id in it, including
|
|
2395
|
+
* the id the sender picked to represent THEMSELVES, is whatever they
|
|
2396
|
+
* typed. Without this, `room-list`'s membership filter and `mayAct`'s
|
|
2397
|
+
* role gate both key off a string the caller chose for itself, which
|
|
2398
|
+
* makes the whole boundary decorative: connect, claim to be the id
|
|
2399
|
+
* already recorded as owner, done.
|
|
2400
|
+
*
|
|
2401
|
+
* So a room cannot be opened at all without a connection that proved an
|
|
2402
|
+
* identity at `hello` (`this.clientId`) -- an unauthenticated-by-identity
|
|
2403
|
+
* opener could otherwise claim to be anyone, including someone already
|
|
2404
|
+
* seated in an existing room. And whichever participant in the request is
|
|
2405
|
+
* marked `role: 'owner'` has its id OVERWRITTEN with that proven
|
|
2406
|
+
* identity, never trusted as sent.
|
|
2407
|
+
*
|
|
2408
|
+
* The overwrite below is `.find()` -- it stamps the FIRST human carrying
|
|
2409
|
+
* `role: 'owner'`. Nothing here, or upstream of this call, guarantees a
|
|
2410
|
+
* request names only one: `.find()` picks the first regardless of how
|
|
2411
|
+
* many there are. A request naming more than one owner is separately
|
|
2412
|
+
* refused, wholesale, by `validateRoomRequest`'s own one-owner check
|
|
2413
|
+
* (packages/crew/room/open.ts) -- but that runs INSIDE `rooms.open`,
|
|
2414
|
+
* after this overwrite has already happened, and this code neither
|
|
2415
|
+
* depends on nor is ordered by it. If that downstream rule were ever
|
|
2416
|
+
* relaxed, this line would silently start choosing arbitrarily among
|
|
2417
|
+
* several claimed owners rather than refusing anything itself.
|
|
2418
|
+
*
|
|
2419
|
+
* Every OTHER human in the request keeps the id it was sent with: those
|
|
2420
|
+
* are clientIds the owner picked from their own attached connections, and
|
|
2421
|
+
* this connection has no way to verify them. A wrong one there grants
|
|
2422
|
+
* nobody rather than displacing the person it should have named -- widen
|
|
2423
|
+
* never redirect.
|
|
2424
|
+
*
|
|
2425
|
+
* ALL OF THAT IS GATED ON `--mode team`. With team mode off this handler
|
|
2426
|
+
* must be exactly what it was before rooms grew roles: a CLI, an ACP
|
|
2427
|
+
* client or an older host that sends no `clientId` at `hello` opens rooms
|
|
2428
|
+
* as it always did, and the id it sent for itself stands. Refusing them
|
|
2429
|
+
* unconditionally would make the opt-in flag not an opt-in -- see the
|
|
2430
|
+
* team-mode-off test in host.rooms.test.ts, which is the merge-safety
|
|
2431
|
+
* claim for this whole branch. */
|
|
2432
|
+
if (this.opts.teamMode && this.roomIdentity() === undefined) {
|
|
2433
|
+
this.ch.send({
|
|
2434
|
+
t: 'room-open-result',
|
|
2435
|
+
id: m.id,
|
|
2436
|
+
ok: false,
|
|
2437
|
+
problems: ['This connection has no verified identity (no clientId from hello), so it cannot open a room.'],
|
|
2438
|
+
});
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const clientId = this.roomIdentity();
|
|
2442
|
+
const owner = clientId === undefined
|
|
2443
|
+
? undefined
|
|
2444
|
+
: m.req.participants.find((p) => p.kind === 'human' && p.role === 'owner');
|
|
2445
|
+
const req = owner && clientId !== undefined
|
|
2446
|
+
? { ...m.req, participants: m.req.participants.map((p) => (p === owner ? { ...p, id: clientId } : p)) }
|
|
2447
|
+
: m.req;
|
|
1591
2448
|
try {
|
|
1592
|
-
const r = await this.opts.rooms.open(
|
|
2449
|
+
const r = await this.opts.rooms.open(req);
|
|
1593
2450
|
this.ch.send(r.ok
|
|
1594
2451
|
? { t: 'room-open-result', id: m.id, ok: true, room: r.room }
|
|
1595
2452
|
: { t: 'room-open-result', id: m.id, ok: false, problems: r.problems });
|
|
@@ -1609,7 +2466,27 @@ export class SidecarServer {
|
|
|
1609
2466
|
return;
|
|
1610
2467
|
}
|
|
1611
2468
|
try {
|
|
1612
|
-
|
|
2469
|
+
const rooms = await this.opts.rooms.list();
|
|
2470
|
+
/* Private by default, but ONLY once an operator opted into `--mode
|
|
2471
|
+
* team` -- off, this is the same list every daemon has always
|
|
2472
|
+
* returned, unnarrowed, for every existing room test and every daemon
|
|
2473
|
+
* nobody has reconfigured.
|
|
2474
|
+
*
|
|
2475
|
+
* Team mode on: membership is judged against `roomIdentity()`, the
|
|
2476
|
+
* connection's OWN proven identity (its verified user, else its clientId), never a participant id read out of
|
|
2477
|
+
* the request -- that is what `onRoomOpen` just finished making
|
|
2478
|
+
* trustworthy. A caller with NO clientId is not "an older client that
|
|
2479
|
+
* predates identity" here the way it is at `hello` -- membership
|
|
2480
|
+
* cannot be proven without an identity, and under private-by-default
|
|
2481
|
+
* the honest answer to "what can you not prove you're in" is nothing,
|
|
2482
|
+
* not everything. Answering with every room would make the boundary
|
|
2483
|
+
* opt-out: connect without a clientId and see it all anyway. */
|
|
2484
|
+
const mine = !this.opts.teamMode
|
|
2485
|
+
? rooms
|
|
2486
|
+
: this.roomIdentity() === undefined
|
|
2487
|
+
? []
|
|
2488
|
+
: rooms.filter((r) => r.participants.some((p) => p.id === this.roomIdentity()));
|
|
2489
|
+
this.ch.send({ t: 'room-list-result', id: m.id, ok: true, rooms: mine });
|
|
1613
2490
|
}
|
|
1614
2491
|
catch (err) {
|
|
1615
2492
|
// A room failure must never kill the connection, and it must not read
|
|
@@ -1625,6 +2502,33 @@ export class SidecarServer {
|
|
|
1625
2502
|
}
|
|
1626
2503
|
try {
|
|
1627
2504
|
const found = await this.opts.rooms.read(m.roomId);
|
|
2505
|
+
/* Private by default extends past `room-list` (R7) to the read itself:
|
|
2506
|
+
* without this, an authenticated connection that merely knows or
|
|
2507
|
+
* guesses a roomId got the full transcript regardless of membership --
|
|
2508
|
+
* the list filter alone was obscurity, not a boundary. Gated under
|
|
2509
|
+
* `teamMode` exactly like `room-list`, and judged against the same
|
|
2510
|
+
* proven `this.clientId`, never anything claimed in the message.
|
|
2511
|
+
*
|
|
2512
|
+
* A refusal here says SO -- "not a member" -- rather than answering
|
|
2513
|
+
* `room: null`, which is the honest "no such room" answer and would be
|
|
2514
|
+
* a lie about a room that does in fact exist. */
|
|
2515
|
+
if (found && this.opts.teamMode) {
|
|
2516
|
+
const me = this.roomIdentity();
|
|
2517
|
+
if (me === undefined) {
|
|
2518
|
+
this.ch.send({
|
|
2519
|
+
t: 'room-read-result', id: m.id, ok: false,
|
|
2520
|
+
error: 'This connection has no verified identity, so membership in this room cannot be proven.',
|
|
2521
|
+
});
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (!found.room.participants.some((p) => p.id === me)) {
|
|
2525
|
+
this.ch.send({
|
|
2526
|
+
t: 'room-read-result', id: m.id, ok: false,
|
|
2527
|
+
error: `"${me}" is not a member of room "${m.roomId}".`,
|
|
2528
|
+
});
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
1628
2532
|
// `found === null` here is Task 5's considered "no such room" -- keep
|
|
1629
2533
|
// it a successful `ok: true, room: null`, never conflated with the
|
|
1630
2534
|
// catch below's "could not read it" failure.
|
|
@@ -1654,13 +2558,63 @@ export class SidecarServer {
|
|
|
1654
2558
|
this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1655
2559
|
}
|
|
1656
2560
|
}
|
|
2561
|
+
/** WHO THIS CONNECTION IS, INSIDE A ROOM.
|
|
2562
|
+
*
|
|
2563
|
+
* The user its pairing JWT proved (`verifiedUserId`: API-signed, aud = this
|
|
2564
|
+
* daemon, single-use nonce, allowlisted azp) whenever there is one -- the
|
|
2565
|
+
* same user id the browser seats a person under, sanitized by the same rule
|
|
2566
|
+
* (an OpenAI `name` field accepts only [A-Za-z0-9_-]; src/rooms/participantId.ts).
|
|
2567
|
+
* Keying on `clientId` instead -- the browser's random wire id -- refused
|
|
2568
|
+
* people their own rooms: the room seated `user_...`, the check compared
|
|
2569
|
+
* `1463897a-...`.
|
|
2570
|
+
*
|
|
2571
|
+
* No verified user (no verifier configured, an older client, a JWT that did
|
|
2572
|
+
* not verify): `clientId`, exactly as before. Never a claimed id. */
|
|
2573
|
+
roomIdentity() {
|
|
2574
|
+
const verified = this.verifiedUserId?.replace(/[^A-Za-z0-9_-]/g, '');
|
|
2575
|
+
return verified || this.clientId;
|
|
2576
|
+
}
|
|
2577
|
+
/* ONE identity rule for every room verb.
|
|
2578
|
+
*
|
|
2579
|
+
* `room-read` judges membership on `roomIdentity()` -- the user this
|
|
2580
|
+
* connection's pairing JWT proved, else the clientId it PROVED at `hello`. The mutating verbs used to hand
|
|
2581
|
+
* `m.participantId` straight off the wire to `requireRole`/`mayAct`, so one
|
|
2582
|
+
* branch enforced while another trusted the caller: a viewer could send the
|
|
2583
|
+
* owner's participantId and close the room. Under team mode every verb now
|
|
2584
|
+
* judges the caller the same way `room-read` does, and a connection with no
|
|
2585
|
+
* proven identity is refused rather than attributed to whatever it claimed.
|
|
2586
|
+
*
|
|
2587
|
+
* KNOWN, DELIBERATE CONSEQUENCE (R9): the observed `clientId` and the
|
|
2588
|
+
* participant ids a room is opened with are currently DIFFERENT NAMESPACES
|
|
2589
|
+
* for everyone but the owner (whose id `onRoomOpen` overwrites with the
|
|
2590
|
+
* observed one). Until those namespaces are reconciled, these verbs will
|
|
2591
|
+
* REFUSE non-owner participants under `--mode team`. That refusal is the
|
|
2592
|
+
* intended outcome -- fail closed, consistently -- and must NOT be "fixed"
|
|
2593
|
+
* by falling back to the wire id, which is the hole this closes.
|
|
2594
|
+
*
|
|
2595
|
+
* With `--mode team` OFF this is the pre-existing behaviour, untouched: the
|
|
2596
|
+
* wire id is attribution only, exactly as the spec knowingly accepted. */
|
|
2597
|
+
actingId(claimed) {
|
|
2598
|
+
if (!this.opts.teamMode)
|
|
2599
|
+
return { ok: true, id: claimed };
|
|
2600
|
+
const me = this.roomIdentity();
|
|
2601
|
+
if (me === undefined) {
|
|
2602
|
+
return { ok: false, error: 'This connection has no verified identity (no clientId from hello), so it cannot act on a room.' };
|
|
2603
|
+
}
|
|
2604
|
+
return { ok: true, id: me };
|
|
2605
|
+
}
|
|
1657
2606
|
async onRoomAnswer(m) {
|
|
1658
2607
|
if (!this.opts.rooms) {
|
|
1659
2608
|
this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: 'this sidecar does not host rooms' });
|
|
1660
2609
|
return;
|
|
1661
2610
|
}
|
|
1662
2611
|
try {
|
|
1663
|
-
const
|
|
2612
|
+
const actor = this.actingId(m.participantId);
|
|
2613
|
+
if (!actor.ok) {
|
|
2614
|
+
this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: actor.error });
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
const room = await this.opts.rooms.answer(m.roomId, actor.id, m.text, m.addressed);
|
|
1664
2618
|
this.ch.send({ t: 'room-answer-result', id: m.id, ok: true, room });
|
|
1665
2619
|
}
|
|
1666
2620
|
catch (err) {
|
|
@@ -1675,7 +2629,12 @@ export class SidecarServer {
|
|
|
1675
2629
|
return;
|
|
1676
2630
|
}
|
|
1677
2631
|
try {
|
|
1678
|
-
const
|
|
2632
|
+
const actor = this.actingId(m.participantId);
|
|
2633
|
+
if (!actor.ok) {
|
|
2634
|
+
this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: actor.error });
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
const room = await this.opts.rooms.interject(m.roomId, actor.id, m.text);
|
|
1679
2638
|
this.ch.send({ t: 'room-interject-result', id: m.id, ok: true, room });
|
|
1680
2639
|
}
|
|
1681
2640
|
catch (err) {
|
|
@@ -1690,7 +2649,12 @@ export class SidecarServer {
|
|
|
1690
2649
|
return;
|
|
1691
2650
|
}
|
|
1692
2651
|
try {
|
|
1693
|
-
const
|
|
2652
|
+
const actor = this.actingId(m.participantId);
|
|
2653
|
+
if (!actor.ok) {
|
|
2654
|
+
this.ch.send({ t: 'room-close-result', id: m.id, ok: false, error: actor.error });
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
const room = await this.opts.rooms.close(m.roomId, actor.id, m.reason);
|
|
1694
2658
|
this.ch.send({ t: 'room-close-result', id: m.id, ok: true, room });
|
|
1695
2659
|
}
|
|
1696
2660
|
catch (err) {
|
|
@@ -1809,7 +2773,12 @@ export class SidecarServer {
|
|
|
1809
2773
|
return;
|
|
1810
2774
|
}
|
|
1811
2775
|
try {
|
|
1812
|
-
const
|
|
2776
|
+
const actor = this.actingId(m.participantId);
|
|
2777
|
+
if (!actor.ok) {
|
|
2778
|
+
this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: false, error: actor.error });
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
const findings = await this.opts.rooms.editFinding(m.roomId, actor.id, m.edit);
|
|
1813
2782
|
this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: true, findings });
|
|
1814
2783
|
}
|
|
1815
2784
|
catch (err) {
|