deepdebug-local-agent 1.2.0 → 1.4.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/.github/workflows/deploy-gcp.yml +58 -26
- package/CHANGELOG.md +13 -0
- package/docs/la-protocol-v2.md +68 -0
- package/docs/ws-metadata-only.md +44 -0
- package/env.example +7 -0
- package/package.json +2 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
- package/src/la-protocol-v2/chunks.js +45 -0
- package/src/la-protocol-v2/config.js +27 -0
- package/src/la-protocol-v2/content.js +57 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
- package/src/la-protocol-v2/contract/README.md +103 -0
- package/src/la-protocol-v2/contract/ack.schema.json +51 -0
- package/src/la-protocol-v2/contract/alert.schema.json +62 -0
- package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
- package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
- package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
- package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
- package/src/la-protocol-v2/contract/defs.schema.json +191 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
- package/src/la-protocol-v2/contract/error.schema.json +59 -0
- package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
- package/src/la-protocol-v2/contract/hello.schema.json +111 -0
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
- package/src/la-protocol-v2/contract/index.json +166 -0
- package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
- package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
- package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
- package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
- package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
- package/src/la-protocol-v2/contract/ready.schema.json +106 -0
- package/src/la-protocol-v2/contract/register.schema.json +92 -0
- package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
- package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
- package/src/la-protocol-v2/contract.js +90 -0
- package/src/la-protocol-v2/errors.js +34 -0
- package/src/la-protocol-v2/index.js +8 -0
- package/src/la-protocol-v2/link.js +93 -0
- package/src/la-protocol-v2/runtime.js +72 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/mcp-http-server.js +5 -3
- package/src/server.js +70 -9
- package/src/utils/exec-policy.js +8 -1
- package/src/ws-channel/allowlist.js +143 -0
- package/src/ws-channel/config.js +22 -0
- package/src/ws-channel/connection-state.js +18 -0
- package/src/ws-channel/index.js +6 -0
- package/src/ws-channel/metadata-guard.js +78 -0
- package/src/ws-channel/policy-offline.js +40 -0
- package/src/ws-channel/transit-guard.js +55 -0
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
- package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
- package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
- package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
- package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
- package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
- package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
- package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
- package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
- package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/la-rig.js +83 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
- package/test/la-protocol-v2/link.test.js +141 -0
- package/test/la-protocol-v2/session-inbound.test.js +279 -0
- package/test/la-protocol-v2/session-outbound.test.js +382 -0
- package/test/la-protocol-v2/traces.test.js +270 -0
- package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
- package/test/ws-channel/allowlist.test.js +67 -0
- package/test/ws-channel/metadata-guard.test.js +104 -0
- package/test/ws-channel/policy-offline.test.js +99 -0
- package/test/ws-channel/tunnel-e2e.test.js +88 -0
- package/tools/graphify-validation/extras/README.md +19 -0
- package/tools/graphify-validation/extras/bymethod.py +35 -0
- package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
- package/tools/graphify-validation/extras/docker/inner.sh +7 -0
- package/tools/graphify-validation/extras/g10.py +59 -0
- package/tools/graphify-validation/extras/g3aug.py +49 -0
- package/tools/graphify-validation/extras/hybrid.py +34 -0
- package/tools/graphify-validation/extras/truth2.py +44 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +21 -0
- package/tools/interop/la-interop-client.mjs +97 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Reference control plane for the tests of protocol v2 (F3-05b). NOT product code and NOT the gateway: a
|
|
2
|
+
// small Node model of the CP side that applies the rules of the contract README in the same order as the
|
|
3
|
+
// gateway's LaV2Session (size, parse, allowlist, schema, tenant, agent, direction, state, seq, chunks),
|
|
4
|
+
// signs its frames like AgentRequestSigner (VH-AGENT-SIG-V1, method WS, path = type) and keeps a session
|
|
5
|
+
// across connections so resume can be exercised. What it proves is limited by that: see the note
|
|
6
|
+
// "Fase 3 F3-05b" (interoperability with the real LaV2Session is a separate test, test/interop).
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
9
|
+
import { loadContract } from "../../../src/la-protocol-v2/contract.js";
|
|
10
|
+
import { canonicalString, sha256Hex } from "../../../src/utils/request-signing.js";
|
|
11
|
+
import { parseStrict } from "../../../src/la-protocol-v2/strict-json.js";
|
|
12
|
+
import { closeCodeOf, isFatal, isRetryable } from "../../../src/la-protocol-v2/errors.js";
|
|
13
|
+
|
|
14
|
+
export const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
15
|
+
export const AGENT = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d";
|
|
16
|
+
export const uuid = () => crypto.randomUUID();
|
|
17
|
+
|
|
18
|
+
export function newKeyPair() {
|
|
19
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
20
|
+
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32).toString("base64");
|
|
21
|
+
return { privateKey, publicKeyRaw: raw };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_LIMITS = { maxFrameBytes: 49152, chunkRawBytes: 32768, maxChunks: 64, ackTimeoutMs: 5000, maxRetries: 3,
|
|
25
|
+
heartbeatSeconds: 30, contentFirstByteMs: 10000, contentTotalMs: 30000 };
|
|
26
|
+
|
|
27
|
+
export class ReferenceCp {
|
|
28
|
+
constructor({ tenantId = TENANT, agentId = AGENT, privateKey, publicKeyRaw, keyId = "vh-cp-test", requireEnforce = false,
|
|
29
|
+
now = Date.now, limits = {}, workspaceId = "", skewSeconds = 60 } = {}) {
|
|
30
|
+
this.publicKeyRaw = publicKeyRaw;
|
|
31
|
+
this.contract = loadContract();
|
|
32
|
+
if (!privateKey) ({ privateKey, publicKeyRaw: this.publicKeyRaw } = newKeyPair());
|
|
33
|
+
Object.assign(this, { tenantId, agentId, privateKey, keyId, requireEnforce, now, skewSeconds, workspaceId });
|
|
34
|
+
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
|
35
|
+
this.sessionId = uuid();
|
|
36
|
+
this.laSeq = 0; this.cpSeq = 0;
|
|
37
|
+
this.ring = new Map();
|
|
38
|
+
this.unacked = new Map();
|
|
39
|
+
this.chunkStates = new Map();
|
|
40
|
+
this.state = "await_register";
|
|
41
|
+
this.reattached = false;
|
|
42
|
+
this.resume = null;
|
|
43
|
+
this.sendFn = () => {}; this.closeFn = () => {};
|
|
44
|
+
// observations
|
|
45
|
+
this.received = []; // LA data frames accepted, in order
|
|
46
|
+
this.duplicates = []; // seq of duplicate frames re-acked
|
|
47
|
+
this.errorsSent = []; // codes sent to the LA
|
|
48
|
+
this.peerErrors = []; // error frames the LA sent us
|
|
49
|
+
this.acksFromLa = [];
|
|
50
|
+
this.registers = [];
|
|
51
|
+
this.hellos = [];
|
|
52
|
+
this.rawIn = [];
|
|
53
|
+
this.dropAcks = 0; // do not ack the next n accepted LA data frames (simulates lost acks)
|
|
54
|
+
this.silent = false; // do not ack anything
|
|
55
|
+
this.ackDelayMs = 0; // answer acks late (slow consumer)
|
|
56
|
+
this.onReceived = () => {};
|
|
57
|
+
this.contentRequests = new Map(); // requestId -> {chunks:[], done}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A new connection of the same agent (resume candidate). */
|
|
61
|
+
attach(send, close) {
|
|
62
|
+
this.sendFn = send; this.closeFn = close;
|
|
63
|
+
for (const u of this.unacked.values()) clearTimeout(u.timer);
|
|
64
|
+
this.state = "await_register";
|
|
65
|
+
this.reattached = this.sessionStarted === true;
|
|
66
|
+
this.resume = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
detach() {
|
|
70
|
+
for (const u of this.unacked.values()) { clearTimeout(u.timer); u.timer = null; }
|
|
71
|
+
this.sendFn = () => {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ------------------------------------------------------------------ inbound (LA -> CP)
|
|
75
|
+
|
|
76
|
+
/** @returns {null | {code:string}} the refusal (error frame already sent, close done when fatal) */
|
|
77
|
+
feed(text) {
|
|
78
|
+
this.rawIn.push(text);
|
|
79
|
+
const r = this.#process(text);
|
|
80
|
+
if (r) {
|
|
81
|
+
this.errorsSent.push(r.code);
|
|
82
|
+
this.#sendError(r.code, r.refSeq, r.refMsgId);
|
|
83
|
+
if (isFatal(r.code)) { this.closeFn(closeCodeOf(r.code), r.code); this.detach(); }
|
|
84
|
+
}
|
|
85
|
+
return r;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#process(text) {
|
|
89
|
+
const L = this.limits;
|
|
90
|
+
if (Buffer.byteLength(text, "utf8") > L.maxFrameBytes) return { code: "frame_too_large" };
|
|
91
|
+
let m;
|
|
92
|
+
try { m = parseStrict(text); } catch { return { code: "bad_frame" }; }
|
|
93
|
+
if (m === null || typeof m !== "object" || Array.isArray(m)) return { code: "bad_frame" };
|
|
94
|
+
const info = this.contract.typeInfo(m.type);
|
|
95
|
+
if (!info) return { code: "unknown_type" };
|
|
96
|
+
const v = this.contract.validateFrame(m);
|
|
97
|
+
if (!v.ok) return { code: v.code };
|
|
98
|
+
if (m.tenantId !== this.tenantId) return { code: "tenant_mismatch" };
|
|
99
|
+
if (m.agentId !== this.agentId) return { code: "agent_mismatch" };
|
|
100
|
+
if (info.direction === "cp->la") return { code: "wrong_direction" };
|
|
101
|
+
if (this.state === "await_register") return m.type === "register" ? this.#onRegister(m) : { code: "handshake_violation" };
|
|
102
|
+
if (this.state === "await_hello") return m.type === "hello" ? this.#onHello(m) : { code: "not_ready" };
|
|
103
|
+
if (m.type === "register" || m.type === "hello") return { code: "handshake_violation" };
|
|
104
|
+
if (info.control) {
|
|
105
|
+
if (m.type === "ack") this.#onAck(m);
|
|
106
|
+
else if (m.type === "error") this.peerErrors.push(m.payload.code);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return this.#onData(m);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#onRegister(m) {
|
|
113
|
+
const skew = Math.abs(Date.parse(m.ts) - this.now()) / 1000;
|
|
114
|
+
if (skew > this.skewSeconds) return { code: "clock_skew" };
|
|
115
|
+
this.registers.push(m.payload);
|
|
116
|
+
this.resume = m.payload.resume || null;
|
|
117
|
+
this.state = "await_hello";
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#onHello(m) {
|
|
122
|
+
if (this.requireEnforce && m.payload.signatureMode !== "enforce") return { code: "signature_mode_insufficient" };
|
|
123
|
+
this.hellos.push(m.payload);
|
|
124
|
+
const resumed = !!(this.reattached && this.resume && this.resume.sessionId === this.sessionId && this.resume.lastRecvSeq <= this.cpSeq);
|
|
125
|
+
if (this.reattached && !resumed) this.#reset();
|
|
126
|
+
this.reattached = false;
|
|
127
|
+
this.sessionStarted = true;
|
|
128
|
+
if (resumed) this.#ackUpTo(this.resume.lastRecvSeq);
|
|
129
|
+
this.#sendReady(resumed);
|
|
130
|
+
this.state = "ready";
|
|
131
|
+
if (resumed) for (const u of this.unacked.values()) { u.retries = 0; this.#transmit(u); }
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#reset() {
|
|
136
|
+
for (const u of this.unacked.values()) clearTimeout(u.timer);
|
|
137
|
+
this.unacked.clear(); this.ring.clear(); this.chunkStates.clear();
|
|
138
|
+
this.laSeq = 0; this.cpSeq = 0; this.sessionId = uuid();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#onAck(m) {
|
|
142
|
+
this.acksFromLa.push({ ackSeq: m.payload.ackSeq, status: m.payload.status, errorCode: m.payload.errorCode });
|
|
143
|
+
if (m.payload.ackSeq > this.cpSeq) return;
|
|
144
|
+
if (m.payload.status === "rejected") {
|
|
145
|
+
this.#ackUpTo(m.payload.ackSeq - 1);
|
|
146
|
+
const u = this.unacked.get(m.payload.ackSeq);
|
|
147
|
+
if (u) { clearTimeout(u.timer); this.unacked.delete(m.payload.ackSeq); }
|
|
148
|
+
} else this.#ackUpTo(m.payload.ackSeq);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#ackUpTo(n) {
|
|
152
|
+
for (const [seq, u] of this.unacked) { if (seq > n) break; clearTimeout(u.timer); this.unacked.delete(seq); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#onData(m) {
|
|
156
|
+
const { seq, msgId } = m;
|
|
157
|
+
if (seq <= this.laSeq) {
|
|
158
|
+
if (this.ring.get(seq) === msgId) { this.duplicates.push(seq); if (!this.silent) this.#sendAck(seq, "duplicate"); return null; }
|
|
159
|
+
return { code: "seq_replayed", refSeq: seq, refMsgId: msgId };
|
|
160
|
+
}
|
|
161
|
+
if (seq !== this.laSeq + 1) return { code: "seq_gap", refSeq: seq, refMsgId: msgId };
|
|
162
|
+
if (m.type === "content.response") {
|
|
163
|
+
const bad = this.#chunkRule(m);
|
|
164
|
+
if (bad) return { code: bad, refSeq: seq, refMsgId: msgId };
|
|
165
|
+
}
|
|
166
|
+
this.laSeq = seq;
|
|
167
|
+
this.ring.set(seq, msgId);
|
|
168
|
+
while (this.ring.size > 512) this.ring.delete(this.ring.keys().next().value);
|
|
169
|
+
if (m.ack !== undefined) this.#ackUpTo(m.ack);
|
|
170
|
+
this.received.push(m);
|
|
171
|
+
if (m.type === "content.response") {
|
|
172
|
+
const c = this.contentRequests.get(m.requestId) || { chunks: [], done: false };
|
|
173
|
+
c.chunks.push(Buffer.from(m.payload.data, "base64"));
|
|
174
|
+
if (m.chunk.last) c.done = true;
|
|
175
|
+
this.contentRequests.set(m.requestId, c);
|
|
176
|
+
}
|
|
177
|
+
if (this.dropAcks > 0) this.dropAcks--;
|
|
178
|
+
else if (!this.silent) {
|
|
179
|
+
if (this.ackDelayMs > 0) setTimeout(() => this.#sendAck(seq, "accepted"), this.ackDelayMs);
|
|
180
|
+
else this.#sendAck(seq, "accepted");
|
|
181
|
+
}
|
|
182
|
+
this.onReceived(m);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#chunkRule(m) {
|
|
187
|
+
const { index, total, last } = m.chunk;
|
|
188
|
+
const st = this.chunkStates.get(m.requestId);
|
|
189
|
+
if (st && st.done) return "chunk_out_of_order";
|
|
190
|
+
if (st && st.total !== total) return "chunk_inconsistent";
|
|
191
|
+
if (index !== (st ? st.next : 0)) return "chunk_out_of_order";
|
|
192
|
+
if (last !== (index === total - 1)) return "chunk_inconsistent";
|
|
193
|
+
this.chunkStates.set(m.requestId, { total, next: index + 1, done: last });
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ------------------------------------------------------------------ outbound (CP -> LA)
|
|
198
|
+
|
|
199
|
+
#envelope(type, seq, requestId, workspaceId) {
|
|
200
|
+
return { schemaVersion: 2, type, msgId: uuid(), seq, tenantId: this.tenantId, agentId: this.agentId, workspaceId,
|
|
201
|
+
ts: new Date(this.now()).toISOString(), ...(requestId ? { requestId } : {}) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#sign(frame, type, payload, workspaceId) {
|
|
205
|
+
const raw = JSON.stringify(payload);
|
|
206
|
+
const timestamp = String(Math.floor(this.now() / 1000));
|
|
207
|
+
const nonce = crypto.randomBytes(18).toString("base64url");
|
|
208
|
+
const canonical = canonicalString({ method: "WS", pathAndQuery: type, bodySha256: sha256Hex(Buffer.from(raw, "utf8")),
|
|
209
|
+
tenantId: this.tenantId, workspaceId, timestamp, nonce });
|
|
210
|
+
const signature = crypto.sign(null, Buffer.from(canonical, "utf8"), this.privateKey).toString("base64");
|
|
211
|
+
frame.paramsRaw = raw;
|
|
212
|
+
frame.sig = { keyId: this.keyId, timestamp, nonce, tenantId: this.tenantId, workspaceId, signature };
|
|
213
|
+
frame.payload = payload;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#sendReady(resumed) {
|
|
217
|
+
const f = this.#envelope("ready", 0, null, this.workspaceId);
|
|
218
|
+
this.#sign(f, "ready", { protocol: 2, sessionId: this.sessionId, resumed, peerLastSeq: this.laSeq, limits: { ...this.limits } }, this.workspaceId);
|
|
219
|
+
this.sendFn(JSON.stringify(f));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#sendAck(ackSeq, status, errorCode) {
|
|
223
|
+
const f = this.#envelope("ack", 0, null, "");
|
|
224
|
+
f.payload = { ackSeq, status, ...(errorCode ? { errorCode } : {}) };
|
|
225
|
+
this.sendFn(JSON.stringify(f));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#sendError(code, refSeq, refMsgId) {
|
|
229
|
+
const f = this.#envelope("error", 0, null, "");
|
|
230
|
+
f.payload = { code, retryable: isRetryable(code), ...(refSeq ? { refSeq } : {}), ...(refMsgId ? { refMsgId } : {}), ...(isFatal(code) ? { closeCode: closeCodeOf(code) } : {}) };
|
|
231
|
+
this.sendFn(JSON.stringify(f));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Sends a CP->LA data frame (job.assign, content.request, ...). Retransmitted (re-signed) until acked. */
|
|
235
|
+
sendData(type, payload, { workspaceId = this.workspaceId, requestId } = {}) {
|
|
236
|
+
const seq = ++this.cpSeq;
|
|
237
|
+
const u = { seq, type, payload, workspaceId, requestId, msgId: uuid(), retries: 0, timer: null };
|
|
238
|
+
this.unacked.set(seq, u);
|
|
239
|
+
this.#transmit(u);
|
|
240
|
+
return seq;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#transmit(u) {
|
|
244
|
+
const f = this.#envelope(u.type, u.seq, u.requestId, u.workspaceId);
|
|
245
|
+
f.msgId = u.msgId;
|
|
246
|
+
if (this.laSeq > 0) f.ack = this.laSeq;
|
|
247
|
+
this.#sign(f, u.type, u.payload, u.workspaceId);
|
|
248
|
+
this.sendFn(JSON.stringify(f));
|
|
249
|
+
clearTimeout(u.timer);
|
|
250
|
+
u.timer = setTimeout(() => {
|
|
251
|
+
if (!this.unacked.has(u.seq)) return;
|
|
252
|
+
if (u.retries >= this.limits.maxRetries) { this.timedOut = true; this.closeFn(closeCodeOf("timeout"), "ack_timeout"); this.detach(); return; }
|
|
253
|
+
u.retries++; this.retransmits = (this.retransmits || 0) + 1;
|
|
254
|
+
this.#transmit(u);
|
|
255
|
+
}, this.limits.ackTimeoutMs);
|
|
256
|
+
u.timer.unref?.();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A signed v1 command envelope ({requestId, command, params, paramsRaw, sig}) as the v1 gateway sends it. */
|
|
260
|
+
v1Command(command, params = {}) {
|
|
261
|
+
const f = { requestId: uuid(), command, params };
|
|
262
|
+
const raw = JSON.stringify(params);
|
|
263
|
+
const timestamp = String(Math.floor(this.now() / 1000));
|
|
264
|
+
const nonce = crypto.randomBytes(18).toString("base64url");
|
|
265
|
+
const canonical = canonicalString({ method: "WS", pathAndQuery: command, bodySha256: sha256Hex(Buffer.from(raw, "utf8")),
|
|
266
|
+
tenantId: this.tenantId, workspaceId: this.workspaceId, timestamp, nonce });
|
|
267
|
+
f.paramsRaw = raw;
|
|
268
|
+
f.sig = { keyId: this.keyId, timestamp, nonce, tenantId: this.tenantId, workspaceId: this.workspaceId,
|
|
269
|
+
signature: crypto.sign(null, Buffer.from(canonical, "utf8"), this.privateKey).toString("base64") };
|
|
270
|
+
return JSON.stringify(f);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Sends exactly this frame (for the tests that need an invalid CP frame). */
|
|
274
|
+
sendRaw(text) { this.sendFn(text); }
|
|
275
|
+
frameWith(type, payload, over = {}) {
|
|
276
|
+
const seq = ++this.cpSeq;
|
|
277
|
+
const f = this.#envelope(type, seq, over.requestId, this.workspaceId);
|
|
278
|
+
this.#sign(f, type, payload, this.workspaceId);
|
|
279
|
+
return Object.assign(f, over.frame || {});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A WebSocket server in front of one ReferenceCp (one agent). Authorization: Bearer <token>. */
|
|
284
|
+
export class ReferenceCpServer {
|
|
285
|
+
constructor(opts = {}) {
|
|
286
|
+
this.cp = new ReferenceCp(opts);
|
|
287
|
+
this.privateKey = this.cp.privateKey;
|
|
288
|
+
this.publicKeyRaw = this.cp.publicKeyRaw;
|
|
289
|
+
this.keyId = this.cp.keyId;
|
|
290
|
+
this.token = opts.token || "tok";
|
|
291
|
+
this.connections = 0;
|
|
292
|
+
this.textsFromLa = []; // everything the LA sent that was not a v2 frame (pings, legacy register, v1 answers)
|
|
293
|
+
this.legacy = []; // parsed JSON frames of v1 shape
|
|
294
|
+
this.v2Disabled = !!opts.v2Disabled; // behave like a gateway without v2: ignore v2 frames
|
|
295
|
+
this.refuseV2 = !!opts.refuseV2; // answer the v2 register with error protocol_unsupported
|
|
296
|
+
this.closes = [];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async start() {
|
|
300
|
+
this.wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
301
|
+
await new Promise((r) => this.wss.on("listening", r));
|
|
302
|
+
this.port = this.wss.address().port;
|
|
303
|
+
this.wss.on("connection", (ws, req) => {
|
|
304
|
+
if (req.headers.authorization !== `Bearer ${this.token}`) { ws.close(1008, "unauthorized"); return; }
|
|
305
|
+
this.connections++;
|
|
306
|
+
this.ws = ws;
|
|
307
|
+
if (!this.v2Disabled) this.cp.attach((t) => { if (ws.readyState === 1) ws.send(t); }, (code, reason) => ws.close(code, reason));
|
|
308
|
+
ws.on("message", (d, isBinary) => {
|
|
309
|
+
const text = d.toString();
|
|
310
|
+
if (text === "ping") { ws.send("pong"); return; }
|
|
311
|
+
let o = null;
|
|
312
|
+
try { o = JSON.parse(text); } catch { /* not json */ }
|
|
313
|
+
if (o && o.schemaVersion !== undefined) {
|
|
314
|
+
if (this.v2Disabled) return;
|
|
315
|
+
if (this.refuseV2) { // like the gateway: the connection is v1 from now on; the v2 frames after the register are ignored
|
|
316
|
+
if (o.type === "register") ws.send(JSON.stringify({ schemaVersion: 2, type: "error", msgId: uuid(), seq: 0, tenantId: this.cp.tenantId, agentId: this.cp.agentId,
|
|
317
|
+
workspaceId: "", ts: new Date().toISOString(), payload: { code: "protocol_unsupported", retryable: false } }));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
this.cp.feed(text);
|
|
321
|
+
} else {
|
|
322
|
+
this.textsFromLa.push(text);
|
|
323
|
+
if (o) this.legacy.push(o);
|
|
324
|
+
}
|
|
325
|
+
void isBinary;
|
|
326
|
+
});
|
|
327
|
+
ws.on("close", (code) => { this.closes.push(code); this.cp.detach(); });
|
|
328
|
+
});
|
|
329
|
+
return this;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
get url() { return `http://127.0.0.1:${this.port}`; }
|
|
333
|
+
kill() { this.ws && this.ws.terminate(); }
|
|
334
|
+
async stop() {
|
|
335
|
+
for (const c of this.wss.clients) c.terminate();
|
|
336
|
+
this.cp.detach();
|
|
337
|
+
await new Promise((r) => this.wss.close(r));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function waitFor(cond, ms = 4000, step = 10) {
|
|
342
|
+
const t0 = Date.now();
|
|
343
|
+
while (Date.now() - t0 < ms) {
|
|
344
|
+
const v = await cond();
|
|
345
|
+
if (v) return v;
|
|
346
|
+
await new Promise((r) => setTimeout(r, step));
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`waitFor timed out after ${ms} ms: ${cond.toString().slice(0, 120)}`);
|
|
349
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Boots the REAL agent (src/server.js of `root`) against a ReferenceCpServer and returns what it did.
|
|
2
|
+
// Used by the flag-off characterization and by the end-to-end test of the v2 tunnel.
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
11
|
+
export const freePort = () => new Promise((res) => { const s = net.createServer().listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => res(p)); }); });
|
|
12
|
+
|
|
13
|
+
export async function runAgent({ srv, root = DEFAULT_ROOT, env = {}, cfg, until, ms = 20000 }) {
|
|
14
|
+
const ws = fs.mkdtempSync(path.join(os.tmpdir(), "v2e2e-ws-"));
|
|
15
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "v2e2e-home-"));
|
|
16
|
+
if (cfg) {
|
|
17
|
+
fs.mkdirSync(path.join(home, ".deepdebug"));
|
|
18
|
+
fs.writeFileSync(path.join(home, ".deepdebug", "config.json"), JSON.stringify(cfg));
|
|
19
|
+
}
|
|
20
|
+
const full = { ...process.env, PORT: String(await freePort()), GATEWAY_URL: srv.url, AUTH_TOKEN: srv.token, WORKSPACE_ROOT: ws, HOME: home, ...env };
|
|
21
|
+
delete full.LOCAL_AGENT_TOKEN;
|
|
22
|
+
for (const k of Object.keys(full)) if (full[k] === undefined) delete full[k];
|
|
23
|
+
const child = spawn(process.execPath, ["src/server.js"], { cwd: root, env: full, stdio: "ignore" });
|
|
24
|
+
try {
|
|
25
|
+
const deadline = Date.now() + ms;
|
|
26
|
+
while (Date.now() < deadline) {
|
|
27
|
+
if (until && (await until())) break;
|
|
28
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
29
|
+
}
|
|
30
|
+
} finally {
|
|
31
|
+
child.kill("SIGKILL");
|
|
32
|
+
fs.rmSync(ws, { recursive: true, force: true });
|
|
33
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// F3-05b: the glue between the v1 tunnel and the v2 session, on real sockets: negotiation, fallback to v1 on the
|
|
2
|
+
// same connection, v1 commands on a v2 connection, and what is (not) routed to the v2 pipeline.
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { ReferenceCpServer, uuid, waitFor } from "./helpers/reference-cp.js";
|
|
6
|
+
import { startRig } from "./helpers/la-rig.js";
|
|
7
|
+
import { isV2Frame } from "../../src/la-protocol-v2/link.js";
|
|
8
|
+
|
|
9
|
+
async function withRig(srvOpts, rigOpts, body) {
|
|
10
|
+
const srv = await new ReferenceCpServer(srvOpts).start();
|
|
11
|
+
const rig = startRig(srv, rigOpts);
|
|
12
|
+
try { await body(srv, rig); } finally { await rig.stop(); await srv.stop(); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("negotiation: v2 register+hello first; the legacy register is NOT sent when the gateway speaks v2", async () => {
|
|
16
|
+
await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
|
|
17
|
+
await rig.connect();
|
|
18
|
+
await waitFor(() => rig.link.mode === "v2");
|
|
19
|
+
assert.equal(rig.legacyRegisters, 0);
|
|
20
|
+
assert.equal(srv.legacy.length, 0, "no v1 frame was sent");
|
|
21
|
+
assert.deepEqual(srv.cp.registers.map((r) => r.protocol), [2]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("fallback: a gateway that ignores v2 gets the v1 register on the SAME connection after the handshake timeout", async () => {
|
|
26
|
+
await withRig({ v2Disabled: true }, { fallbackMs: 60_000 }, async (srv, rig) => {
|
|
27
|
+
await rig.connect();
|
|
28
|
+
assert.equal(rig.legacyRegisters, 0, "not yet: v2 owns the registration while it negotiates");
|
|
29
|
+
await waitFor(() => rig.legacyRegisters === 1, 2000);
|
|
30
|
+
assert.equal(rig.link.mode, "v1");
|
|
31
|
+
assert.equal(srv.connections, 1, "same connection");
|
|
32
|
+
await waitFor(() => srv.legacy.some((f) => f.type === "register" && "workspacePath" in f));
|
|
33
|
+
// v1 keeps working: a command reaches the v1 handler
|
|
34
|
+
srv.ws.send(JSON.stringify({ requestId: "r1", command: "health", params: {} }));
|
|
35
|
+
await waitFor(() => rig.v1Messages.length === 1);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("fallback is remembered: the next connection goes straight to v1 (no 10 s wait per reconnect), until fallbackMs passes", async () => {
|
|
40
|
+
await withRig({ v2Disabled: true }, { fallbackMs: 150 }, async (srv, rig) => {
|
|
41
|
+
await rig.connect();
|
|
42
|
+
await waitFor(() => rig.legacyRegisters === 1, 2000);
|
|
43
|
+
rig.drop();
|
|
44
|
+
await waitFor(() => srv.closes.length === 1);
|
|
45
|
+
await rig.connect();
|
|
46
|
+
assert.equal(rig.legacyRegisters, 2, "legacy register immediately on the second connection");
|
|
47
|
+
rig.drop();
|
|
48
|
+
await waitFor(() => srv.closes.length === 2);
|
|
49
|
+
await new Promise((r) => setTimeout(r, 200)); // fallbackMs elapsed
|
|
50
|
+
await rig.connect();
|
|
51
|
+
assert.equal(rig.legacyRegisters, 2, "v2 is offered again");
|
|
52
|
+
await waitFor(() => rig.legacyRegisters === 3, 2000); // ... and falls back again after the timeout
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("fallback: error protocol_unsupported falls back at once, without waiting for the timeout", async () => {
|
|
57
|
+
await withRig({ refuseV2: true }, { options: { handshakeTimeoutMs: 5000 } }, async (srv, rig) => {
|
|
58
|
+
const t0 = Date.now();
|
|
59
|
+
await rig.connect();
|
|
60
|
+
await waitFor(() => rig.legacyRegisters === 1, 1500);
|
|
61
|
+
assert.ok(Date.now() - t0 < 1200, `fell back after ${Date.now() - t0} ms`);
|
|
62
|
+
assert.equal(rig.link.mode, "v1");
|
|
63
|
+
assert.equal(srv.connections, 1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("no fallback when the gateway refuses the handshake for a reason that v1 would not fix (signature_mode_insufficient)", async () => {
|
|
68
|
+
await withRig({ requireEnforce: true }, { mode: "warn", options: { handshakeTimeoutMs: 300 } }, async (srv, rig) => {
|
|
69
|
+
await rig.connect();
|
|
70
|
+
await waitFor(() => srv.cp.errorsSent.includes("signature_mode_insufficient"));
|
|
71
|
+
await new Promise((r) => setTimeout(r, 450));
|
|
72
|
+
assert.equal(rig.legacyRegisters, 0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("v1 commands keep working on a v2 connection: they reach the existing handler, not the v2 pipeline", async () => {
|
|
77
|
+
await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
|
|
78
|
+
await rig.connect();
|
|
79
|
+
await waitFor(() => rig.link.mode === "v2");
|
|
80
|
+
const cmd = srv.cp.v1Command("health", {});
|
|
81
|
+
srv.ws.send(cmd);
|
|
82
|
+
await waitFor(() => rig.v1Messages.length === 1);
|
|
83
|
+
assert.equal(rig.v1Messages[0], cmd);
|
|
84
|
+
assert.deepEqual(srv.cp.peerErrors, []);
|
|
85
|
+
assert.equal(rig.session.stats.framesIn, 1, "only the ready went through the v2 pipeline");
|
|
86
|
+
// and v2 is unaffected
|
|
87
|
+
await rig.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("routing: anything that claims schemaVersion goes to the v2 pipeline (and is refused if wrong); everything else is v1's business", async () => {
|
|
92
|
+
await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
|
|
93
|
+
await rig.connect();
|
|
94
|
+
await waitFor(() => rig.link.mode === "v2");
|
|
95
|
+
srv.ws.send("not json at all");
|
|
96
|
+
srv.ws.send(JSON.stringify({ type: "something-v1-only" }));
|
|
97
|
+
await waitFor(() => rig.v1Messages.length === 2);
|
|
98
|
+
assert.deepEqual(srv.cp.peerErrors, []);
|
|
99
|
+
srv.ws.send(JSON.stringify({ schemaVersion: 2, type: "shell.exec" }));
|
|
100
|
+
await waitFor(() => srv.cp.peerErrors.length === 1);
|
|
101
|
+
assert.deepEqual(srv.cp.peerErrors, ["unknown_type"]);
|
|
102
|
+
await waitFor(() => srv.closes.includes(4401));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("routing: a binary frame is dropped by the v2 pipeline, never handed to the v1 handler", async () => {
|
|
107
|
+
await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
|
|
108
|
+
await rig.connect();
|
|
109
|
+
await waitFor(() => rig.link.mode === "v2");
|
|
110
|
+
srv.ws.send(Buffer.from([0, 1, 2]), { binary: true });
|
|
111
|
+
await waitFor(() => rig.session.stats.nonText === 1);
|
|
112
|
+
assert.equal(rig.v1Messages.length, 0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("isV2Frame: schemaVersion present = v2; a v1 command, text and arrays are not; an oversized frame is judged by its head", () => {
|
|
117
|
+
assert.equal(isV2Frame('{"schemaVersion":2,"type":"x"}', 49152), true);
|
|
118
|
+
assert.equal(isV2Frame('{"schemaVersion":1}', 49152), true, "a wrong version is still a v2 attempt (and gets refused)");
|
|
119
|
+
assert.equal(isV2Frame('{"requestId":"r","command":"health","params":{}}', 49152), false);
|
|
120
|
+
assert.equal(isV2Frame('["schemaVersion"]', 49152), false);
|
|
121
|
+
assert.equal(isV2Frame("pong", 49152), false);
|
|
122
|
+
assert.equal(isV2Frame(`{"schemaVersion":2,"pad":"${"x".repeat(60_000)}"}`, 49152), true);
|
|
123
|
+
assert.equal(isV2Frame(`{"requestId":"r","command":"workspace.safe-patch","params":{"diff":"${"x".repeat(60_000)}"}}`, 49152), false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the link survives a reconnect with the session intact (resume offered on the new socket)", async () => {
|
|
127
|
+
await withRig({ requireEnforce: true }, {}, async (srv, rig) => {
|
|
128
|
+
await rig.connect();
|
|
129
|
+
await waitFor(() => rig.link.mode === "v2");
|
|
130
|
+
await rig.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
|
|
131
|
+
const sid = rig.session.sessionId;
|
|
132
|
+
rig.drop();
|
|
133
|
+
await waitFor(() => rig.session.state === "detached");
|
|
134
|
+
await rig.connect();
|
|
135
|
+
await waitFor(() => rig.link.mode === "v2" && rig.session.state === "ready");
|
|
136
|
+
assert.equal(rig.session.sessionId, sid);
|
|
137
|
+
assert.equal(srv.cp.registers.at(-1).resume.sessionId, sid);
|
|
138
|
+
assert.equal(rig.legacyRegisters, 0);
|
|
139
|
+
assert.ok(uuid());
|
|
140
|
+
});
|
|
141
|
+
});
|