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.
- package/docs/la-protocol-v2.md +69 -5
- package/env.example +4 -0
- package/package.json +1 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
- package/src/la-protocol-v2/content-handlers/audit.js +36 -0
- package/src/la-protocol-v2/content-handlers/config.js +54 -0
- package/src/la-protocol-v2/content-handlers/content-index.js +161 -0
- package/src/la-protocol-v2/content-handlers/egress.js +65 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +142 -0
- package/src/la-protocol-v2/content-handlers/handler.js +182 -0
- package/src/la-protocol-v2/content-handlers/index.js +6 -0
- package/src/la-protocol-v2/content-handlers/install.js +65 -0
- package/src/la-protocol-v2/content-handlers/path-guard.js +183 -0
- package/src/la-protocol-v2/content-handlers/ref-registry.js +105 -0
- package/src/la-protocol-v2/content.js +7 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +9 -6
- package/src/la-protocol-v2/contract/README.md +49 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +92 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/runtime.js +23 -4
- package/src/server.js +4 -1
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3777 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
- package/test/la-protocol-v2/content-alignment.test.js +608 -0
- package/test/la-protocol-v2/content-handlers.test.js +627 -0
- package/test/la-protocol-v2/content-path-guard.test.js +219 -0
- package/test/la-protocol-v2/content-wiring.test.js +250 -0
- package/test/la-protocol-v2/contract.test.js +5 -5
- package/test/la-protocol-v2/helpers/content-rig.js +94 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +2 -1
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +262 -0
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/README.md +17 -0
- package/tools/interop/la-interop-content-client.mjs +102 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handler.js - `content.request` handlers of the Local Agent (F3-07). Registered by runtime.js only when
|
|
3
|
+
* LOCAL_AGENT_PROTOCOL_V2=true AND LOCAL_AGENT_CONTENT_HANDLERS=true.
|
|
4
|
+
*
|
|
5
|
+
* Order of every request (nothing is read before the checks, nothing leaves before the DLP):
|
|
6
|
+
* 1. accept: the handler answers the session at once ({accept:true} = ack `accepted`); the work goes on in the
|
|
7
|
+
* background (the first chunk may take seconds, the ack timeout is 5 s). A request without requestId is refused.
|
|
8
|
+
* 2. admission: at most `maxConcurrent` requests and `maxInFlightBytes` in memory; above it -> `rate_limited`.
|
|
9
|
+
* 3. kind: `file` and `diff` are served; every other kind of the allowlist -> status `error` + `protocol_unsupported`
|
|
10
|
+
* (the contract has no "not supported" status; this is the code already used for "no handler").
|
|
11
|
+
* 4. workspace: the envelope's workspace must be registered here (and belong to this tenant) -> else `not_found`.
|
|
12
|
+
* 5. ref: an opaque id (ref-registry.js) valid for this tenant+workspace+root+kind -> else `not_found`, whatever the
|
|
13
|
+
* reason (unknown, expired, other workspace, or "../etc/passwd": no oracle, nothing echoed). The ref is never
|
|
14
|
+
* interpreted as a path.
|
|
15
|
+
* 6. path (file): realpath inside the root, not sensitive, regular file, <= maxBytes (path-guard.js) -> `forbidden` /
|
|
16
|
+
* `not_found` / `payload_too_large`.
|
|
17
|
+
* 7. DLP + range + size (egress.js), then 8. sendContent: <= 32 KiB per chunk, <= 64 chunks, window + backpressure of the
|
|
18
|
+
* session, aborted by cancel()/deadline/connection loss.
|
|
19
|
+
* Answers carry a status and a wire code only. Logs, errors and the audit never contain a path, a ref or content.
|
|
20
|
+
*/
|
|
21
|
+
import { ProtocolError } from "../errors.js";
|
|
22
|
+
import { sendContent, sendContentFailure } from "../content.js";
|
|
23
|
+
import { REF_KINDS } from "./ref-registry.js";
|
|
24
|
+
import { normalizeRel, resolveInside, readRegular } from "./path-guard.js";
|
|
25
|
+
import { worktreeDiff } from "./git-diff.js";
|
|
26
|
+
import { prepareOutgoing, contentTypeOf, decodeText } from "./egress.js";
|
|
27
|
+
import { createAudit, short } from "./audit.js";
|
|
28
|
+
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
29
|
+
|
|
30
|
+
export const SERVED_KINDS = Object.freeze(["file", "diff"]);
|
|
31
|
+
/** Symbolic ref of kind `diff`: the whole work tree of the envelope's workspace (carries no client data). */
|
|
32
|
+
export const WORKTREE_REF = "worktree";
|
|
33
|
+
|
|
34
|
+
export function createContentHandler({ session, tenantId, resolveWorkspace, registry, config, env = process.env, log = console, audit = null, now = Date.now }) {
|
|
35
|
+
const inflight = new Map(); // requestId -> { ac, timer }
|
|
36
|
+
let reservedBytes = 0;
|
|
37
|
+
const auditor = audit || createAudit({ file: config.auditFile, now });
|
|
38
|
+
const reserveEach = 2 * config.maxBytes;
|
|
39
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
40
|
+
|
|
41
|
+
const stats = { received: 0, ok: 0, notFound: 0, forbidden: 0, error: 0, cancelled: 0, byCode: {} };
|
|
42
|
+
const bump = (k) => { stats.byCode[k] = (stats.byCode[k] || 0) + 1; };
|
|
43
|
+
|
|
44
|
+
/** @returns {Promise<{status:string, errorCode?:string, buffer?:Buffer, contentType?:string, dlp?:object}>} */
|
|
45
|
+
async function produce(ctx, p, signal) {
|
|
46
|
+
if (!SERVED_KINDS.includes(p.kind)) return { status: "error", errorCode: "protocol_unsupported" };
|
|
47
|
+
// F3-07b: content never leaves unmasked. runtime.js does not even register this handler without DLP enforce; this is the
|
|
48
|
+
// same rule at the moment of the request (the env is read per request), so a changed mode can not open the door.
|
|
49
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") return { status: "error", errorCode: "protocol_unsupported" };
|
|
50
|
+
const ws = await resolveWorkspace(ctx.workspaceId);
|
|
51
|
+
if (!ws || !ws.root) return { status: "not_found" };
|
|
52
|
+
const target = registry.resolve(p.ref, { workspaceId: ctx.workspaceId, root: ws.root, kind: p.kind });
|
|
53
|
+
if (p.kind === "diff" && !target && p.ref === WORKTREE_REF) return produceDiff(p, ws, null, signal);
|
|
54
|
+
if (!target) return { status: "not_found" };
|
|
55
|
+
if (p.kind === "diff") return produceDiff(p, ws, target.rel, signal);
|
|
56
|
+
return produceFile(p, ws, target.rel, signal);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function produceFile(p, ws, rel, signal) {
|
|
60
|
+
const norm = rel && normalizeRel(rel);
|
|
61
|
+
if (!norm) return { status: "not_found" };
|
|
62
|
+
const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs, { allowHardlinks: config.allowHardlinks });
|
|
63
|
+
if (!resolved.ok) return { status: resolved.status };
|
|
64
|
+
if (signal.aborted) return { status: "error", errorCode: "agent_offline" };
|
|
65
|
+
const read = await readRegular(resolved, config.maxBytes, signal, { allowHardlinks: config.allowHardlinks });
|
|
66
|
+
if (!read.ok) return read.status === "too_large" ? { status: "error", errorCode: "payload_too_large" } : { status: read.status };
|
|
67
|
+
const isText = decodeText(read.buffer) !== null;
|
|
68
|
+
return finish(p, read.buffer, contentTypeOf(norm, isText), "content.file");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function produceDiff(p, ws, rel, signal) {
|
|
72
|
+
const d = await worktreeDiff({ root: ws.root, rel, maxBytes: config.maxBytes, maxFiles: config.maxDiffFiles,
|
|
73
|
+
timeoutMs: config.gitTimeoutMs, denyGlobs: config.extraDenyGlobs, allowHardlinks: config.allowHardlinks, signal });
|
|
74
|
+
if (!d.ok) return d;
|
|
75
|
+
return finish(p, Buffer.from(d.text, "utf8"), "text/x-diff", "content.diff");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function finish(p, buffer, contentType, source) {
|
|
79
|
+
const out = prepareOutgoing({ buffer, contentType, range: p.range, maxBytes: config.maxBytes, env, source });
|
|
80
|
+
return out.ok ? { status: "ok", buffer: out.buffer, contentType: out.contentType, dlp: out.dlp }
|
|
81
|
+
: { status: out.status, errorCode: out.errorCode, dlp: out.dlp };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function run(ctx, p, entry) {
|
|
85
|
+
const t0 = now();
|
|
86
|
+
// F3-07b: always defined. If produce() throws, the gateway still gets an answer (a code, never the message of the
|
|
87
|
+
// exception: it may carry a path) and the request is audited like any other.
|
|
88
|
+
let result = { status: "error", errorCode: "internal" };
|
|
89
|
+
let sent = null;
|
|
90
|
+
let errorCode;
|
|
91
|
+
try {
|
|
92
|
+
if (inflight.size > config.maxConcurrent || reservedBytes + reserveEach > config.maxInFlightBytes) {
|
|
93
|
+
result = { status: "error", errorCode: "rate_limited" };
|
|
94
|
+
} else {
|
|
95
|
+
reservedBytes += reserveEach;
|
|
96
|
+
entry.reserved = true;
|
|
97
|
+
try {
|
|
98
|
+
result = await produce(ctx, p, entry.ac.signal);
|
|
99
|
+
} catch {
|
|
100
|
+
result = { status: "error", errorCode: "internal" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (entry.ac.signal.aborted) {
|
|
104
|
+
errorCode = "cancelled";
|
|
105
|
+
// out of time before the first chunk: the gateway still waits for an answer, give it one (a code only)
|
|
106
|
+
if (entry.reason === "deadline") {
|
|
107
|
+
await sendContentFailure(session, { requestId: ctx.requestId, status: "error", errorCode: "timeout", workspaceId: ctx.workspaceId }).catch(() => {});
|
|
108
|
+
errorCode = "timeout";
|
|
109
|
+
}
|
|
110
|
+
} else if (result.status === "ok") {
|
|
111
|
+
try {
|
|
112
|
+
sent = await sendContent(session, { requestId: ctx.requestId, source: result.buffer, contentType: result.contentType,
|
|
113
|
+
workspaceId: ctx.workspaceId, signal: entry.ac.signal });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
errorCode = e instanceof ProtocolError ? (entry.ac.signal.aborted ? "cancelled" : e.code) : "internal";
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
await sendContentFailure(session, { requestId: ctx.requestId, status: result.status,
|
|
119
|
+
errorCode: result.errorCode || null, workspaceId: ctx.workspaceId });
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
errorCode = e instanceof ProtocolError ? e.code : "internal";
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(entry.timer);
|
|
125
|
+
if (entry.reserved) reservedBytes -= reserveEach;
|
|
126
|
+
inflight.delete(ctx.requestId);
|
|
127
|
+
}
|
|
128
|
+
const status = errorCode === "cancelled" || errorCode === "timeout" ? errorCode : errorCode ? "send_failed" : result.status;
|
|
129
|
+
if (status === "ok") stats.ok++;
|
|
130
|
+
else if (status === "not_found") stats.notFound++;
|
|
131
|
+
else if (status === "forbidden") stats.forbidden++;
|
|
132
|
+
else if (status === "cancelled" || status === "timeout") stats.cancelled++;
|
|
133
|
+
else stats.error++;
|
|
134
|
+
bump(errorCode || result.errorCode || status);
|
|
135
|
+
try {
|
|
136
|
+
auditor.record({
|
|
137
|
+
requestId: ctx.requestId, workspaceId: ctx.workspaceId, kind: p.kind, status, errorCode: errorCode || result.errorCode || null,
|
|
138
|
+
bytes: sent ? sent.bytes : 0, chunks: sent ? sent.chunks : 0, ms: now() - t0,
|
|
139
|
+
dlpMode: result.dlp ? result.dlp.mode : null, dlpHits: result.dlp ? result.dlp.total : 0, dlpRules: result.dlp ? result.dlp.redactions : {},
|
|
140
|
+
refSha: short(p.ref), actorRole: p.actor && p.actor.role, actorSha: short(p.actor && p.actor.userId),
|
|
141
|
+
});
|
|
142
|
+
} catch { /* the audit must never take the data path down (nor leave a request without its answer) */ }
|
|
143
|
+
if (status === "send_failed") warn(`[la-content] ${p.kind} not delivered: ${errorCode}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
stats,
|
|
148
|
+
audit: auditor,
|
|
149
|
+
get inflight() { return inflight.size; },
|
|
150
|
+
get reservedBytes() { return reservedBytes; },
|
|
151
|
+
|
|
152
|
+
/** The session handler for `content.request` (session.setHandler). */
|
|
153
|
+
handle(ctx) {
|
|
154
|
+
stats.received++;
|
|
155
|
+
const p = ctx.params;
|
|
156
|
+
if (!ctx.requestId) return { reject: "schema_violation" };
|
|
157
|
+
if (inflight.has(ctx.requestId)) return { accept: true }; // already working on it (a retransmission with another seq)
|
|
158
|
+
const entry = { ac: new AbortController(), timer: null, reserved: false, reason: null };
|
|
159
|
+
entry.timer = setTimeout(() => { entry.reason = "deadline"; entry.ac.abort(); }, config.totalMs);
|
|
160
|
+
inflight.set(ctx.requestId, entry);
|
|
161
|
+
// background: ack first, answer later
|
|
162
|
+
queueMicrotask(() => { run(ctx, p, entry).catch(() => warn("[la-content] request failed internally")); });
|
|
163
|
+
return { accept: true };
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/** Stops one request (no further chunk is read or written). There is no content.cancel frame in the contract. */
|
|
167
|
+
cancel(requestId) {
|
|
168
|
+
const e = inflight.get(requestId);
|
|
169
|
+
if (!e) return false;
|
|
170
|
+
e.reason = e.reason || "cancel";
|
|
171
|
+
e.ac.abort();
|
|
172
|
+
session.cancelWaiting(requestId);
|
|
173
|
+
return true;
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
cancelAll() {
|
|
177
|
+
for (const id of [...inflight.keys()]) this.cancel(id);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export { REF_KINDS };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { CONTENT_HANDLERS_FLAG, resolveContentHandlersConfig } from "./config.js";
|
|
2
|
+
export { ContentRefRegistry, REF_PATTERN } from "./ref-registry.js";
|
|
3
|
+
export { createContentHandler, SERVED_KINDS, WORKTREE_REF } from "./handler.js";
|
|
4
|
+
export { installContentHandlers, CONTENT_CAPABILITIES } from "./install.js";
|
|
5
|
+
export { isSensitivePath, normalizeRel, resolveInside, DEFAULT_DENY_GLOBS } from "./path-guard.js";
|
|
6
|
+
export { createAudit } from "./audit.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install.js - puts the content handlers on a v2 link (F3-07, F3-07b). Called by runtime.js only when
|
|
3
|
+
* LOCAL_AGENT_CONTENT_HANDLERS=true, v2 is on AND LOCAL_AGENT_DLP_MODE=enforce. Returns what the rest of the agent needs:
|
|
4
|
+
*
|
|
5
|
+
* mintRef({workspaceId, kind, rel, size?}) the HOOK for whoever reports metadata to the control plane (file lists, diff
|
|
6
|
+
* summaries): returns {id, expiresAt} for that item (id = 43 base64url chars), or null
|
|
7
|
+
* (sensitive path, unknown workspace). The id is also announced to the control plane in a
|
|
8
|
+
* `content.index` frame ({kind, ref, size}: no name, no path). Nothing in the agent calls
|
|
9
|
+
* it yet (the inventory producer is a later increment); `announce()` re-sends what is live.
|
|
10
|
+
*/
|
|
11
|
+
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
12
|
+
import { ContentRefRegistry } from "./ref-registry.js";
|
|
13
|
+
import { createContentHandler, SERVED_KINDS } from "./handler.js";
|
|
14
|
+
import { createContentIndexer } from "./content-index.js";
|
|
15
|
+
import { sizeInsideSync } from "./path-guard.js";
|
|
16
|
+
|
|
17
|
+
export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
|
|
18
|
+
|
|
19
|
+
export function installContentHandlers({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console }) {
|
|
20
|
+
// F3-07b, defence in depth: runtime.js already refuses; nobody can install the handlers without DLP enforce
|
|
21
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("content handlers need LOCAL_AGENT_DLP_MODE=enforce");
|
|
22
|
+
const tenant = String(tenantId).toLowerCase();
|
|
23
|
+
const registry = new ContentRefRegistry({ tenantId: tenant, ttlMs: config.refTtlMs, maxEntries: config.maxRefs, denyGlobs: config.extraDenyGlobs });
|
|
24
|
+
|
|
25
|
+
/** A workspace "registered here": open in the manager (of this tenant, or of none yet) or the default root. */
|
|
26
|
+
const resolveWorkspace = (workspaceId) => {
|
|
27
|
+
if (typeof workspaceId !== "string" || !workspaceId) return null;
|
|
28
|
+
const listed = (wsManager?.list?.() || []).find((w) => w && w.id === workspaceId);
|
|
29
|
+
if (listed) {
|
|
30
|
+
if (listed.tenantId && String(listed.tenantId).toLowerCase() !== tenant) return null;
|
|
31
|
+
return listed.root ? { root: listed.root } : null;
|
|
32
|
+
}
|
|
33
|
+
if (workspaceId === "default") { const root = getWorkspaceRoot(); return root ? { root } : null; }
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handler = createContentHandler({ session: link.session, tenantId: tenant, resolveWorkspace, registry, config, env, log });
|
|
38
|
+
link.session.setHandler("content.request", (ctx) => handler.handle(ctx));
|
|
39
|
+
const indexer = createContentIndexer({ session: link.session, registry, config, log });
|
|
40
|
+
|
|
41
|
+
const closeLink = link.close.bind(link);
|
|
42
|
+
link.close = () => { handler.cancelAll(); indexer.close(); registry.clear(); closeLink(); };
|
|
43
|
+
|
|
44
|
+
link.content = {
|
|
45
|
+
registry,
|
|
46
|
+
handler,
|
|
47
|
+
indexer,
|
|
48
|
+
mintRef: ({ workspaceId, kind, rel, size }) => {
|
|
49
|
+
try {
|
|
50
|
+
const ws = resolveWorkspace(workspaceId);
|
|
51
|
+
if (!ws) return null;
|
|
52
|
+
const bytes = Number.isSafeInteger(size) ? size
|
|
53
|
+
: kind === "file" && typeof rel === "string" ? sizeInsideSync(ws.root, rel.replace(/\\/g, "/"), { allowHardlinks: config.allowHardlinks }) : 0;
|
|
54
|
+
const minted = registry.mint({ workspaceId, root: ws.root, kind, rel, size: bytes });
|
|
55
|
+
if (minted) indexer.add({ workspaceId, kind, ref: minted.id, size: bytes, expiresAt: minted.expiresAt });
|
|
56
|
+
return minted;
|
|
57
|
+
} catch {
|
|
58
|
+
return null; // a failing workspace manager means "no id", never an exception with a path in it
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
/** Announces again the live ids (of one workspace or all) that the gateway does not know yet. */
|
|
62
|
+
announce: (opts) => indexer.announce(opts),
|
|
63
|
+
};
|
|
64
|
+
return link.content;
|
|
65
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* path-guard.js - the Local Agent is the authority on what a content.request may read (F3-07).
|
|
3
|
+
* The gateway lets anything through (it only sees an opaque id); every check that matters is here.
|
|
4
|
+
*
|
|
5
|
+
* normalizeRel a relative path as the agent itself minted it: no NUL/control chars, not absolute, no `..`.
|
|
6
|
+
* isSensitivePath deny list (built-in + LOCAL_AGENT_CONTENT_DENY_GLOBS): .env*, private keys, .git/, .ssh/, ...
|
|
7
|
+
* resolveInside realpath of root and target; the real target must stay inside the real root (a symlink that
|
|
8
|
+
* leaves the workspace is `forbidden`), must not be sensitive under its REAL name either
|
|
9
|
+
* (a harmless-looking link to .env), and must be a regular file.
|
|
10
|
+
* readRegular opens with O_NOFOLLOW, checks it is the same inode that was checked, reads at most `maxBytes`.
|
|
11
|
+
*
|
|
12
|
+
* Failures are small codes ("not_found" | "forbidden" | "too_large"); no message ever carries a path.
|
|
13
|
+
* Hard links (F3-07b): a file with more than one link (st.nlink > 1) is `forbidden` by default, because a hard link
|
|
14
|
+
* is indistinguishable from the file itself and may be a second name of a file OUTSIDE the root (the realpath check
|
|
15
|
+
* cannot see it). LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS=true lifts it (pnpm stores and some build trees hard-link
|
|
16
|
+
* every file). It is checked twice: on the path (resolveInside) and again on the open descriptor (readRegular).
|
|
17
|
+
* Limit that remains (documented in the note): a directory swapped for a symlink between the check and the open
|
|
18
|
+
* (TOCTOU on parents).
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
24
|
+
|
|
25
|
+
/** Built-in deny list. A pattern with no `/` matches any path segment; with `/` it matches a path (or its prefix). */
|
|
26
|
+
export const DEFAULT_DENY_GLOBS = Object.freeze([
|
|
27
|
+
// secrets by name
|
|
28
|
+
".env", ".env.*", "*.env",
|
|
29
|
+
"*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", "*.p8", "*.ppk", "*.kdbx", "*.gpg", "*.crt.key",
|
|
30
|
+
"id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
|
|
31
|
+
".npmrc", ".pypirc", ".netrc", "_netrc", ".pgpass", ".my.cnf", ".git-credentials", ".htpasswd", ".vault-token",
|
|
32
|
+
"credentials*", "service-account*.json", "serviceaccount*.json", "secrets.json", "secrets.yml", "secrets.yaml",
|
|
33
|
+
"*.secret", "*.secrets", "*.tfstate", "*.tfstate.*", "*.tfvars", "*.tfvars.json", "terraform.rc",
|
|
34
|
+
// F3-07b: editor backups / autosaves of the env file, kubeconfig files
|
|
35
|
+
".env~", ".env#", ".#.env", "kubeconfig", "kubeconfig.*", "*.kubeconfig",
|
|
36
|
+
// F3-07b: Windows 8.3 aliases ("short names") of the files above: ".env" is also reachable as ENV~1, ".npmrc" as NPMRC~1,
|
|
37
|
+
// "credentials.json" as CREDEN~1.JSO, "id_ed25519" as ID_ED2~1 ... The real name is checked as well (resolveInside), this
|
|
38
|
+
// covers the alias form on its own. `~?*` = "~" + at least one more character.
|
|
39
|
+
"env~?*", "npmrc~?*", "pypirc~?*", "netrc~?*", "pgpass~?*", "htpass~?*", "git-cr~?*", "vault-~?*", "kubeco~?*", "creden~?*",
|
|
40
|
+
"servic~?*", "secret~?*", "id_rsa~?*", "id_dsa~?*", "id_ecd~?*", "id_ed2~?*", "terraf~?*",
|
|
41
|
+
// (an alias keeps the first 3 characters of the extension: `*.pem`, `*.key`, `*.p12`... already catch their aliases; these are the ones whose
|
|
42
|
+
// extension is longer than 3: .tfstate -> .TFS, .tfvars -> .TFV, .kdbx -> .KDB, .secret(s) -> .SEC)
|
|
43
|
+
"*~?*.tfs", "*~?*.tfv", "*~?*.kdb", "*~?*.sec",
|
|
44
|
+
// directories that hold credentials or the repository internals (.git/config may carry a token in a URL)
|
|
45
|
+
".git/", ".ssh/", ".aws/", ".gnupg/", ".kube/", ".docker/", ".azure/", ".terraform/", ".config/gcloud/",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const RE_CACHE = new Map();
|
|
49
|
+
|
|
50
|
+
function globToRegExp(glob) {
|
|
51
|
+
const g = glob.toLowerCase();
|
|
52
|
+
const dir = g.endsWith("/");
|
|
53
|
+
const body = dir ? g.slice(0, -1) : g;
|
|
54
|
+
let re = "";
|
|
55
|
+
for (let i = 0; i < body.length; i++) {
|
|
56
|
+
const c = body[i];
|
|
57
|
+
if (c === "*") {
|
|
58
|
+
if (body[i + 1] === "*") { re += ".*"; i++; } else re += "[^/]*";
|
|
59
|
+
} else if (c === "?") re += "[^/]";
|
|
60
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
// anchored at a segment boundary on the left; on the right it must end the path or be followed by `/` (a directory prefix)
|
|
63
|
+
return new RegExp(`(?:^|/)${re}(?:/|$)`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function regexFor(glob) {
|
|
67
|
+
let r = RE_CACHE.get(glob);
|
|
68
|
+
if (!r) { r = globToRegExp(glob); RE_CACHE.set(glob, r); if (RE_CACHE.size > 1000) RE_CACHE.delete(RE_CACHE.keys().next().value); }
|
|
69
|
+
return r;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Lower-case, posix, and without the trailing dots/spaces that Windows ignores (".env." is ".env"). */
|
|
73
|
+
function foldForMatch(rel) {
|
|
74
|
+
return rel.split("/").map((seg) => seg.replace(/[. ]+$/, "") || seg).join("/").toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* NTFS alternate data streams: ".env::$DATA" and ".env:stream" read the data of ".env". Matching also uses the name without
|
|
79
|
+
* the stream part of every segment (the original is matched too: on POSIX a colon is an ordinary character).
|
|
80
|
+
*/
|
|
81
|
+
function withoutStreams(folded) {
|
|
82
|
+
return folded.split("/").map((seg) => { const i = seg.indexOf(":"); return i > 0 ? seg.slice(0, i) : seg; }).join("/");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isSensitivePath(rel, extraGlobs = []) {
|
|
86
|
+
if (typeof rel !== "string") return true;
|
|
87
|
+
const folded = foldForMatch(rel.replace(/\\/g, "/"));
|
|
88
|
+
const stripped = withoutStreams(folded);
|
|
89
|
+
const forms = stripped === folded ? [folded] : [folded, stripped];
|
|
90
|
+
for (const g of DEFAULT_DENY_GLOBS) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
|
|
91
|
+
for (const g of extraGlobs) if (typeof g === "string" && g) { const re = regexFor(g); if (forms.some((f) => re.test(f))) return true; }
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
96
|
+
|
|
97
|
+
/** @returns {string | null} posix relative path, or null when it is not an acceptable relative path */
|
|
98
|
+
export function normalizeRel(rel) {
|
|
99
|
+
if (typeof rel !== "string" || rel.length === 0 || rel.length > 1024) return null;
|
|
100
|
+
if (CONTROL.test(rel)) return null;
|
|
101
|
+
const p = rel.replace(/\\/g, "/");
|
|
102
|
+
if (p.startsWith("/") || /^[A-Za-z]:/.test(p)) return null;
|
|
103
|
+
const parts = [];
|
|
104
|
+
for (const seg of p.split("/")) {
|
|
105
|
+
if (seg === "" || seg === ".") continue;
|
|
106
|
+
if (seg === "..") return null;
|
|
107
|
+
parts.push(seg);
|
|
108
|
+
}
|
|
109
|
+
return parts.length ? parts.join("/") : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toPosix = (p) => p.split(path.sep).join("/");
|
|
113
|
+
const inside = (root, abs) => abs === root || abs.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} root the workspace root as registered
|
|
117
|
+
* @param {string} rel relative path already normalized (normalizeRel)
|
|
118
|
+
* @returns {Promise<{ok:true, real:string, relReal:string, stat:fs.Stats} | {ok:false, status:"not_found"|"forbidden"}>}
|
|
119
|
+
*/
|
|
120
|
+
export async function resolveInside(root, rel, extraGlobs = [], { allowHardlinks = false } = {}) {
|
|
121
|
+
let rootReal;
|
|
122
|
+
try { rootReal = await fs.promises.realpath(root); } catch { return { ok: false, status: "not_found" }; }
|
|
123
|
+
const abs = path.join(rootReal, ...rel.split("/"));
|
|
124
|
+
if (!inside(rootReal, abs)) return { ok: false, status: "forbidden" };
|
|
125
|
+
if (isSensitivePath(rel, extraGlobs)) return { ok: false, status: "forbidden" };
|
|
126
|
+
let real;
|
|
127
|
+
try { real = await fs.promises.realpath(abs); } catch (e) { return { ok: false, status: e && (e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" }; }
|
|
128
|
+
if (!inside(rootReal, real)) return { ok: false, status: "forbidden" }; // symlink that leaves the workspace
|
|
129
|
+
const relReal = toPosix(path.relative(rootReal, real));
|
|
130
|
+
if (!relReal || isSensitivePath(relReal, extraGlobs)) return { ok: false, status: "forbidden" }; // link to a sensitive file
|
|
131
|
+
let st;
|
|
132
|
+
try { st = await fs.promises.lstat(real); } catch { return { ok: false, status: "not_found" }; }
|
|
133
|
+
if (!st.isFile()) return { ok: false, status: "not_found" };
|
|
134
|
+
if (st.nlink > 1 && !allowHardlinks) return { ok: false, status: "forbidden" }; // a hard link may be a second name of a file outside the root
|
|
135
|
+
return { ok: true, real, relReal, stat: st };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Size of a regular file inside the root, for content.index (informative). 0 for anything else: a link that leaves the
|
|
140
|
+
* root, a missing file, a hard link (when refused). Nothing is read and nothing but a number comes back.
|
|
141
|
+
*/
|
|
142
|
+
export function sizeInsideSync(root, rel, { allowHardlinks = false } = {}) {
|
|
143
|
+
try {
|
|
144
|
+
const rootReal = fs.realpathSync(root);
|
|
145
|
+
const real = fs.realpathSync(path.join(rootReal, ...rel.split("/")));
|
|
146
|
+
if (!inside(rootReal, real) || real === rootReal) return 0;
|
|
147
|
+
const st = fs.lstatSync(real);
|
|
148
|
+
if (!st.isFile() || (st.nlink > 1 && !allowHardlinks)) return 0;
|
|
149
|
+
return st.size;
|
|
150
|
+
} catch { return 0; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Reads the whole file (<= maxBytes) through a descriptor opened without following links and verified to be the
|
|
155
|
+
* file that resolveInside() checked.
|
|
156
|
+
* @returns {Promise<{ok:true, buffer:Buffer} | {ok:false, status:"not_found"|"forbidden"|"too_large"}>}
|
|
157
|
+
*/
|
|
158
|
+
export async function readRegular(resolved, maxBytes, signal, { allowHardlinks = false } = {}) {
|
|
159
|
+
if (resolved.stat.size > maxBytes) return { ok: false, status: "too_large" };
|
|
160
|
+
let fh;
|
|
161
|
+
try {
|
|
162
|
+
fh = await fs.promises.open(resolved.real, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
163
|
+
const st = await fh.stat();
|
|
164
|
+
if (!st.isFile()) return { ok: false, status: "not_found" };
|
|
165
|
+
if (st.nlink > 1 && !allowHardlinks) return { ok: false, status: "forbidden" }; // linked again after the path check
|
|
166
|
+
if (resolved.stat.ino !== 0 && (st.ino !== resolved.stat.ino || st.dev !== resolved.stat.dev)) return { ok: false, status: "forbidden" };
|
|
167
|
+
if (st.size > maxBytes) return { ok: false, status: "too_large" };
|
|
168
|
+
const buf = Buffer.allocUnsafe(Math.min(st.size, maxBytes) + 1);
|
|
169
|
+
let n = 0;
|
|
170
|
+
while (n < buf.length) {
|
|
171
|
+
if (signal && signal.aborted) return { ok: false, status: "not_found" };
|
|
172
|
+
const { bytesRead } = await fh.read(buf, n, buf.length - n, n);
|
|
173
|
+
if (bytesRead === 0) break;
|
|
174
|
+
n += bytesRead;
|
|
175
|
+
}
|
|
176
|
+
if (n > maxBytes) return { ok: false, status: "too_large" }; // grew while reading
|
|
177
|
+
return { ok: true, buffer: Buffer.from(buf.subarray(0, n)) };
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return { ok: false, status: e && (e.code === "ELOOP" || e.code === "EACCES" || e.code === "EPERM") ? "forbidden" : "not_found" };
|
|
180
|
+
} finally {
|
|
181
|
+
if (fh) await fh.close().catch(() => {});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ref-registry.js - opaque content ids (F3-07).
|
|
3
|
+
*
|
|
4
|
+
* The control plane never sees a path: the `ref` of a `content.request` is an id that THIS agent minted when it
|
|
5
|
+
* reported metadata (a file list, a diff summary) and that only this agent can map back to (workspace, relative path).
|
|
6
|
+
*
|
|
7
|
+
* id 43 base64url chars, NO prefix = 256 random bits from crypto.randomBytes(32) (F3-07b: this is the shape
|
|
8
|
+
* the gateway and the contract require; the F3-07 form `cr1_` + 22 chars is no longer minted nor accepted).
|
|
9
|
+
* NOT derived from the path (no hash, no HMAC of it): knowing a path tells nothing about its id and the
|
|
10
|
+
* other way round. The only symbolic ref is `worktree` (kind diff), handled by handler.js, never minted.
|
|
11
|
+
* validity `ttlMs` from the mint (default 15 min); expired ids are gone (not_found), the CP asks for a new listing.
|
|
12
|
+
* In memory only: a restart of the agent forgets every id (also not_found), by design.
|
|
13
|
+
* binding an id is valid for ONE tenant, ONE workspace (and the root that workspace had when it was minted),
|
|
14
|
+
* ONE kind. Any other combination resolves to null, indistinguishable from "never existed".
|
|
15
|
+
* size at most `maxEntries`; the oldest go first.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here logs, throws a message with a path, or exposes an id's target to anything but resolve().
|
|
18
|
+
*/
|
|
19
|
+
import crypto from "node:crypto";
|
|
20
|
+
import { normalizeRel, isSensitivePath } from "./path-guard.js";
|
|
21
|
+
|
|
22
|
+
export const REF_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
23
|
+
export const REF_KINDS = Object.freeze(["file", "diff"]);
|
|
24
|
+
|
|
25
|
+
export class ContentRefRegistry {
|
|
26
|
+
constructor({ tenantId, ttlMs = 15 * 60_000, maxEntries = 20_000, now = Date.now, denyGlobs = [] } = {}) {
|
|
27
|
+
this.tenantId = tenantId;
|
|
28
|
+
this.ttlMs = ttlMs;
|
|
29
|
+
this.maxEntries = maxEntries;
|
|
30
|
+
this.now = now;
|
|
31
|
+
this.denyGlobs = denyGlobs;
|
|
32
|
+
this.entries = new Map(); // id -> { tenantId, workspaceId, root, kind, rel, size, expiresAt } (insertion order = age)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get size() { return this.entries.size; }
|
|
36
|
+
|
|
37
|
+
#purge() {
|
|
38
|
+
const t = this.now();
|
|
39
|
+
for (const [id, e] of this.entries) {
|
|
40
|
+
if (e.expiresAt > t) break; // insertion order is expiry order (one ttl for all)
|
|
41
|
+
this.entries.delete(id);
|
|
42
|
+
}
|
|
43
|
+
while (this.entries.size > this.maxEntries) this.entries.delete(this.entries.keys().next().value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{workspaceId:string, root:string, kind:"file"|"diff", rel?:string|null, size?:number}} o
|
|
48
|
+
* rel = path relative to the workspace root (file: required; diff: optional, null = the whole work tree)
|
|
49
|
+
* size = informative size in bytes that content.index announces (0 when unknown); never a name or a path
|
|
50
|
+
* @returns {{id:string, expiresAt:number} | null} null = refused (bad kind/path or a sensitive file): no id exists
|
|
51
|
+
*/
|
|
52
|
+
mint({ workspaceId, root, kind, rel = null, size = 0 }) {
|
|
53
|
+
if (!REF_KINDS.includes(kind) || typeof workspaceId !== "string" || !workspaceId || typeof root !== "string" || !root) return null;
|
|
54
|
+
let norm = null;
|
|
55
|
+
if (rel !== null && rel !== undefined) {
|
|
56
|
+
norm = normalizeRel(rel);
|
|
57
|
+
if (!norm || isSensitivePath(norm, this.denyGlobs)) return null; // never hand out an id for a file we would refuse
|
|
58
|
+
} else if (kind === "file") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
this.#purge();
|
|
62
|
+
let id;
|
|
63
|
+
do { id = crypto.randomBytes(32).toString("base64url"); } while (this.entries.has(id)); // 43 chars; a repeat is astronomically unlikely, never reused
|
|
64
|
+
const expiresAt = this.now() + this.ttlMs;
|
|
65
|
+
const sz = Number.isSafeInteger(size) && size >= 0 && size <= 2 ** 40 ? size : 0;
|
|
66
|
+
this.entries.set(id, { tenantId: this.tenantId, workspaceId, root, kind, rel: norm, size: sz, expiresAt });
|
|
67
|
+
this.#purge(); // capacity includes the new entry
|
|
68
|
+
return { id, expiresAt };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @returns {{rel:string|null}} | null null for: not an id, unknown, expired, other tenant/workspace/root/kind
|
|
73
|
+
*/
|
|
74
|
+
resolve(id, { workspaceId, root, kind }) {
|
|
75
|
+
if (typeof id !== "string" || !REF_PATTERN.test(id)) return null;
|
|
76
|
+
const e = this.entries.get(id);
|
|
77
|
+
if (!e) return null;
|
|
78
|
+
if (e.expiresAt <= this.now()) { this.entries.delete(id); return null; }
|
|
79
|
+
if (e.tenantId !== this.tenantId || e.workspaceId !== workspaceId || e.root !== root || e.kind !== kind) return null;
|
|
80
|
+
return { rel: e.rel };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The live ids of this tenant, oldest first, for content.index (re-announcing after a reconnect). Only what that
|
|
85
|
+
* frame may carry: workspace, kind, id, size. The path stays here.
|
|
86
|
+
* @returns {{workspaceId:string, kind:string, id:string, size:number, expiresAt:number}[]}
|
|
87
|
+
*/
|
|
88
|
+
live() {
|
|
89
|
+
this.#purge();
|
|
90
|
+
const t = this.now();
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const [id, e] of this.entries) if (e.expiresAt > t) out.push({ workspaceId: e.workspaceId, kind: e.kind, id, size: e.size, expiresAt: e.expiresAt });
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
revoke(id) { return this.entries.delete(id); }
|
|
97
|
+
|
|
98
|
+
revokeWorkspace(workspaceId) {
|
|
99
|
+
let n = 0;
|
|
100
|
+
for (const [id, e] of this.entries) if (e.workspaceId === workspaceId) { this.entries.delete(id); n++; }
|
|
101
|
+
return n;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
clear() { this.entries.clear(); }
|
|
105
|
+
}
|
|
@@ -28,6 +28,9 @@ export async function sendContent(session, { requestId, source, contentType = "a
|
|
|
28
28
|
const total = chunkCount(size, chunkRawBytes);
|
|
29
29
|
if (total > maxChunks) throw new ProtocolError("payload_too_large", "response above the chunk limit");
|
|
30
30
|
const acks = [];
|
|
31
|
+
// an abort must also free a chunk that is waiting for a window slot (enqueue() does not look at the signal)
|
|
32
|
+
const onAbort = () => session.cancelWaiting(requestId);
|
|
33
|
+
if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }
|
|
31
34
|
try {
|
|
32
35
|
for (let index = 0; index < total; index++) {
|
|
33
36
|
if (signal && signal.aborted) throw new ProtocolError("agent_offline", "cancelled");
|
|
@@ -44,6 +47,8 @@ export async function sendContent(session, { requestId, source, contentType = "a
|
|
|
44
47
|
session.cancelWaiting(requestId);
|
|
45
48
|
for (const a of acks) a.catch(() => {});
|
|
46
49
|
throw e;
|
|
50
|
+
} finally {
|
|
51
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
47
52
|
}
|
|
48
53
|
await Promise.all(acks);
|
|
49
54
|
return { chunks: total, bytes: size };
|
|
@@ -51,7 +56,8 @@ export async function sendContent(session, { requestId, source, contentType = "a
|
|
|
51
56
|
|
|
52
57
|
/** A failed answer: one empty chunk with the status (error | not_found | forbidden) and a wire error code. */
|
|
53
58
|
export async function sendContentFailure(session, { requestId, status = "error", errorCode = "internal", workspaceId }) {
|
|
59
|
+
// errorCode: null = none on the wire (not_found / forbidden need no code); undefined = "internal" (as before F3-07)
|
|
54
60
|
await session.send("content.response", {
|
|
55
|
-
status, errorCode, encoding: "base64", data: "", byteLength: 0,
|
|
61
|
+
status, ...(errorCode === null ? {} : { errorCode }), encoding: "base64", data: "", byteLength: 0,
|
|
56
62
|
}, { requestId, workspaceId, chunk: { index: 0, total: 1, last: true } });
|
|
57
63
|
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repo": "VISIONHAMMER/vh-api-gateway",
|
|
3
3
|
"path": "docs/contracts/la-protocol-v2",
|
|
4
|
-
"branch": "
|
|
5
|
-
"commit": "
|
|
6
|
-
"branchTip": "7043f75e3ed1b201e2c958fd52f721b5a1177efe",
|
|
4
|
+
"branch": "feat/edu/f3-07b-gateway-contract",
|
|
5
|
+
"commit": "55921963e461464b34daf8be604bae162a1c3289",
|
|
7
6
|
"note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
|
|
8
7
|
"files": {
|
|
9
|
-
"README.md": "
|
|
8
|
+
"README.md": "77e95e25b4fa0eb5eb82c1bb3d9676fed4d946d8ce0e02838d8c3f7f57f972a9",
|
|
10
9
|
"ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
|
|
11
10
|
"alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
|
|
12
11
|
"approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
|
|
13
12
|
"audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
|
|
14
13
|
"config.update.schema.json": "f5318f24230f542c76a3cb996d08f33210c0b2cc2fc55ad73c4f95648582033f",
|
|
14
|
+
"content.index.schema.json": "ba770a0b429331cc801b7b402b74c4e73d229d3b72f3052781720b34c14fb777",
|
|
15
15
|
"content.request.schema.json": "9966b55ec051832ac93ee43e9cd226e4f0efaeb49741947bfc48193e40e00a32",
|
|
16
16
|
"content.response.schema.json": "c9a66ac9cbbcaec7fc182b3e3fa23b34877bd342592521c7e393779230332231",
|
|
17
17
|
"defs.schema.json": "f19fd27fd103d8da02de4506c086263c6236250c5c127aa3aab41833a90b743a",
|
|
18
|
-
"envelope.schema.json": "
|
|
18
|
+
"envelope.schema.json": "92b3e5b31e6235689a1c22fae1d1eab3f93d377a19032809e47ee014c0ce67e1",
|
|
19
19
|
"error.schema.json": "b1fd663d4a1ea73d88cb575422f15f8c7279001f9385ebb2d5e1889b5d5ef878",
|
|
20
20
|
"heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
|
|
21
21
|
"hello.schema.json": "bd500320f01ff10da49ded6f99c7e8081252019984afb3c178e27003df20b963",
|
|
22
22
|
"incident.candidate.schema.json": "b292a85917b5958fc6de10a2895732e30a35420385623930c78e47341862dc62",
|
|
23
|
-
"index.json": "
|
|
23
|
+
"index.json": "cf8f05e70b6265a169b0117d4577f78b699818eb5091df6cd4c7c7416385582a",
|
|
24
24
|
"job.assign.schema.json": "fdebb4ae98bf38a2c723281c1cf9477f71d877171df9a55b0baeb0b65244f60c",
|
|
25
25
|
"job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
|
|
26
26
|
"job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"vectors/approval.decision.vectors.json": "338115973bf2c4390d2bb33ce8f4079f3132cd739cd5505e1e873fdc252d2271",
|
|
37
37
|
"vectors/audit.event.vectors.json": "d05c1820103eef041a84eb8b13d9a67a8a5b54b9b54ee3b6e43fd4ef15ed7000",
|
|
38
38
|
"vectors/config.update.vectors.json": "8a5088d547e3eb4e460b25a5e413caaa02d4402a601340e279166d8c17758e1c",
|
|
39
|
+
"vectors/content.index.vectors.json": "0a59f6b633388d0d1dcb1ebfc6fa6b5e9ad5e399b5a9d8c60b625a9f318f99f9",
|
|
39
40
|
"vectors/content.request.vectors.json": "6922748edfc630ce881a1b79d24827de03f849e05c441ef7eecc684bf2238e58",
|
|
40
41
|
"vectors/content.response.vectors.json": "35ef18edbcec24c029781e97d60dcb0b9111697fa3a5a2a8dfd159744e7f8258",
|
|
41
42
|
"vectors/cross-type.vectors.json": "f7ed403d3e3f53150b715a5423fde1bed708e8c6245709ed4ad74389dc8c3307",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"vectors/traces/invalid-chunk-out-of-order.json": "c6560290755d86b524316823d32fcbd1420c170b13e198813c3b7e7b7f32ecb6",
|
|
60
61
|
"vectors/traces/invalid-chunk-total-changes.json": "9b7ade197252275725bc0754e40eadc12a289c063fed13a2905c55583a27a767",
|
|
61
62
|
"vectors/traces/invalid-clock-skew.json": "de16e0bd72e08bb03d44d5f2f04a238dcd38447ff1690f8acfe712ca3fe2028b",
|
|
63
|
+
"vectors/traces/invalid-content-index-before-ready.json": "360ea95992bdc7f0530c4983db0035d73eb0a4c15db952b8c3d9886f4bd00ad7",
|
|
62
64
|
"vectors/traces/invalid-event-before-ready.json": "17081f197fab5f4490b15a088f2bce620921007b5fdd7ed63bd988899d674883",
|
|
63
65
|
"vectors/traces/invalid-first-frame-not-register.json": "cf41c2d0e67f6df382790dab13bb9e49db99a34a88c1f9d108214be5df1e4ea3",
|
|
64
66
|
"vectors/traces/invalid-frame-over-48kib.json": "912e37c9a326ad92284ba22af752269cb5dd52ed06cdc22b17fd3b74a8423734",
|
|
@@ -73,6 +75,7 @@
|
|
|
73
75
|
"vectors/traces/invalid-tenant-mismatch.json": "dcc04ea423bddef20524ead7bfd146a5d9c42fe474c9216dc38c90544d291151",
|
|
74
76
|
"vectors/traces/invalid-type-outside-allowlist.json": "e492d326cfa044d3fc9f7e48dee63a52b715a62ba4867fc5ce21f1d86e3c435f",
|
|
75
77
|
"vectors/traces/valid-content-chunks-in-order.json": "c5fc301527b062b30c625da2798c7cfab3e3debac29a36f6ec7c699705267ab3",
|
|
78
|
+
"vectors/traces/valid-content-index.json": "9305dc955c4cca1f09158a9c8cf21d9e89d6a6b03646088c547f64a61cd59c30",
|
|
76
79
|
"vectors/traces/valid-handshake-and-job.json": "9aa3c423c496403ca77d63965f1805f0a57ea9f6f8a01351310515b265aeb9cc",
|
|
77
80
|
"vectors/traces/valid-resume-continues-seq.json": "8aa90f24598f8f2d87a5355ec58c3531d16fd11ad41c782f53eca063372b704a",
|
|
78
81
|
"vectors/traces/valid-retransmit-is-idempotent.json": "924e9d296a67f0006c289e7cc978b9529ae1aa03bec2cd331a62f31354a90257",
|