@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 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`: a JSON-backed durable outbox with atomic state replacement.
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. The API envelope-encrypts each segment with a separate data
33
- key and keeps ciphertext apart from metadata.
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. The service survives logout/restart according to the host OS policy.
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
- # Stop continuous sync and remove both its definition and private enrollment.
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 is under `$XDG_STATE_HOME/zeye-capture/` (or
147
- `~/.local/state/...`) and is also owner-restricted. `ZEYE_CAPTURE_OUTBOX_DIR`,
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_... # runtime only; never written by CLI
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 context = captureContext(identity);
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
- "Upload is offline-only until all ZEYE_CAPTURE_* runtime variables are supplied. The token is never stored by this CLI.",
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
- await sync(repositoryRoot, false);
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(repoPath, configPath);
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 files = await (0, service_1.installCaptureService)({
302
- repositoryRoot: identity.repositoryRoot,
303
- enrollment: serviceEnrollment(intervalSeconds),
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 files = await (0, service_1.uninstallCaptureService)(identity.repositoryRoot);
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,
@@ -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;
@@ -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 payloadHash = (0, canonical_1.sha256)({
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 (parsed.version !== 1 || !parsed.deliveries || !parsed.checkpoints)
187
+ if (!parsed.deliveries || !parsed.checkpoints)
174
188
  throw new Error('Unsupported or corrupt outbox state');
175
- return parsed;
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
- await (0, promises_1.writeFile)(temporaryPath, `${(0, canonical_1.canonicalJson)(state)}\n`, { encoding: 'utf8', mode: 0o600 });
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}`;
@@ -8,6 +8,7 @@ export type RepositoryIdentity = {
8
8
  branch?: string;
9
9
  headCommitSha?: string;
10
10
  remoteUrlHash?: string;
11
+ worktreePathHash?: string;
11
12
  };
12
13
  export type LocalHostConfig = {
13
14
  version: 1;
@@ -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,
@@ -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 serviceFiles(repositoryRoot: string, environment?: NodeJS.ProcessEnv, os?: NodeJS.Platform): ServiceFiles;
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;