deepdebug-local-agent 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/docs/la-protocol-v2.md +69 -5
  2. package/env.example +4 -0
  3. package/package.json +1 -1
  4. package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
  5. package/src/la-protocol-v2/content-handlers/audit.js +36 -0
  6. package/src/la-protocol-v2/content-handlers/config.js +54 -0
  7. package/src/la-protocol-v2/content-handlers/content-index.js +161 -0
  8. package/src/la-protocol-v2/content-handlers/egress.js +65 -0
  9. package/src/la-protocol-v2/content-handlers/git-diff.js +142 -0
  10. package/src/la-protocol-v2/content-handlers/handler.js +182 -0
  11. package/src/la-protocol-v2/content-handlers/index.js +6 -0
  12. package/src/la-protocol-v2/content-handlers/install.js +65 -0
  13. package/src/la-protocol-v2/content-handlers/path-guard.js +183 -0
  14. package/src/la-protocol-v2/content-handlers/ref-registry.js +105 -0
  15. package/src/la-protocol-v2/content.js +7 -1
  16. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +9 -6
  17. package/src/la-protocol-v2/contract/README.md +49 -2
  18. package/src/la-protocol-v2/contract/content.index.schema.json +92 -0
  19. package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
  20. package/src/la-protocol-v2/contract/index.json +7 -0
  21. package/src/la-protocol-v2/runtime.js +23 -4
  22. package/src/server.js +4 -1
  23. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3777 -0
  24. package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
  25. package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
  26. package/test/la-protocol-v2/content-alignment.test.js +608 -0
  27. package/test/la-protocol-v2/content-handlers.test.js +627 -0
  28. package/test/la-protocol-v2/content-path-guard.test.js +219 -0
  29. package/test/la-protocol-v2/content-wiring.test.js +250 -0
  30. package/test/la-protocol-v2/contract.test.js +5 -5
  31. package/test/la-protocol-v2/helpers/content-rig.js +94 -0
  32. package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
  33. package/test/la-protocol-v2/helpers/spawn-agent.js +2 -1
  34. package/test/la-protocol-v2/traces.test.js +29 -4
  35. package/tools/interop/LaV2NodeContentInteropTest.java +262 -0
  36. package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
  37. package/tools/interop/README.md +17 -0
  38. package/tools/interop/la-interop-content-client.mjs +102 -0
@@ -1,4 +1,4 @@
1
- // F3-05b: the 24 whole-session traces of the contract (F3-22), reproduced in Node. "Traces are language
1
+ // F3-05b (+2 of F3-07b, content.index): the 26 whole-session traces of the contract (F3-22), reproduced in Node. "Traces are language
2
2
  // neutral; the LA must reproduce each expect.error." Three things are shown for them:
3
3
  // 1. the reference CP (test helper) reproduces every trace: same error at the same frame (so the model the
4
4
  // other tests use is the model of the contract);
@@ -18,9 +18,9 @@ import { V2Session } from "../../src/la-protocol-v2/session.js";
18
18
  const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors/traces");
19
19
  const traces = fs.readdirSync(DIR).filter((f) => f.endsWith(".json")).sort().map((f) => ({ file: f, name: f.replace(".json", ""), ...JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8")) }));
20
20
 
21
- test("the suite has all 24 traces (20 invalid, 4 valid)", () => {
22
- assert.equal(traces.length, 24);
23
- assert.equal(traces.filter((t) => t.expect.ok).length, 4);
21
+ test("the suite has all 26 traces (21 invalid, 5 valid)", () => {
22
+ assert.equal(traces.length, 26);
23
+ assert.equal(traces.filter((t) => t.expect.ok).length, 5);
24
24
  });
25
25
 
26
26
  const textOf = (fr) => {
@@ -223,6 +223,31 @@ test("valid-content-chunks-in-order: the three chunks of the trace go out in ord
223
223
  env.session.close();
224
224
  });
225
225
 
226
+ test("valid-content-index: the two inventories of the trace go out in order after ready and the CP accepts them", async () => {
227
+ const t = traces.find((x) => x.name === "valid-content-index");
228
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
229
+ const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
230
+ await waitFor(() => env.session.state === "ready");
231
+ for (const fr of LA_FRAMES(t).filter((f) => f.frame.type === "content.index")) {
232
+ await env.session.send("content.index", fr.frame.payload, { workspaceId: fr.frame.workspaceId });
233
+ }
234
+ assert.deepEqual(cp.received.map((f) => [f.type, f.seq]), [["content.index", 1], ["content.index", 2]]);
235
+ assert.deepEqual(cp.errorsSent, []);
236
+ env.session.close();
237
+ });
238
+
239
+ test("invalid-content-index-before-ready: the LA never produces it (no data before ready: not_ready, nothing written)", async () => {
240
+ const cp = new ReferenceCp();
241
+ const sent = [];
242
+ const env = inMemory(cp);
243
+ env.session.attach({ send: (x) => sent.push(JSON.parse(x)), close() {}, bufferedAmount: () => 0 });
244
+ const t = traces.find((x) => x.name === "invalid-content-index-before-ready");
245
+ const bad = t.frames[t.expect.atFrame].frame;
246
+ await assert.rejects(env.session.enqueue("content.index", bad.payload, { workspaceId: bad.workspaceId }), (e) => e.code === "not_ready");
247
+ assert.deepEqual(sent.map((f) => f.type), ["register", "hello"]);
248
+ env.session.close();
249
+ });
250
+
226
251
  test("valid-retransmit-is-idempotent: an ack lost -> the same frame again -> the CP re-acks and counts one event", async () => {
227
252
  const cp = new ReferenceCp({ limits: { ackTimeoutMs: 100 } });
228
253
  const env = inMemory(cp).start();
@@ -0,0 +1,262 @@
1
+ package io.visionhammer.gateway.cloud.infrastructure.byoc;
2
+
3
+ import com.fasterxml.jackson.databind.JsonNode;
4
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaProtocolV2Properties;
5
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Chunk;
6
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2InboundListener;
7
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Metrics;
8
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Session;
9
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2SessionStore;
10
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit;
11
+ import io.visionhammer.gateway.cloud.application.content.ContentIndexRegistry;
12
+ import io.visionhammer.gateway.cloud.application.content.ContentRelayProperties;
13
+ import io.visionhammer.gateway.governance.application.DataPlanePolicy;
14
+ import io.visionhammer.gateway.identity.application.AgentAuthorizationEntity;
15
+ import io.visionhammer.gateway.identity.application.AgentAuthorizationStore;
16
+ import io.visionhammer.gateway.shared.localagent.AgentRequestSigner;
17
+ import io.visionhammer.gateway.workspace.application.WorkspaceRepository;
18
+ import org.junit.jupiter.api.Test;
19
+ import org.springframework.http.server.reactive.HttpHandler;
20
+ import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
21
+ import org.springframework.web.reactive.socket.server.WebSocketService;
22
+ import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService;
23
+ import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy;
24
+ import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
25
+ import reactor.core.publisher.Mono;
26
+ import reactor.core.scheduler.Schedulers;
27
+ import reactor.netty.DisposableServer;
28
+ import reactor.netty.http.server.HttpServer;
29
+
30
+ import java.io.BufferedReader;
31
+ import java.io.InputStreamReader;
32
+ import java.nio.charset.StandardCharsets;
33
+ import java.security.MessageDigest;
34
+ import java.time.Clock;
35
+ import java.time.Duration;
36
+ import java.util.ArrayList;
37
+ import java.util.Collections;
38
+ import java.util.HexFormat;
39
+ import java.util.List;
40
+ import java.util.Map;
41
+ import java.util.UUID;
42
+ import java.util.concurrent.BlockingQueue;
43
+ import java.util.concurrent.LinkedBlockingQueue;
44
+ import java.util.concurrent.TimeUnit;
45
+
46
+ import static io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit.OM;
47
+ import static org.assertj.core.api.Assertions.assertThat;
48
+ import static org.junit.jupiter.api.Assumptions.assumeTrue;
49
+ import static org.mockito.ArgumentMatchers.any;
50
+ import static org.mockito.ArgumentMatchers.anyString;
51
+ import static org.mockito.Mockito.mock;
52
+ import static org.mockito.Mockito.when;
53
+
54
+ /**
55
+ * F3-07 interoperability: the REAL Node Local Agent content handlers (opaque ids, path guard, DLP enforce, git diff,
56
+ * chunking) against the REAL gateway {@link LaV2Session} behind a real Reactor Netty WebSocket on loopback, real
57
+ * system clock, real Ed25519 signer. Run from the gateway checkout (JDK 21):
58
+ *
59
+ * LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeContentInteropTest
60
+ *
61
+ * Skipped when LA_REPO is not set. LA_DLP_MODE=off is the negative control: the agent then registers NO content handler
62
+ * (F3-07b) and the test fails at the first request. Not proven here: Cloud Run / LB / TLS, Memorystore, two gateway instances, the HTTP
63
+ * relay endpoint (F3-06) in front of this session.
64
+ */
65
+ class LaV2NodeContentInteropTest {
66
+
67
+ private static final String TENANT = LaV2TestKit.TENANT;
68
+ private static final String AGENT = LaV2TestKit.AGENT;
69
+
70
+ private static Map<String, Object> request(String kind, String ref) {
71
+ return Map.of("kind", kind, "ref", ref, "actor", Map.of("userId", "usr-42", "role", "member"));
72
+ }
73
+
74
+ @Test
75
+ void realNodeContentHandlersAgainstTheRealLaV2Session() throws Exception {
76
+ String laRepo = System.getenv("LA_REPO");
77
+ assumeTrue(laRepo != null && !laRepo.isBlank(), "LA_REPO not set");
78
+
79
+ Clock clock = Clock.systemUTC();
80
+ LaProtocolV2Properties props = new LaProtocolV2Properties();
81
+ props.setEnabled(true);
82
+ List<String> rejected = Collections.synchronizedList(new ArrayList<>());
83
+ LaV2Metrics metrics = new LaV2Metrics() {
84
+ @Override public void rejected(String code) { rejected.add(code); }
85
+ };
86
+ LaV2InboundListener listener = (t, a, type, rid, payload) -> { };
87
+ AgentRequestSigner signer = new AgentRequestSigner(LaV2TestKit.PEM, "k1", clock);
88
+ // F3-07b: the agent announces its ids with content.index; the gateway keeps them in this registry (without a sink it would answer
89
+ // `rejected protocol_unsupported`, which the metrics below count)
90
+ ContentRelayProperties relayProps = new ContentRelayProperties();
91
+ relayProps.setEnabled(true);
92
+ ContentIndexRegistry index = new ContentIndexRegistry(relayProps, null, System::currentTimeMillis);
93
+ LaV2Session.Env env = new LaV2Session.Env(LaV2TestKit.CONTRACT, props, signer, OM, clock, metrics, Schedulers.parallel(), List.of(listener), index);
94
+
95
+ AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
96
+ WorkspaceRepository workspaces = mock(WorkspaceRepository.class);
97
+ DataPlanePolicy policy = mock(DataPlanePolicy.class);
98
+ when(workspaces.findDefaultByTenantId(anyString())).thenReturn(Mono.empty());
99
+ when(store.save(any())).thenAnswer(i -> Mono.just(i.getArgument(0)));
100
+ when(store.touchLastSeen(any())).thenReturn(Mono.just(1));
101
+ when(policy.requiresEnforcedSigning(anyString())).thenReturn(Mono.just(true));
102
+ AgentAuthorizationEntity auth = new AgentAuthorizationEntity();
103
+ auth.setId(UUID.fromString(AGENT));
104
+ auth.setTenantId(UUID.fromString(TENANT));
105
+ auth.setAuthorizationToken("tok");
106
+ when(store.findByAuthorizationToken("tok")).thenReturn(Mono.just(auth));
107
+ AgentWebSocketHandler handler = new AgentWebSocketHandler(OM, workspaces, store, signer, null, null, null,
108
+ new LaV2SessionStore(env), props, policy);
109
+
110
+ WebSocketService wsService = new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy());
111
+ HttpHandler http = WebHttpHandlerBuilder.webHandler(exchange -> wsService.handleRequest(exchange, handler)).build();
112
+ DisposableServer server = HttpServer.create().host("127.0.0.1").port(0).handle(new ReactorHttpHandlerAdapter(http)).bindNow();
113
+
114
+ ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-content-client.mjs");
115
+ pb.environment().putAll(Map.of(
116
+ "GW_URL", "ws://127.0.0.1:" + server.port() + "/api/v1/agent/ws", "TOKEN", "tok", "TENANT", TENANT, "AGENT", AGENT,
117
+ "KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce",
118
+ "LOCAL_AGENT_DLP_MODE", System.getenv().getOrDefault("LA_DLP_MODE", "enforce")));
119
+ pb.redirectErrorStream(true);
120
+ Process proc = pb.start();
121
+ BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
122
+ List<String> transcript = Collections.synchronizedList(new ArrayList<>());
123
+ Thread reader = new Thread(() -> {
124
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
125
+ String line;
126
+ while ((line = r.readLine()) != null) {
127
+ transcript.add(line);
128
+ try {
129
+ events.add(OM.readTree(line));
130
+ } catch (Exception ignored) {
131
+ // node warnings
132
+ }
133
+ }
134
+ } catch (Exception ignored) {
135
+ // process ended
136
+ }
137
+ });
138
+ reader.setDaemon(true);
139
+ reader.start();
140
+ java.io.Writer stdin = new java.io.OutputStreamWriter(proc.getOutputStream(), StandardCharsets.UTF_8);
141
+
142
+ try {
143
+ JsonNode refsEvt = await(events, "refs", 20);
144
+ await(events, "ready", 15);
145
+ assertThat(handler.isAgentConnected(TENANT)).isTrue();
146
+ JsonNode refs = refsEvt.path("refs");
147
+ String ghp = refsEvt.path("ghp").asText();
148
+ assertThat(refs.path("envFile").isNull()).as("no id is minted for .env").isTrue();
149
+ assertThat(refs.path("envBackup").isNull()).as("no id is minted for .env~ (F3-07b)").isTrue();
150
+ // F3-07b: the ids are the 43-char base64url ones the gateway requires
151
+ for (String k : List.of("app", "big", "mid", "swapped", "bin", "diffFile")) {
152
+ assertThat(refs.path(k).asText()).as(k).matches("^[A-Za-z0-9_-]{43}$");
153
+ }
154
+
155
+ // 1. a file with a token: served, the token masked (DLP enforce), the rest intact
156
+ List<LaV2Chunk> app = content("file", refs.path("app").asText(), handler);
157
+ assertThat(app.get(0).status()).isEqualTo("ok");
158
+ String appText = new String(concat(app), StandardCharsets.UTF_8);
159
+ assertThat(appText).contains("first line").contains("[REDACTED:github_token]").contains("last line").doesNotContain(ghp);
160
+
161
+ // 2. 1 MiB = 32 chunks, byte for byte (no secrets in it)
162
+ List<LaV2Chunk> big = content("file", refs.path("big").asText(), handler);
163
+ assertThat(big).hasSize(32);
164
+ assertThat(big.get(31).last()).isTrue();
165
+ assertThat(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(concat(big)))).isEqualTo(refsEvt.path("bigSha256").asText());
166
+ assertThat(concat(big)).hasSize(refsEvt.path("bigBytes").asInt());
167
+
168
+ // 3. a token straddling the 32 KiB chunk boundary of the ORIGINAL file never appears, whole or in part
169
+ List<LaV2Chunk> mid = content("file", refs.path("mid").asText(), handler);
170
+ assertThat(mid.get(0).status()).isEqualTo("ok");
171
+ assertThat(mid.size()).isGreaterThanOrEqualTo(2);
172
+ for (LaV2Chunk c : mid) {
173
+ assertThat(new String(c.data(), StandardCharsets.UTF_8)).doesNotContain(ghp.substring(0, 12));
174
+ }
175
+ assertThat(new String(concat(mid), StandardCharsets.UTF_8)).contains("[REDACTED:github_token]");
176
+
177
+ // 4. an id handed out for an innocent file, later swapped for a link to a file outside the workspace: forbidden, no data
178
+ List<LaV2Chunk> swapped = content("file", refs.path("swapped").asText(), handler);
179
+ assertThat(swapped).hasSize(1);
180
+ assertThat(swapped.get(0).status()).isEqualTo("forbidden");
181
+ assertThat(swapped.get(0).data()).isEmpty();
182
+
183
+ // 5. paths are never ids: traversal, absolute, plain relative -> not_found; nothing else comes back
184
+ for (String ref : List.of("../../etc/passwd", "/etc/passwd", "src/app.txt", "..%2f..%2fetc%2fpasswd", "cr1_AAAAAAAAAAAAAAAAAAAAAA", "A".repeat(43))) {
185
+ List<LaV2Chunk> r = content("file", ref, handler);
186
+ assertThat(r).hasSize(1);
187
+ assertThat(r.get(0).status()).as(ref).isEqualTo("not_found");
188
+ assertThat(r.get(0).data()).isEmpty();
189
+ }
190
+
191
+ // 6. a binary file cannot be inspected by the DLP: withheld in enforce
192
+ assertThat(content("file", refs.path("bin").asText(), handler).get(0).status()).isEqualTo("forbidden");
193
+
194
+ // 7. kinds the agent does not serve yet: error + protocol_unsupported (a code of the contract)
195
+ List<LaV2Chunk> report = content("report", refs.path("app").asText(), handler);
196
+ assertThat(report.get(0).status()).isEqualTo("error");
197
+ assertThat(report.get(0).errorCode()).isEqualTo("protocol_unsupported");
198
+
199
+ // 8. diff of the work tree (symbolic ref, no client data in it) and of one minted file: masked
200
+ List<LaV2Chunk> diff = content("diff", "worktree", handler);
201
+ assertThat(diff.get(0).status()).isEqualTo("ok");
202
+ assertThat(diff.get(0).contentType()).isEqualTo("text/x-diff");
203
+ String diffText = new String(concat(diff), StandardCharsets.UTF_8);
204
+ assertThat(diffText).contains("tracked.js").contains("[REDACTED:github_token]").doesNotContain(ghp).doesNotContain("SECRET-ENV-VALUE").doesNotContain(".env");
205
+ assertThat(new String(concat(content("diff", refs.path("diffFile").asText(), handler)), StandardCharsets.UTF_8)).contains("tracked.js");
206
+
207
+ // 9. the consumer cancels after the first chunk (the relay's client went away): the agent is not left blocked
208
+ List<LaV2Chunk> first = handler.requestContent(TENANT, null, request("file", refs.path("big").asText()), "ws-1")
209
+ .take(1).collectList().block(Duration.ofSeconds(20));
210
+ assertThat(first).hasSize(1);
211
+ List<LaV2Chunk> afterCancel = content("file", refs.path("app").asText(), handler);
212
+ assertThat(afterCancel.get(0).status()).isEqualTo("ok");
213
+
214
+ // nothing the Node agent put on the wire was refused by the real gateway session
215
+ assertThat(rejected).as("frames the real LaV2Session rejected: %s", rejected).isEmpty();
216
+ // nothing in what the agent printed (events, warnings) carries content or a path
217
+ String printed = String.join("\n", transcript);
218
+ assertThat(printed).doesNotContain("OUTSIDE-SECRET").doesNotContain("SECRET-ENV-VALUE").doesNotContain("f307-interop-");
219
+ System.out.println("CONTENT INTEROP OK: rejected=" + rejected);
220
+ } finally {
221
+ try {
222
+ send(stdin, "quit");
223
+ } catch (Exception ignored) {
224
+ // gone
225
+ }
226
+ proc.waitFor(5, TimeUnit.SECONDS);
227
+ proc.destroyForcibly();
228
+ server.disposeNow();
229
+ }
230
+ }
231
+
232
+ private static List<LaV2Chunk> content(String kind, String ref, AgentWebSocketHandler handler) {
233
+ return handler.requestContent(TENANT, null, request(kind, ref), "ws-1").collectList().block(Duration.ofSeconds(30));
234
+ }
235
+
236
+ private static void send(java.io.Writer w, String line) throws Exception {
237
+ w.write(line + "\n");
238
+ w.flush();
239
+ }
240
+
241
+ private static JsonNode await(BlockingQueue<JsonNode> q, String evt, int seconds) throws Exception {
242
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
243
+ List<String> seen = new ArrayList<>();
244
+ while (System.nanoTime() < deadline) {
245
+ JsonNode n = q.poll(100, TimeUnit.MILLISECONDS);
246
+ if (n == null) {
247
+ continue;
248
+ }
249
+ if (evt.equals(n.path("evt").asText())) {
250
+ return n;
251
+ }
252
+ seen.add(n.path("evt").asText() + (n.has("code") ? ":" + n.path("code").asText() : "") + (n.has("m") ? ":" + n.path("m").asText() : ""));
253
+ }
254
+ throw new AssertionError("no '" + evt + "' event from the Node LA within " + seconds + " s; saw " + seen);
255
+ }
256
+
257
+ private static byte[] concat(List<LaV2Chunk> chunks) {
258
+ java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
259
+ chunks.forEach(c -> o.writeBytes(c.data()));
260
+ return o.toByteArray();
261
+ }
262
+ }