deepdebug-local-agent 1.6.0 → 1.8.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 (56) hide show
  1. package/docs/la-protocol-v2.md +41 -11
  2. package/env.example +10 -1
  3. package/package.json +1 -1
  4. package/scripts/incident-parity/Gen.java +256 -0
  5. package/scripts/incident-parity/LogEntry.java +17 -0
  6. package/scripts/incident-parity/extract.py +51 -0
  7. package/scripts/incident-parity/gen.sh +32 -0
  8. package/src/incident-detector/README.md +51 -0
  9. package/src/incident-detector/aggregator.js +73 -0
  10. package/src/incident-detector/categorize.js +48 -0
  11. package/src/incident-detector/config.js +95 -0
  12. package/src/incident-detector/detector.js +92 -0
  13. package/src/incident-detector/emitter.js +154 -0
  14. package/src/incident-detector/index.js +44 -0
  15. package/src/incident-detector/java-compat.js +66 -0
  16. package/src/incident-detector/normalize.js +209 -0
  17. package/src/incident-detector/parse.js +114 -0
  18. package/src/incident-detector/sources/file-source.js +113 -0
  19. package/src/incident-detector/sources/loki-source.js +101 -0
  20. package/src/incident-detector/sources/stream-source.js +39 -0
  21. package/src/incident-detector/store.js +119 -0
  22. package/src/la-protocol-v2/content-handlers/config.js +3 -1
  23. package/src/la-protocol-v2/content-handlers/content-index.js +54 -19
  24. package/src/la-protocol-v2/content-handlers/egress.js +22 -0
  25. package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
  26. package/src/la-protocol-v2/content-handlers/git-diff.js +43 -8
  27. package/src/la-protocol-v2/content-handlers/handler.js +28 -4
  28. package/src/la-protocol-v2/content-handlers/install.js +24 -19
  29. package/src/la-protocol-v2/content-handlers/path-guard.js +74 -5
  30. package/src/la-protocol-v2/content-handlers/ref-registry.js +18 -8
  31. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -8
  32. package/src/la-protocol-v2/contract/README.md +38 -0
  33. package/src/la-protocol-v2/contract/content.index.schema.json +1 -0
  34. package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
  35. package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
  36. package/src/la-protocol-v2/contract.js +9 -0
  37. package/src/la-protocol-v2/runtime.js +11 -2
  38. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +32 -0
  39. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
  40. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
  41. package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
  42. package/test/incident-detector/parity.test.js +66 -0
  43. package/test/incident-detector/parity.vectors.json +2619 -0
  44. package/test/incident-detector/sources.test.js +179 -0
  45. package/test/incident-detector/units.test.js +430 -0
  46. package/test/incident-detector/wiring.test.js +165 -0
  47. package/test/la-protocol-v2/content-alignment.test.js +71 -10
  48. package/test/la-protocol-v2/content-deny-list.test.js +138 -0
  49. package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
  50. package/test/la-protocol-v2/content-file-list.test.js +385 -0
  51. package/test/la-protocol-v2/content-path-guard.test.js +1 -1
  52. package/test/la-protocol-v2/content-wiring.test.js +2 -2
  53. package/test/la-protocol-v2/contract.test.js +14 -4
  54. package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
  55. package/tools/interop/README.md +16 -0
  56. package/tools/interop/la-interop-list-client.mjs +93 -0
@@ -0,0 +1,369 @@
1
+ package io.visionhammer.gateway.cloud.infrastructure.byoc;
2
+
3
+ import com.fasterxml.jackson.databind.JsonNode;
4
+ import com.fasterxml.jackson.databind.ObjectMapper;
5
+ import io.micrometer.core.instrument.MeterRegistry;
6
+ import io.visionhammer.gateway.cloud.api.content.ContentRelayController;
7
+ import io.visionhammer.gateway.cloud.application.byoc.LocalAgentSessionRegistry;
8
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaProtocolV2Properties;
9
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2InboundListener;
10
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Metrics;
11
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Session;
12
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2SessionStore;
13
+ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit;
14
+ import io.visionhammer.gateway.cloud.application.content.ContentBufferBudget;
15
+ import io.visionhammer.gateway.cloud.application.content.ContentIndexRegistry;
16
+ import io.visionhammer.gateway.cloud.application.content.ContentKind;
17
+ import io.visionhammer.gateway.cloud.application.content.ContentRelayLogStore;
18
+ import io.visionhammer.gateway.cloud.application.content.ContentRelayProperties;
19
+ import io.visionhammer.gateway.cloud.application.content.ContentRelayService;
20
+ import io.visionhammer.gateway.governance.application.DataPlanePolicy;
21
+ import io.visionhammer.gateway.identity.application.AgentAuthorizationEntity;
22
+ import io.visionhammer.gateway.identity.application.AgentAuthorizationStore;
23
+ import io.visionhammer.gateway.security.AuthenticatedUser;
24
+ import io.visionhammer.gateway.security.TenantAccessGuard;
25
+ import io.visionhammer.gateway.shared.localagent.AgentRequestSigner;
26
+ import io.visionhammer.gateway.workspace.application.WorkspaceEntity;
27
+ import io.visionhammer.gateway.workspace.application.WorkspaceRepository;
28
+ import io.visionhammer.gateway.workspace.application.WorkspaceRowStore;
29
+ import org.junit.jupiter.api.Test;
30
+ import org.springframework.beans.factory.ObjectProvider;
31
+ import org.springframework.http.MediaType;
32
+ import org.springframework.http.server.reactive.HttpHandler;
33
+ import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
34
+ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
35
+ import org.springframework.security.core.Authentication;
36
+ import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers;
37
+ import org.springframework.security.web.reactive.result.method.annotation.AuthenticationPrincipalArgumentResolver;
38
+ import org.springframework.test.web.reactive.server.WebTestClient;
39
+ import org.springframework.web.reactive.socket.server.WebSocketService;
40
+ import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService;
41
+ import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy;
42
+ import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
43
+ import reactor.core.publisher.Mono;
44
+ import reactor.core.scheduler.Schedulers;
45
+ import reactor.netty.DisposableServer;
46
+ import reactor.netty.http.server.HttpServer;
47
+
48
+ import java.io.BufferedReader;
49
+ import java.io.InputStreamReader;
50
+ import java.nio.charset.StandardCharsets;
51
+ import java.security.MessageDigest;
52
+ import java.time.Clock;
53
+ import java.time.Duration;
54
+ import java.util.ArrayList;
55
+ import java.util.Collections;
56
+ import java.util.HexFormat;
57
+ import java.util.List;
58
+ import java.util.Map;
59
+ import java.util.UUID;
60
+ import java.util.concurrent.BlockingQueue;
61
+ import java.util.concurrent.LinkedBlockingQueue;
62
+ import java.util.concurrent.TimeUnit;
63
+
64
+ import static io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit.OM;
65
+ import static org.assertj.core.api.Assertions.assertThat;
66
+ import static org.junit.jupiter.api.Assumptions.assumeTrue;
67
+ import static org.mockito.ArgumentMatchers.any;
68
+ import static org.mockito.ArgumentMatchers.anyString;
69
+ import static org.mockito.Mockito.mock;
70
+ import static org.mockito.Mockito.when;
71
+
72
+ /**
73
+ * F3-07c end to end, WITHOUT any patch on either side: HTTP ({@code POST /api/v1/content/file.list/query} and the id-bearing calls
74
+ * that follow) -> the REAL {@link ContentRelayController} and {@link ContentRelayService} -> the REAL {@link AgentWebSocketHandler} /
75
+ * {@link LaV2Session} behind a Reactor Netty WebSocket on loopback, real Ed25519 signer -> the REAL Node Local Agent
76
+ * (tools/interop/la-interop-list-client.mjs: real content handlers, DLP enforce, path guard). The agent mints NO id by itself here:
77
+ * every id the test uses came out of a {@code file.list} answer (the producer).
78
+ *
79
+ * <p>Copy it next to the other byoc tests of the gateway and run (JDK 21; node >= 18; {@code npm ci} in the LA repo; gateway branch
80
+ * with the F3-07c contract, i.e. the {@code file.list} kind and the {@code content.file.list} capability gate):
81
+ * <pre>
82
+ * cp tools/interop/LaV2NodeFileListE2ETest.java \
83
+ * &lt;gateway&gt;/src/test/java/io/visionhammer/gateway/cloud/infrastructure/byoc/
84
+ * LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeFileListE2ETest
85
+ * </pre>
86
+ * Skipped when LA_REPO is not set. Still mocked: the workspace store, the data plane policy (tenant in {@code client}), the log store,
87
+ * the JWT (a principal). Not proven: Cloud Run / LB / TLS, Memorystore EU, two gateway instances, load, a tenant of QA/PROD.
88
+ */
89
+ class LaV2NodeFileListE2ETest {
90
+
91
+ private static final String TENANT = LaV2TestKit.TENANT;
92
+ private static final String AGENT = LaV2TestKit.AGENT;
93
+ private static final UUID USER = UUID.fromString("33333333-3333-4333-8333-333333333333");
94
+ private static final String BASE = "/api/v1/content/";
95
+ private static final String ID43 = "^[A-Za-z0-9_-]{43}$";
96
+
97
+ private static Authentication principal(String role) {
98
+ AuthenticatedUser user = AuthenticatedUser.builder().userId(USER).tenantId(UUID.fromString(TENANT)).role(role)
99
+ .email("ana@acme.com").build();
100
+ return new UsernamePasswordAuthenticationToken(user, "n/a", List.of());
101
+ }
102
+
103
+ @SuppressWarnings("unchecked")
104
+ @Test
105
+ void httpToControllerToLaV2SessionToTheRealNodeAgent() throws Exception {
106
+ String laRepo = System.getenv("LA_REPO");
107
+ assumeTrue(laRepo != null && !laRepo.isBlank(), "LA_REPO not set");
108
+
109
+ // ---- the gateway side: real session store + real handler, the content.index registry as the sink --------------------------
110
+ Clock clock = Clock.systemUTC();
111
+ LaProtocolV2Properties props = new LaProtocolV2Properties();
112
+ props.setEnabled(true);
113
+ ContentRelayProperties relayProps = new ContentRelayProperties();
114
+ relayProps.setEnabled(true);
115
+ ContentIndexRegistry index = new ContentIndexRegistry(relayProps, null, System::currentTimeMillis);
116
+ List<String> rejected = Collections.synchronizedList(new ArrayList<>());
117
+ LaV2Metrics metrics = new LaV2Metrics() {
118
+ @Override public void rejected(String code) { rejected.add(code); }
119
+ };
120
+ LaV2InboundListener listener = (t, a, type, rid, payload) -> { };
121
+ AgentRequestSigner signer = new AgentRequestSigner(LaV2TestKit.PEM, "k1", clock);
122
+ LaV2Session.Env env = new LaV2Session.Env(LaV2TestKit.CONTRACT, props, signer, OM, clock, metrics, Schedulers.parallel(), List.of(listener), index);
123
+
124
+ AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
125
+ WorkspaceRepository workspaceRepo = mock(WorkspaceRepository.class);
126
+ DataPlanePolicy policy = mock(DataPlanePolicy.class);
127
+ when(workspaceRepo.findDefaultByTenantId(anyString())).thenReturn(Mono.empty());
128
+ when(store.save(any())).thenAnswer(i -> Mono.just(i.getArgument(0)));
129
+ when(store.touchLastSeen(any())).thenReturn(Mono.just(1));
130
+ when(policy.requiresEnforcedSigning(anyString())).thenReturn(Mono.just(true));
131
+ when(policy.modeWire(anyString())).thenReturn(Mono.just("client"));
132
+ AgentAuthorizationEntity auth = new AgentAuthorizationEntity();
133
+ auth.setId(UUID.fromString(AGENT));
134
+ auth.setTenantId(UUID.fromString(TENANT));
135
+ auth.setAuthorizationToken("tok");
136
+ when(store.findByAuthorizationToken("tok")).thenReturn(Mono.just(auth));
137
+ AgentWebSocketHandler handler = new AgentWebSocketHandler(OM, workspaceRepo, store, signer, null, null, null,
138
+ new LaV2SessionStore(env), props, policy);
139
+
140
+ // ---- the HTTP side: the real controller over the real service, the handler as the LocalAgentContentGateway ---------------------
141
+ ContentRelayLogStore logStore = mock(ContentRelayLogStore.class);
142
+ when(logStore.insert(any())).thenReturn(Mono.empty());
143
+ ObjectProvider<MeterRegistry> noMeters = mock(ObjectProvider.class);
144
+ ObjectProvider<LocalAgentSessionRegistry> noRegistry = mock(ObjectProvider.class);
145
+ ObjectProvider<ContentIndexRegistry> indexProvider = mock(ObjectProvider.class);
146
+ when(indexProvider.getIfAvailable()).thenReturn(index);
147
+ WorkspaceRowStore workspaces = mock(WorkspaceRowStore.class);
148
+ WorkspaceEntity ws1 = new WorkspaceEntity();
149
+ ws1.setId("ws-1");
150
+ ws1.setTenantId(TENANT);
151
+ when(workspaces.findByTenantIdAndId(TENANT, "ws-1")).thenReturn(Mono.just(ws1));
152
+ when(workspaces.findByTenantIdAndId(anyString(), anyString())).thenAnswer(i ->
153
+ TENANT.equals(i.getArgument(0)) && "ws-1".equals(i.getArgument(1)) ? Mono.just(ws1) : Mono.empty());
154
+
155
+ ContentRelayService service = new ContentRelayService(handler, policy, logStore, relayProps, noMeters, noRegistry, indexProvider);
156
+ ContentRelayController controller = new ContentRelayController(service, new TenantAccessGuard(workspaces), new ObjectMapper(),
157
+ new ContentBufferBudget(relayProps, null));
158
+ WebTestClient http = WebTestClient.bindToController(controller)
159
+ .argumentResolvers(r -> r.addCustomResolver(
160
+ new AuthenticationPrincipalArgumentResolver(new org.springframework.core.ReactiveAdapterRegistry())))
161
+ .controllerAdvice(new io.visionhammer.gateway.web.GlobalExceptionAdvice())
162
+ .apply(SecurityMockServerConfigurers.springSecurity())
163
+ .configureClient().codecs(c -> c.defaultCodecs().maxInMemorySize(8 << 20)).responseTimeout(Duration.ofSeconds(45)).build()
164
+ .mutateWith(SecurityMockServerConfigurers.mockAuthentication(principal("admin")));
165
+
166
+ WebSocketService wsService = new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy());
167
+ HttpHandler wsHttp = WebHttpHandlerBuilder.webHandler(exchange -> wsService.handleRequest(exchange, handler)).build();
168
+ DisposableServer server = reactor.netty.http.server.HttpServer.create().host("127.0.0.1").port(0).handle(new ReactorHttpHandlerAdapter(wsHttp)).bindNow();
169
+
170
+ // ---- the real Node Local Agent --------------------------------------------------------------------------------------------
171
+ ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-list-client.mjs");
172
+ pb.environment().putAll(Map.of(
173
+ "GW_URL", "ws://127.0.0.1:" + server.port() + "/api/v1/agent/ws", "TOKEN", "tok", "TENANT", TENANT, "AGENT", AGENT,
174
+ "KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce",
175
+ "LOCAL_AGENT_DLP_MODE", System.getenv().getOrDefault("LA_DLP_MODE", "enforce")));
176
+ pb.redirectErrorStream(true);
177
+ Process proc = pb.start();
178
+ BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
179
+ List<String> transcript = Collections.synchronizedList(new ArrayList<>());
180
+ Thread reader = new Thread(() -> {
181
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
182
+ String line;
183
+ while ((line = r.readLine()) != null) {
184
+ transcript.add(line);
185
+ try {
186
+ events.add(OM.readTree(line));
187
+ } catch (Exception ignored) {
188
+ // node warnings
189
+ }
190
+ }
191
+ } catch (Exception ignored) {
192
+ // process ended
193
+ }
194
+ });
195
+ reader.setDaemon(true);
196
+ reader.start();
197
+ java.io.Writer stdin = new java.io.OutputStreamWriter(proc.getOutputStream(), StandardCharsets.UTF_8);
198
+
199
+ try {
200
+ JsonNode fx = await(events, "fixture", 20);
201
+ await(events, "ready", 15);
202
+ assertThat(handler.isAgentConnected(TENANT)).isTrue();
203
+ String ghp = fx.path("ghp").asText();
204
+ boolean hardLinked = fx.path("hardLinked").asBoolean();
205
+
206
+ // 1. the root: names the agent may serve, dirs first; nothing that is a credential, not even its name
207
+ var rootRes = http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
208
+ .bodyValue(Map.of("ref", "root", "workspaceId", "ws-1")).exchange()
209
+ .expectStatus().isOk().expectHeader().contentType("application/json").expectHeader().valueEquals("Cache-Control", "no-store")
210
+ .expectBody().returnResult();
211
+ String rootBody = new String(rootRes.getResponseBody(), StandardCharsets.UTF_8);
212
+ JsonNode root = OM.readTree(rootBody);
213
+ List<String> names = new ArrayList<>();
214
+ root.path("entries").forEach(e -> names.add(e.path("name").asText()));
215
+ assertThat(names).containsExactly("docs", "src", "README.md", "credentials.service.ts", "mid.txt");
216
+ for (String never : List.of(".env", ".envrc", "credentials.json", "secrets", "id_rsa", "link-out", "link-env", "hard.txt", "SECRET", "outside")) {
217
+ assertThat(rootBody).as("never in the listing: " + never).doesNotContain(never);
218
+ }
219
+ assertThat(rootBody).doesNotContain(ghp);
220
+ assertThat(root.has("nextRef")).isFalse();
221
+ Map<String, JsonNode> byName = new java.util.HashMap<>();
222
+ root.path("entries").forEach(e -> {
223
+ assertThat(e.path("id").asText()).matches(ID43);
224
+ assertThat(e.path("type").asText()).isIn("file", "dir");
225
+ assertThat(e.path("mtime").asText()).matches("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}(\\.[0-9]{1,6})?Z$");
226
+ byName.put(e.path("name").asText(), e);
227
+ });
228
+ assertThat(byName.get("src").path("type").asText()).isEqualTo("dir");
229
+ assertThat(byName.get("credentials.service.ts").path("size").asInt()).isEqualTo(
230
+ "export class CredentialsService { token = 'x'; }\n".getBytes(StandardCharsets.UTF_8).length);
231
+
232
+ // 2. content.index: the gateway learned every id the listing minted (a dir as file.list, a file as file), size included
233
+ String credId = byName.get("credentials.service.ts").path("id").asText();
234
+ awaitTrue(() -> index.find(TENANT, "ws-1", ContentKind.FILE, credId).isPresent(), 10, "content.index reached the gateway registry");
235
+ for (String n : List.of("README.md", "credentials.service.ts", "mid.txt")) {
236
+ assertThat(index.find(TENANT, "ws-1", ContentKind.FILE, byName.get(n).path("id").asText())).as("announced " + n).isPresent();
237
+ }
238
+ assertThat(index.find(TENANT, "ws-1", ContentKind.FILE_LIST, byName.get("src").path("id").asText())).as("a folder id is a file.list id").isPresent();
239
+ assertThat(index.find(TENANT, "ws-1", ContentKind.FILE, byName.get("src").path("id").asText())).isEmpty();
240
+ assertThat(index.find(TENANT, "ws-1", ContentKind.FILE, credId).get().size()).isEqualTo(byName.get("credentials.service.ts").path("size").asLong());
241
+ assertThat(index.find(UUID.randomUUID().toString(), "ws-1", ContentKind.FILE, credId)).as("other tenant").isEmpty();
242
+
243
+ // 3. the ids of the listing open the files (masked) through the normal file route; the code file named credentials.* is served
244
+ String cred = http.get().uri(BASE + "file/" + credId + "?workspaceId=ws-1").exchange().expectStatus().isOk()
245
+ .expectBody(String.class).returnResult().getResponseBody();
246
+ assertThat(cred).contains("CredentialsService");
247
+ String readme = new String(http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
248
+ .bodyValue(Map.of("ref", byName.get("README.md").path("id").asText(), "workspaceId", "ws-1")).exchange()
249
+ .expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
250
+ assertThat(readme).contains("# readme").contains("[REDACTED:github_token]").doesNotContain(ghp);
251
+ String mid = new String(http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
252
+ .bodyValue(Map.of("ref", byName.get("mid.txt").path("id").asText(), "workspaceId", "ws-1")).exchange()
253
+ .expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
254
+ assertThat(mid).contains("[REDACTED:github_token]").doesNotContain(ghp.substring(0, 12));
255
+
256
+ // 4. a folder id lists the folder (and GET works as POST); the sub folder named credentials is listable, its code is served
257
+ String srcId = byName.get("src").path("id").asText();
258
+ JsonNode src = OM.readTree(http.get().uri(BASE + "file.list/" + srcId + "?workspaceId=ws-1").exchange().expectStatus().isOk()
259
+ .expectBody().returnResult().getResponseBody());
260
+ List<String> srcNames = new ArrayList<>();
261
+ src.path("entries").forEach(e -> srcNames.add(e.path("name").asText()));
262
+ assertThat(srcNames).containsExactly("credentials", "main.ts");
263
+ String credDirId = src.path("entries").get(0).path("id").asText();
264
+ JsonNode credDir = OM.readTree(http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
265
+ .bodyValue(Map.of("ref", credDirId, "workspaceId", "ws-1")).exchange().expectStatus().isOk().expectBody().returnResult().getResponseBody());
266
+ assertThat(credDir.path("entries").get(0).path("name").asText()).isEqualTo("index.ts");
267
+
268
+ // 5. paging: limit 2 over the 5 root entries = 3 pages by nextRef; the pages add up, and the nextRef is an id too
269
+ List<String> paged = new ArrayList<>();
270
+ String ref = "root";
271
+ int pages = 0;
272
+ while (ref != null && pages < 10) {
273
+ JsonNode page = OM.readTree(http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
274
+ .bodyValue(Map.of("ref", ref, "workspaceId", "ws-1", "limit", 2)).exchange().expectStatus().isOk().expectBody().returnResult().getResponseBody());
275
+ page.path("entries").forEach(e -> paged.add(e.path("name").asText()));
276
+ ref = page.has("nextRef") ? page.path("nextRef").asText() : null;
277
+ if (ref != null) {
278
+ assertThat(ref).matches(ID43);
279
+ }
280
+ pages++;
281
+ }
282
+ assertThat(pages).isEqualTo(3);
283
+ assertThat(paged).isEqualTo(names);
284
+
285
+ // 6. the gateway and the agent refuse what they must: kinds do not mix, a path, a foreign workspace, an id never minted, root for another kind
286
+ http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
287
+ .bodyValue(Map.of("ref", credId, "workspaceId", "ws-1")).exchange().expectStatus().isNotFound(); // a file id is not a folder id
288
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
289
+ .bodyValue(Map.of("ref", srcId, "workspaceId", "ws-1")).exchange().expectStatus().isNotFound(); // a folder id is not a file id
290
+ http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
291
+ .bodyValue(Map.of("ref", "A".repeat(43), "workspaceId", "ws-1")).exchange().expectStatus().isNotFound();
292
+ http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
293
+ .bodyValue(Map.of("ref", srcId, "workspaceId", "ws-of-somebody-else")).exchange().expectStatus().isNotFound();
294
+ http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
295
+ .bodyValue(Map.of("ref", "src", "workspaceId", "ws-1")).exchange().expectStatus().isBadRequest();
296
+ http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
297
+ .bodyValue(Map.of("ref", "../..", "workspaceId", "ws-1")).exchange().expectStatus().isBadRequest();
298
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
299
+ .bodyValue(Map.of("ref", "root", "workspaceId", "ws-1")).exchange().expectStatus().isBadRequest();
300
+ http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
301
+ .bodyValue(Map.of("ref", "root")).exchange().expectStatus().isBadRequest(); // workspaceId is required
302
+ http.post().uri(BASE + "file.list/query").contentType(MediaType.APPLICATION_JSON)
303
+ .bodyValue(Map.of("ref", "root", "workspaceId", "ws-1", "offset", 0, "length", 10)).exchange().expectStatus().isBadRequest();
304
+
305
+ // 7. the agent's counters: everything announced was accepted, nothing the agent sent was refused by the real session
306
+ // (the ids minted by the last requests are announced after a 100 ms batch delay: ask until they are in)
307
+ JsonNode stats = null;
308
+ for (int i = 0; i < 40; i++) {
309
+ send(stdin, "stats");
310
+ stats = await(events, "stats", 10);
311
+ if (stats.path("index").path("accepted").asInt() >= 15 && stats.path("pending").asInt() == 0) {
312
+ break;
313
+ }
314
+ Thread.sleep(100);
315
+ }
316
+ assertThat(stats.path("index").path("rejected").asInt()).isZero();
317
+ // exactly every id the listings handed out: 5 (root) + 2 (src) + 1 (src/credentials) + 5 entries and 2 continuation ids of the 3 pages
318
+ assertThat(stats.path("index").path("accepted").asInt()).isEqualTo(15);
319
+ assertThat(rejected).as("frames the real LaV2Session rejected: %s", rejected).isEmpty();
320
+ // nothing in what the agent printed (events, warnings) carries content, a name or a path of the workspace
321
+ String printed = String.join("\n", transcript.stream().filter(l -> !l.contains("\"evt\":\"fixture\"")).toList());
322
+ for (String leak : List.of("OUTSIDE-SECRET", "SECRET-ENV", "credentials.service", "README", "f307c-list-", "mid.txt", "src")) {
323
+ assertThat(printed).as("agent output: " + leak).doesNotContain(leak);
324
+ }
325
+ System.out.println("FILE LIST E2E OK: hardLinked=" + hardLinked + " index accepted=" + stats.path("index").path("accepted").asInt() + " rejected=" + rejected);
326
+ } finally {
327
+ try {
328
+ send(stdin, "quit");
329
+ } catch (Exception ignored) {
330
+ // gone
331
+ }
332
+ proc.waitFor(5, TimeUnit.SECONDS);
333
+ proc.destroyForcibly();
334
+ server.disposeNow();
335
+ }
336
+ }
337
+
338
+ private static void send(java.io.Writer w, String line) throws Exception {
339
+ w.write(line + "\n");
340
+ w.flush();
341
+ }
342
+
343
+ private static void awaitTrue(java.util.function.BooleanSupplier cond, int seconds, String what) throws Exception {
344
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
345
+ while (System.nanoTime() < deadline) {
346
+ if (cond.getAsBoolean()) {
347
+ return;
348
+ }
349
+ Thread.sleep(50);
350
+ }
351
+ throw new AssertionError("timed out: " + what);
352
+ }
353
+
354
+ private static JsonNode await(BlockingQueue<JsonNode> q, String evt, int seconds) throws Exception {
355
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
356
+ List<String> seen = new ArrayList<>();
357
+ while (System.nanoTime() < deadline) {
358
+ JsonNode n = q.poll(100, TimeUnit.MILLISECONDS);
359
+ if (n == null) {
360
+ continue;
361
+ }
362
+ if (evt.equals(n.path("evt").asText())) {
363
+ return n;
364
+ }
365
+ seen.add(n.path("evt").asText() + (n.has("code") ? ":" + n.path("code").asText() : "") + (n.has("m") ? ":" + n.path("m").asText() : ""));
366
+ }
367
+ throw new AssertionError("no '" + evt + "' event from the Node LA within " + seconds + " s; saw " + seen);
368
+ }
369
+ }
@@ -36,3 +36,19 @@ boundary never appears, whole or in part; an id swapped for a link to a file out
36
36
  `report` -> `error` + `protocol_unsupported`; the diff of the work tree masked and without `.env`; the consumer cancels
37
37
  after the first chunk and the next request still works; zero frames refused by the gateway; nothing printed by the agent
38
38
  carries content or a path. Negative control: with the DLP step disabled in the agent the test fails.
39
+
40
+ ## F3-07c: `file.list`, the producer of the ids, against the real gateway
41
+
42
+ `la-interop-list-client.mjs` is a Local Agent with the REAL content handlers that mints NO id itself (the only source of ids is the
43
+ `file.list` handler); `LaV2NodeFileListE2ETest.java` drives it from HTTP: `POST /api/v1/content/file.list/query` -> controller ->
44
+ `LaV2Session` -> Node. Needs the gateway branch with the `file.list` kind and the `content.file.list` capability gate.
45
+
46
+ ```bash
47
+ cp tools/interop/LaV2NodeFileListE2ETest.java <gateway>/src/test/java/io/visionhammer/gateway/cloud/infrastructure/byoc/
48
+ LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeFileListE2ETest # JDK 21
49
+ ```
50
+
51
+ It checks: the root lists `credentials.service.ts` (code) and not `.env`, `.envrc`, `credentials.json`, `secrets/`, `id_rsa`, a link out of the
52
+ workspace, a link to `.env`, a hard link (nor their names); the ids of the listing open the files (token masked, also across a 32 KiB
53
+ boundary); folder ids list folders; paging by `nextRef`; kinds do not mix; the gateway registry learned every id (dirs as `file.list`, files as
54
+ `file`, size included) and the agent announced exactly the ids it handed out; no name or path in the agent's output or the gateway's logs.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // F3-07c interoperability client: the REAL Local Agent content handlers around the real v2 link, against the real gateway (driven by
3
+ // LaV2NodeFileListE2ETest: HTTP -> ContentRelayController -> LaV2Session -> this client). Unlike the F3-07b client it does NOT mint any
4
+ // id itself: the only source of ids is the `file.list` handler (the producer), exactly as in production. Nothing is faked on the agent
5
+ // side except the workspace manager (one temp workspace). It prepares a folder with files that may and may not be listed (.env,
6
+ // credentials.json, credentials.service.ts, links, a hard link, a secret in the middle of a file) and prints facts, one JSON per line.
7
+ //
8
+ // env: GW_URL TOKEN TENANT AGENT KEY_ID PUBLIC_KEY MODE(enforce) LOCAL_AGENT_DLP_MODE ... (LOCAL_AGENT_CONTENT_HANDLERS is forced on)
9
+ // LOCAL_AGENT_DLP_MODE must be enforce: otherwise the handlers are NOT registered (F3-07b) and every content.request is refused
10
+ // stdin: "stats" (prints the content.index counters) | "quit"
11
+ import crypto from "node:crypto";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import readline from "node:readline";
16
+ import { execFileSync } from "node:child_process";
17
+ import WebSocket from "ws";
18
+ import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
19
+ import { resolveSigningConfig } from "../../src/utils/request-signing.js";
20
+
21
+ const { GW_URL, TOKEN, TENANT, AGENT, KEY_ID, PUBLIC_KEY, MODE = "enforce" } = process.env;
22
+ const out = (o) => process.stdout.write(JSON.stringify(o) + "\n");
23
+
24
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "f307c-list-"));
25
+ const ROOT = path.join(base, "ws");
26
+ fs.mkdirSync(ROOT);
27
+ const put = (rel, data) => { const p = path.join(ROOT, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, data); };
28
+
29
+ export const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
30
+ // a secret in the middle of a file that straddles two 32 KiB chunks, as in the F3-07b client
31
+ const straddle = "a".repeat(32766 - 20 - 1) + "\n" + `k=${GH}\n` + "z\n".repeat(60000);
32
+
33
+ put("README.md", `# readme\ntoken = "${GH}"\nend\n`);
34
+ put("mid.txt", straddle);
35
+ put("credentials.service.ts", "export class CredentialsService { token = 'x'; }\n");
36
+ put("src/main.ts", "export {};\n");
37
+ put("src/credentials/index.ts", "export {};\n");
38
+ put("docs/guide.md", "# guide\n");
39
+ // what must never be listed, nor named
40
+ put(".env", "API=SECRET-ENV-VALUE-123456\n");
41
+ put(".envrc", "export API=SECRET-ENVRC-VALUE-123\n");
42
+ put("credentials.json", '{"token":"SECRET-CREDENTIALS-JSON-123"}');
43
+ put("secrets/api.txt", "SECRET-IN-SECRETS-DIR");
44
+ put("id_rsa", "SECRET-PRIVATE-KEY");
45
+ fs.writeFileSync(path.join(base, "outside-secret.txt"), "OUTSIDE-SECRET-CONTENT");
46
+ fs.symlinkSync(path.join(base, "outside-secret.txt"), path.join(ROOT, "link-out.txt"));
47
+ fs.symlinkSync(path.join(ROOT, ".env"), path.join(ROOT, "link-env.txt"));
48
+ let hardLinked = true;
49
+ try { fs.linkSync(path.join(base, "outside-secret.txt"), path.join(ROOT, "hard.txt")); } catch { hardLinked = false; }
50
+
51
+ const signing = resolveSigningConfig({
52
+ LOCAL_AGENT_SIGNATURE_MODE: MODE, LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${KEY_ID}=${PUBLIC_KEY}`, LOCAL_AGENT_TENANT_ID: TENANT,
53
+ }, { defaultFile: null, fileCfg: {} });
54
+
55
+ const env = { ...process.env, LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_PROTOCOL_V2: "true" };
56
+ const link = await buildV2Link({
57
+ tenantId: TENANT, agentId: AGENT, signing, getWorkspaceRoot: () => ROOT, env,
58
+ wsManager: { list: () => [{ id: "ws-1", root: ROOT, tenantId: TENANT }] },
59
+ log: { info: () => {}, log: () => {}, warn: (m) => out({ evt: "warn", m }) },
60
+ });
61
+ const session = link.session;
62
+ session.on("ready", (i) => out({ evt: "ready", resumed: i.resumed, sessionId: i.sessionId }));
63
+ session.on("refused", (c) => out({ evt: "refused", code: c }));
64
+ session.on("peer-error", (c) => out({ evt: "peer-error", code: c }));
65
+
66
+ out({ evt: "fixture", hardLinked, ghp: GH, root: ROOT, base });
67
+
68
+ let ws;
69
+ let quitting = false;
70
+ function connect() {
71
+ ws = new WebSocket(GW_URL, { headers: { Authorization: `Bearer ${TOKEN}` } });
72
+ const mine = ws;
73
+ ws.on("open", () => {
74
+ const legacy = () => { out({ evt: "legacy-register" }); mine.send(JSON.stringify({ type: "register", tenantId: TENANT, port: 5055, workspacePath: null })); };
75
+ if (!link.begin(mine, legacy)) legacy();
76
+ const hb = setInterval(() => { if (mine.readyState === 1) mine.send("ping"); else clearInterval(hb); }, 5000);
77
+ });
78
+ ws.on("message", (data, isBinary) => {
79
+ const text = data.toString();
80
+ if (text === "pong") return;
81
+ if (link.handleMessage(mine, text, isBinary)) return;
82
+ out({ evt: "v1-message" });
83
+ });
84
+ ws.on("close", (code) => { out({ evt: "closed", code }); link.onClose(mine); if (!quitting) setTimeout(connect, 100); });
85
+ ws.on("error", () => {});
86
+ }
87
+ connect();
88
+
89
+ const done = () => { quitting = true; try { ws.terminate(); } catch { /* gone */ } link.close(); fs.rmSync(base, { recursive: true, force: true }); process.exit(0); };
90
+ readline.createInterface({ input: process.stdin }).on("line", (l) => {
91
+ if (l.trim() === "stats") out({ evt: "stats", index: { ...link.content.indexer.stats }, announced: link.content.indexer.announcedCount, pending: link.content.indexer.pending });
92
+ if (l.trim() === "quit") done();
93
+ }).on("close", done);