deepdebug-local-agent 1.5.0 → 1.7.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 (44) hide show
  1. package/docs/la-protocol-v2.md +70 -16
  2. package/env.example +6 -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/config.js +10 -2
  6. package/src/la-protocol-v2/content-handlers/content-index.js +196 -0
  7. package/src/la-protocol-v2/content-handlers/egress.js +22 -0
  8. package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
  9. package/src/la-protocol-v2/content-handlers/git-diff.js +95 -10
  10. package/src/la-protocol-v2/content-handlers/handler.js +52 -16
  11. package/src/la-protocol-v2/content-handlers/install.js +35 -15
  12. package/src/la-protocol-v2/content-handlers/path-guard.js +120 -10
  13. package/src/la-protocol-v2/content-handlers/ref-registry.js +41 -13
  14. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
  15. package/src/la-protocol-v2/contract/README.md +87 -2
  16. package/src/la-protocol-v2/contract/content.index.schema.json +93 -0
  17. package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
  18. package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
  19. package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
  20. package/src/la-protocol-v2/contract/index.json +7 -0
  21. package/src/la-protocol-v2/contract.js +9 -0
  22. package/src/la-protocol-v2/runtime.js +12 -2
  23. package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3809 -0
  24. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
  25. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
  26. package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
  27. package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
  28. package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
  29. package/test/la-protocol-v2/content-alignment.test.js +669 -0
  30. package/test/la-protocol-v2/content-deny-list.test.js +138 -0
  31. package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
  32. package/test/la-protocol-v2/content-file-list.test.js +385 -0
  33. package/test/la-protocol-v2/content-handlers.test.js +55 -61
  34. package/test/la-protocol-v2/content-path-guard.test.js +10 -5
  35. package/test/la-protocol-v2/content-wiring.test.js +39 -14
  36. package/test/la-protocol-v2/contract.test.js +15 -5
  37. package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
  38. package/test/la-protocol-v2/traces.test.js +29 -4
  39. package/tools/interop/LaV2NodeContentInteropTest.java +18 -4
  40. package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
  41. package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
  42. package/tools/interop/README.md +16 -0
  43. package/tools/interop/la-interop-content-client.mjs +19 -8
  44. package/tools/interop/la-interop-list-client.mjs +93 -0
@@ -8,6 +8,8 @@ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Metrics;
8
8
  import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Session;
9
9
  import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2SessionStore;
10
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;
11
13
  import io.visionhammer.gateway.governance.application.DataPlanePolicy;
12
14
  import io.visionhammer.gateway.identity.application.AgentAuthorizationEntity;
13
15
  import io.visionhammer.gateway.identity.application.AgentAuthorizationStore;
@@ -56,7 +58,8 @@ import static org.mockito.Mockito.when;
56
58
  *
57
59
  * LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeContentInteropTest
58
60
  *
59
- * Skipped when LA_REPO is not set. Not proven here: Cloud Run / LB / TLS, Memorystore, two gateway instances, the HTTP
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
60
63
  * relay endpoint (F3-06) in front of this session.
61
64
  */
62
65
  class LaV2NodeContentInteropTest {
@@ -82,7 +85,12 @@ class LaV2NodeContentInteropTest {
82
85
  };
83
86
  LaV2InboundListener listener = (t, a, type, rid, payload) -> { };
84
87
  AgentRequestSigner signer = new AgentRequestSigner(LaV2TestKit.PEM, "k1", clock);
85
- LaV2Session.Env env = new LaV2Session.Env(LaV2TestKit.CONTRACT, props, signer, OM, clock, metrics, Schedulers.parallel(), List.of(listener));
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);
86
94
 
87
95
  AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
88
96
  WorkspaceRepository workspaces = mock(WorkspaceRepository.class);
@@ -106,7 +114,8 @@ class LaV2NodeContentInteropTest {
106
114
  ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-content-client.mjs");
107
115
  pb.environment().putAll(Map.of(
108
116
  "GW_URL", "ws://127.0.0.1:" + server.port() + "/api/v1/agent/ws", "TOKEN", "tok", "TENANT", TENANT, "AGENT", AGENT,
109
- "KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce", "LOCAL_AGENT_DLP_MODE", "enforce"));
117
+ "KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce",
118
+ "LOCAL_AGENT_DLP_MODE", System.getenv().getOrDefault("LA_DLP_MODE", "enforce")));
110
119
  pb.redirectErrorStream(true);
111
120
  Process proc = pb.start();
112
121
  BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
@@ -137,6 +146,11 @@ class LaV2NodeContentInteropTest {
137
146
  JsonNode refs = refsEvt.path("refs");
138
147
  String ghp = refsEvt.path("ghp").asText();
139
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
+ }
140
154
 
141
155
  // 1. a file with a token: served, the token masked (DLP enforce), the rest intact
142
156
  List<LaV2Chunk> app = content("file", refs.path("app").asText(), handler);
@@ -167,7 +181,7 @@ class LaV2NodeContentInteropTest {
167
181
  assertThat(swapped.get(0).data()).isEmpty();
168
182
 
169
183
  // 5. paths are never ids: traversal, absolute, plain relative -> not_found; nothing else comes back
170
- for (String ref : List.of("../../etc/passwd", "/etc/passwd", "src/app.txt", "..%2f..%2fetc%2fpasswd", "cr1_AAAAAAAAAAAAAAAAAAAAAA")) {
184
+ for (String ref : List.of("../../etc/passwd", "/etc/passwd", "src/app.txt", "..%2f..%2fetc%2fpasswd", "cr1_AAAAAAAAAAAAAAAAAAAAAA", "A".repeat(43))) {
171
185
  List<LaV2Chunk> r = content("file", ref, handler);
172
186
  assertThat(r).hasSize(1);
173
187
  assertThat(r.get(0).status()).as(ref).isEqualTo("not_found");
@@ -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
+ }