@zewstid/zeye-capture 0.2.0 → 0.3.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/README.md +55 -10
- package/dist/src/cli.js +52 -16
- package/dist/src/fileOutbox.d.ts +7 -0
- package/dist/src/fileOutbox.js +134 -5
- package/dist/src/localHost.d.ts +1 -0
- package/dist/src/localHost.js +5 -0
- package/dist/src/service.d.ts +28 -1
- package/dist/src/service.js +85 -6
- package/dist/src/transport.js +4 -1
- package/dist/src/types.d.ts +3 -0
- package/package.json +4 -3
- package/dist/test/fileOutbox.test.d.ts +0 -1
- package/dist/test/fileOutbox.test.js +0 -104
- package/dist/test/host.test.d.ts +0 -1
- package/dist/test/host.test.js +0 -58
- package/dist/test/localHost.test.d.ts +0 -1
- package/dist/test/localHost.test.js +0 -122
- package/dist/test/providers-and-transport.test.d.ts +0 -1
- package/dist/test/providers-and-transport.test.js +0 -222
- package/dist/test/transcript-and-service.test.d.ts +0 -1
- package/dist/test/transcript-and-service.test.js +0 -50
package/README.md
CHANGED
|
@@ -5,9 +5,20 @@ capture is the default. Complete provider-visible prompts, responses and tool
|
|
|
5
5
|
records are captured only after explicit Zeye consent and local secret
|
|
6
6
|
redaction. Hidden model reasoning is never captured.
|
|
7
7
|
|
|
8
|
+
Install the public CLI with Node 20.18.1 or newer:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install --global @zewstid/zeye-capture@0.3.0
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Capture `0.3.x` uses the `@zewstid/zeye-shared` `0.5.x` contracts.
|
|
15
|
+
|
|
8
16
|
## What exists now
|
|
9
17
|
|
|
10
|
-
- `FileOutbox`:
|
|
18
|
+
- `FileOutbox`: an atomic durable outbox. Consented transcript fields are
|
|
19
|
+
authenticated AES-256-GCM ciphertext at rest; the separate local key and
|
|
20
|
+
queue are owner-only. Existing v1 plaintext queues are encrypted in place on
|
|
21
|
+
their first read before any record is returned.
|
|
11
22
|
- Idempotent delivery records keyed by `adapter + sessionId + sourceEventId`,
|
|
12
23
|
including immutable repository/branch/HEAD facts observed at enqueue time.
|
|
13
24
|
- Stable content hashes: replays with the same source and content collapse;
|
|
@@ -18,8 +29,12 @@ redaction. Hidden model reasoning is never captured.
|
|
|
18
29
|
to replay safely after an offline period.
|
|
19
30
|
- Claude Code hook and Codex JSONL adapters for lifecycle metadata and,
|
|
20
31
|
when explicitly consented, provider-visible messages and tool records.
|
|
21
|
-
- A one-command background service that continuously imports and retries on
|
|
32
|
+
- A one-command, machine-level background service that continuously imports and retries on
|
|
22
33
|
macOS (launchd), Linux (systemd user service), and Windows (Task Scheduler).
|
|
34
|
+
The host continuously discovers repositories from installed Claude/Codex
|
|
35
|
+
adapters as well as explicit Zeye bindings. One `service install` is enough;
|
|
36
|
+
repositories configured later join the same service automatically, and Git
|
|
37
|
+
worktrees remain independent roots.
|
|
23
38
|
- `CaptureHttpTransport`, which turns one durable local delivery into one
|
|
24
39
|
authenticated, idempotent `POST /engineering-memory/ingest` batch.
|
|
25
40
|
- A production-oriented local CLI: Claude Code hook install/removal, configured
|
|
@@ -29,8 +44,15 @@ redaction. Hidden model reasoning is never captured.
|
|
|
29
44
|
## Security and limits
|
|
30
45
|
|
|
31
46
|
- Transcript content is locally redacted before either the durable outbox or
|
|
32
|
-
network boundary.
|
|
33
|
-
key
|
|
47
|
+
network boundary. It is then AES-256-GCM encrypted in the local queue with a
|
|
48
|
+
generated owner-only sidecar key. Authentication binds ciphertext to its
|
|
49
|
+
delivery/source/payload identity, transcript-bearing idempotency digests are
|
|
50
|
+
keyed HMACs rather than guessable plain hashes, and missing or altered
|
|
51
|
+
keys/ciphertext fail closed. The API independently envelope-encrypts each
|
|
52
|
+
uploaded segment.
|
|
53
|
+
- Every queued content record is bound to the consent ID active at capture.
|
|
54
|
+
Removing or replacing that consent stops replay; the client never silently
|
|
55
|
+
re-labels offline content under a later consent.
|
|
34
56
|
- The one-time enrollment token is kept in an owner-only local service config,
|
|
35
57
|
never in the service definition, command line, repository, or outbox. Native
|
|
36
58
|
keychain integration remains future hardening.
|
|
@@ -128,10 +150,14 @@ zeye-capture status
|
|
|
128
150
|
zeye-capture heartbeat
|
|
129
151
|
|
|
130
152
|
# With ZEYE_CAPTURE_* values present, install and immediately start continuous
|
|
131
|
-
# sync.
|
|
153
|
+
# sync. Repositories with a Zeye Claude hook or Codex source are discovered
|
|
154
|
+
# automatically, including ones configured after startup. Re-run only when an
|
|
155
|
+
# explicit internal Zeye repository binding is needed. The service survives
|
|
156
|
+
# logout/restart according to the host OS policy.
|
|
132
157
|
zeye-capture service install --repo /path/to/repo
|
|
133
158
|
|
|
134
|
-
#
|
|
159
|
+
# Unregister one checkout. The host and credential remain until the final
|
|
160
|
+
# registered checkout is removed.
|
|
135
161
|
zeye-capture service uninstall --repo /path/to/repo
|
|
136
162
|
|
|
137
163
|
# Remove only hook groups/sources created for Zeye.
|
|
@@ -143,8 +169,16 @@ zeye-capture uninstall codex --jsonl /absolute/path/to/rollout.jsonl
|
|
|
143
169
|
modify that file if Git tracks it. It preserves other hook groups. The host
|
|
144
170
|
configuration is stored under `$XDG_CONFIG_HOME/zeye-capture/host-v1.json` (or
|
|
145
171
|
`~/.config/...`) with owner-only permissions where the OS supports POSIX modes.
|
|
146
|
-
The default outbox
|
|
147
|
-
`~/.local/state/...`) and
|
|
172
|
+
The default outbox and its separate 256-bit encryption key are under
|
|
173
|
+
`$XDG_STATE_HOME/zeye-capture/` (or `~/.local/state/...`) and are also
|
|
174
|
+
owner-restricted. Back up or protect both together: the key is deliberately
|
|
175
|
+
not recoverable from Zeye, and capture fails closed if it is missing. It never
|
|
176
|
+
generates a replacement key over an encrypted queue. Deliberately deleting the
|
|
177
|
+
key is cryptographic revocation of all transcript content in that repository's
|
|
178
|
+
queue and is irreversible; remove the corresponding queue as an explicit
|
|
179
|
+
separate cleanup action. Native OS keychain/credential-vault storage is not
|
|
180
|
+
claimed in this release—the fallback is an owner-only local key file.
|
|
181
|
+
`ZEYE_CAPTURE_OUTBOX_DIR`,
|
|
148
182
|
when set, is a private base directory: Zeye creates a distinct hashed child for
|
|
149
183
|
each repository so records cannot cross repository boundaries. Neither location
|
|
150
184
|
stores the upload token.
|
|
@@ -155,7 +189,7 @@ by hand:
|
|
|
155
189
|
|
|
156
190
|
```text
|
|
157
191
|
ZEYE_CAPTURE_ENDPOINT=https://api.example/engineering-memory/ingest
|
|
158
|
-
ZEYE_CAPTURE_TOKEN=zem_... #
|
|
192
|
+
ZEYE_CAPTURE_TOKEN=zem_... # persisted only by service install in its owner-only enrollment
|
|
159
193
|
ZEYE_CAPTURE_ORGANIZATION_ID=...
|
|
160
194
|
ZEYE_CAPTURE_INSTALLATION_ID=...
|
|
161
195
|
ZEYE_CAPTURE_ACTOR_JSON={...}
|
|
@@ -177,7 +211,18 @@ available; the raw remote URL is not stored or printed. A configured internal
|
|
|
177
211
|
repository ID enables the server to bind those facts to existing Zeye evidence.
|
|
178
212
|
|
|
179
213
|
For continuous capture, run `zeye-capture service install --repo /path/to/repo`
|
|
180
|
-
once. The CLI
|
|
214
|
+
once. The CLI maintains one machine-level service and combines its private
|
|
215
|
+
binding registry with repository roots already configured by Claude and Codex,
|
|
216
|
+
instead of creating one scheduler per repo. Raw paths remain local and are
|
|
217
|
+
never sent in the capture protocol; only canonical repository facts and
|
|
218
|
+
one-way hashes cross the network boundary.
|
|
219
|
+
Discovery is bounded to the Zeye-owned local adapter registry and explicit
|
|
220
|
+
enrollment roots. It does not crawl the home directory, workspace folders or
|
|
221
|
+
Git remotes. Discovery also grants no server permission: the API still resolves
|
|
222
|
+
the repository inside the installation's tenant and approved repository scope.
|
|
223
|
+
Each repository keeps its own outbox, lock, Git identity and optional Zeye
|
|
224
|
+
repository binding, so offline replay is idempotent and cannot cross repository
|
|
225
|
+
boundaries. The CLI
|
|
181
226
|
uses an owner-only single-process lock and waits briefly for an overlapping
|
|
182
227
|
hook/run before failing, so normal bursts are queued rather than dropped. A
|
|
183
228
|
`sync` exits non-zero if an input source or upload fails, while retaining its
|
package/dist/src/cli.js
CHANGED
|
@@ -49,17 +49,17 @@ async function readStdin() {
|
|
|
49
49
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
50
50
|
return Buffer.concat(chunks).toString("utf8");
|
|
51
51
|
}
|
|
52
|
-
async function resolveRepository(repoPath) {
|
|
52
|
+
async function resolveRepository(repoPath, binding) {
|
|
53
53
|
const identity = await (0, localHost_1.discoverRepositoryIdentity)(repoPath, {
|
|
54
|
-
providerRepositoryId: process.env.ZEYE_CAPTURE_PROVIDER_REPOSITORY_ID,
|
|
54
|
+
providerRepositoryId: binding?.providerRepositoryId || process.env.ZEYE_CAPTURE_PROVIDER_REPOSITORY_ID,
|
|
55
55
|
});
|
|
56
56
|
if (!identity)
|
|
57
57
|
throw new Error(`No Git repository found at ${repoPath}. Run this command from a repository or pass --repo PATH.`);
|
|
58
58
|
return identity;
|
|
59
59
|
}
|
|
60
|
-
function captureContext(identity) {
|
|
60
|
+
function captureContext(identity, binding) {
|
|
61
61
|
return {
|
|
62
|
-
repositories: (0, localHost_1.captureRepositoryReference)(identity, process.env.ZEYE_CAPTURE_REPOSITORY_ID),
|
|
62
|
+
repositories: (0, localHost_1.captureRepositoryReference)(identity, binding?.repositoryId || process.env.ZEYE_CAPTURE_REPOSITORY_ID),
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
async function runtimeConfig(identity) {
|
|
@@ -110,11 +110,17 @@ async function flushIfConfigured(outbox, configuration, adapter) {
|
|
|
110
110
|
await transport.publishManifest((0, transport_1.capabilityManifestForAdapter)(adapter, Boolean(configuration.privacy.contentConsentId)));
|
|
111
111
|
return { uploadConfigured: true, ...(await transport.flush()) };
|
|
112
112
|
}
|
|
113
|
-
async function withRepositoryCapture(identity, operation) {
|
|
113
|
+
async function withRepositoryCapture(identity, operation, binding) {
|
|
114
114
|
const directory = (0, localHost_1.localOutboxDirectory)(identity.repositoryRoot);
|
|
115
|
-
const
|
|
115
|
+
const repositoryContext = captureContext(identity, binding);
|
|
116
116
|
return (0, localHost_1.withCaptureLock)(directory, async () => {
|
|
117
117
|
const runtime = await runtimeConfig(identity);
|
|
118
|
+
const context = {
|
|
119
|
+
...repositoryContext,
|
|
120
|
+
...(runtime?.privacy.contentConsentId
|
|
121
|
+
? { contentConsentId: runtime.privacy.contentConsentId }
|
|
122
|
+
: {}),
|
|
123
|
+
};
|
|
118
124
|
return operation(new fileOutbox_1.FileOutbox(directory, runtime?.privacy.contentMode === "REDACTED_CONTENT"
|
|
119
125
|
? redaction_1.consentedTranscriptRedactor
|
|
120
126
|
: redaction_1.metadataRedactor), runtime, context);
|
|
@@ -154,12 +160,12 @@ async function status(repositoryRoot) {
|
|
|
154
160
|
diagnostics: configured
|
|
155
161
|
? []
|
|
156
162
|
: [
|
|
157
|
-
"
|
|
163
|
+
"No runtime enrollment is configured for this process; captured records remain in the private local outbox.",
|
|
158
164
|
],
|
|
159
165
|
});
|
|
160
166
|
}
|
|
161
|
-
async function sync(repositoryRoot, exitOnFailure = true) {
|
|
162
|
-
const identity = await resolveRepository(repositoryRoot);
|
|
167
|
+
async function sync(repositoryRoot, exitOnFailure = true, binding) {
|
|
168
|
+
const identity = await resolveRepository(repositoryRoot, binding);
|
|
163
169
|
const config = await (0, localHost_1.readLocalHostConfig)();
|
|
164
170
|
const sources = (0, localHost_1.configuredSourcesForRepository)(config, identity.repositoryRoot);
|
|
165
171
|
const result = await withRepositoryCapture(identity, async (outbox, runtime, context) => {
|
|
@@ -211,7 +217,7 @@ async function sync(repositoryRoot, exitOnFailure = true) {
|
|
|
211
217
|
error: error instanceof Error ? error.message : "capture_sync_failed",
|
|
212
218
|
};
|
|
213
219
|
}
|
|
214
|
-
});
|
|
220
|
+
}, binding);
|
|
215
221
|
print(result);
|
|
216
222
|
if (exitOnFailure &&
|
|
217
223
|
(result.imports.some((entry) => entry.error) ||
|
|
@@ -258,6 +264,13 @@ function serviceEnrollment(intervalSeconds) {
|
|
|
258
264
|
: {}),
|
|
259
265
|
};
|
|
260
266
|
}
|
|
267
|
+
function repositoryBinding(identity) {
|
|
268
|
+
return {
|
|
269
|
+
repositoryRoot: identity.repositoryRoot,
|
|
270
|
+
...(process.env.ZEYE_CAPTURE_REPOSITORY_ID?.trim() ? { repositoryId: process.env.ZEYE_CAPTURE_REPOSITORY_ID.trim() } : {}),
|
|
271
|
+
...(identity.providerRepositoryId ? { providerRepositoryId: identity.providerRepositoryId } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
261
274
|
async function runService(repositoryRoot, configPath) {
|
|
262
275
|
const enrollment = await (0, service_1.readServiceEnrollment)(configPath);
|
|
263
276
|
(0, service_1.applyServiceEnrollment)(enrollment);
|
|
@@ -269,7 +282,18 @@ async function runService(repositoryRoot, configPath) {
|
|
|
269
282
|
stopping = true;
|
|
270
283
|
});
|
|
271
284
|
while (!stopping) {
|
|
272
|
-
|
|
285
|
+
// Re-read adapter configuration every cycle. Repositories configured for
|
|
286
|
+
// Claude/Codex after the service started are discovered without another
|
|
287
|
+
// scheduler or service enrollment edit.
|
|
288
|
+
const repositories = (0, service_1.discoverServiceRepositories)(enrollment.repositories, await (0, localHost_1.readLocalHostConfig)(), repositoryRoot);
|
|
289
|
+
for (const repository of repositories) {
|
|
290
|
+
if (stopping)
|
|
291
|
+
break;
|
|
292
|
+
// A checkout may be temporarily unavailable (detached volume, deleted
|
|
293
|
+
// worktree, sleeping network mount). Keep servicing every other
|
|
294
|
+
// registered repository; its durable outbox will replay when it returns.
|
|
295
|
+
await sync(repository.repositoryRoot, false, repository).catch(() => undefined);
|
|
296
|
+
}
|
|
273
297
|
if (stopping)
|
|
274
298
|
break;
|
|
275
299
|
await new Promise((resolveWait) => setTimeout(resolveWait, enrollment.intervalSeconds * 1000));
|
|
@@ -283,7 +307,7 @@ async function main() {
|
|
|
283
307
|
const configPath = optional(flags, "config");
|
|
284
308
|
if (!configPath)
|
|
285
309
|
throw new Error("Service configuration is required");
|
|
286
|
-
await runService(
|
|
310
|
+
await runService(optional(flags, "repo"), configPath);
|
|
287
311
|
return;
|
|
288
312
|
}
|
|
289
313
|
if (command === "service") {
|
|
@@ -298,9 +322,12 @@ async function main() {
|
|
|
298
322
|
if (process.env.ZEYE_CAPTURE_CONTENT_CONSENT_ID &&
|
|
299
323
|
process.env.ZEYE_CAPTURE_TRANSCRIPT_CONSENT !== "yes")
|
|
300
324
|
throw new Error("Transcript service installation requires ZEYE_CAPTURE_TRANSCRIPT_CONSENT=yes");
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
325
|
+
const filesForHost = (0, service_1.hostServiceFiles)();
|
|
326
|
+
const prior = await (0, service_1.readServiceEnrollment)(filesForHost.enrollmentPath).catch(() => undefined);
|
|
327
|
+
const binding = repositoryBinding(identity);
|
|
328
|
+
const repositories = (0, service_1.mergeRepositoryRegistry)(prior?.repositories, binding);
|
|
329
|
+
const files = await (0, service_1.installCaptureHostService)({
|
|
330
|
+
enrollment: { ...serviceEnrollment(intervalSeconds), repositories },
|
|
304
331
|
cliPath: (0, node_path_1.resolve)(process.argv[1]),
|
|
305
332
|
});
|
|
306
333
|
print({
|
|
@@ -308,12 +335,21 @@ async function main() {
|
|
|
308
335
|
service: files.serviceId,
|
|
309
336
|
kind: files.kind,
|
|
310
337
|
intervalSeconds,
|
|
338
|
+
registeredRepositories: repositories.length,
|
|
311
339
|
transcriptCapture: Boolean(process.env.ZEYE_CAPTURE_CONTENT_CONSENT_ID),
|
|
312
340
|
});
|
|
313
341
|
return;
|
|
314
342
|
}
|
|
315
343
|
if (action === "uninstall") {
|
|
316
|
-
const
|
|
344
|
+
const filesForHost = (0, service_1.hostServiceFiles)();
|
|
345
|
+
const prior = await (0, service_1.readServiceEnrollment)(filesForHost.enrollmentPath).catch(() => undefined);
|
|
346
|
+
const repositories = (prior?.repositories || []).filter(repository => repository.repositoryRoot !== identity.repositoryRoot);
|
|
347
|
+
if (prior && repositories.length) {
|
|
348
|
+
await (0, service_1.installCaptureHostService)({ enrollment: { ...prior, repositories }, cliPath: (0, node_path_1.resolve)(process.argv[1]) });
|
|
349
|
+
print({ unregistered: true, service: filesForHost.serviceId, registeredRepositories: repositories.length, credentialsRemoved: false });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const files = await (0, service_1.uninstallCaptureHostService)();
|
|
317
353
|
print({
|
|
318
354
|
uninstalled: true,
|
|
319
355
|
service: files.serviceId,
|
package/dist/src/fileOutbox.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { CaptureEvent, CaptureDeliveryContext, DeliveryRecord, DurableOutbo
|
|
|
2
2
|
export declare class FileOutbox implements DurableOutbox {
|
|
3
3
|
private readonly redactor;
|
|
4
4
|
private readonly statePath;
|
|
5
|
+
private readonly keyPath;
|
|
5
6
|
private writeQueue;
|
|
6
7
|
constructor(directory: string, redactor: RedactionBoundary);
|
|
7
8
|
enqueue(event: CaptureEvent, context?: CaptureDeliveryContext): Promise<EnqueueResult>;
|
|
@@ -15,6 +16,12 @@ export declare class FileOutbox implements DurableOutbox {
|
|
|
15
16
|
private update;
|
|
16
17
|
private readState;
|
|
17
18
|
private writeState;
|
|
19
|
+
private encryptState;
|
|
20
|
+
private decryptState;
|
|
21
|
+
private readEncryptionKey;
|
|
22
|
+
private encryptionBinding;
|
|
23
|
+
private encryptTranscript;
|
|
24
|
+
private decryptTranscript;
|
|
18
25
|
private sourceKey;
|
|
19
26
|
private checkpointKey;
|
|
20
27
|
private requireDelivery;
|
package/dist/src/fileOutbox.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FileOutbox = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
4
5
|
const promises_1 = require("node:fs/promises");
|
|
5
6
|
const node_path_1 = require("node:path");
|
|
6
7
|
const canonical_1 = require("./canonical");
|
|
@@ -9,14 +10,19 @@ const EMPTY_STATE = { version: 1, deliveries: {}, checkpoints: {} };
|
|
|
9
10
|
class FileOutbox {
|
|
10
11
|
redactor;
|
|
11
12
|
statePath;
|
|
13
|
+
keyPath;
|
|
12
14
|
writeQueue = Promise.resolve();
|
|
13
15
|
constructor(directory, redactor) {
|
|
14
16
|
this.redactor = redactor;
|
|
15
17
|
this.statePath = (0, node_path_1.join)(directory, 'outbox-v1.json');
|
|
18
|
+
this.keyPath = (0, node_path_1.join)(directory, 'outbox-v1.key');
|
|
16
19
|
}
|
|
17
20
|
async enqueue(event, context) {
|
|
18
21
|
this.validateEvent(event);
|
|
19
22
|
const redacted = await this.redactor.redact(event);
|
|
23
|
+
if (redacted.transcript && !context?.contentConsentId?.trim()) {
|
|
24
|
+
throw new Error('A content consent ID is required before transcript content can enter the durable outbox');
|
|
25
|
+
}
|
|
20
26
|
// Parse and clone the context before it reaches durable storage. This keeps
|
|
21
27
|
// provider event IDs and the repository checkout facts in the same
|
|
22
28
|
// idempotency boundary without retaining a raw remote URL or local path.
|
|
@@ -24,7 +30,7 @@ class FileOutbox {
|
|
|
24
30
|
? zeye_shared_1.CaptureRepositoryReference.array().max(100).parse(context.repositories).map(repository => ({ ...repository }))
|
|
25
31
|
: undefined;
|
|
26
32
|
const sourceKey = this.sourceKey(event);
|
|
27
|
-
const
|
|
33
|
+
const hashInput = {
|
|
28
34
|
adapter: event.adapter,
|
|
29
35
|
sessionId: event.sessionId,
|
|
30
36
|
sourceEventId: event.sourceEventId,
|
|
@@ -33,12 +39,19 @@ class FileOutbox {
|
|
|
33
39
|
...(event.sourceGenerationId ? { sourceGenerationId: event.sourceGenerationId } : {}),
|
|
34
40
|
...(event.relationship ? { relationship: event.relationship } : {}),
|
|
35
41
|
...(event.relatedSourceSessionId ? { relatedSourceSessionId: event.relatedSourceSessionId } : {}),
|
|
42
|
+
...(redacted.transcript ? { contentConsentId: context.contentConsentId.trim() } : {}),
|
|
36
43
|
type: event.type,
|
|
37
44
|
occurredAt: event.occurredAt,
|
|
38
45
|
...(captureRepositories ? { captureRepositories } : {}),
|
|
39
46
|
payload: redacted.payload,
|
|
40
47
|
...(redacted.transcript ? { transcript: redacted.transcript } : {}),
|
|
41
|
-
}
|
|
48
|
+
};
|
|
49
|
+
// A plain digest of short transcript text can be guessed offline. Use the
|
|
50
|
+
// local outbox key as an HMAC key whenever content is present; metadata-only
|
|
51
|
+
// hashes retain their portable deterministic behavior.
|
|
52
|
+
const payloadHash = redacted.transcript
|
|
53
|
+
? (0, node_crypto_1.createHmac)('sha256', await this.readEncryptionKey(true)).update((0, canonical_1.canonicalJson)(hashInput), 'utf8').digest('hex')
|
|
54
|
+
: (0, canonical_1.sha256)(hashInput);
|
|
42
55
|
return this.update(async (state) => {
|
|
43
56
|
const existing = Object.values(state.deliveries).find(record => record.sourceKey === sourceKey || (event.deliveryId !== undefined && record.deliveryId === event.deliveryId));
|
|
44
57
|
if (existing) {
|
|
@@ -62,6 +75,7 @@ class FileOutbox {
|
|
|
62
75
|
...(event.sourceGenerationId ? { sourceGenerationId: event.sourceGenerationId } : {}),
|
|
63
76
|
relationship: event.relationship || 'ROOT',
|
|
64
77
|
...(event.relatedSourceSessionId ? { relatedSourceSessionId: event.relatedSourceSessionId } : {}),
|
|
78
|
+
...(redacted.transcript ? { contentConsentId: context.contentConsentId.trim() } : {}),
|
|
65
79
|
type: event.type,
|
|
66
80
|
sequence,
|
|
67
81
|
occurredAt: event.occurredAt,
|
|
@@ -170,9 +184,19 @@ class FileOutbox {
|
|
|
170
184
|
try {
|
|
171
185
|
const content = await (0, promises_1.readFile)(this.statePath, 'utf8');
|
|
172
186
|
const parsed = JSON.parse(content);
|
|
173
|
-
if (
|
|
187
|
+
if (!parsed.deliveries || !parsed.checkpoints)
|
|
174
188
|
throw new Error('Unsupported or corrupt outbox state');
|
|
175
|
-
|
|
189
|
+
if (parsed.version === 2)
|
|
190
|
+
return this.decryptState(parsed);
|
|
191
|
+
if (parsed.version !== 1)
|
|
192
|
+
throw new Error('Unsupported or corrupt outbox state');
|
|
193
|
+
// v1 queues may contain locally redacted but plaintext transcript text.
|
|
194
|
+
// Rewrite them before returning any records so a read-only status/sync
|
|
195
|
+
// invocation also completes the migration. A failed key write leaves the
|
|
196
|
+
// original queue untouched and fails closed.
|
|
197
|
+
const legacy = parsed;
|
|
198
|
+
await this.writeState(legacy);
|
|
199
|
+
return legacy;
|
|
176
200
|
}
|
|
177
201
|
catch (error) {
|
|
178
202
|
if (error.code === 'ENOENT')
|
|
@@ -182,9 +206,114 @@ class FileOutbox {
|
|
|
182
206
|
}
|
|
183
207
|
async writeState(state) {
|
|
184
208
|
await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.statePath), { recursive: true, mode: 0o700 });
|
|
209
|
+
await (0, promises_1.chmod)((0, node_path_1.dirname)(this.statePath), 0o700).catch(() => undefined);
|
|
185
210
|
const temporaryPath = `${this.statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
186
|
-
|
|
211
|
+
const diskState = await this.encryptState(state);
|
|
212
|
+
await (0, promises_1.writeFile)(temporaryPath, `${(0, canonical_1.canonicalJson)(diskState)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
187
213
|
await (0, promises_1.rename)(temporaryPath, this.statePath);
|
|
214
|
+
await (0, promises_1.chmod)(this.statePath, 0o600).catch(() => undefined);
|
|
215
|
+
}
|
|
216
|
+
async encryptState(state) {
|
|
217
|
+
const containsTranscript = Object.values(state.deliveries).some(record => Boolean(record.transcript));
|
|
218
|
+
const key = containsTranscript ? await this.readEncryptionKey(true) : undefined;
|
|
219
|
+
const deliveries = {};
|
|
220
|
+
for (const [deliveryId, record] of Object.entries(state.deliveries)) {
|
|
221
|
+
const { transcript, ...metadata } = record;
|
|
222
|
+
deliveries[deliveryId] = {
|
|
223
|
+
...metadata,
|
|
224
|
+
...(transcript && key ? { encryptedTranscript: this.encryptTranscript(transcript, record, key) } : {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { version: 2, deliveries, checkpoints: structuredClone(state.checkpoints) };
|
|
228
|
+
}
|
|
229
|
+
async decryptState(state) {
|
|
230
|
+
const containsTranscript = Object.values(state.deliveries).some(record => Boolean(record.encryptedTranscript));
|
|
231
|
+
const key = containsTranscript ? await this.readEncryptionKey(false) : undefined;
|
|
232
|
+
const deliveries = {};
|
|
233
|
+
for (const [deliveryId, record] of Object.entries(state.deliveries)) {
|
|
234
|
+
const { encryptedTranscript, ...metadata } = record;
|
|
235
|
+
deliveries[deliveryId] = {
|
|
236
|
+
...metadata,
|
|
237
|
+
...(encryptedTranscript && key
|
|
238
|
+
? { transcript: this.decryptTranscript(encryptedTranscript, metadata, key) }
|
|
239
|
+
: {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { version: 1, deliveries, checkpoints: structuredClone(state.checkpoints) };
|
|
243
|
+
}
|
|
244
|
+
async readEncryptionKey(create) {
|
|
245
|
+
try {
|
|
246
|
+
const key = await (0, promises_1.readFile)(this.keyPath);
|
|
247
|
+
if (key.length !== 32)
|
|
248
|
+
throw new Error('Capture outbox encryption key is invalid');
|
|
249
|
+
return key;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error.code !== 'ENOENT')
|
|
253
|
+
throw error;
|
|
254
|
+
if (!create)
|
|
255
|
+
throw new Error('Capture outbox encryption key is missing; encrypted records cannot be read');
|
|
256
|
+
}
|
|
257
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.keyPath), { recursive: true, mode: 0o700 });
|
|
258
|
+
await (0, promises_1.chmod)((0, node_path_1.dirname)(this.keyPath), 0o700).catch(() => undefined);
|
|
259
|
+
const key = (0, node_crypto_1.randomBytes)(32);
|
|
260
|
+
try {
|
|
261
|
+
const handle = await (0, promises_1.open)(this.keyPath, 'wx', 0o600);
|
|
262
|
+
try {
|
|
263
|
+
await handle.writeFile(key);
|
|
264
|
+
await handle.sync();
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await handle.close();
|
|
268
|
+
}
|
|
269
|
+
await (0, promises_1.chmod)(this.keyPath, 0o600).catch(() => undefined);
|
|
270
|
+
return key;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error.code !== 'EEXIST')
|
|
274
|
+
throw error;
|
|
275
|
+
const existing = await (0, promises_1.readFile)(this.keyPath);
|
|
276
|
+
if (existing.length !== 32)
|
|
277
|
+
throw new Error('Capture outbox encryption key is invalid');
|
|
278
|
+
return existing;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
encryptionBinding(record) {
|
|
282
|
+
return Buffer.from(`${record.deliveryId}\0${record.sourceKey}\0${record.payloadHash}`, 'utf8');
|
|
283
|
+
}
|
|
284
|
+
encryptTranscript(transcript, record, key) {
|
|
285
|
+
const iv = (0, node_crypto_1.randomBytes)(12);
|
|
286
|
+
const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', key, iv);
|
|
287
|
+
cipher.setAAD(this.encryptionBinding(record));
|
|
288
|
+
const ciphertext = Buffer.concat([cipher.update((0, canonical_1.canonicalJson)(transcript), 'utf8'), cipher.final()]);
|
|
289
|
+
return {
|
|
290
|
+
version: 1,
|
|
291
|
+
algorithm: 'A256GCM',
|
|
292
|
+
keyId: (0, node_crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 16),
|
|
293
|
+
iv: iv.toString('base64'),
|
|
294
|
+
authTag: cipher.getAuthTag().toString('base64'),
|
|
295
|
+
ciphertext: ciphertext.toString('base64'),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
decryptTranscript(encrypted, record, key) {
|
|
299
|
+
if (encrypted.version !== 1 || encrypted.algorithm !== 'A256GCM')
|
|
300
|
+
throw new Error('Unsupported capture outbox transcript encryption');
|
|
301
|
+
const keyId = (0, node_crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 16);
|
|
302
|
+
if (encrypted.keyId !== keyId)
|
|
303
|
+
throw new Error('Capture outbox encryption key does not match this queue');
|
|
304
|
+
try {
|
|
305
|
+
const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', key, Buffer.from(encrypted.iv, 'base64'));
|
|
306
|
+
decipher.setAAD(this.encryptionBinding(record));
|
|
307
|
+
decipher.setAuthTag(Buffer.from(encrypted.authTag, 'base64'));
|
|
308
|
+
const plaintext = Buffer.concat([
|
|
309
|
+
decipher.update(Buffer.from(encrypted.ciphertext, 'base64')),
|
|
310
|
+
decipher.final(),
|
|
311
|
+
]).toString('utf8');
|
|
312
|
+
return JSON.parse(plaintext);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw new Error('Capture outbox transcript authentication failed');
|
|
316
|
+
}
|
|
188
317
|
}
|
|
189
318
|
sourceKey(event) {
|
|
190
319
|
return `${event.adapter}:${event.sessionId}:${event.sourceGenerationId || event.sessionId}:${event.sourceEventId}`;
|
package/dist/src/localHost.d.ts
CHANGED
package/dist/src/localHost.js
CHANGED
|
@@ -17,6 +17,7 @@ exports.uninstallCodexSource = uninstallCodexSource;
|
|
|
17
17
|
exports.configuredSourcesForRepository = configuredSourcesForRepository;
|
|
18
18
|
const promises_1 = require("node:fs/promises");
|
|
19
19
|
const node_child_process_1 = require("node:child_process");
|
|
20
|
+
const node_crypto_1 = require("node:crypto");
|
|
20
21
|
const node_os_1 = require("node:os");
|
|
21
22
|
const node_path_1 = require("node:path");
|
|
22
23
|
const node_util_1 = require("node:util");
|
|
@@ -111,6 +112,9 @@ async function discoverRepositoryIdentity(repositoryPath, options = {}) {
|
|
|
111
112
|
branch: branch || undefined,
|
|
112
113
|
headCommitSha: headCommitSha && /^[a-f0-9]{7,64}$/i.test(headCommitSha) ? headCommitSha : undefined,
|
|
113
114
|
remoteUrlHash: remote ? (0, canonical_1.sha256)(remote) : undefined,
|
|
115
|
+
// Same one-way worktree identity used by the MCP bridge. The local path is
|
|
116
|
+
// never uploaded, but simultaneous worktrees remain independently scoped.
|
|
117
|
+
worktreePathHash: (0, node_crypto_1.createHash)('sha256').update(repositoryRoot, 'utf8').digest('hex'),
|
|
114
118
|
};
|
|
115
119
|
}
|
|
116
120
|
function captureRepositoryReference(identity, repositoryId) {
|
|
@@ -121,6 +125,7 @@ function captureRepositoryReference(identity, repositoryId) {
|
|
|
121
125
|
...(identity.providerRepositoryId ? { providerRepositoryId: identity.providerRepositoryId } : {}),
|
|
122
126
|
...(identity.fullName ? { canonicalName: identity.fullName } : {}),
|
|
123
127
|
...(identity.remoteUrlHash ? { remoteUrlHash: identity.remoteUrlHash } : {}),
|
|
128
|
+
...(identity.worktreePathHash ? { worktreePathHash: identity.worktreePathHash } : {}),
|
|
124
129
|
...(identity.branch ? { branch: identity.branch } : {}),
|
|
125
130
|
...(identity.headCommitSha ? { headCommitSha: identity.headCommitSha } : {}),
|
|
126
131
|
isPrimary: true,
|
package/dist/src/service.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LocalHostConfig } from './localHost';
|
|
1
2
|
export type ServiceEnrollment = {
|
|
2
3
|
endpoint: string;
|
|
3
4
|
token: string;
|
|
@@ -7,6 +8,11 @@ export type ServiceEnrollment = {
|
|
|
7
8
|
contentConsentId?: string;
|
|
8
9
|
transcriptConsent?: 'yes';
|
|
9
10
|
intervalSeconds: number;
|
|
11
|
+
repositories?: Array<{
|
|
12
|
+
repositoryRoot: string;
|
|
13
|
+
repositoryId?: string;
|
|
14
|
+
providerRepositoryId?: string;
|
|
15
|
+
}>;
|
|
10
16
|
};
|
|
11
17
|
export type ServiceFiles = {
|
|
12
18
|
serviceId: string;
|
|
@@ -14,7 +20,21 @@ export type ServiceFiles = {
|
|
|
14
20
|
definitionPath: string;
|
|
15
21
|
kind: 'launchd' | 'systemd' | 'windows-task';
|
|
16
22
|
};
|
|
17
|
-
export declare function
|
|
23
|
+
export declare function mergeRepositoryRegistry(existing: NonNullable<ServiceEnrollment["repositories"]> | undefined, repository: NonNullable<ServiceEnrollment['repositories']>[number]): {
|
|
24
|
+
repositoryRoot: string;
|
|
25
|
+
repositoryId?: string;
|
|
26
|
+
providerRepositoryId?: string;
|
|
27
|
+
}[];
|
|
28
|
+
/**
|
|
29
|
+
* Build the service's live repository set from both explicit Zeye bindings and
|
|
30
|
+
* capture adapters installed on this machine. The result stays local: callers
|
|
31
|
+
* use roots only to open repository-scoped outboxes, while the wire contract
|
|
32
|
+
* receives hashes/canonical repository facts from captureRepositoryReference.
|
|
33
|
+
* Explicit enrollment bindings win so internal/provider IDs are preserved.
|
|
34
|
+
*/
|
|
35
|
+
export declare function discoverServiceRepositories(enrolled: NonNullable<ServiceEnrollment["repositories"]> | undefined, hostConfig: LocalHostConfig, fallbackRepositoryRoot?: string): NonNullable<ServiceEnrollment['repositories']>;
|
|
36
|
+
export declare function serviceFiles(repositoryRoot: string, environment?: NodeJS.ProcessEnv, os?: NodeJS.Platform, machineHost?: boolean): ServiceFiles;
|
|
37
|
+
export declare function hostServiceFiles(environment?: NodeJS.ProcessEnv, os?: NodeJS.Platform): ServiceFiles;
|
|
18
38
|
export declare function serviceDefinition(files: ServiceFiles, repositoryRoot: string, nodePath: string, cliPath: string): string;
|
|
19
39
|
export declare function installCaptureService(input: {
|
|
20
40
|
repositoryRoot: string;
|
|
@@ -23,6 +43,13 @@ export declare function installCaptureService(input: {
|
|
|
23
43
|
nodePath?: string;
|
|
24
44
|
os?: NodeJS.Platform;
|
|
25
45
|
}): Promise<ServiceFiles>;
|
|
46
|
+
export declare function installCaptureHostService(input: {
|
|
47
|
+
enrollment: ServiceEnrollment;
|
|
48
|
+
cliPath: string;
|
|
49
|
+
nodePath?: string;
|
|
50
|
+
os?: NodeJS.Platform;
|
|
51
|
+
}): Promise<ServiceFiles>;
|
|
26
52
|
export declare function uninstallCaptureService(repositoryRoot: string, os?: NodeJS.Platform): Promise<ServiceFiles>;
|
|
53
|
+
export declare function uninstallCaptureHostService(os?: NodeJS.Platform): Promise<ServiceFiles>;
|
|
27
54
|
export declare function readServiceEnrollment(path: string): Promise<ServiceEnrollment>;
|
|
28
55
|
export declare function applyServiceEnrollment(enrollment: ServiceEnrollment): void;
|