deepdebug-local-agent 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-gcp.yml +58 -26
- package/CHANGELOG.md +6 -0
- package/docs/la-protocol-v2.md +102 -0
- package/package.json +2 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
- package/src/la-protocol-v2/chunks.js +45 -0
- package/src/la-protocol-v2/config.js +27 -0
- package/src/la-protocol-v2/content-handlers/audit.js +36 -0
- package/src/la-protocol-v2/content-handlers/config.js +48 -0
- package/src/la-protocol-v2/content-handlers/egress.js +65 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +92 -0
- package/src/la-protocol-v2/content-handlers/handler.js +170 -0
- package/src/la-protocol-v2/content-handlers/index.js +6 -0
- package/src/la-protocol-v2/content-handlers/install.js +50 -0
- package/src/la-protocol-v2/content-handlers/path-guard.js +142 -0
- package/src/la-protocol-v2/content-handlers/ref-registry.js +87 -0
- package/src/la-protocol-v2/content.js +63 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
- package/src/la-protocol-v2/contract/README.md +103 -0
- package/src/la-protocol-v2/contract/ack.schema.json +51 -0
- package/src/la-protocol-v2/contract/alert.schema.json +62 -0
- package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
- package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
- package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
- package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
- package/src/la-protocol-v2/contract/defs.schema.json +191 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
- package/src/la-protocol-v2/contract/error.schema.json +59 -0
- package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
- package/src/la-protocol-v2/contract/hello.schema.json +111 -0
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
- package/src/la-protocol-v2/contract/index.json +166 -0
- package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
- package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
- package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
- package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
- package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
- package/src/la-protocol-v2/contract/ready.schema.json +106 -0
- package/src/la-protocol-v2/contract/register.schema.json +92 -0
- package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
- package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
- package/src/la-protocol-v2/contract.js +90 -0
- package/src/la-protocol-v2/errors.js +34 -0
- package/src/la-protocol-v2/index.js +8 -0
- package/src/la-protocol-v2/link.js +93 -0
- package/src/la-protocol-v2/runtime.js +81 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/server.js +25 -4
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
- package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
- package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
- package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
- package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
- package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
- package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
- package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
- package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
- package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
- package/test/la-protocol-v2/content-handlers.test.js +633 -0
- package/test/la-protocol-v2/content-path-guard.test.js +214 -0
- package/test/la-protocol-v2/content-wiring.test.js +225 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/content-rig.js +94 -0
- package/test/la-protocol-v2/helpers/la-rig.js +83 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +36 -0
- package/test/la-protocol-v2/link.test.js +141 -0
- package/test/la-protocol-v2/session-inbound.test.js +279 -0
- package/test/la-protocol-v2/session-outbound.test.js +382 -0
- package/test/la-protocol-v2/traces.test.js +270 -0
- package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
- package/tools/graphify-validation/extras/README.md +19 -0
- package/tools/graphify-validation/extras/bymethod.py +35 -0
- package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
- package/tools/graphify-validation/extras/docker/inner.sh +7 -0
- package/tools/graphify-validation/extras/g10.py +59 -0
- package/tools/graphify-validation/extras/g3aug.py +49 -0
- package/tools/graphify-validation/extras/hybrid.py +34 -0
- package/tools/graphify-validation/extras/truth2.py +44 -0
- package/tools/interop/LaV2NodeContentInteropTest.java +248 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +38 -0
- package/tools/interop/la-interop-client.mjs +97 -0
- package/tools/interop/la-interop-content-client.mjs +91 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
package io.visionhammer.gateway.cloud.infrastructure.byoc;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.databind.JsonNode;
|
|
4
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaProtocolV2Properties;
|
|
5
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Chunk;
|
|
6
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2InboundListener;
|
|
7
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Metrics;
|
|
8
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Session;
|
|
9
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2SessionStore;
|
|
10
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit;
|
|
11
|
+
import io.visionhammer.gateway.governance.application.DataPlanePolicy;
|
|
12
|
+
import io.visionhammer.gateway.identity.application.AgentAuthorizationEntity;
|
|
13
|
+
import io.visionhammer.gateway.identity.application.AgentAuthorizationStore;
|
|
14
|
+
import io.visionhammer.gateway.shared.localagent.AgentRequestSigner;
|
|
15
|
+
import io.visionhammer.gateway.workspace.application.WorkspaceRepository;
|
|
16
|
+
import org.junit.jupiter.api.Test;
|
|
17
|
+
import org.springframework.http.server.reactive.HttpHandler;
|
|
18
|
+
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
|
19
|
+
import org.springframework.web.reactive.socket.server.WebSocketService;
|
|
20
|
+
import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService;
|
|
21
|
+
import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy;
|
|
22
|
+
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
|
|
23
|
+
import reactor.core.publisher.Mono;
|
|
24
|
+
import reactor.core.scheduler.Schedulers;
|
|
25
|
+
import reactor.netty.DisposableServer;
|
|
26
|
+
import reactor.netty.http.server.HttpServer;
|
|
27
|
+
|
|
28
|
+
import java.io.BufferedReader;
|
|
29
|
+
import java.io.InputStreamReader;
|
|
30
|
+
import java.nio.charset.StandardCharsets;
|
|
31
|
+
import java.security.MessageDigest;
|
|
32
|
+
import java.time.Clock;
|
|
33
|
+
import java.time.Duration;
|
|
34
|
+
import java.util.ArrayList;
|
|
35
|
+
import java.util.Collections;
|
|
36
|
+
import java.util.HexFormat;
|
|
37
|
+
import java.util.List;
|
|
38
|
+
import java.util.Map;
|
|
39
|
+
import java.util.UUID;
|
|
40
|
+
import java.util.concurrent.BlockingQueue;
|
|
41
|
+
import java.util.concurrent.LinkedBlockingQueue;
|
|
42
|
+
import java.util.concurrent.TimeUnit;
|
|
43
|
+
|
|
44
|
+
import static io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit.OM;
|
|
45
|
+
import static org.assertj.core.api.Assertions.assertThat;
|
|
46
|
+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
|
47
|
+
import static org.mockito.ArgumentMatchers.any;
|
|
48
|
+
import static org.mockito.ArgumentMatchers.anyString;
|
|
49
|
+
import static org.mockito.Mockito.mock;
|
|
50
|
+
import static org.mockito.Mockito.when;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* F3-07 interoperability: the REAL Node Local Agent content handlers (opaque ids, path guard, DLP enforce, git diff,
|
|
54
|
+
* chunking) against the REAL gateway {@link LaV2Session} behind a real Reactor Netty WebSocket on loopback, real
|
|
55
|
+
* system clock, real Ed25519 signer. Run from the gateway checkout (JDK 21):
|
|
56
|
+
*
|
|
57
|
+
* LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeContentInteropTest
|
|
58
|
+
*
|
|
59
|
+
* Skipped when LA_REPO is not set. Not proven here: Cloud Run / LB / TLS, Memorystore, two gateway instances, the HTTP
|
|
60
|
+
* relay endpoint (F3-06) in front of this session.
|
|
61
|
+
*/
|
|
62
|
+
class LaV2NodeContentInteropTest {
|
|
63
|
+
|
|
64
|
+
private static final String TENANT = LaV2TestKit.TENANT;
|
|
65
|
+
private static final String AGENT = LaV2TestKit.AGENT;
|
|
66
|
+
|
|
67
|
+
private static Map<String, Object> request(String kind, String ref) {
|
|
68
|
+
return Map.of("kind", kind, "ref", ref, "actor", Map.of("userId", "usr-42", "role", "member"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
void realNodeContentHandlersAgainstTheRealLaV2Session() throws Exception {
|
|
73
|
+
String laRepo = System.getenv("LA_REPO");
|
|
74
|
+
assumeTrue(laRepo != null && !laRepo.isBlank(), "LA_REPO not set");
|
|
75
|
+
|
|
76
|
+
Clock clock = Clock.systemUTC();
|
|
77
|
+
LaProtocolV2Properties props = new LaProtocolV2Properties();
|
|
78
|
+
props.setEnabled(true);
|
|
79
|
+
List<String> rejected = Collections.synchronizedList(new ArrayList<>());
|
|
80
|
+
LaV2Metrics metrics = new LaV2Metrics() {
|
|
81
|
+
@Override public void rejected(String code) { rejected.add(code); }
|
|
82
|
+
};
|
|
83
|
+
LaV2InboundListener listener = (t, a, type, rid, payload) -> { };
|
|
84
|
+
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));
|
|
86
|
+
|
|
87
|
+
AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
|
|
88
|
+
WorkspaceRepository workspaces = mock(WorkspaceRepository.class);
|
|
89
|
+
DataPlanePolicy policy = mock(DataPlanePolicy.class);
|
|
90
|
+
when(workspaces.findDefaultByTenantId(anyString())).thenReturn(Mono.empty());
|
|
91
|
+
when(store.save(any())).thenAnswer(i -> Mono.just(i.getArgument(0)));
|
|
92
|
+
when(store.touchLastSeen(any())).thenReturn(Mono.just(1));
|
|
93
|
+
when(policy.requiresEnforcedSigning(anyString())).thenReturn(Mono.just(true));
|
|
94
|
+
AgentAuthorizationEntity auth = new AgentAuthorizationEntity();
|
|
95
|
+
auth.setId(UUID.fromString(AGENT));
|
|
96
|
+
auth.setTenantId(UUID.fromString(TENANT));
|
|
97
|
+
auth.setAuthorizationToken("tok");
|
|
98
|
+
when(store.findByAuthorizationToken("tok")).thenReturn(Mono.just(auth));
|
|
99
|
+
AgentWebSocketHandler handler = new AgentWebSocketHandler(OM, workspaces, store, signer, null, null, null,
|
|
100
|
+
new LaV2SessionStore(env), props, policy);
|
|
101
|
+
|
|
102
|
+
WebSocketService wsService = new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy());
|
|
103
|
+
HttpHandler http = WebHttpHandlerBuilder.webHandler(exchange -> wsService.handleRequest(exchange, handler)).build();
|
|
104
|
+
DisposableServer server = HttpServer.create().host("127.0.0.1").port(0).handle(new ReactorHttpHandlerAdapter(http)).bindNow();
|
|
105
|
+
|
|
106
|
+
ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-content-client.mjs");
|
|
107
|
+
pb.environment().putAll(Map.of(
|
|
108
|
+
"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"));
|
|
110
|
+
pb.redirectErrorStream(true);
|
|
111
|
+
Process proc = pb.start();
|
|
112
|
+
BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
|
|
113
|
+
List<String> transcript = Collections.synchronizedList(new ArrayList<>());
|
|
114
|
+
Thread reader = new Thread(() -> {
|
|
115
|
+
try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
|
|
116
|
+
String line;
|
|
117
|
+
while ((line = r.readLine()) != null) {
|
|
118
|
+
transcript.add(line);
|
|
119
|
+
try {
|
|
120
|
+
events.add(OM.readTree(line));
|
|
121
|
+
} catch (Exception ignored) {
|
|
122
|
+
// node warnings
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (Exception ignored) {
|
|
126
|
+
// process ended
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
reader.setDaemon(true);
|
|
130
|
+
reader.start();
|
|
131
|
+
java.io.Writer stdin = new java.io.OutputStreamWriter(proc.getOutputStream(), StandardCharsets.UTF_8);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
JsonNode refsEvt = await(events, "refs", 20);
|
|
135
|
+
await(events, "ready", 15);
|
|
136
|
+
assertThat(handler.isAgentConnected(TENANT)).isTrue();
|
|
137
|
+
JsonNode refs = refsEvt.path("refs");
|
|
138
|
+
String ghp = refsEvt.path("ghp").asText();
|
|
139
|
+
assertThat(refs.path("envFile").isNull()).as("no id is minted for .env").isTrue();
|
|
140
|
+
|
|
141
|
+
// 1. a file with a token: served, the token masked (DLP enforce), the rest intact
|
|
142
|
+
List<LaV2Chunk> app = content("file", refs.path("app").asText(), handler);
|
|
143
|
+
assertThat(app.get(0).status()).isEqualTo("ok");
|
|
144
|
+
String appText = new String(concat(app), StandardCharsets.UTF_8);
|
|
145
|
+
assertThat(appText).contains("first line").contains("[REDACTED:github_token]").contains("last line").doesNotContain(ghp);
|
|
146
|
+
|
|
147
|
+
// 2. 1 MiB = 32 chunks, byte for byte (no secrets in it)
|
|
148
|
+
List<LaV2Chunk> big = content("file", refs.path("big").asText(), handler);
|
|
149
|
+
assertThat(big).hasSize(32);
|
|
150
|
+
assertThat(big.get(31).last()).isTrue();
|
|
151
|
+
assertThat(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(concat(big)))).isEqualTo(refsEvt.path("bigSha256").asText());
|
|
152
|
+
assertThat(concat(big)).hasSize(refsEvt.path("bigBytes").asInt());
|
|
153
|
+
|
|
154
|
+
// 3. a token straddling the 32 KiB chunk boundary of the ORIGINAL file never appears, whole or in part
|
|
155
|
+
List<LaV2Chunk> mid = content("file", refs.path("mid").asText(), handler);
|
|
156
|
+
assertThat(mid.get(0).status()).isEqualTo("ok");
|
|
157
|
+
assertThat(mid.size()).isGreaterThanOrEqualTo(2);
|
|
158
|
+
for (LaV2Chunk c : mid) {
|
|
159
|
+
assertThat(new String(c.data(), StandardCharsets.UTF_8)).doesNotContain(ghp.substring(0, 12));
|
|
160
|
+
}
|
|
161
|
+
assertThat(new String(concat(mid), StandardCharsets.UTF_8)).contains("[REDACTED:github_token]");
|
|
162
|
+
|
|
163
|
+
// 4. an id handed out for an innocent file, later swapped for a link to a file outside the workspace: forbidden, no data
|
|
164
|
+
List<LaV2Chunk> swapped = content("file", refs.path("swapped").asText(), handler);
|
|
165
|
+
assertThat(swapped).hasSize(1);
|
|
166
|
+
assertThat(swapped.get(0).status()).isEqualTo("forbidden");
|
|
167
|
+
assertThat(swapped.get(0).data()).isEmpty();
|
|
168
|
+
|
|
169
|
+
// 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")) {
|
|
171
|
+
List<LaV2Chunk> r = content("file", ref, handler);
|
|
172
|
+
assertThat(r).hasSize(1);
|
|
173
|
+
assertThat(r.get(0).status()).as(ref).isEqualTo("not_found");
|
|
174
|
+
assertThat(r.get(0).data()).isEmpty();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 6. a binary file cannot be inspected by the DLP: withheld in enforce
|
|
178
|
+
assertThat(content("file", refs.path("bin").asText(), handler).get(0).status()).isEqualTo("forbidden");
|
|
179
|
+
|
|
180
|
+
// 7. kinds the agent does not serve yet: error + protocol_unsupported (a code of the contract)
|
|
181
|
+
List<LaV2Chunk> report = content("report", refs.path("app").asText(), handler);
|
|
182
|
+
assertThat(report.get(0).status()).isEqualTo("error");
|
|
183
|
+
assertThat(report.get(0).errorCode()).isEqualTo("protocol_unsupported");
|
|
184
|
+
|
|
185
|
+
// 8. diff of the work tree (symbolic ref, no client data in it) and of one minted file: masked
|
|
186
|
+
List<LaV2Chunk> diff = content("diff", "worktree", handler);
|
|
187
|
+
assertThat(diff.get(0).status()).isEqualTo("ok");
|
|
188
|
+
assertThat(diff.get(0).contentType()).isEqualTo("text/x-diff");
|
|
189
|
+
String diffText = new String(concat(diff), StandardCharsets.UTF_8);
|
|
190
|
+
assertThat(diffText).contains("tracked.js").contains("[REDACTED:github_token]").doesNotContain(ghp).doesNotContain("SECRET-ENV-VALUE").doesNotContain(".env");
|
|
191
|
+
assertThat(new String(concat(content("diff", refs.path("diffFile").asText(), handler)), StandardCharsets.UTF_8)).contains("tracked.js");
|
|
192
|
+
|
|
193
|
+
// 9. the consumer cancels after the first chunk (the relay's client went away): the agent is not left blocked
|
|
194
|
+
List<LaV2Chunk> first = handler.requestContent(TENANT, null, request("file", refs.path("big").asText()), "ws-1")
|
|
195
|
+
.take(1).collectList().block(Duration.ofSeconds(20));
|
|
196
|
+
assertThat(first).hasSize(1);
|
|
197
|
+
List<LaV2Chunk> afterCancel = content("file", refs.path("app").asText(), handler);
|
|
198
|
+
assertThat(afterCancel.get(0).status()).isEqualTo("ok");
|
|
199
|
+
|
|
200
|
+
// nothing the Node agent put on the wire was refused by the real gateway session
|
|
201
|
+
assertThat(rejected).as("frames the real LaV2Session rejected: %s", rejected).isEmpty();
|
|
202
|
+
// nothing in what the agent printed (events, warnings) carries content or a path
|
|
203
|
+
String printed = String.join("\n", transcript);
|
|
204
|
+
assertThat(printed).doesNotContain("OUTSIDE-SECRET").doesNotContain("SECRET-ENV-VALUE").doesNotContain("f307-interop-");
|
|
205
|
+
System.out.println("CONTENT INTEROP OK: rejected=" + rejected);
|
|
206
|
+
} finally {
|
|
207
|
+
try {
|
|
208
|
+
send(stdin, "quit");
|
|
209
|
+
} catch (Exception ignored) {
|
|
210
|
+
// gone
|
|
211
|
+
}
|
|
212
|
+
proc.waitFor(5, TimeUnit.SECONDS);
|
|
213
|
+
proc.destroyForcibly();
|
|
214
|
+
server.disposeNow();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private static List<LaV2Chunk> content(String kind, String ref, AgentWebSocketHandler handler) {
|
|
219
|
+
return handler.requestContent(TENANT, null, request(kind, ref), "ws-1").collectList().block(Duration.ofSeconds(30));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private static void send(java.io.Writer w, String line) throws Exception {
|
|
223
|
+
w.write(line + "\n");
|
|
224
|
+
w.flush();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private static JsonNode await(BlockingQueue<JsonNode> q, String evt, int seconds) throws Exception {
|
|
228
|
+
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
|
229
|
+
List<String> seen = new ArrayList<>();
|
|
230
|
+
while (System.nanoTime() < deadline) {
|
|
231
|
+
JsonNode n = q.poll(100, TimeUnit.MILLISECONDS);
|
|
232
|
+
if (n == null) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (evt.equals(n.path("evt").asText())) {
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
seen.add(n.path("evt").asText() + (n.has("code") ? ":" + n.path("code").asText() : "") + (n.has("m") ? ":" + n.path("m").asText() : ""));
|
|
239
|
+
}
|
|
240
|
+
throw new AssertionError("no '" + evt + "' event from the Node LA within " + seconds + " s; saw " + seen);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private static byte[] concat(List<LaV2Chunk> chunks) {
|
|
244
|
+
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
|
|
245
|
+
chunks.forEach(c -> o.writeBytes(c.data()));
|
|
246
|
+
return o.toByteArray();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
package io.visionhammer.gateway.cloud.infrastructure.byoc;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.databind.JsonNode;
|
|
4
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaProtocolV2Properties;
|
|
5
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Chunk;
|
|
6
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Exception;
|
|
7
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2InboundListener;
|
|
8
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Metrics;
|
|
9
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Session;
|
|
10
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2SessionStore;
|
|
11
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit;
|
|
12
|
+
import io.visionhammer.gateway.governance.application.DataPlanePolicy;
|
|
13
|
+
import io.visionhammer.gateway.identity.application.AgentAuthorizationEntity;
|
|
14
|
+
import io.visionhammer.gateway.identity.application.AgentAuthorizationStore;
|
|
15
|
+
import io.visionhammer.gateway.shared.localagent.AgentRequestSigner;
|
|
16
|
+
import io.visionhammer.gateway.workspace.application.WorkspaceRepository;
|
|
17
|
+
import org.junit.jupiter.api.Test;
|
|
18
|
+
import org.springframework.http.server.reactive.HttpHandler;
|
|
19
|
+
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
|
20
|
+
import org.springframework.web.reactive.socket.server.WebSocketService;
|
|
21
|
+
import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService;
|
|
22
|
+
import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy;
|
|
23
|
+
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
|
|
24
|
+
import reactor.core.publisher.Mono;
|
|
25
|
+
import reactor.core.scheduler.Schedulers;
|
|
26
|
+
import reactor.netty.DisposableServer;
|
|
27
|
+
import reactor.netty.http.server.HttpServer;
|
|
28
|
+
|
|
29
|
+
import java.io.BufferedReader;
|
|
30
|
+
import java.io.InputStreamReader;
|
|
31
|
+
import java.nio.charset.StandardCharsets;
|
|
32
|
+
import java.time.Clock;
|
|
33
|
+
import java.time.Duration;
|
|
34
|
+
import java.util.ArrayList;
|
|
35
|
+
import java.util.Collections;
|
|
36
|
+
import java.util.List;
|
|
37
|
+
import java.util.Map;
|
|
38
|
+
import java.util.UUID;
|
|
39
|
+
import java.util.concurrent.BlockingQueue;
|
|
40
|
+
import java.util.concurrent.LinkedBlockingQueue;
|
|
41
|
+
import java.util.concurrent.TimeUnit;
|
|
42
|
+
|
|
43
|
+
import static io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit.OM;
|
|
44
|
+
import static org.assertj.core.api.Assertions.assertThat;
|
|
45
|
+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
|
46
|
+
import static org.mockito.ArgumentMatchers.any;
|
|
47
|
+
import static org.mockito.ArgumentMatchers.anyString;
|
|
48
|
+
import static org.mockito.Mockito.mock;
|
|
49
|
+
import static org.mockito.Mockito.when;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* F3-05b interoperability: the REAL Node Local Agent client (deepdebug-local-agent, protocol v2 link) against
|
|
53
|
+
* the REAL gateway {@link LaV2Session} behind a real Reactor Netty WebSocket on loopback, real system clock,
|
|
54
|
+
* real Ed25519 signer. Nothing is mocked on the protocol path (only the authorization store / workspaces /
|
|
55
|
+
* data-plane policy lookups). Run from the gateway checkout (JDK 21):
|
|
56
|
+
*
|
|
57
|
+
* LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeInteropTest
|
|
58
|
+
*
|
|
59
|
+
* Skipped when LA_REPO is not set. Not proven here: Cloud Run / LB / TLS, Memorystore, two gateway instances.
|
|
60
|
+
*/
|
|
61
|
+
class LaV2NodeInteropTest {
|
|
62
|
+
|
|
63
|
+
private static final String TENANT = LaV2TestKit.TENANT;
|
|
64
|
+
private static final String AGENT = LaV2TestKit.AGENT;
|
|
65
|
+
|
|
66
|
+
private static byte[] pattern(int n) {
|
|
67
|
+
byte[] b = new byte[n];
|
|
68
|
+
for (int i = 0; i < n; i++) {
|
|
69
|
+
b[i] = (byte) ((i * 31 + 7) & 0xff);
|
|
70
|
+
}
|
|
71
|
+
return b;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private static Map<String, Object> fileRequest(String ref) {
|
|
75
|
+
return Map.of("kind", "file", "ref", ref, "actor", Map.of("userId", "usr-42", "role", "member"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Test
|
|
79
|
+
void realNodeLocalAgentAgainstTheRealLaV2Session() throws Exception {
|
|
80
|
+
String laRepo = System.getenv("LA_REPO");
|
|
81
|
+
assumeTrue(laRepo != null && !laRepo.isBlank(), "LA_REPO not set");
|
|
82
|
+
|
|
83
|
+
Clock clock = Clock.systemUTC();
|
|
84
|
+
LaProtocolV2Properties props = new LaProtocolV2Properties();
|
|
85
|
+
props.setEnabled(true);
|
|
86
|
+
List<String> rejected = Collections.synchronizedList(new ArrayList<>());
|
|
87
|
+
int[] retransmits = {0};
|
|
88
|
+
int[] duplicates = {0};
|
|
89
|
+
LaV2Metrics metrics = new LaV2Metrics() {
|
|
90
|
+
@Override public void rejected(String code) { rejected.add(code); }
|
|
91
|
+
@Override public void retransmit() { retransmits[0]++; }
|
|
92
|
+
@Override public void duplicate() { duplicates[0]++; }
|
|
93
|
+
};
|
|
94
|
+
List<JsonNode> inbound = Collections.synchronizedList(new ArrayList<>());
|
|
95
|
+
LaV2InboundListener listener = (t, a, type, rid, payload) -> {
|
|
96
|
+
assertThat(t).isEqualTo(TENANT);
|
|
97
|
+
assertThat(a).isEqualTo(AGENT);
|
|
98
|
+
var n = OM.createObjectNode().put("type", type);
|
|
99
|
+
n.set("payload", payload);
|
|
100
|
+
inbound.add(n);
|
|
101
|
+
};
|
|
102
|
+
AgentRequestSigner signer = new AgentRequestSigner(LaV2TestKit.PEM, "k1", clock);
|
|
103
|
+
LaV2Session.Env env = new LaV2Session.Env(LaV2TestKit.CONTRACT, props, signer, OM, clock, metrics, Schedulers.parallel(), List.of(listener));
|
|
104
|
+
|
|
105
|
+
AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
|
|
106
|
+
WorkspaceRepository workspaces = mock(WorkspaceRepository.class);
|
|
107
|
+
DataPlanePolicy policy = mock(DataPlanePolicy.class);
|
|
108
|
+
when(workspaces.findDefaultByTenantId(anyString())).thenReturn(Mono.empty());
|
|
109
|
+
when(store.save(any())).thenAnswer(i -> Mono.just(i.getArgument(0)));
|
|
110
|
+
when(store.touchLastSeen(any())).thenReturn(Mono.just(1));
|
|
111
|
+
when(policy.requiresEnforcedSigning(anyString())).thenReturn(Mono.just(true)); // a hybrid/client tenant: enforce required
|
|
112
|
+
AgentAuthorizationEntity auth = new AgentAuthorizationEntity();
|
|
113
|
+
auth.setId(UUID.fromString(AGENT));
|
|
114
|
+
auth.setTenantId(UUID.fromString(TENANT));
|
|
115
|
+
auth.setAuthorizationToken("tok");
|
|
116
|
+
when(store.findByAuthorizationToken("tok")).thenReturn(Mono.just(auth));
|
|
117
|
+
AgentWebSocketHandler handler = new AgentWebSocketHandler(OM, workspaces, store, signer, null, null, null,
|
|
118
|
+
new LaV2SessionStore(env), props, policy);
|
|
119
|
+
|
|
120
|
+
WebSocketService wsService = new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy());
|
|
121
|
+
HttpHandler http = WebHttpHandlerBuilder.webHandler(exchange -> wsService.handleRequest(exchange, handler)).build();
|
|
122
|
+
DisposableServer server = HttpServer.create().host("127.0.0.1").port(0).handle(new ReactorHttpHandlerAdapter(http)).bindNow();
|
|
123
|
+
|
|
124
|
+
ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-client.mjs");
|
|
125
|
+
pb.environment().putAll(Map.of(
|
|
126
|
+
"GW_URL", "ws://127.0.0.1:" + server.port() + "/api/v1/agent/ws", "TOKEN", "tok", "TENANT", TENANT, "AGENT", AGENT,
|
|
127
|
+
"KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce"));
|
|
128
|
+
pb.redirectErrorStream(true);
|
|
129
|
+
Process proc = pb.start();
|
|
130
|
+
BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
|
|
131
|
+
List<String> transcript = Collections.synchronizedList(new ArrayList<>());
|
|
132
|
+
Thread reader = new Thread(() -> {
|
|
133
|
+
try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
|
|
134
|
+
String line;
|
|
135
|
+
while ((line = r.readLine()) != null) {
|
|
136
|
+
transcript.add(line);
|
|
137
|
+
try {
|
|
138
|
+
events.add(OM.readTree(line));
|
|
139
|
+
} catch (Exception ignored) {
|
|
140
|
+
// node warnings
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (Exception ignored) {
|
|
144
|
+
// process ended
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
reader.setDaemon(true);
|
|
148
|
+
reader.start();
|
|
149
|
+
java.io.Writer stdin = new java.io.OutputStreamWriter(proc.getOutputStream(), StandardCharsets.UTF_8);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
JsonNode ready = await(events, "ready", 15);
|
|
153
|
+
assertThat(ready.path("resumed").asBoolean()).isFalse();
|
|
154
|
+
assertThat(handler.isAgentConnected(TENANT)).isTrue();
|
|
155
|
+
|
|
156
|
+
// 1. content: 100 KiB (4 chunks), the maximum 2 MiB (64 chunks), a not_found answer
|
|
157
|
+
List<LaV2Chunk> small = handler.requestContent(TENANT, null, fileRequest("bytes:102400"), "ws-1").collectList().block(Duration.ofSeconds(20));
|
|
158
|
+
assertThat(small).hasSize(4);
|
|
159
|
+
assertThat(concat(small)).isEqualTo(pattern(102400));
|
|
160
|
+
assertThat(small.get(3).last()).isTrue();
|
|
161
|
+
|
|
162
|
+
List<LaV2Chunk> max = handler.requestContent(TENANT, null, fileRequest("bytes:2097152"), "ws-1").collectList().block(Duration.ofSeconds(30));
|
|
163
|
+
assertThat(max).hasSize(64);
|
|
164
|
+
assertThat(concat(max)).isEqualTo(pattern(2097152));
|
|
165
|
+
|
|
166
|
+
List<LaV2Chunk> nf = handler.requestContent(TENANT, null, fileRequest("notfound"), "ws-1").collectList().block(Duration.ofSeconds(20));
|
|
167
|
+
assertThat(nf).hasSize(1);
|
|
168
|
+
assertThat(nf.get(0).status()).isEqualTo("not_found");
|
|
169
|
+
|
|
170
|
+
// 2. signed CP->LA data frames: the Node LA verifies the Java signature (enforce) and acks
|
|
171
|
+
Map<String, Object> assign = Map.of("jobId", "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", "kind", "fix",
|
|
172
|
+
"repoRef", Map.of("provider", "github", "org", "acme", "repo", "billing", "branch", "main"),
|
|
173
|
+
"policy", Map.of("operationMode", "assist", "automationLevel", 2, "ceiling", "autonomous"),
|
|
174
|
+
"budget", Map.of("tokens", 200000, "seconds", 900), "llm", Map.of("alias", "vh-coder", "keyRef", "key-la-1"));
|
|
175
|
+
handler.sendV2(TENANT, null, "job.assign", assign, "ws-1").block(Duration.ofSeconds(10));
|
|
176
|
+
assertThat(await(events, "job.assign", 5).path("jobId").asText()).isEqualTo("c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f");
|
|
177
|
+
handler.sendV2(TENANT, null, "job.cancel", Map.of("jobId", "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", "reason", "user"), "ws-1").block(Duration.ofSeconds(10));
|
|
178
|
+
await(events, "job.cancel", 5);
|
|
179
|
+
|
|
180
|
+
// a type the LA has no handler for: rejected ack with protocol_unsupported -> the CP call fails with that code
|
|
181
|
+
org.assertj.core.api.Assertions.assertThatThrownBy(() -> handler.sendV2(TENANT, null, "config.update", Map.of("retentionDays", 7), "ws-1").block(Duration.ofSeconds(10)))
|
|
182
|
+
.isInstanceOf(LaV2Exception.class).extracting(e -> ((LaV2Exception) e).code().wire()).isEqualTo("protocol_unsupported");
|
|
183
|
+
|
|
184
|
+
// 3. LA -> CP: 20 events, in order, no duplicates
|
|
185
|
+
send(stdin, "events 20");
|
|
186
|
+
await(events, "events-acked", 10);
|
|
187
|
+
assertThat(iterations(inbound)).containsExactlyElementsOf(range(0, 20));
|
|
188
|
+
|
|
189
|
+
// 4. the socket drops; the LA reconnects offering {sessionId,lastRecvSeq}; the gateway resumes the session
|
|
190
|
+
send(stdin, "drop");
|
|
191
|
+
JsonNode again = await(events, "ready", 15);
|
|
192
|
+
assertThat(again.path("resumed").asBoolean()).as("resumed on the same gateway instance").isTrue();
|
|
193
|
+
assertThat(again.path("sessionId").asText()).isEqualTo(ready.path("sessionId").asText());
|
|
194
|
+
send(stdin, "events 5");
|
|
195
|
+
await(events, "events-acked", 10);
|
|
196
|
+
assertThat(iterations(inbound)).hasSize(25);
|
|
197
|
+
assertThat(iterations(inbound).subList(20, 25)).containsExactlyElementsOf(range(0, 5));
|
|
198
|
+
|
|
199
|
+
// 5. content still works after the resume
|
|
200
|
+
List<LaV2Chunk> after = handler.requestContent(TENANT, null, fileRequest("bytes:40000"), "ws-1").collectList().block(Duration.ofSeconds(20));
|
|
201
|
+
assertThat(concat(after)).isEqualTo(pattern(40000));
|
|
202
|
+
|
|
203
|
+
// nothing the gateway refused from the Node LA
|
|
204
|
+
assertThat(rejected).as("frames the real LaV2Session rejected: %s", rejected).isEmpty();
|
|
205
|
+
System.out.println("INTEROP OK: retransmits=" + retransmits[0] + " duplicates=" + duplicates[0] + " rejected=" + rejected
|
|
206
|
+
+ " events=" + inbound.size());
|
|
207
|
+
} finally {
|
|
208
|
+
try {
|
|
209
|
+
send(stdin, "quit");
|
|
210
|
+
} catch (Exception ignored) {
|
|
211
|
+
// gone
|
|
212
|
+
}
|
|
213
|
+
proc.waitFor(5, TimeUnit.SECONDS);
|
|
214
|
+
proc.destroyForcibly();
|
|
215
|
+
server.disposeNow();
|
|
216
|
+
System.out.println("LA transcript (no content): " + transcript.size() + " lines");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private static void send(java.io.Writer w, String line) throws Exception {
|
|
221
|
+
w.write(line + "\n");
|
|
222
|
+
w.flush();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private static JsonNode await(BlockingQueue<JsonNode> q, String evt, int seconds) throws Exception {
|
|
226
|
+
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
|
227
|
+
List<String> seen = new ArrayList<>();
|
|
228
|
+
while (System.nanoTime() < deadline) {
|
|
229
|
+
JsonNode n = q.poll(100, TimeUnit.MILLISECONDS);
|
|
230
|
+
if (n == null) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (evt.equals(n.path("evt").asText())) {
|
|
234
|
+
return n;
|
|
235
|
+
}
|
|
236
|
+
seen.add(n.path("evt").asText() + (n.has("code") ? ":" + n.path("code").asText() : "") + (n.has("m") ? ":" + n.path("m").asText() : ""));
|
|
237
|
+
}
|
|
238
|
+
throw new AssertionError("no '" + evt + "' event from the Node LA within " + seconds + " s; saw " + seen);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private static byte[] concat(List<LaV2Chunk> chunks) {
|
|
242
|
+
java.io.ByteArrayOutputStream o = new java.io.ByteArrayOutputStream();
|
|
243
|
+
chunks.forEach(c -> o.writeBytes(c.data()));
|
|
244
|
+
return o.toByteArray();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private static List<Integer> iterations(List<JsonNode> inbound) {
|
|
248
|
+
List<Integer> out = new ArrayList<>();
|
|
249
|
+
synchronized (inbound) {
|
|
250
|
+
for (JsonNode n : inbound) {
|
|
251
|
+
if ("job.event".equals(n.path("type").asText())) {
|
|
252
|
+
out.add(n.at("/payload/iteration").asInt());
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private static List<Integer> range(int from, int to) {
|
|
260
|
+
List<Integer> l = new ArrayList<>();
|
|
261
|
+
for (int i = from; i < to; i++) {
|
|
262
|
+
l.add(i);
|
|
263
|
+
}
|
|
264
|
+
return l;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Interoperability test: this Local Agent against the real gateway `LaV2Session`
|
|
2
|
+
|
|
3
|
+
`la-interop-client.mjs` is a Local Agent built from this repo's v2 link, with test-only handlers
|
|
4
|
+
(`content.request`, `job.assign`, `job.cancel`) that the product does not have yet. `LaV2NodeInteropTest.java` is a
|
|
5
|
+
JUnit test for the gateway repo: it starts the real `AgentWebSocketHandler` + `LaV2Session` (Reactor Netty,
|
|
6
|
+
loopback, system clock, real Ed25519 signer, `enforce` required), starts the Node client, and drives it.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# 1. in a worktree of vh-api-gateway (release/qa, tip >= 7043f75e), JDK 21
|
|
10
|
+
cp /path/to/deepdebug-local-agent/tools/interop/LaV2NodeInteropTest.java \
|
|
11
|
+
src/test/java/io/visionhammer/gateway/cloud/infrastructure/byoc/
|
|
12
|
+
# 2. run it (needs `node` >= 18 on PATH and `npm ci` done in the LA repo)
|
|
13
|
+
LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeInteropTest
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It checks: signed `ready` verified by Node; 100 KiB (4 chunks), 2 MiB (64 chunks) and `not_found` content streamed
|
|
17
|
+
LA -> CP and rebuilt byte for byte; signed `job.assign`/`job.cancel` from the Java signer verified by Node and acked;
|
|
18
|
+
a type with no handler -> rejected ack `protocol_unsupported`; 20 `job.event` in order; socket drop -> `resumed:true`
|
|
19
|
+
with the same session id and consecutive seq; content after the resume; zero frames refused by the gateway.
|
|
20
|
+
It is skipped when `LA_REPO` is not set. It does not prove Cloud Run / the load balancer / TLS / Memorystore /
|
|
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,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A Local Agent for the interoperability test with the REAL gateway session (LaV2Session, Java): the v2 link of
|
|
3
|
+
// this repo around a plain `ws` client, with TEST-ONLY handlers (content.request / job.assign / job.cancel) that
|
|
4
|
+
// the product does not have yet (F3-07). Driven by the Java harness over stdin; reports on stdout, one JSON per line.
|
|
5
|
+
//
|
|
6
|
+
// env: GW_URL (ws://127.0.0.1:port/api/v1/agent/ws) TOKEN TENANT AGENT KEY_ID PUBLIC_KEY MODE(enforce)
|
|
7
|
+
// stdin: "events <n>" send n job.event frames (iteration 0..n-1)
|
|
8
|
+
// "drop" terminate the socket and reconnect (resume)
|
|
9
|
+
// "quit"
|
|
10
|
+
// content.request ref: "bytes:<n>" -> n deterministic bytes; "notfound" -> a not_found answer
|
|
11
|
+
import readline from "node:readline";
|
|
12
|
+
import WebSocket from "ws";
|
|
13
|
+
import { createV2Link } from "../../src/la-protocol-v2/link.js";
|
|
14
|
+
import { sendContent, sendContentFailure } from "../../src/la-protocol-v2/content.js";
|
|
15
|
+
import { resolveSigningConfig } from "../../src/utils/request-signing.js";
|
|
16
|
+
|
|
17
|
+
const { GW_URL, TOKEN, TENANT, AGENT, KEY_ID, PUBLIC_KEY, MODE = "enforce" } = process.env;
|
|
18
|
+
const out = (o) => process.stdout.write(JSON.stringify(o) + "\n");
|
|
19
|
+
|
|
20
|
+
export const patternBytes = (n) => Buffer.from(Array.from({ length: n }, (_, i) => (i * 31 + 7) & 0xff));
|
|
21
|
+
|
|
22
|
+
const signing = resolveSigningConfig({
|
|
23
|
+
LOCAL_AGENT_SIGNATURE_MODE: MODE, LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${KEY_ID}=${PUBLIC_KEY}`, LOCAL_AGENT_TENANT_ID: TENANT,
|
|
24
|
+
}, { defaultFile: null, fileCfg: {} });
|
|
25
|
+
|
|
26
|
+
const link = createV2Link({
|
|
27
|
+
log: { info: () => {}, warn: (m) => out({ evt: "warn", m }) },
|
|
28
|
+
sessionOptions: {
|
|
29
|
+
tenantId: TENANT, agentId: AGENT, signing, laVersion: "1.3.0", capabilities: ["protocol.v2", "v1.commands", "chunk.base64"],
|
|
30
|
+
getWorkspaces: () => [{ workspaceId: "ws-1", pathSha256: "ab".repeat(32) }],
|
|
31
|
+
getHealth: () => ({ diskFreeBytes: 123456789 }),
|
|
32
|
+
options: { handshakeTimeoutMs: 5000, heartbeatMs: 500 },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const session = link.session;
|
|
36
|
+
session.on("ready", (i) => out({ evt: "ready", resumed: i.resumed, sessionId: i.sessionId, limits: i.limits }));
|
|
37
|
+
session.on("refused", (c) => out({ evt: "refused", code: c }));
|
|
38
|
+
session.on("peer-error", (c) => out({ evt: "peer-error", code: c }));
|
|
39
|
+
|
|
40
|
+
// test-only handlers
|
|
41
|
+
session.setHandler("job.assign", (ctx) => { out({ evt: "job.assign", jobId: ctx.params.jobId, seq: ctx.seq }); return { accept: true }; });
|
|
42
|
+
session.setHandler("job.cancel", (ctx) => { out({ evt: "job.cancel", jobId: ctx.params.jobId, seq: ctx.seq }); return { accept: true }; });
|
|
43
|
+
session.setHandler("content.request", (ctx) => {
|
|
44
|
+
const { ref } = ctx.params;
|
|
45
|
+
// ack first (accepted), answer in the background: the CP waits up to 10 s for the first chunk
|
|
46
|
+
queueMicrotask(async () => {
|
|
47
|
+
try {
|
|
48
|
+
if (ref === "notfound") await sendContentFailure(session, { requestId: ctx.requestId, status: "not_found", errorCode: "internal", workspaceId: ctx.workspaceId });
|
|
49
|
+
else await sendContent(session, { requestId: ctx.requestId, source: patternBytes(Number(ref.split(":")[1])), contentType: "application/octet-stream", workspaceId: ctx.workspaceId });
|
|
50
|
+
out({ evt: "content.sent", ref });
|
|
51
|
+
} catch (e) { out({ evt: "content.failed", code: e.code }); }
|
|
52
|
+
});
|
|
53
|
+
return { accept: true };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let ws;
|
|
57
|
+
function connect() {
|
|
58
|
+
ws = new WebSocket(GW_URL, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
59
|
+
const mine = ws;
|
|
60
|
+
ws.on("open", () => {
|
|
61
|
+
const legacy = () => { out({ evt: "legacy-register" }); mine.send(JSON.stringify({ type: "register", tenantId: TENANT, port: 5055, workspacePath: null })); };
|
|
62
|
+
if (!link.begin(mine, legacy)) legacy();
|
|
63
|
+
const hb = setInterval(() => { if (mine.readyState === 1) mine.send("ping"); else clearInterval(hb); }, 5000);
|
|
64
|
+
});
|
|
65
|
+
ws.on("message", (data, isBinary) => {
|
|
66
|
+
const text = data.toString();
|
|
67
|
+
if (text === "pong") return;
|
|
68
|
+
if (link.handleMessage(mine, text, isBinary)) return;
|
|
69
|
+
out({ evt: "v1-message" });
|
|
70
|
+
});
|
|
71
|
+
ws.on("close", (code) => { out({ evt: "closed", code }); link.onClose(mine); if (!quitting) setTimeout(connect, 100); });
|
|
72
|
+
ws.on("error", () => {});
|
|
73
|
+
}
|
|
74
|
+
let quitting = false;
|
|
75
|
+
connect();
|
|
76
|
+
|
|
77
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
78
|
+
rl.on("line", async (line) => {
|
|
79
|
+
const [cmd, arg] = line.trim().split(/\s+/);
|
|
80
|
+
if (cmd === "events") {
|
|
81
|
+
const n = Number(arg);
|
|
82
|
+
const jobId = "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f";
|
|
83
|
+
const sends = [];
|
|
84
|
+
for (let i = 0; i < n; i++) sends.push(session.send("job.event", { jobId, state: "running", iteration: i }).catch((e) => out({ evt: "event-failed", code: e.code })));
|
|
85
|
+
await Promise.all(sends);
|
|
86
|
+
out({ evt: "events-acked", n });
|
|
87
|
+
} else if (cmd === "drop") {
|
|
88
|
+
out({ evt: "dropping" });
|
|
89
|
+
ws.terminate();
|
|
90
|
+
} else if (cmd === "quit") {
|
|
91
|
+
quitting = true;
|
|
92
|
+
ws.terminate();
|
|
93
|
+
link.close();
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
rl.on("close", () => { quitting = true; try { ws.terminate(); } catch { /* gone */ } process.exit(0); });
|