From da9c15ce20b7ffd89e9e8c11724e6cd31d22ccfd Mon Sep 17 00:00:00 2001 From: Copybara Date: Sun, 20 Sep 2026 06:12:03 +0000 Subject: [PATCH 01/12] Project import generated by Copybara. FolderOrigin-RevId: b31318e06ac415532fa16ad18652f8fb1fced134 --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 39ef6b0..e20cfd0 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "3acc63c2c655c22b5ca7f2d55b63a6b87eced2bc", + "sourceSha": "b31318e06ac415532fa16ad18652f8fb1fced134", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "8c060f9036d64edc9ac43f3688ab9677b5445a81", + "priorProjectedBase": "f4066d844bd3efd0bab8bc5103640aa072f7c484", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", "contentDigest": "b7dab773d1ffeaedde2249bd6fd15fcdd6a95336302c1bf33cc65236a7c44383", From 2e3ada5333ae2eef16df0a88012468984ac7cb3e Mon Sep 17 00:00:00 2001 From: Copybara Date: Sun, 20 Sep 2026 20:41:20 +0000 Subject: [PATCH 02/12] Project import generated by Copybara. FolderOrigin-RevId: 8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352 --- .repository-projection.json | 8 ++-- scripts/distribution-validation.mjs | 61 +++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index e20cfd0..7d9ada5 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "b31318e06ac415532fa16ad18652f8fb1fced134", + "sourceSha": "8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "f4066d844bd3efd0bab8bc5103640aa072f7c484", + "priorProjectedBase": "b5283731919e221d7df68218eb26466633184e92", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", - "contentDigest": "b7dab773d1ffeaedde2249bd6fd15fcdd6a95336302c1bf33cc65236a7c44383", + "toolDigest": "de718162d050963e0c04ca9cbc0e38b1db545e838e587f949bcd9fd8aded6bcd", + "contentDigest": "dd0b8e695a159bcf4d2f59d7514763b5326055f74fdce496bd2fdafa262561ec", "publicationEligible": true } diff --git a/scripts/distribution-validation.mjs b/scripts/distribution-validation.mjs index 9cc6486..84a0515 100644 --- a/scripts/distribution-validation.mjs +++ b/scripts/distribution-validation.mjs @@ -6,9 +6,20 @@ import { join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; -const NAMES = new Set(["endpoint", "private-runner", "private-deployment", "api", "examples", "plugins"]); +const NAMES = new Set(["endpoint", "private-runner", "private-deployment", "api", "examples", "plugins", "capobara"]); const SHA = /^[0-9a-f]{40}$/; const HEX = /^[0-9a-f]{64}$/; +// `toolDigest` is the one provenance field with two legitimate shapes. Node +// hashes the contents of its own TOOL_INPUTS script list, giving a 64-hex +// SHA-256; Capobara embeds the git tree id of `rust/tools/capobara`, giving a +// 40-hex object name. Both are valid "the tool that ran this matches the tool +// committed at this revision" proofs, and the Rust side's +// `git::is_tree_id_or_digest` accepts both widths for exactly this reason -- +// this predicate is its twin and must stay in step with it. A receipt written +// by either implementation has to validate here, or a clone of +// `dx-corp/capobara` fails `invalid provenance toolDigest` against a +// perfectly correct tree. +const TOOL_DIGEST = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const PROTO = [ "common/v1/analytics.proto", "common/v1/authz.proto", "common/v1/classification.proto", "common/v1/delivery.proto", "common/v1/entity.proto", "common/v1/risk.proto", "common/v1/surface.proto", @@ -82,7 +93,8 @@ export async function validateProvenance(name, root) { requireValue(provenance.projection === name && provenance.sourceRepository === "dx-corp/mono" && provenance.destinationRepository === `dx-corp/${name}`, "projection provenance identity mismatch"); requireValue(SHA.test(provenance.sourceSha) && SHA.test(provenance.priorProjectedBase), "projection SHAs are invalid"); - for (const key of ["definitionDigest", "toolDigest", "contentDigest"]) requireValue(HEX.test(provenance[key]), `invalid provenance ${key}`); + for (const key of ["definitionDigest", "contentDigest"]) requireValue(HEX.test(provenance[key]), `invalid provenance ${key}`); + requireValue(TOOL_DIGEST.test(provenance.toolDigest), "invalid provenance toolDigest"); requireValue(typeof provenance.publicationEligible === "boolean", "invalid publication eligibility"); return provenance; } @@ -244,6 +256,49 @@ async function validatePlugins(root, files) { for (const path of files) requireValue(!/(?:prompt-audit|session-history|product-kit)/i.test(path), `private plugin surface: ${path}`); } +// `capobara` is admitted here rather than routed around this validation by +// the workflow's `matrix.name == 'capobara'` condition. That condition only +// governs the `sync` job; `node scripts/projections/verify-catalog.mjs +// --validate` -- the `repository-projections` component's own CI gate -- +// builds and validates *every* catalog entry, and `validate.mjs` admits any +// name the catalog holds, so a catalog entry with no validator here fails +// that gate on `unsupported distribution: capobara`. +// +// The compile proof for the projected crate lives in the crate's own +// `tests/standalone_build.rs`, which copies it out of the workspace and runs +// `cargo build --locked`. Repeating that here would add a full dependency +// build to every catalog verification, so this checks the standalone +// closure without compiling: the manifest, the crate-root lockfile and the +// single-package workspace that the projection has to produce, plus the +// internal surfaces it must not carry. +async function validateCapobara(root, files) { + for (const required of ["Cargo.toml", "Cargo.lock", "README.md", "build.rs", "src/main.rs", "src/lib.rs"]) { + requireValue(files.includes(required), `capobara is missing ${required}`); + } + for (const path of files) { + requireValue(!/^scripts\//.test(path), `capobara contains an internal surface: ${path}`); + requireValue(!/^tests\/fixtures\/definitions\//.test(path), `capobara contains an excluded fixture: ${path}`); + } + const manifest = await readFile(join(root, "Cargo.toml"), "utf8"); + requireValue(!/\bworkspace\s*=\s*true/.test(manifest), "capobara Cargo.toml still inherits from the Mono workspace"); + // Resolution must include dependencies here: with `--no-deps` nothing is + // resolved, so `--locked` has nothing to compare and a stale lockfile + // passes. With the full graph, `--locked` fails when the projected + // crate-root lockfile does not match the projected manifest, which is the + // failure this projection is most exposed to -- the standalone lockfile is + // generated separately from the workspace one and can go stale without any + // Mono build noticing. + // + // This resolves all 178 locked packages, so on a cold runner it fetches the + // crates.io index and downloads every `.crate`. That is a network-dependent + // step in the component's CI gate; the component already declares `rust` in + // `ci.test.tools`, and nothing cheaper discriminates (see above). + const metadata = JSON.parse(run("cargo", ["metadata", "--locked", "--format-version", "1"], root)); + requireValue(metadata.workspace_members.length === 1, "capobara standalone workspace gained a member"); + const member = metadata.packages.find(pkg => pkg.id === metadata.workspace_members[0]); + requireValue(member?.name === "capobara", "capobara standalone workspace member is not the crate"); +} + export async function validateDistribution({ name, target }) { requireValue(NAMES.has(name), `unsupported distribution: ${name}`); const root = resolve(target); @@ -252,7 +307,7 @@ export async function validateDistribution({ name, target }) { await validateProvenance(name, root); const validators = { endpoint: validateEndpoint, "private-runner": validateRunner, "private-deployment": validateDeployment, - api: validateApi, examples: validateExamples, plugins: validatePlugins, + api: validateApi, examples: validateExamples, plugins: validatePlugins, capobara: validateCapobara, }; await validators[name](root, files); return { name, target: root, files: files.length, valid: true }; From 1977eb7d403a7410971d11db4589d1fe4b55f8db Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Sun, 20 Sep 2026 21:30:59 +0000 Subject: [PATCH 03/12] chore: project endpoint from Mono a2e8b231b208 --- .repository-projection.json | 8 ++++---- scripts/distribution-validation.mjs | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 7d9ada5..d6b73b3 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352", + "sourceSha": "a2e8b231b208f9e30b0a115eb132176e1e7c301a", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "b5283731919e221d7df68218eb26466633184e92", + "priorProjectedBase": "4502c9a5953e083e592bb03d9b8ae1e71217e8da", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "de718162d050963e0c04ca9cbc0e38b1db545e838e587f949bcd9fd8aded6bcd", - "contentDigest": "dd0b8e695a159bcf4d2f59d7514763b5326055f74fdce496bd2fdafa262561ec", + "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", + "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } diff --git a/scripts/distribution-validation.mjs b/scripts/distribution-validation.mjs index 84a0515..13c5b1b 100644 --- a/scripts/distribution-validation.mjs +++ b/scripts/distribution-validation.mjs @@ -21,10 +21,12 @@ const HEX = /^[0-9a-f]{64}$/; // perfectly correct tree. const TOOL_DIGEST = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const PROTO = [ + "agentruntime/v1/runtime.proto", "agents/v1/agents.proto", "codex/v1/codex.proto", "common/v1/analytics.proto", "common/v1/authz.proto", "common/v1/classification.proto", - "common/v1/delivery.proto", "common/v1/entity.proto", "common/v1/risk.proto", "common/v1/surface.proto", - "connectors/v1/connectors.proto", "console/v1/console.proto", "deixic/v1/deixic.proto", - "memory/v1/memory.proto", "meter/v1/meter.proto", "orbcontrol/v1/orb_control.proto", + "common/v1/delivery.proto", "common/v1/entity.proto", "common/v1/risk.proto", + "common/v1/surface.proto", "connectors/v1/connectors.proto", "console/v1/console.proto", + "deixic/v1/deixic.proto", "memory/v1/memory.proto", "meter/v1/meter.proto", + "objectives/v1/objectives.proto", "orbcontrol/v1/orb_control.proto", "platform/v1/platform.proto", "remoterunner/v1/remoterunner.proto", "toolexecution/v1/toolexecution.proto", "traces/v1/traces.proto", "vfs/v1/filesystem.proto", ].sort(); @@ -256,13 +258,11 @@ async function validatePlugins(root, files) { for (const path of files) requireValue(!/(?:prompt-audit|session-history|product-kit)/i.test(path), `private plugin surface: ${path}`); } -// `capobara` is admitted here rather than routed around this validation by -// the workflow's `matrix.name == 'capobara'` condition. That condition only -// governs the `sync` job; `node scripts/projections/verify-catalog.mjs -// --validate` -- the `repository-projections` component's own CI gate -- -// builds and validates *every* catalog entry, and `validate.mjs` admits any -// name the catalog holds, so a catalog entry with no validator here fails -// that gate on `unsupported distribution: capobara`. +// `capobara` is admitted here like every other catalog entry: `validate.mjs` +// admits any name the catalog holds, and the `sync` job validates every +// prepared projection between Capobara's dry run and its real run, so a +// catalog entry with no validator here fails its publication on +// `unsupported distribution: capobara`. // // The compile proof for the projected crate lives in the crate's own // `tests/standalone_build.rs`, which copies it out of the workspace and runs From ff85ac72a07b6b31641a6d2e1af54714b1ea7d5a Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 00:30:33 +0000 Subject: [PATCH 04/12] chore: project endpoint from Mono 9b559bc70e7d --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index d6b73b3..3aa9d53 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "a2e8b231b208f9e30b0a115eb132176e1e7c301a", + "sourceSha": "9b559bc70e7d8afc723d2ac67c8c62fe0cfbdeeb", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "4502c9a5953e083e592bb03d9b8ae1e71217e8da", + "priorProjectedBase": "1ffe76cec7cdda72764e75dc7272f694691eb36c", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", From 6188d83fbbfc428ac882ac52513fd46505964522 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 02:47:11 +0000 Subject: [PATCH 05/12] chore: project endpoint from Mono 9a003786d767 --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 3aa9d53..17f4bf7 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "9b559bc70e7d8afc723d2ac67c8c62fe0cfbdeeb", + "sourceSha": "9a003786d7679e9029fa077557bc7ab4d00eb48f", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "1ffe76cec7cdda72764e75dc7272f694691eb36c", + "priorProjectedBase": "aa0a3c9668c437e963f699f26871840e221c65cc", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", From a8e83c3c29b9f3fc18f3e41762a77991e35ec4dc Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 03:24:13 +0000 Subject: [PATCH 06/12] chore: project endpoint from Mono 511bd2305f5a --- .repository-projection.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 17f4bf7..1ef1477 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "9a003786d7679e9029fa077557bc7ab4d00eb48f", + "sourceSha": "511bd2305f5a92cfbedb91208d7bc0820fda29b5", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "aa0a3c9668c437e963f699f26871840e221c65cc", + "priorProjectedBase": "20a6ce5b453007e16fdc04c22ac7fbc5160f6d67", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", + "toolDigest": "e9fc82741fdf4a797b076d29d51e14aced32368b", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } From b0061c7bcc2b2866499d25377f5af072158524a8 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 06:11:08 +0000 Subject: [PATCH 07/12] chore: project endpoint from Mono ad7b9df0b712 --- .repository-projection.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 1ef1477..fd41b1f 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "511bd2305f5a92cfbedb91208d7bc0820fda29b5", + "sourceSha": "ad7b9df0b7129a2fe8a6cda64eed199b0ea9a748", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "20a6ce5b453007e16fdc04c22ac7fbc5160f6d67", + "priorProjectedBase": "165896493585a7ec66e1f728933d9dedd2ecaf49", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "e9fc82741fdf4a797b076d29d51e14aced32368b", + "toolDigest": "1885e6c68d4a94f6633f0a96aecfbe92501547e6", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } From 26567019a4aac03ba72563a0bae2b4ead8947452 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 09:41:59 +0000 Subject: [PATCH 08/12] chore: project endpoint from Mono 4ab4845398fd --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index fd41b1f..4076463 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "ad7b9df0b7129a2fe8a6cda64eed199b0ea9a748", + "sourceSha": "4ab4845398fdca60f84eb815733b8e2361b4856d", "destinationRepository": "dx-corp/endpoint", "priorProjectedBase": "165896493585a7ec66e1f728933d9dedd2ecaf49", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "1885e6c68d4a94f6633f0a96aecfbe92501547e6", + "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } From f6b9091a06578003c097a9a3aa5ebdd9322cfaba Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Tue, 22 Sep 2026 02:35:04 +0000 Subject: [PATCH 09/12] chore: project endpoint from Mono 2df44a8283c0 --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 4076463..8f11400 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "4ab4845398fdca60f84eb815733b8e2361b4856d", + "sourceSha": "2df44a8283c07c0ba875174e70d2aaabb72d19cc", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "165896493585a7ec66e1f728933d9dedd2ecaf49", + "priorProjectedBase": "660b0439d00290cce5dd22718176d08b9ac59d1c", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", From 1bddf52a023d86040f0df55c92908270652e3feb Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Wed, 23 Sep 2026 16:08:05 +0000 Subject: [PATCH 10/12] chore: project endpoint from Mono 19eb7fff08ad --- .repository-projection.json | 6 +++--- merlin-ebpf/src/main.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 8f11400..09205ce 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "2df44a8283c07c0ba875174e70d2aaabb72d19cc", + "sourceSha": "19eb7fff08ad340878e60f71ba1a8f11ad7a9885", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "660b0439d00290cce5dd22718176d08b9ac59d1c", + "priorProjectedBase": "7b5dfbae8e453c8314b57aa938ee93add17c5a0b", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", - "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", + "contentDigest": "c1d3a602cc99b9fec3856e2dedcc0a18782713e82822ab9a5a4ea3a5b86a55c7", "publicationEligible": true } diff --git a/merlin-ebpf/src/main.rs b/merlin-ebpf/src/main.rs index 6f11e7e..aa8cdbf 100644 --- a/merlin-ebpf/src/main.rs +++ b/merlin-ebpf/src/main.rs @@ -54,7 +54,7 @@ fn panic(_info: &core::panic::PanicInfo) -> ! { fn submit(event: &Event) { // Dropped events (full ring) are acceptable for a teaching sensor; the // alternative is blocking the kernel path, which is not. - if EVENTS.output(event, 0).is_err() { + if EVENTS.output::(event, 0).is_err() { if let Some(ptr) = EVENT_STATS.get_ptr_mut(0) { unsafe { *ptr = (*ptr).wrapping_add(1) }; } From 8b9551af4f36295f569acc31f019ef4e07733105 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Wed, 23 Sep 2026 18:41:03 +0000 Subject: [PATCH 11/12] chore: project endpoint from Mono be57256872b9 --- .repository-projection.json | 8 +- macos/Sources/MerlinMacOS/Inventory.swift | 176 +++++++ macos/Tests/MerlinMacOSTests/SyncTests.swift | 44 ++ merlin/src/sync.rs | 453 +++++++++++++++++++ 4 files changed, 677 insertions(+), 4 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 09205ce..6a593ee 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "19eb7fff08ad340878e60f71ba1a8f11ad7a9885", + "sourceSha": "be57256872b9a0b021c1da667b7803f806e8f289", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "7b5dfbae8e453c8314b57aa938ee93add17c5a0b", + "priorProjectedBase": "f906d29bae651484eca29bf35d5577b0919a50df", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", - "contentDigest": "c1d3a602cc99b9fec3856e2dedcc0a18782713e82822ab9a5a4ea3a5b86a55c7", + "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", + "contentDigest": "306e18a97c82824dfe39bd5e021dfbf2fa6ca4f28b2eb200ed1224cc052615bc", "publicationEligible": true } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index 5ccc4a2..cb995ff 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import Darwin /// Bounded endpoint hygiene inventory for managed macOS devices. Collection is /// read-only and intentionally avoids file contents, usernames, addresses, or @@ -17,6 +18,9 @@ struct DeviceInventory: Encodable, Sendable { let fim: [DeviceFIMEntry] let sca: [DeviceSCAResult] let vulnerabilities: [DeviceVulnerability] + let agentCLIs: [DeviceAgentCLI] + let mcpServers: [DeviceMCPServer] + let agentAssets: [DeviceAgentAsset] let cloudProvider: String let cloudInstanceID: String let cloudRegion: String @@ -28,6 +32,9 @@ struct DeviceInventory: Encodable, Sendable { case packages, services, users, groups case listeningPorts = "listening_ports" case containers, processes, fim, sca, vulnerabilities + case agentCLIs = "agent_clis" + case mcpServers = "mcp_servers" + case agentAssets = "agent_assets" case cloudProvider = "cloud_provider" case cloudInstanceID = "cloud_instance_id" case cloudRegion = "cloud_region" @@ -35,6 +42,10 @@ struct DeviceInventory: Encodable, Sendable { } } +struct DeviceAgentCLI: Encodable, Sendable { let name: String } +struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String } +struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String } + struct DevicePackage: Encodable, Sendable { let name: String let version: String @@ -131,6 +142,7 @@ private let inventoryReadLimit = 2 << 20 func collectDeviceInventory() -> DeviceInventory { let packages = collectMacPackages() + let discovery = collectMacAgentDiscovery() return DeviceInventory( collectedAt: String(format: "%.3f", Date().timeIntervalSince1970), packageManager: packages.manager, @@ -144,6 +156,9 @@ func collectDeviceInventory() -> DeviceInventory { fim: collectMacFIM(), sca: collectMacSCA(), vulnerabilities: [], + agentCLIs: discovery.clis, + mcpServers: discovery.servers, + agentAssets: discovery.assets, cloudProvider: inventoryText(ProcessInfo.processInfo.environment["MERLIN_CLOUD_PROVIDER"], 128), cloudInstanceID: inventoryText(ProcessInfo.processInfo.environment["MERLIN_CLOUD_INSTANCE_ID"], 128), cloudRegion: inventoryText(ProcessInfo.processInfo.environment["MERLIN_CLOUD_REGION"], 128), @@ -151,6 +166,167 @@ func collectDeviceInventory() -> DeviceInventory { ) } +private func collectMacAgentDiscovery() -> (clis: [DeviceAgentCLI], servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) { + let root = "/Users" + let users = ((try? FileManager.default.contentsOfDirectory(atPath: root)) ?? []).sorted().prefix(64) + let homes = ["/var/root"] + users.map { "\(root)/\($0)" }.filter { path in + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } + return collectMacAgentDiscovery(homes: homes, systemBins: ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]) +} + +// Fixed probes only: no CLI execution and no configuration values are emitted. +func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [DeviceAgentCLI], servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) { + let names = ["codex", "claude", "gemini", "opencode", "aider", "maestro", "amp", "goose", "qwen", "pi"] + let bins = systemBins + homes.flatMap { ["\($0)/.local/bin", "\($0)/.npm-global/bin", "\($0)/.bun/bin", "\($0)/.cargo/bin", "\($0)/.codex/bin"] } + let clis = names.filter { name in + bins.contains { bin in + let path = "\(bin)/\(name)" + let attributes = try? FileManager.default.attributesOfItem(atPath: path) + return FileManager.default.isExecutableFile(atPath: path) && attributes?[.type] as? FileAttributeType == .typeRegular + } + }.map { DeviceAgentCLI(name: $0) } + + let configs: [(String, String, Bool)] = [ + ("claude", "Library/Application Support/Claude/claude_desktop_config.json", false), + ("claude", ".claude.json", false), + ("cursor", ".cursor/mcp.json", false), + ("gemini", ".gemini/settings.json", false), + ("vscode", "Library/Application Support/Code/User/mcp.json", false), + ("codex", ".codex/config.toml", true), + ("opencode", ".config/opencode/opencode.json", false), + ("claude", ".claude/settings.json", false), + ("amp", ".config/amp/settings.json", false), + ("qwen", ".qwen/settings.json", false), + ("pi", ".pi/agent/settings.json", false), + ] + var found = Set() + var assetNames = Set() + for home in homes.prefix(65) { + let assetDirs: [(String, String, String, String)] = [ + ("agents", "skill", ".agents/skills", "skill"), + ("codex", "skill", ".codex/skills", "skill"), + ("claude", "skill", ".claude/skills", "skill"), + ("claude", "agent", ".claude/agents", "md"), + ("gemini", "skill", ".gemini/skills", "skill"), + ("gemini", "extension", ".gemini/extensions", "directory"), + ("opencode", "skill", ".config/opencode/skills", "skill"), + ("opencode", "plugin", ".config/opencode/plugins", "js-ts"), + ("opencode", "agent", ".config/opencode/agents", "md"), + ("amp", "skill", ".config/amp/skills", "skill"), + ("qwen", "skill", ".qwen/skills", "skill"), + ("pi", "skill", ".pi/agent/skills", "skill"), + ("pi", "extension", ".pi/agent/extensions", "js-ts"), + ] + for (client, kind, relative, format) in assetDirs { + let directory = "\(home)/\(relative)" + var directoryInfo = stat() + guard lstat(directory, &directoryInfo) == 0, (directoryInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR) else { continue } + for entry in ((try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []).sorted().prefix(256) { + let path = "\(directory)/\(entry)" + var info = stat() + guard lstat(path, &info) == 0 else { continue } + let type = info.st_mode & mode_t(S_IFMT) + let name: String? + if format == "skill" && type == mode_t(S_IFDIR) { + var manifest = stat() + let manifestPath = "\(path)/SKILL.md" + name = lstat(manifestPath, &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil + } else if format == "directory" && type == mode_t(S_IFDIR) { + var manifest = stat() + name = lstat("\(path)/gemini-extension.json", &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil + } else if type == mode_t(S_IFREG) && format == "md" && entry.hasSuffix(".md") { + name = String(entry.dropLast(3)) + } else if type == mode_t(S_IFREG) && format == "js-ts" && (entry.hasSuffix(".js") || entry.hasSuffix(".ts")) { + name = String(entry.dropLast(3)) + } else { name = nil } + if let name, safeAgentAssetName(name) { + assetNames.insert("\(client)\u{0}\(kind)\u{0}\(name)") + if client == "gemini" && kind == "extension", let data = readAgentConfigNoFollow("\(path)/gemini-extension.json") { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + for server in ((object?["mcpServers"] as? [String: Any]).map { Array($0.keys) } ?? []) where safeAgentAssetName(server) { + found.insert("gemini\u{0}\(server)") + } + } + if assetNames.count >= 128 { break } + } + } + if assetNames.count >= 128 { break } + } + for (client, relative, isTOML) in configs { + guard let data = readAgentConfigNoFollow("\(home)/\(relative)") else { continue } + assetNames.insert("\(client)\u{0}config\u{0}user") + if client == "claude" && relative == ".claude/settings.json" { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + let plugins = object?["enabledPlugins"] as? [String: Bool] ?? [:] + for (name, enabled) in plugins where enabled && safeAgentAssetName(name) { + assetNames.insert("claude\u{0}plugin\u{0}\(name)") + } + continue + } + let names: [String] + if client == "amp" { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + names = (object?["amp.mcpServers"] as? [String: Any]).map { Array($0.keys) } ?? [] + } else if client == "opencode" { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + names = (object?["mcp"] as? [String: Any]).map { Array($0.keys) } ?? [] + } else if isTOML { + let body = String(data: data, encoding: .utf8) ?? "" + names = body.split(separator: "\n").compactMap { line in + let section = line.trimmingCharacters(in: .whitespaces) + guard section.hasPrefix("[mcp_servers."), section.hasSuffix("]") else { return nil } + let raw = String(section.dropFirst("[mcp_servers.".count).dropLast()) + let quoted = raw.hasPrefix("\"") && raw.hasSuffix("\"") && raw.count >= 2 + let name = quoted ? String(raw.dropFirst().dropLast()) : raw + return name.isEmpty || name.contains(where: { "[]".contains($0) }) || (!quoted && name.contains(".")) ? nil : name + } + } else { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + let entries = (object?["mcpServers"] ?? object?["servers"]) as? [String: Any] + names = entries.map { Array($0.keys) } ?? [] + } + for name in names where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) { + found.insert("\(client)\u{0}\(name)") + if found.count >= 128 { break } + } + if found.count >= 128 { break } + } + if found.count >= 128 { break } + } + let servers = found.sorted().prefix(128).compactMap { entry -> DeviceMCPServer? in + let parts = entry.split(separator: "\u{0}", maxSplits: 1) + guard parts.count == 2 else { return nil } + return DeviceMCPServer(client: String(parts[0]), name: String(parts[1])) + } + let assets = assetNames.sorted().prefix(128).compactMap { entry -> DeviceAgentAsset? in + let parts = entry.split(separator: "\u{0}") + guard parts.count == 3 else { return nil } + return DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2])) + } + return (clis, servers, assets) +} + +private func safeAgentAssetName(_ name: String) -> Bool { + !name.isEmpty && name.utf8.count <= 128 && !name.hasPrefix(".") && + !name.contains("/") && !name.contains("\\") && + !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) +} + +private func readAgentConfigNoFollow(_ path: String) -> Data? { + let fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { return nil } + defer { close(fd) } + var info = stat() + guard fstat(fd, &info) == 0, (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), info.st_size >= 0, info.st_size <= 64 << 10 else { return nil } + var bytes = [UInt8](repeating: 0, count: Int(info.st_size) + 1) + let capacity = bytes.count + let count = read(fd, &bytes, capacity) + guard count >= 0, count <= 64 << 10 else { return nil } + return Data(bytes.prefix(count)) +} + private func inventoryText(_ value: String?, _ limit: Int) -> String { guard let value else { return "" } return String(value.trimmingCharacters(in: .whitespacesAndNewlines).prefix(limit)) diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index 7c24ff9..f30db72 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -135,6 +135,50 @@ private func makeClient(spoolPath: String, rulesPath: String, rulesBox: RulesBox @Suite("sync", .serialized) struct SyncTests { + @Test("agent discovery reports identifiers without configuration values") + func agentDiscovery() throws { + let home = NSTemporaryDirectory() + "merlin-discovery-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: home) } + try FileManager.default.createDirectory(atPath: home + "/.codex", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.cursor", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.local/bin", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.agents/skills/review", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.gemini/extensions/workspace", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.gemini/extensions/not-extension", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.claude/agents", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.config/amp", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.config/opencode/plugins", withIntermediateDirectories: true) + try "[mcp_servers.github]\nurl = 'https://secret.example'\n".write(toFile: home + "/.codex/config.toml", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"docs":{"command":"secret"}}}"#.write(toFile: home + "/.cursor/mcp.json", atomically: true, encoding: .utf8) + try "secret instructions".write(toFile: home + "/.agents/skills/review/SKILL.md", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"search":{"env":{"TOKEN":"secret"}}}}"#.write(toFile: home + "/.gemini/extensions/workspace/gemini-extension.json", atomically: true, encoding: .utf8) + try "ignored".write(toFile: home + "/.gemini/extensions/not-extension/SKILL.md", atomically: true, encoding: .utf8) + try "secret prompt".write(toFile: home + "/.claude/agents/reviewer.md", atomically: true, encoding: .utf8) + try #"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false}}"#.write(toFile: home + "/.claude/settings.json", atomically: true, encoding: .utf8) + try #"{"amp.mcpServers":{"db":{"command":"secret"}}}"#.write(toFile: home + "/.config/amp/settings.json", atomically: true, encoding: .utf8) + try "secret plugin".write(toFile: home + "/.config/opencode/plugins/trace.ts", atomically: true, encoding: .utf8) + let cli = home + "/.local/bin/codex" + try "#!/bin/sh\n".write(toFile: cli, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli) + + let discovered = collectMacAgentDiscovery(homes: [home], systemBins: []) + #expect(discovered.clis.map(\.name) == ["codex"]) + #expect(discovered.servers.map { "\($0.client):\($0.name)" } == ["amp:db", "codex:github", "cursor:docs", "gemini:search"]) + #expect(discovered.assets.contains { $0.client == "agents" && $0.kind == "skill" && $0.name == "review" }) + #expect(discovered.assets.contains { $0.client == "claude" && $0.kind == "agent" && $0.name == "reviewer" }) + #expect(discovered.assets.contains { $0.client == "claude" && $0.kind == "plugin" && $0.name == "audit@marketplace" }) + #expect(discovered.assets.contains { $0.client == "opencode" && $0.kind == "plugin" && $0.name == "trace" }) + #expect(!discovered.assets.contains { $0.name == "off@marketplace" }) + #expect(!discovered.assets.contains { $0.name == "not-extension" }) + let encoded = try JSONEncoder().encode(discovered.servers) + #expect(!String(decoding: encoded, as: UTF8.self).contains("secret")) + #expect(!String(decoding: try JSONEncoder().encode(discovered.assets), as: UTF8.self).contains("secret")) + + try FileManager.default.removeItem(atPath: home + "/.cursor/mcp.json") + try FileManager.default.createSymbolicLink(atPath: home + "/.cursor/mcp.json", withDestinationPath: home + "/.codex/config.toml") + #expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 3) + } + @Test("host id is a 16-char hash, not the raw UUID") func hostId() { let id = syncHostId() diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index dbe4ba1..fa01bef 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -388,10 +388,34 @@ struct DeviceInventory { sca: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] vulnerabilities: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + agent_clis: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + agent_assets: Vec, #[serde(skip_serializing_if = "String::is_empty")] collection_source: String, } +#[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] +struct DeviceAgentCLI { + name: String, +} + +#[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] +struct DeviceMCPServer { + client: String, + name: String, +} + +#[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] +struct DeviceAgentAsset { + client: String, + kind: String, + name: String, +} + #[derive(Serialize, Debug, PartialEq)] struct DevicePackage { name: String, @@ -1803,6 +1827,7 @@ fn collect_inventory() -> DeviceInventory { let (package_manager, packages) = collect_packages(); let (users, groups) = collect_users_groups(); let (cloud_provider, cloud_instance_id, cloud_region) = cloud_metadata(); + let (agent_clis, mcp_servers, agent_assets) = collect_agent_discovery(); DeviceInventory { collected_at: format!("{:.3}", spool::now_ts()), package_manager, @@ -1819,10 +1844,318 @@ fn collect_inventory() -> DeviceInventory { fim: collect_fim(), sca: collect_sca(), vulnerabilities: Vec::new(), + agent_clis, + mcp_servers, + agent_assets, collection_source: "linux-agent".into(), } } +// Probe only fixed executable names and fixed configuration locations. Never +// execute a CLI or include configuration values in a managed heartbeat. +fn collect_agent_discovery() -> ( + Vec, + Vec, + Vec, +) { + let mut homes = vec![PathBuf::from("/root")]; + if let Ok(entries) = fs::read_dir("/home") { + let mut candidates: Vec<_> = entries + .take(256) + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .collect(); + candidates.sort(); + homes.extend(candidates.into_iter().take(64)); + } + collect_agent_discovery_from( + &homes, + &[ + "/usr/local/bin", + "/usr/bin", + "/home/linuxbrew/.linuxbrew/bin", + ], + ) +} + +fn collect_agent_discovery_from( + homes: &[PathBuf], + system_bins: &[&str], +) -> ( + Vec, + Vec, + Vec, +) { + const CLIS: &[&str] = &[ + "codex", "claude", "gemini", "opencode", "aider", "maestro", "amp", "goose", "qwen", "pi", + ]; + const CONFIGS: &[(&str, &str, bool)] = &[ + ("claude", ".config/Claude/claude_desktop_config.json", false), + ("claude", ".claude.json", false), + ("cursor", ".cursor/mcp.json", false), + ("gemini", ".gemini/settings.json", false), + ("vscode", ".config/Code/User/mcp.json", false), + ("codex", ".codex/config.toml", true), + ("opencode", ".config/opencode/opencode.json", false), + ("claude", ".claude/settings.json", false), + ("amp", ".config/amp/settings.json", false), + ("qwen", ".qwen/settings.json", false), + ("pi", ".pi/agent/settings.json", false), + ]; + let mut clis = BTreeSet::new(); + let mut servers = BTreeSet::new(); + let mut assets = BTreeSet::new(); + for name in CLIS { + let found = system_bins + .iter() + .map(PathBuf::from) + .chain(homes.iter().flat_map(|home| { + [ + home.join(".local/bin"), + home.join(".npm-global/bin"), + home.join(".bun/bin"), + home.join(".cargo/bin"), + home.join(".codex/bin"), + ] + })) + .any(|dir| { + fs::metadata(dir.join(name)) + .is_ok_and(|meta| meta.is_file() && meta.mode() & 0o111 != 0) + }); + if found { + clis.insert(DeviceAgentCLI { + name: (*name).into(), + }); + } + } + for home in homes.iter().take(65) { + for (client, kind, relative, extension) in [ + ("agents", "skill", ".agents/skills", ""), + ("codex", "skill", ".codex/skills", ""), + ("claude", "skill", ".claude/skills", ""), + ("claude", "agent", ".claude/agents", "md"), + ("gemini", "skill", ".gemini/skills", ""), + ("gemini", "extension", ".gemini/extensions", ""), + ("opencode", "skill", ".config/opencode/skills", ""), + ("opencode", "plugin", ".config/opencode/plugins", "js-ts"), + ("opencode", "agent", ".config/opencode/agents", "md"), + ("amp", "skill", ".config/amp/skills", ""), + ("qwen", "skill", ".qwen/skills", ""), + ("pi", "skill", ".pi/agent/skills", ""), + ("pi", "extension", ".pi/agent/extensions", "js-ts"), + ] { + let directory = home.join(relative); + if !directory + .symlink_metadata() + .is_ok_and(|meta| meta.is_dir() && !meta.file_type().is_symlink()) + { + continue; + } + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for entry in entries.take(256).flatten() { + let Ok(kind_on_disk) = entry.file_type() else { + continue; + }; + let file_name = entry.file_name().to_string_lossy().into_owned(); + let name = if extension == "md" && kind_on_disk.is_file() { + file_name.strip_suffix(".md") + } else if extension == "js-ts" && kind_on_disk.is_file() { + file_name + .strip_suffix(".js") + .or_else(|| file_name.strip_suffix(".ts")) + } else if extension.is_empty() + && kind == "skill" + && kind_on_disk.is_dir() + && directory + .join(&file_name) + .join("SKILL.md") + .symlink_metadata() + .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + { + Some(file_name.as_str()) + } else if extension.is_empty() + && kind == "extension" + && kind_on_disk.is_dir() + && directory + .join(&file_name) + .join("gemini-extension.json") + .symlink_metadata() + .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + { + Some(file_name.as_str()) + } else { + None + }; + if let Some(name) = name.filter(|name| safe_agent_asset_name(name)) { + assets.insert(DeviceAgentAsset { + client: client.into(), + kind: kind.into(), + name: name.into(), + }); + if client == "gemini" && kind == "extension" { + if let Some(body) = read_agent_config( + &directory.join(&file_name).join("gemini-extension.json"), + ) { + for server in json_mcp_names(&body) { + if safe_agent_asset_name(&server) { + servers.insert(DeviceMCPServer { + client: "gemini".into(), + name: server, + }); + } + } + } + } + if assets.len() >= 128 { + break; + } + } + } + if assets.len() >= 128 { + break; + } + } + for (client, relative, is_toml) in CONFIGS { + let Some(body) = read_agent_config(&home.join(relative)) else { + continue; + }; + assets.insert(DeviceAgentAsset { + client: (*client).into(), + kind: "config".into(), + name: "user".into(), + }); + if *client == "claude" && *relative == ".claude/settings.json" { + if let Ok(value) = serde_json::from_str::(&body) { + if let Some(plugins) = value + .get("enabledPlugins") + .and_then(|value| value.as_object()) + { + for (name, enabled) in plugins { + if enabled.as_bool() == Some(true) && safe_agent_asset_name(name) { + assets.insert(DeviceAgentAsset { + client: "claude".into(), + kind: "plugin".into(), + name: name.clone(), + }); + } + } + } + } + continue; + } + let names: Vec = if *client == "amp" { + serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value + .get("amp.mcpServers")? + .as_object() + .map(|object| object.keys().cloned().collect()) + }) + .unwrap_or_default() + } else if *client == "opencode" { + serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value + .get("mcp")? + .as_object() + .map(|object| object.keys().cloned().collect()) + }) + .unwrap_or_default() + } else if *is_toml { + codex_mcp_names(&body) + } else { + json_mcp_names(&body) + }; + for name in names { + if name.len() <= 128 && !name.chars().any(char::is_control) { + servers.insert(DeviceMCPServer { + client: (*client).into(), + name, + }); + if servers.len() >= 128 { + break; + } + } + } + if servers.len() >= 128 { + break; + } + } + if servers.len() >= 128 { + break; + } + } + ( + clis.into_iter().collect(), + servers.into_iter().take(128).collect(), + assets.into_iter().take(128).collect(), + ) +} + +fn read_agent_config(path: &std::path::Path) -> Option { + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .ok()?; + if !file + .metadata() + .ok() + .is_some_and(|meta| meta.is_file() && meta.len() <= 64 << 10) + { + return None; + } + let mut body = String::new(); + file.take((64 << 10) + 1).read_to_string(&mut body).ok()?; + (body.len() <= 64 << 10).then_some(body) +} + +fn safe_agent_asset_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && !name.starts_with('.') + && !name.chars().any(char::is_control) + && !name.contains('/') + && !name.contains('\\') +} + +fn json_mcp_names(body: &str) -> Vec { + let Ok(value) = serde_json::from_str::(body) else { + return Vec::new(); + }; + ["mcpServers", "servers"] + .iter() + .filter_map(|key| value.get(key)?.as_object()) + .flat_map(|object| object.keys().cloned()) + .collect() +} + +fn codex_mcp_names(body: &str) -> Vec { + body.lines() + .filter_map(|line| { + let section = line + .trim() + .strip_prefix("[mcp_servers.")? + .strip_suffix(']')?; + let quoted = section.starts_with('"') && section.ends_with('"') && section.len() >= 2; + let name = if quoted { + §ion[1..section.len() - 1] + } else { + section + }; + (!name.is_empty() + && !name.chars().any(|ch| "[]".contains(ch)) + && (quoted || !name.contains('.'))) + .then(|| name.to_string()) + }) + .collect() +} + fn collect_os_info() -> DeviceOSInfo { let os_release = bounded_read("/etc/os-release", 64 << 10) .and_then(|body| String::from_utf8(body).ok()) @@ -1923,6 +2256,126 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn agent_discovery_reports_names_without_config_values_or_symlinks() { + let root = tmpdir("agent-discovery"); + let home = root.join("home"); + let bin = home.join(".local/bin"); + fs::create_dir_all(&bin).unwrap(); + let executable = bin.join("codex"); + fs::write(&executable, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + fs::create_dir_all(home.join(".codex")).unwrap(); + fs::write( + home.join(".codex/config.toml"), + "[mcp_servers.github]\nurl = 'https://secret.example'\n", + ) + .unwrap(); + fs::create_dir_all(home.join(".cursor")).unwrap(); + fs::write( + home.join(".cursor/mcp.json"), + r#"{"mcpServers":{"docs":{"command":"secret"}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".agents/skills/review")).unwrap(); + fs::write( + home.join(".agents/skills/review/SKILL.md"), + "secret instructions", + ) + .unwrap(); + fs::create_dir_all(home.join(".gemini/extensions/workspace")).unwrap(); + fs::write( + home.join(".gemini/extensions/workspace/gemini-extension.json"), + r#"{"mcpServers":{"search":{"env":{"TOKEN":"secret"}}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".gemini/extensions/not-extension")).unwrap(); + fs::write( + home.join(".gemini/extensions/not-extension/SKILL.md"), + "ignored", + ) + .unwrap(); + fs::create_dir_all(home.join(".claude/agents")).unwrap(); + fs::write(home.join(".claude/agents/reviewer.md"), "secret prompt").unwrap(); + fs::write( + home.join(".claude/settings.json"), + r#"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".config/amp")).unwrap(); + fs::write( + home.join(".config/amp/settings.json"), + r#"{"amp.mcpServers":{"db":{"command":"secret"}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".config/opencode/plugins")).unwrap(); + fs::write( + home.join(".config/opencode/plugins/trace.ts"), + "secret plugin", + ) + .unwrap(); + let (clis, servers, assets) = collect_agent_discovery_from(&[home.clone()], &[]); + assert_eq!( + clis, + vec![DeviceAgentCLI { + name: "codex".into() + }] + ); + assert_eq!( + servers, + vec![ + DeviceMCPServer { + client: "amp".into(), + name: "db".into() + }, + DeviceMCPServer { + client: "codex".into(), + name: "github".into() + }, + DeviceMCPServer { + client: "cursor".into(), + name: "docs".into() + }, + DeviceMCPServer { + client: "gemini".into(), + name: "search".into() + }, + ] + ); + let serialized = serde_json::to_string(&servers).unwrap(); + assert!(!serialized.contains("secret")); + assert!( + assets + .iter() + .any(|item| item.client == "codex" && item.kind == "config") + ); + assert!( + assets.iter().any(|item| item.client == "agents" + && item.kind == "skill" + && item.name == "review") + ); + assert!(assets.iter().any(|item| item.client == "claude" + && item.kind == "agent" + && item.name == "reviewer")); + assert!(assets.iter().any(|item| item.client == "claude" + && item.kind == "plugin" + && item.name == "audit@marketplace")); + assert!(assets.iter().any(|item| item.client == "opencode" + && item.kind == "plugin" + && item.name == "trace")); + assert!(!assets.iter().any(|item| item.name == "off@marketplace")); + assert!(!assets.iter().any(|item| item.name == "not-extension")); + assert!(!serde_json::to_string(&assets).unwrap().contains("secret")); + fs::remove_file(home.join(".cursor/mcp.json")).unwrap(); + std::os::unix::fs::symlink( + home.join(".codex/config.toml"), + home.join(".cursor/mcp.json"), + ) + .unwrap(); + assert_eq!(collect_agent_discovery_from(&[home], &[]).1.len(), 3); + fs::remove_dir_all(root).unwrap(); + } + /// Minimal one-shot HTTP responder: reads one request (headers + /// content-length body), calls `respond` with (headers, body), writes /// back the returned raw response. From 0f1b3ef660f5f0df05b8bc515acf97ebc2e488af Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Wed, 23 Sep 2026 19:43:13 +0000 Subject: [PATCH 12/12] chore: project endpoint from Mono fc21676e9136 --- .repository-projection.json | 6 +++--- Cargo.lock | 8 ++++---- macos/Sources/MerlinMacOS/Inventory.swift | 19 ++++++++++++++++--- macos/Tests/MerlinMacOSTests/SyncTests.swift | 3 +++ merlin/src/sync.rs | 8 ++++++++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 6a593ee..ef809db 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "be57256872b9a0b021c1da667b7803f806e8f289", + "sourceSha": "fc21676e9136ce5ddca1260a75b4044e3c3b8bc3", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "f906d29bae651484eca29bf35d5577b0919a50df", + "priorProjectedBase": "2243d823a7fbcbb69382c681cc7858bda33136d4", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "306e18a97c82824dfe39bd5e021dfbf2fa6ca4f28b2eb200ed1224cc052615bc", + "contentDigest": "9f84e7f599492777fd805f10e8f2cab5c9d2772d1f68134bebfe089edadc7e20", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index b3f32ae..206eda6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -815,9 +815,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -839,9 +839,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index cb995ff..46f0eb9 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -221,9 +221,7 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [ ] for (client, kind, relative, format) in assetDirs { let directory = "\(home)/\(relative)" - var directoryInfo = stat() - guard lstat(directory, &directoryInfo) == 0, (directoryInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR) else { continue } - for entry in ((try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []).sorted().prefix(256) { + for entry in boundedAgentDirectoryEntries(directory) { let path = "\(directory)/\(entry)" var info = stat() guard lstat(path, &info) == 0 else { continue } @@ -308,6 +306,21 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [ return (clis, servers, assets) } +private func boundedAgentDirectoryEntries(_ path: String) -> [String] { + let fd = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { return [] } + guard let directory = fdopendir(fd) else { close(fd); return [] } + defer { closedir(directory) } + var names = [String]() + while names.count < 256, let entry = readdir(directory) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) } + } + if name != "." && name != ".." { names.append(name) } + } + return names.sorted() +} + private func safeAgentAssetName(_ name: String) -> Bool { !name.isEmpty && name.utf8.count <= 128 && !name.hasPrefix(".") && !name.contains("/") && !name.contains("\\") && diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index f30db72..f113b9c 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -143,6 +143,8 @@ struct SyncTests { try FileManager.default.createDirectory(atPath: home + "/.cursor", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: home + "/.local/bin", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: home + "/.agents/skills/review", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.pi/agent", withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: home + "/.pi/agent/skills", withDestinationPath: home + "/.agents/skills") try FileManager.default.createDirectory(atPath: home + "/.gemini/extensions/workspace", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: home + "/.gemini/extensions/not-extension", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: home + "/.claude/agents", withIntermediateDirectories: true) @@ -170,6 +172,7 @@ struct SyncTests { #expect(discovered.assets.contains { $0.client == "opencode" && $0.kind == "plugin" && $0.name == "trace" }) #expect(!discovered.assets.contains { $0.name == "off@marketplace" }) #expect(!discovered.assets.contains { $0.name == "not-extension" }) + #expect(!discovered.assets.contains { $0.client == "pi" && $0.name == "review" }) let encoded = try JSONEncoder().encode(discovered.servers) #expect(!String(decoding: encoded, as: UTF8.self).contains("secret")) #expect(!String(decoding: try JSONEncoder().encode(discovered.assets), as: UTF8.self).contains("secret")) diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index fa01bef..56f7531 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -2283,6 +2283,9 @@ mod tests { "secret instructions", ) .unwrap(); + fs::create_dir_all(home.join(".pi/agent")).unwrap(); + std::os::unix::fs::symlink(home.join(".agents/skills"), home.join(".pi/agent/skills")) + .unwrap(); fs::create_dir_all(home.join(".gemini/extensions/workspace")).unwrap(); fs::write( home.join(".gemini/extensions/workspace/gemini-extension.json"), @@ -2365,6 +2368,11 @@ mod tests { && item.name == "trace")); assert!(!assets.iter().any(|item| item.name == "off@marketplace")); assert!(!assets.iter().any(|item| item.name == "not-extension")); + assert!( + !assets + .iter() + .any(|item| item.client == "pi" && item.name == "review") + ); assert!(!serde_json::to_string(&assets).unwrap().contains("secret")); fs::remove_file(home.join(".cursor/mcp.json")).unwrap(); std::os::unix::fs::symlink(