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
@@ -0,0 +1,374 @@
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-07b end to end, WITHOUT any patch on either side: HTTP ({@code GET} and {@code POST /api/v1/content/{kind}...}) -> the
74
+ * REAL {@link ContentRelayController} and {@link ContentRelayService} (real {@code ContentRef} validation, real
75
+ * {@code TenantAccessGuard.requireOwnedWorkspace}) -> the REAL {@link AgentWebSocketHandler} / {@link LaV2Session} behind a
76
+ * Reactor Netty WebSocket on loopback, real system clock, real Ed25519 signer -> the REAL Node Local Agent
77
+ * (tools/interop/la-interop-content-client.mjs: real content handlers, DLP enforce, path guard, git diff, content.index).
78
+ * The ids are the 43-char ones the Local Agent minted and announced; the diff of the work tree uses {@code worktree}.
79
+ *
80
+ * <p>Copy it next to the other byoc tests of the gateway (it needs package access to nothing, but it lives with them) and run
81
+ * (JDK 21; node >= 18; {@code npm ci} done in the LA repo; gateway branch with F3-06b + F3-07b):
82
+ * <pre>
83
+ * cp tools/interop/LaV2NodeContentRelayE2ETest.java \
84
+ * &lt;gateway&gt;/src/test/java/io/visionhammer/gateway/cloud/infrastructure/byoc/
85
+ * LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeContentRelayE2ETest
86
+ * </pre>
87
+ * Skipped when LA_REPO is not set. Still mocked: the workspace store, the data plane policy (tenant in {@code client}), the
88
+ * log store, the JWT (a principal). Not proven: Cloud Run / LB / TLS, Memorystore EU (AUTH + TLS 6378), two gateway
89
+ * instances, load, a tenant of QA/PROD.
90
+ */
91
+ class LaV2NodeContentRelayE2ETest {
92
+
93
+ private static final String TENANT = LaV2TestKit.TENANT;
94
+ private static final String AGENT = LaV2TestKit.AGENT;
95
+ private static final UUID USER = UUID.fromString("33333333-3333-4333-8333-333333333333");
96
+ private static final String BASE = "/api/v1/content/";
97
+ private static final String ID43 = "^[A-Za-z0-9_-]{43}$";
98
+
99
+ private static Authentication principal(String role) {
100
+ AuthenticatedUser user = AuthenticatedUser.builder().userId(USER).tenantId(UUID.fromString(TENANT)).role(role)
101
+ .email("ana@acme.com").build();
102
+ return new UsernamePasswordAuthenticationToken(user, "n/a", List.of());
103
+ }
104
+
105
+ @SuppressWarnings("unchecked")
106
+ @Test
107
+ void httpToControllerToLaV2SessionToTheRealNodeAgent() throws Exception {
108
+ String laRepo = System.getenv("LA_REPO");
109
+ assumeTrue(laRepo != null && !laRepo.isBlank(), "LA_REPO not set");
110
+
111
+ // ---- the gateway side: real session store + real handler, the content.index registry as the sink --------------------------
112
+ Clock clock = Clock.systemUTC();
113
+ LaProtocolV2Properties props = new LaProtocolV2Properties();
114
+ props.setEnabled(true);
115
+ ContentRelayProperties relayProps = new ContentRelayProperties();
116
+ relayProps.setEnabled(true);
117
+ ContentIndexRegistry index = new ContentIndexRegistry(relayProps, null, System::currentTimeMillis);
118
+ List<String> rejected = Collections.synchronizedList(new ArrayList<>());
119
+ LaV2Metrics metrics = new LaV2Metrics() {
120
+ @Override public void rejected(String code) { rejected.add(code); }
121
+ };
122
+ LaV2InboundListener listener = (t, a, type, rid, payload) -> { };
123
+ AgentRequestSigner signer = new AgentRequestSigner(LaV2TestKit.PEM, "k1", clock);
124
+ LaV2Session.Env env = new LaV2Session.Env(LaV2TestKit.CONTRACT, props, signer, OM, clock, metrics, Schedulers.parallel(), List.of(listener), index);
125
+
126
+ AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
127
+ WorkspaceRepository workspaceRepo = mock(WorkspaceRepository.class);
128
+ DataPlanePolicy policy = mock(DataPlanePolicy.class);
129
+ when(workspaceRepo.findDefaultByTenantId(anyString())).thenReturn(Mono.empty());
130
+ when(store.save(any())).thenAnswer(i -> Mono.just(i.getArgument(0)));
131
+ when(store.touchLastSeen(any())).thenReturn(Mono.just(1));
132
+ when(policy.requiresEnforcedSigning(anyString())).thenReturn(Mono.just(true));
133
+ when(policy.modeWire(anyString())).thenReturn(Mono.just("client"));
134
+ AgentAuthorizationEntity auth = new AgentAuthorizationEntity();
135
+ auth.setId(UUID.fromString(AGENT));
136
+ auth.setTenantId(UUID.fromString(TENANT));
137
+ auth.setAuthorizationToken("tok");
138
+ when(store.findByAuthorizationToken("tok")).thenReturn(Mono.just(auth));
139
+ AgentWebSocketHandler handler = new AgentWebSocketHandler(OM, workspaceRepo, store, signer, null, null, null,
140
+ new LaV2SessionStore(env), props, policy);
141
+
142
+ // ---- the HTTP side: the real controller over the real service, the handler as the LocalAgentContentGateway ---------------------
143
+ ContentRelayLogStore logStore = mock(ContentRelayLogStore.class);
144
+ when(logStore.insert(any())).thenReturn(Mono.empty());
145
+ ObjectProvider<MeterRegistry> noMeters = mock(ObjectProvider.class);
146
+ ObjectProvider<LocalAgentSessionRegistry> noRegistry = mock(ObjectProvider.class);
147
+ ObjectProvider<ContentIndexRegistry> indexProvider = mock(ObjectProvider.class);
148
+ when(indexProvider.getIfAvailable()).thenReturn(index);
149
+ WorkspaceRowStore workspaces = mock(WorkspaceRowStore.class);
150
+ WorkspaceEntity ws1 = new WorkspaceEntity();
151
+ ws1.setId("ws-1");
152
+ ws1.setTenantId(TENANT);
153
+ when(workspaces.findByTenantIdAndId(TENANT, "ws-1")).thenReturn(Mono.just(ws1));
154
+ when(workspaces.findByTenantIdAndId(anyString(), anyString())).thenAnswer(i ->
155
+ TENANT.equals(i.getArgument(0)) && "ws-1".equals(i.getArgument(1)) ? Mono.just(ws1) : Mono.empty());
156
+
157
+ ContentRelayService service = new ContentRelayService(handler, policy, logStore, relayProps, noMeters, noRegistry, indexProvider);
158
+ ContentRelayController controller = new ContentRelayController(service, new TenantAccessGuard(workspaces), new ObjectMapper(),
159
+ new ContentBufferBudget(relayProps, null));
160
+ WebTestClient http = WebTestClient.bindToController(controller)
161
+ .argumentResolvers(r -> r.addCustomResolver(
162
+ new AuthenticationPrincipalArgumentResolver(new org.springframework.core.ReactiveAdapterRegistry())))
163
+ .controllerAdvice(new io.visionhammer.gateway.web.GlobalExceptionAdvice())
164
+ .apply(SecurityMockServerConfigurers.springSecurity())
165
+ .configureClient().codecs(c -> c.defaultCodecs().maxInMemorySize(8 << 20)).responseTimeout(Duration.ofSeconds(45)).build()
166
+ .mutateWith(SecurityMockServerConfigurers.mockAuthentication(principal("admin")));
167
+
168
+ WebSocketService wsService = new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy());
169
+ HttpHandler wsHttp = WebHttpHandlerBuilder.webHandler(exchange -> wsService.handleRequest(exchange, handler)).build();
170
+ DisposableServer server = reactor.netty.http.server.HttpServer.create().host("127.0.0.1").port(0).handle(new ReactorHttpHandlerAdapter(wsHttp)).bindNow();
171
+
172
+ // ---- the real Node Local Agent --------------------------------------------------------------------------------------------
173
+ ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-content-client.mjs");
174
+ pb.environment().putAll(Map.of(
175
+ "GW_URL", "ws://127.0.0.1:" + server.port() + "/api/v1/agent/ws", "TOKEN", "tok", "TENANT", TENANT, "AGENT", AGENT,
176
+ "KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce",
177
+ "LOCAL_AGENT_DLP_MODE", System.getenv().getOrDefault("LA_DLP_MODE", "enforce")));
178
+ pb.redirectErrorStream(true);
179
+ Process proc = pb.start();
180
+ BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
181
+ List<String> transcript = Collections.synchronizedList(new ArrayList<>());
182
+ Thread reader = new Thread(() -> {
183
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
184
+ String line;
185
+ while ((line = r.readLine()) != null) {
186
+ transcript.add(line);
187
+ try {
188
+ events.add(OM.readTree(line));
189
+ } catch (Exception ignored) {
190
+ // node warnings
191
+ }
192
+ }
193
+ } catch (Exception ignored) {
194
+ // process ended
195
+ }
196
+ });
197
+ reader.setDaemon(true);
198
+ reader.start();
199
+ java.io.Writer stdin = new java.io.OutputStreamWriter(proc.getOutputStream(), StandardCharsets.UTF_8);
200
+
201
+ try {
202
+ JsonNode refsEvt = await(events, "refs", 20);
203
+ await(events, "ready", 15);
204
+ assertThat(handler.isAgentConnected(TENANT)).isTrue();
205
+ JsonNode refs = refsEvt.path("refs");
206
+ String ghp = refsEvt.path("ghp").asText();
207
+ for (String k : List.of("app", "big", "mid", "swapped", "bin", "diffFile")) {
208
+ assertThat(refs.path(k).asText()).as("id minted by the LA: " + k).matches(ID43);
209
+ }
210
+ assertThat(refs.path("envFile").isNull()).isTrue();
211
+ assertThat(refs.path("envBackup").isNull()).isTrue();
212
+
213
+ // 0. content.index: the gateway learned the ids the agent announced (kind + workspace + id), nothing else
214
+ String appId = refs.path("app").asText();
215
+ awaitTrue(() -> index.find(TENANT, "ws-1", ContentKind.FILE, appId).isPresent(), 10, "content.index reached the gateway registry");
216
+ for (String k : List.of("app", "big", "mid", "swapped", "bin")) {
217
+ assertThat(index.find(TENANT, "ws-1", ContentKind.FILE, refs.path(k).asText())).as("announced " + k).isPresent();
218
+ }
219
+ assertThat(index.find(TENANT, "ws-1", ContentKind.DIFF, refs.path("diffFile").asText())).isPresent();
220
+ assertThat(index.find(TENANT, "ws-1", ContentKind.FILE, appId).get().size()).isEqualTo(
221
+ ("first line\ntoken = \"" + ghp + "\"\nlast line\n").getBytes(StandardCharsets.UTF_8).length);
222
+ assertThat(index.find(UUID.randomUUID().toString(), "ws-1", ContentKind.FILE, appId)).as("other tenant").isEmpty();
223
+ assertThat(index.find(TENANT, "ws-2", ContentKind.FILE, appId)).as("other workspace").isEmpty();
224
+
225
+ // 1. GET with the minted id: served, token masked (DLP enforce), the rest intact; Content-Type from the agent
226
+ String body = new String(http.get().uri(BASE + "file/" + appId + "?workspaceId=ws-1").exchange()
227
+ .expectStatus().isOk().expectHeader().valueEquals("Cache-Control", "no-store")
228
+ .expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
229
+ assertThat(body).contains("first line").contains("[REDACTED:github_token]").contains("last line").doesNotContain(ghp);
230
+
231
+ // 2. POST (the id in the body, not in the URL): the same
232
+ String viaPost = new String(http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
233
+ .bodyValue(Map.of("ref", appId, "workspaceId", "ws-1")).exchange()
234
+ .expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
235
+ assertThat(viaPost).isEqualTo(body);
236
+
237
+ // 3. 1 MiB byte for byte through the whole chain (32 chunks), by GET and by SSE
238
+ byte[] big = http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
239
+ .bodyValue(Map.of("ref", refs.path("big").asText(), "workspaceId", "ws-1")).exchange()
240
+ .expectStatus().isOk().expectBody().returnResult().getResponseBody();
241
+ assertThat(big).hasSize(refsEvt.path("bigBytes").asInt());
242
+ assertThat(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(big))).isEqualTo(refsEvt.path("bigSha256").asText());
243
+ String sse = new String(http.post().uri(BASE + "file/query").accept(MediaType.TEXT_EVENT_STREAM).contentType(MediaType.APPLICATION_JSON)
244
+ .bodyValue(Map.of("ref", refs.path("big").asText(), "workspaceId", "ws-1")).exchange()
245
+ .expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
246
+ assertThat(sse.split("event: chunk", -1).length - 1).as("SSE chunk events").isEqualTo(32);
247
+ assertThat(sse).contains("event: done").doesNotContain("event: error");
248
+
249
+ // 4. a token straddling the 32 KiB boundary is never visible, whole or in part
250
+ String mid = new String(http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
251
+ .bodyValue(Map.of("ref", refs.path("mid").asText(), "workspaceId", "ws-1")).exchange()
252
+ .expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
253
+ assertThat(mid).contains("[REDACTED:github_token]").doesNotContain(ghp.substring(0, 12));
254
+
255
+ // 5. the symbolic ref worktree (kind diff, needs the workspace): GET and POST; masked, without .env
256
+ for (String diff : List.of(
257
+ new String(http.get().uri(BASE + "diff/worktree?workspaceId=ws-1").exchange().expectStatus().isOk()
258
+ .expectHeader().contentType("text/x-diff").expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8),
259
+ new String(http.post().uri(BASE + "diff/query").contentType(MediaType.APPLICATION_JSON)
260
+ .bodyValue(Map.of("ref", "worktree", "workspaceId", "ws-1")).exchange().expectStatus().isOk()
261
+ .expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8))) {
262
+ assertThat(diff).contains("tracked.js").contains("[REDACTED:github_token]").doesNotContain(ghp)
263
+ .doesNotContain("SECRET-ENV-VALUE").doesNotContain("EDITOR-BACKUP-SECRET").doesNotContain(".env");
264
+ }
265
+ http.get().uri(BASE + "diff/worktree").exchange().expectStatus().isBadRequest(); // needs a workspace
266
+ http.get().uri(BASE + "file/worktree?workspaceId=ws-1").exchange().expectStatus().isBadRequest(); // diff only
267
+ assertThat(new String(http.post().uri(BASE + "diff/query").contentType(MediaType.APPLICATION_JSON)
268
+ .bodyValue(Map.of("ref", refs.path("diffFile").asText(), "workspaceId", "ws-1")).exchange().expectStatus().isOk()
269
+ .expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8)).contains("tracked.js");
270
+
271
+ // 6. the agent is the authority: link swapped to a file outside the workspace = 403; binary in enforce = 403;
272
+ // a hard link to a file outside the workspace = 403 (F3-07b); an id the agent never minted = 404
273
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
274
+ .bodyValue(Map.of("ref", refs.path("swapped").asText(), "workspaceId", "ws-1")).exchange()
275
+ .expectStatus().isForbidden().expectBody(String.class).value(b -> assertThat(b).doesNotContain("OUTSIDE-SECRET"));
276
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
277
+ .bodyValue(Map.of("ref", refs.path("bin").asText(), "workspaceId", "ws-1")).exchange().expectStatus().isForbidden();
278
+ if (refsEvt.path("hardLinked").asBoolean()) {
279
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
280
+ .bodyValue(Map.of("ref", refs.path("hard").asText(), "workspaceId", "ws-1")).exchange()
281
+ .expectStatus().isForbidden().expectBody(String.class).value(b -> assertThat(b).doesNotContain("OUTSIDE-SECRET"));
282
+ }
283
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
284
+ .bodyValue(Map.of("ref", "A".repeat(43), "workspaceId", "ws-1")).exchange().expectStatus().isNotFound();
285
+
286
+ // 7. the gateway refuses before asking the agent: the F3-07 id of 26 chars, a path, a foreign workspace
287
+ http.get().uri(BASE + "file/cr1_AAAAAAAAAAAAAAAAAAAAAA?workspaceId=ws-1").exchange().expectStatus().isBadRequest();
288
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
289
+ .bodyValue(Map.of("ref", "../../etc/passwd", "workspaceId", "ws-1")).exchange().expectStatus().isBadRequest();
290
+ http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
291
+ .bodyValue(Map.of("ref", appId, "workspaceId", "ws-of-somebody-else")).exchange().expectStatus().isNotFound();
292
+
293
+ // 8. the same id with the agent's other tenant's role: the viewer is stopped by the permission filter in the full app
294
+ // (ContentRelayPermissionTest, gateway side); here the controller only needs a principal of THIS tenant: another tenant has no agent
295
+ WebTestClient other = http.mutateWith(SecurityMockServerConfigurers.mockAuthentication(
296
+ new UsernamePasswordAuthenticationToken(AuthenticatedUser.builder().userId(USER).tenantId(UUID.fromString(LaV2TestKit.OTHER_TENANT))
297
+ .role("admin").email("eve@evil.com").build(), "n/a", List.of())));
298
+ other.get().uri(BASE + "file/" + appId).exchange().expectStatus().is5xxServerError(); // 503 agent_offline: no agent for that tenant
299
+
300
+ // 9. a kind the agent does not serve (the id is a UUID of an incident): not a 200, and nothing about the files leaks
301
+ http.get().uri(BASE + "incident.detail/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee?workspaceId=ws-1").exchange()
302
+ .expectStatus().value(s -> assertThat(s).isGreaterThanOrEqualTo(400));
303
+
304
+ // 10. require-known-ref ON (one instance only): an id the agent announced passes; one it never announced is refused
305
+ // by the gateway itself, before the agent is asked
306
+ ContentRelayProperties strict = new ContentRelayProperties();
307
+ strict.setEnabled(true);
308
+ strict.setRequireKnownRef(true);
309
+ ContentRelayService strictService = new ContentRelayService(handler, policy, logStore, strict, noMeters, noRegistry, indexProvider);
310
+ WebTestClient strictHttp = WebTestClient.bindToController(new ContentRelayController(strictService, new TenantAccessGuard(workspaces),
311
+ new ObjectMapper(), new ContentBufferBudget(strict, null)))
312
+ .argumentResolvers(r -> r.addCustomResolver(new AuthenticationPrincipalArgumentResolver(new org.springframework.core.ReactiveAdapterRegistry())))
313
+ .controllerAdvice(new io.visionhammer.gateway.web.GlobalExceptionAdvice())
314
+ .apply(SecurityMockServerConfigurers.springSecurity()).configureClient().codecs(c -> c.defaultCodecs().maxInMemorySize(8 << 20)).responseTimeout(Duration.ofSeconds(45)).build()
315
+ .mutateWith(SecurityMockServerConfigurers.mockAuthentication(principal("admin")));
316
+ strictHttp.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
317
+ .bodyValue(Map.of("ref", appId, "workspaceId", "ws-1")).exchange().expectStatus().isOk();
318
+ strictHttp.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
319
+ .bodyValue(Map.of("ref", "A".repeat(43), "workspaceId", "ws-1")).exchange().expectStatus().isNotFound();
320
+
321
+ // 11. the content.index counters of the agent: everything announced was accepted; nothing the agent put on the wire was refused
322
+ send(stdin, "stats");
323
+ JsonNode stats = await(events, "stats", 10);
324
+ assertThat(stats.path("index").path("rejected").asInt()).isZero();
325
+ assertThat(stats.path("index").path("accepted").asInt()).isGreaterThanOrEqualTo(6);
326
+ assertThat(rejected).as("frames the real LaV2Session rejected: %s", rejected).isEmpty();
327
+ // nothing in what the agent printed (events, warnings) carries content, a path or the full id of a file
328
+ String printed = String.join("\n", transcript.stream().filter(l -> !l.contains("\"evt\":\"refs\"")).toList());
329
+ assertThat(printed).doesNotContain("OUTSIDE-SECRET").doesNotContain("SECRET-ENV-VALUE").doesNotContain("EDITOR-BACKUP").doesNotContain("f307-interop-");
330
+ System.out.println("CONTENT RELAY E2E OK: index entries=" + stats.path("index").path("accepted").asInt() + " rejected=" + rejected);
331
+ } finally {
332
+ try {
333
+ send(stdin, "quit");
334
+ } catch (Exception ignored) {
335
+ // gone
336
+ }
337
+ proc.waitFor(5, TimeUnit.SECONDS);
338
+ proc.destroyForcibly();
339
+ server.disposeNow();
340
+ }
341
+ }
342
+
343
+ private static void send(java.io.Writer w, String line) throws Exception {
344
+ w.write(line + "\n");
345
+ w.flush();
346
+ }
347
+
348
+ private static void awaitTrue(java.util.function.BooleanSupplier cond, int seconds, String what) throws Exception {
349
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
350
+ while (System.nanoTime() < deadline) {
351
+ if (cond.getAsBoolean()) {
352
+ return;
353
+ }
354
+ Thread.sleep(50);
355
+ }
356
+ throw new AssertionError("timed out: " + what);
357
+ }
358
+
359
+ private static JsonNode await(BlockingQueue<JsonNode> q, String evt, int seconds) throws Exception {
360
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
361
+ List<String> seen = new ArrayList<>();
362
+ while (System.nanoTime() < deadline) {
363
+ JsonNode n = q.poll(100, TimeUnit.MILLISECONDS);
364
+ if (n == null) {
365
+ continue;
366
+ }
367
+ if (evt.equals(n.path("evt").asText())) {
368
+ return n;
369
+ }
370
+ seen.add(n.path("evt").asText() + (n.has("code") ? ":" + n.path("code").asText() : "") + (n.has("m") ? ":" + n.path("m").asText() : ""));
371
+ }
372
+ throw new AssertionError("no '" + evt + "' event from the Node LA within " + seconds + " s; saw " + seen);
373
+ }
374
+ }
@@ -19,3 +19,20 @@ a type with no handler -> rejected ack `protocol_unsupported`; 20 `job.event` in
19
19
  with the same session id and consecutive seq; content after the resume; zero frames refused by the gateway.
20
20
  It is skipped when `LA_REPO` is not set. It does not prove Cloud Run / the load balancer / TLS / Memorystore /
21
21
  two gateway instances.
22
+
23
+ ## F3-07: content handlers against the real `LaV2Session`
24
+
25
+ `la-interop-content-client.mjs` is a Local Agent with the REAL content handlers (opaque ids, path guard, DLP `enforce`,
26
+ git diff, chunking) on a temp workspace; `LaV2NodeContentInteropTest.java` drives it through `AgentWebSocketHandler.requestContent`.
27
+
28
+ ```bash
29
+ cp tools/interop/LaV2NodeContentInteropTest.java <gateway>/src/test/java/io/visionhammer/gateway/cloud/infrastructure/byoc/
30
+ LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeContentInteropTest # JDK 21
31
+ ```
32
+
33
+ It checks: a token masked in a served file; 1 MiB = 32 chunks byte for byte (sha256); a token straddling the 32 KiB
34
+ boundary never appears, whole or in part; an id swapped for a link to a file outside the workspace -> `forbidden`;
35
+ `../../etc/passwd`, `/etc/passwd`, a plain relative path and a made-up id -> `not_found`; binary -> `forbidden` in enforce;
36
+ `report` -> `error` + `protocol_unsupported`; the diff of the work tree masked and without `.env`; the consumer cancels
37
+ after the first chunk and the next request still works; zero frames refused by the gateway; nothing printed by the agent
38
+ carries content or a path. Negative control: with the DLP step disabled in the agent the test fails.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // F3-07 / F3-07b interoperability client: the REAL Local Agent content handlers (src/la-protocol-v2/content-handlers, DLP, path
3
+ // guard, opaque ids of 43 chars, content.index) around the real v2 link, against the real gateway (driven by
4
+ // LaV2NodeContentInteropTest = straight into LaV2Session, and LaV2NodeContentRelayE2ETest = HTTP -> ContentRelayController ->
5
+ // LaV2Session -> this client). Nothing is faked on the agent side except the workspace manager (one temp workspace). It
6
+ // prepares files, mints the opaque ids the way a future inventory would (link.content.mintRef: the SAME 43-char ids the
7
+ // control plane sees, announced to it in `content.index`) and prints them, one JSON per line, for the test.
8
+ //
9
+ // env: GW_URL TOKEN TENANT AGENT KEY_ID PUBLIC_KEY MODE(enforce) LOCAL_AGENT_DLP_MODE ... (LOCAL_AGENT_CONTENT_HANDLERS is forced on)
10
+ // LOCAL_AGENT_DLP_MODE must be enforce: otherwise the handlers are NOT registered (F3-07b) and every content.request is refused
11
+ // stdin: "stats" (prints the content.index counters) | "quit"
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import readline from "node:readline";
17
+ import { execFileSync } from "node:child_process";
18
+ import WebSocket from "ws";
19
+ import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
20
+ import { resolveSigningConfig } from "../../src/utils/request-signing.js";
21
+
22
+ const { GW_URL, TOKEN, TENANT, AGENT, KEY_ID, PUBLIC_KEY, MODE = "enforce" } = process.env;
23
+ const out = (o) => process.stdout.write(JSON.stringify(o) + "\n");
24
+
25
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "f307-interop-"));
26
+ const ROOT = path.join(base, "ws");
27
+ fs.mkdirSync(ROOT);
28
+ const put = (rel, data) => { const p = path.join(ROOT, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, data); };
29
+ const git = (...a) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "-c", "commit.gpgsign=false", ...a], { cwd: ROOT, stdio: "ignore" });
30
+
31
+ export const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
32
+ // letters only: the DLP phone rule masks long digit runs, which would make 1 MiB grow past the limit
33
+ const bigText = Buffer.from("alpha beta gamma delta epsilon zeta eta theta\n".repeat(30000)).subarray(0, 1024 * 1024);
34
+ const straddle = "a".repeat(32766 - 20 - 1) + "\n" + `k=${GH}\n` + "z\n".repeat(60000);
35
+
36
+ put("src/app.txt", `first line\ntoken = "${GH}"\nlast line\n`);
37
+ put("big.txt", bigText);
38
+ put("mid.txt", straddle);
39
+ put("swapped.txt", "innocent");
40
+ put("bin.png", Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0]), crypto.randomBytes(4000)]));
41
+ put(".env", "API=SECRET-ENV-VALUE-123456\n");
42
+ put("tracked.js", "const a = 1;\n");
43
+ put(".env~", "EDITOR-BACKUP-SECRET-123\n");
44
+ fs.writeFileSync(path.join(base, "outside-secret.txt"), "OUTSIDE-SECRET-CONTENT");
45
+ // a second name (hard link) of a file OUTSIDE the workspace: refused by default (F3-07b), whatever its name
46
+ let hardLinked = true;
47
+ try { fs.linkSync(path.join(base, "outside-secret.txt"), path.join(ROOT, "hard.txt")); } catch { hardLinked = false; }
48
+ git("init", "-q", "-b", "main"); git("add", "-f", "-A"); git("commit", "-q", "-m", "init");
49
+ put("tracked.js", `const a = 2;\nconst t = "${GH}";\n`);
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
+ const mint = (kind, rel) => link.content.mintRef({ workspaceId: "ws-1", kind, rel })?.id ?? null;
67
+ const refs = {
68
+ app: mint("file", "src/app.txt"), big: mint("file", "big.txt"), mid: mint("file", "mid.txt"), swapped: mint("file", "swapped.txt"),
69
+ bin: mint("file", "bin.png"), envFile: mint("file", ".env"), envBackup: mint("file", ".env~"), diffFile: mint("diff", "tracked.js"),
70
+ hard: hardLinked ? mint("file", "hard.txt") : null,
71
+ };
72
+ // after the id was handed out the file becomes a link to a file OUTSIDE the workspace
73
+ fs.rmSync(path.join(ROOT, "swapped.txt"));
74
+ fs.symlinkSync(path.join(base, "outside-secret.txt"), path.join(ROOT, "swapped.txt"));
75
+ out({ evt: "refs", hardLinked, refs, bigSha256: crypto.createHash("sha256").update(bigText).digest("hex"), bigBytes: bigText.length, ghp: GH });
76
+
77
+ let ws;
78
+ let quitting = false;
79
+ function connect() {
80
+ ws = new WebSocket(GW_URL, { headers: { Authorization: `Bearer ${TOKEN}` } });
81
+ const mine = ws;
82
+ ws.on("open", () => {
83
+ const legacy = () => { out({ evt: "legacy-register" }); mine.send(JSON.stringify({ type: "register", tenantId: TENANT, port: 5055, workspacePath: null })); };
84
+ if (!link.begin(mine, legacy)) legacy();
85
+ const hb = setInterval(() => { if (mine.readyState === 1) mine.send("ping"); else clearInterval(hb); }, 5000);
86
+ });
87
+ ws.on("message", (data, isBinary) => {
88
+ const text = data.toString();
89
+ if (text === "pong") return;
90
+ if (link.handleMessage(mine, text, isBinary)) return;
91
+ out({ evt: "v1-message" });
92
+ });
93
+ ws.on("close", (code) => { out({ evt: "closed", code }); link.onClose(mine); if (!quitting) setTimeout(connect, 100); });
94
+ ws.on("error", () => {});
95
+ }
96
+ connect();
97
+
98
+ const done = () => { quitting = true; try { ws.terminate(); } catch { /* gone */ } link.close(); fs.rmSync(base, { recursive: true, force: true }); process.exit(0); };
99
+ readline.createInterface({ input: process.stdin }).on("line", (l) => {
100
+ if (l.trim() === "stats") out({ evt: "stats", index: { ...link.content.indexer.stats }, announced: link.content.indexer.announcedCount, pending: link.content.indexer.pending });
101
+ if (l.trim() === "quit") done();
102
+ }).on("close", done);