@bli-cockpit/cli 0.1.0 → 0.1.1

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
@@ -2,25 +2,81 @@
2
2
 
3
3
  Public BLI Cockpit command-line interface for approved operators and interns.
4
4
 
5
- The CLI pairs a local laptop with the private Cockpit dashboard, records safe
6
- metadata-only work context, and uploads that metadata after dashboard-approved
5
+ The npm package is public; the Cockpit backend and admin tooling are not. The
6
+ CLI pairs a local laptop with the private Cockpit dashboard, records safe
7
+ work context, uploads consented raw Codex/diff evidence to private
8
+ durable private storage, and uploads metadata refs after dashboard-approved
7
9
  device pairing.
8
10
 
11
+ ## One-paste install
12
+
13
+ Run this from the repo where work will happen:
14
+
9
15
  ```bash
10
- npm exec --yes --package=@bli-cockpit/cli -- cockpit onboard \
11
- --dashboard-url <dashboard-url> \
12
- --email <approved-email> \
13
- --device-name "<device-name>" \
16
+ COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
17
+ npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit onboard \
18
+ --dashboard-url <DASHBOARD_URL> \
19
+ --email <APPROVED_EMAIL> \
20
+ --device-name "$COCKPIT_DEVICE_NAME" \
14
21
  --repo "$PWD"
15
22
  ```
16
23
 
24
+ What happens:
25
+
26
+ 1. npm downloads the public `@bli-cockpit/cli` package.
27
+ 2. `cockpit onboard` writes local user config.
28
+ 3. Cockpit prints a dashboard pairing URL and code.
29
+ 4. An Admin approves that exact code.
30
+ 5. The CLI starts general ambient capture, uploads private raw evidence objects
31
+ when present, then uploads one safe metadata/ref envelope.
32
+ 6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
33
+
34
+ `--device-name` is just a readable label in Cockpit. It can be
35
+ `"Savina MacBook"`, `"Box VM 42"`, or the auto-filled macOS computer name.
36
+
37
+ Before the command runs, an Admin must create or approve the email in the
38
+ private dashboard. Default launch path is email + temporary password, handed
39
+ over out of band. Magic link remains available as fallback.
40
+
17
41
  When ticket work starts later:
18
42
 
19
43
  ```bash
20
- npm exec --yes --package=@bli-cockpit/cli -- cockpit start \
44
+ npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit start \
21
45
  --ticket <ticket-id> \
22
46
  --repo "$PWD"
47
+
48
+ npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit sync \
49
+ --dashboard-url <DASHBOARD_URL> \
50
+ --repo "$PWD" \
51
+ --json
23
52
  ```
24
53
 
54
+ No ticket is required for setup, chatting, planning, or general ambient capture.
55
+ Only pass `--ticket` when the work really belongs to a visible ticket.
56
+
57
+ ## What gets saved
58
+
59
+ Local files:
60
+
61
+ - `~/.config/bli-cockpit/config.json`: dashboard URL and local install config.
62
+ - `~/.config/bli-cockpit/session.json`: normal paired device session.
63
+ - `~/.local/state/bli-cockpit/spool/`: safe retry records when upload fails.
64
+ - `.codex-autorunner/contextspace/active_context.md` in the work repo when a
65
+ work context is active.
66
+
67
+ Remote dashboard:
68
+
69
+ - approved user and device identity;
70
+ - repo, branch, optional ticket, source availability, risk flags, and upload
71
+ timestamps;
72
+ - raw evidence refs accepted by `/api/ambient/ingest`;
73
+ - durable private Storage objects accepted by
74
+ `/api/ambient/evidence/upload`.
75
+
76
+ Never provide Supabase service-role keys, raw DB URLs, root env files, cookies,
77
+ or deployment tokens to this CLI. The collector must never read env files.
78
+
79
+ ## Public package boundary
80
+
25
81
  This public package intentionally excludes Cockpit admin bootstrap commands,
26
82
  service-role credential handling, source maps, tests, and internal runbooks.
@@ -1,6 +1,7 @@
1
1
  import { collectCarState } from "./car-state.js";
2
2
  import { makeUnavailableScan, } from "./common.js";
3
3
  import { collectGitState } from "./git-state.js";
4
+ import { collectRawEvidencePack, } from "./raw-evidence.js";
4
5
  import { generateDumbRiskFlags } from "./risk-flags.js";
5
6
  import { resolveTicketBinding, } from "./ticket-binding.js";
6
7
  export async function runLocalSourceCollectors(options) {
@@ -14,8 +15,17 @@ export async function runLocalSourceCollectors(options) {
14
15
  };
15
16
  const git = await collectGitState(context);
16
17
  const car = await collectCarState(context);
18
+ const rawEvidence = options.rawEvidenceStateDir
19
+ ? await collectRawEvidencePack(context, {
20
+ stateDir: options.rawEvidenceStateDir,
21
+ repoRoot: options.repoRoot,
22
+ sessionsDir: options.rawEvidenceSessionsDir,
23
+ })
24
+ : {
25
+ scan: makeUnavailableScan(context, "codex_jsonl", "codex-jsonl", "raw_evidence_state_dir_not_configured"),
26
+ facts: null,
27
+ };
17
28
  const unavailableScans = [
18
- makeUnavailableScan(context, "codex_jsonl", "codex-jsonl", "codex_session_file_not_configured"),
19
29
  makeUnavailableScan(context, "codex_otel", "codex-otel", "codex_otel_export_not_configured"),
20
30
  makeUnavailableScan(context, "github_state", "github-state", "github_auth_not_configured"),
21
31
  makeUnavailableScan(context, "linear_state", "linear-state", "linear_auth_not_configured"),
@@ -25,6 +35,7 @@ export async function runLocalSourceCollectors(options) {
25
35
  const candidates = collectBindingCandidates([
26
36
  git.scan,
27
37
  car.scan,
38
+ rawEvidence.scan,
28
39
  ...unavailableScans,
29
40
  ]);
30
41
  const binding = resolveTicketBinding({
@@ -33,16 +44,20 @@ export async function runLocalSourceCollectors(options) {
33
44
  });
34
45
  const gitFacts = isGitStateFacts(git.facts) ? git.facts : null;
35
46
  const carFacts = isCarStateFacts(car.facts) ? car.facts : null;
47
+ const rawEvidenceFacts = isRawEvidenceFacts(rawEvidence.facts)
48
+ ? rawEvidence.facts
49
+ : null;
36
50
  const riskFlags = generateDumbRiskFlags({
37
51
  context,
38
52
  git: gitFacts,
39
53
  binding,
40
54
  });
41
55
  return {
42
- scans: [git.scan, car.scan, ...unavailableScans],
56
+ scans: [git.scan, car.scan, rawEvidence.scan, ...unavailableScans],
43
57
  facts: {
44
58
  git: gitFacts,
45
59
  car: carFacts,
60
+ raw_evidence: rawEvidenceFacts,
46
61
  },
47
62
  binding,
48
63
  risk_flags: riskFlags,
@@ -56,4 +71,7 @@ function isGitStateFacts(value) {
56
71
  }
57
72
  function isCarStateFacts(value) {
58
73
  return Boolean(value && typeof value === "object" && "present" in value);
74
+ }
75
+ function isRawEvidenceFacts(value) {
76
+ return Boolean(value && typeof value === "object" && "pack_id" in value);
59
77
  }
@@ -0,0 +1,395 @@
1
+ import { SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
+ import { execFile } from "node:child_process";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { makeSourceAdapterIdentity, } from "./common.js";
9
+ const execFileAsync = promisify(execFile);
10
+ const DEFAULT_SINCE_MINUTES = 24 * 60;
11
+ const DEFAULT_SESSION_LIMIT = 50;
12
+ const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
13
+ export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
14
+ export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
15
+ const SECRET_FILE_SEGMENT_PATTERN = /(^|[/\\])(?:\.env(?:\..*)?|.*(?:secret|credential|private[_-]?key|service[_-]?role).*)$/i;
16
+ export async function collectRawEvidencePack(context, options) {
17
+ const startedAt = context.now.toISOString();
18
+ const packId = rawEvidencePackId(context);
19
+ const evidenceDir = path.join(options.stateDir, "raw-evidence", packId);
20
+ const filesDir = path.join(evidenceDir, "files");
21
+ const entries = [];
22
+ const skipped = [];
23
+ try {
24
+ await ensurePrivateDir(evidenceDir);
25
+ await ensurePrivateDir(filesDir);
26
+ await collectCodexJsonlFiles({
27
+ context,
28
+ filesDir,
29
+ packId,
30
+ entries,
31
+ skipped,
32
+ sessionsDir: options.sessionsDir,
33
+ sinceMinutes: options.sinceMinutes ?? DEFAULT_SINCE_MINUTES,
34
+ limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
35
+ });
36
+ await collectGitDiffFiles({
37
+ context,
38
+ filesDir,
39
+ packId,
40
+ repoRoot: options.repoRoot,
41
+ entries,
42
+ skipped,
43
+ });
44
+ if (entries.length === 0) {
45
+ const facts = {
46
+ pack_id: packId,
47
+ manifest_path: path.join(evidenceDir, "manifest.json"),
48
+ evidence_dir: evidenceDir,
49
+ storage_bucket: RAW_EVIDENCE_BUCKET,
50
+ file_count: 0,
51
+ byte_size: 0,
52
+ skipped_count: skipped.length,
53
+ content_kinds: [],
54
+ pointers: [],
55
+ upload_files: [],
56
+ };
57
+ return {
58
+ facts,
59
+ scan: makeRawEvidenceScan({
60
+ context,
61
+ startedAt,
62
+ status: "partial",
63
+ facts,
64
+ }),
65
+ };
66
+ }
67
+ const manifestWithoutSelf = makeManifest({
68
+ context,
69
+ packId,
70
+ evidenceDir,
71
+ entries,
72
+ skipped,
73
+ });
74
+ const manifestPath = path.join(evidenceDir, "manifest.json");
75
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifestWithoutSelf, null, 2)}\n`, "utf8");
76
+ await fs.writeFile(manifestPath, manifestBytes, { mode: 0o600 });
77
+ await chmodPrivate(manifestPath, 0o600);
78
+ const manifestEntry = evidenceEntry({
79
+ kind: "manifest",
80
+ packId,
81
+ operatorId: context.operatorId,
82
+ workContextId: context.workContextId,
83
+ localPath: manifestPath,
84
+ relativePath: "manifest.json",
85
+ mediaType: "application/json",
86
+ redactedSummary: "Local raw evidence pack manifest.",
87
+ bytes: manifestBytes,
88
+ });
89
+ entries.push(manifestEntry);
90
+ const facts = {
91
+ pack_id: packId,
92
+ manifest_path: manifestPath,
93
+ evidence_dir: evidenceDir,
94
+ storage_bucket: RAW_EVIDENCE_BUCKET,
95
+ file_count: entries.length,
96
+ byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
97
+ skipped_count: skipped.length,
98
+ content_kinds: [...new Set(entries.map((entry) => entry.kind))],
99
+ pointers: entries.map(pointerFromEntry),
100
+ upload_files: entries.map((entry) => ({
101
+ pointer: pointerFromEntry(entry),
102
+ local_path: entry.local_path,
103
+ })),
104
+ };
105
+ return {
106
+ facts,
107
+ scan: makeRawEvidenceScan({
108
+ context,
109
+ startedAt,
110
+ status: entries.length > 1 ? "ok" : "partial",
111
+ facts,
112
+ }),
113
+ };
114
+ }
115
+ catch (error) {
116
+ const scan = SourceScanResultSchema.parse({
117
+ adapter: makeSourceAdapterIdentity("collector_runtime", "raw-evidence-pack"),
118
+ work_context_id: context.workContextId,
119
+ status: "failed",
120
+ started_at: startedAt,
121
+ finished_at: context.now.toISOString(),
122
+ diagnostic_labels: [
123
+ `raw_evidence_failed:${error instanceof Error ? error.message : String(error)}`,
124
+ ],
125
+ });
126
+ return { scan, facts: null };
127
+ }
128
+ }
129
+ function makeRawEvidenceScan(options) {
130
+ return SourceScanResultSchema.parse({
131
+ adapter: makeSourceAdapterIdentity("collector_runtime", "raw-evidence-pack"),
132
+ work_context_id: options.context.workContextId,
133
+ status: options.status,
134
+ started_at: options.startedAt,
135
+ finished_at: options.context.now.toISOString(),
136
+ events: [
137
+ {
138
+ source_event_id: `raw-evidence-pack:${options.facts.pack_id}`,
139
+ event_type: "raw_evidence_pack_written",
140
+ occurred_at: options.context.now.toISOString(),
141
+ redaction: {
142
+ privacy_classification: "remote_durable_raw_evidence",
143
+ redaction_status: "raw_remote_durable",
144
+ redacted_fields: ["local_path"],
145
+ raw_evidence_pointer_ids: options.facts.pointers.map((pointer) => pointer.raw_evidence_pointer_id),
146
+ redacted_summary: "Raw prompt/response/tool/session/diff evidence harvested to a private durable remote evidence pack.",
147
+ },
148
+ raw_evidence_pointers: options.facts.pointers,
149
+ },
150
+ ],
151
+ diagnostic_labels: [
152
+ `pack_id:${options.facts.pack_id}`,
153
+ `files:${options.facts.file_count}`,
154
+ `bytes:${options.facts.byte_size}`,
155
+ `skipped:${options.facts.skipped_count}`,
156
+ ...options.facts.content_kinds.map((kind) => `kind:${kind}`),
157
+ ],
158
+ });
159
+ }
160
+ async function collectCodexJsonlFiles(options) {
161
+ const sessionsDir = options.sessionsDir ?? path.join(os.homedir(), ".codex", "sessions");
162
+ const cutoffMs = options.context.now.getTime() - options.sinceMinutes * 60 * 1000;
163
+ const files = (await walkJsonlFiles(sessionsDir, cutoffMs)).slice(0, options.limit);
164
+ let index = 0;
165
+ for (const filePath of files) {
166
+ const fileName = path.basename(filePath);
167
+ if (isSecretLikePath(fileName)) {
168
+ options.skipped.push({
169
+ kind: "codex_jsonl",
170
+ label: fileName,
171
+ reason: "secret_like_file_name",
172
+ });
173
+ continue;
174
+ }
175
+ const raw = await fs.readFile(filePath);
176
+ if (containsSecretLikeContent(raw.toString("utf8"))) {
177
+ options.skipped.push({
178
+ kind: "codex_jsonl",
179
+ label: fileName,
180
+ reason: "secret_like_content_guard",
181
+ });
182
+ continue;
183
+ }
184
+ index += 1;
185
+ const relativePath = path.join("files", `${String(index).padStart(3, "0")}-${shortHash(filePath)}-${fileName}`);
186
+ const destination = path.join(options.filesDir, path.basename(relativePath));
187
+ await fs.writeFile(destination, raw, { mode: 0o600 });
188
+ await chmodPrivate(destination, 0o600);
189
+ options.entries.push(evidenceEntry({
190
+ kind: "codex_jsonl",
191
+ packId: options.packId,
192
+ operatorId: options.context.operatorId,
193
+ workContextId: options.context.workContextId,
194
+ localPath: destination,
195
+ relativePath,
196
+ mediaType: "application/jsonl",
197
+ redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
198
+ bytes: raw,
199
+ }));
200
+ }
201
+ }
202
+ async function collectGitDiffFiles(options) {
203
+ const diffTargets = [
204
+ { label: "unstaged", args: ["diff", "--no-ext-diff", "--"] },
205
+ { label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
206
+ ];
207
+ for (const target of diffTargets) {
208
+ const diff = await runGitDiff(target.args, options.repoRoot);
209
+ if (!diff.trim())
210
+ continue;
211
+ if (containsSecretLikeContent(diff)) {
212
+ options.skipped.push({
213
+ kind: "git_diff",
214
+ label: target.label,
215
+ reason: "secret_like_content_guard",
216
+ });
217
+ continue;
218
+ }
219
+ const raw = Buffer.from(diff.slice(0, MAX_GIT_DIFF_BYTES), "utf8");
220
+ const relativePath = path.join("files", `git-${target.label}.diff`);
221
+ const destination = path.join(options.filesDir, path.basename(relativePath));
222
+ await fs.writeFile(destination, raw, { mode: 0o600 });
223
+ await chmodPrivate(destination, 0o600);
224
+ options.entries.push(evidenceEntry({
225
+ kind: "git_diff",
226
+ packId: options.packId,
227
+ operatorId: options.context.operatorId,
228
+ workContextId: options.context.workContextId,
229
+ localPath: destination,
230
+ relativePath,
231
+ mediaType: "text/x-diff",
232
+ redactedSummary: `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
233
+ bytes: raw,
234
+ }));
235
+ }
236
+ }
237
+ async function runGitDiff(args, repoRoot) {
238
+ const pathspec = [
239
+ ".",
240
+ ":(exclude).env",
241
+ ":(exclude).env.*",
242
+ ":(exclude)**/.env",
243
+ ":(exclude)**/.env.*",
244
+ ":(exclude)**/*secret*",
245
+ ":(exclude)**/*credential*",
246
+ ":(exclude)**/*private-key*",
247
+ ":(exclude)**/*.pem",
248
+ ":(exclude)**/*.key",
249
+ ];
250
+ const { stdout } = await execFileAsync("git", [...args, ...pathspec], {
251
+ cwd: repoRoot,
252
+ timeout: 3_000,
253
+ maxBuffer: MAX_GIT_DIFF_BYTES + 1024,
254
+ });
255
+ return stdout;
256
+ }
257
+ async function walkJsonlFiles(dir, cutoffMs) {
258
+ const out = [];
259
+ const stack = [dir];
260
+ while (stack.length > 0) {
261
+ const current = stack.pop();
262
+ if (!current || isSecretLikePath(current))
263
+ continue;
264
+ let entries;
265
+ try {
266
+ entries = await fs.readdir(current, { withFileTypes: true });
267
+ }
268
+ catch {
269
+ continue;
270
+ }
271
+ for (const entry of entries) {
272
+ const full = path.join(current, entry.name);
273
+ if (isSecretLikePath(full))
274
+ continue;
275
+ if (entry.isDirectory()) {
276
+ stack.push(full);
277
+ continue;
278
+ }
279
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
280
+ continue;
281
+ const stat = await fs.stat(full);
282
+ if (stat.mtimeMs >= cutoffMs)
283
+ out.push({ file: full, mtimeMs: stat.mtimeMs });
284
+ }
285
+ }
286
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
287
+ return out.map((entry) => entry.file);
288
+ }
289
+ function makeManifest(options) {
290
+ return {
291
+ schema: "bli.local_raw_evidence_pack.v1",
292
+ pack_id: options.packId,
293
+ created_at: options.context.now.toISOString(),
294
+ work_context_id: options.context.workContextId,
295
+ session_id: options.context.sessionId,
296
+ operator_id: options.context.operatorId,
297
+ repo_basename: path.basename(options.context.repoRoot),
298
+ branch: options.context.branch,
299
+ storage_bucket: RAW_EVIDENCE_BUCKET,
300
+ raw_policy: {
301
+ raw_prompts: "preserved_private_durable_remote",
302
+ raw_responses: "preserved_private_durable_remote",
303
+ transcripts: "preserved_private_durable_remote",
304
+ tool_payloads: "preserved_private_durable_remote",
305
+ git_diffs: "preserved_private_durable_remote_env_secret_paths_excluded",
306
+ env_files: "never_read",
307
+ stdout: "manifest_only_no_raw_content",
308
+ },
309
+ files: options.entries.map(redactManifestEntry),
310
+ skipped: options.skipped,
311
+ };
312
+ }
313
+ function evidenceEntry(options) {
314
+ const digest = sha256(options.bytes);
315
+ return {
316
+ kind: options.kind,
317
+ local_path: options.localPath,
318
+ relative_path: options.relativePath,
319
+ object_key: remoteObjectKey({
320
+ operatorId: options.operatorId,
321
+ workContextId: options.workContextId,
322
+ packId: options.packId,
323
+ relativePath: options.relativePath,
324
+ }),
325
+ content_hash_sha256: digest,
326
+ byte_size: options.bytes.byteLength,
327
+ media_type: options.mediaType,
328
+ redacted_summary: options.redactedSummary,
329
+ };
330
+ }
331
+ function redactManifestEntry(entry) {
332
+ return {
333
+ kind: entry.kind,
334
+ relative_path: entry.relative_path,
335
+ object_key: entry.object_key,
336
+ content_hash_sha256: entry.content_hash_sha256,
337
+ byte_size: entry.byte_size,
338
+ media_type: entry.media_type,
339
+ redacted_summary: entry.redacted_summary,
340
+ };
341
+ }
342
+ function pointerFromEntry(entry) {
343
+ return {
344
+ raw_evidence_pointer_id: entry.object_key,
345
+ privacy_classification: "remote_durable_raw_evidence",
346
+ retention_policy: {
347
+ mode: RAW_EVIDENCE_RETENTION_MODE,
348
+ privacy_classification: "remote_durable_raw_evidence",
349
+ },
350
+ storage_scope: "remote_object",
351
+ storage_bucket: RAW_EVIDENCE_BUCKET,
352
+ object_key: entry.object_key,
353
+ content_hash_sha256: entry.content_hash_sha256,
354
+ byte_size: entry.byte_size,
355
+ media_type: entry.media_type,
356
+ redacted_summary: entry.redacted_summary,
357
+ };
358
+ }
359
+ function remoteObjectKey(options) {
360
+ return posixPath([
361
+ options.operatorId,
362
+ options.workContextId,
363
+ options.packId,
364
+ options.relativePath,
365
+ ]);
366
+ }
367
+ function posixPath(parts) {
368
+ return parts.join("/").replace(/\\/g, "/").replace(/\/+/g, "/");
369
+ }
370
+ function rawEvidencePackId(context) {
371
+ const material = [
372
+ context.workContextId,
373
+ context.sessionId,
374
+ context.now.toISOString(),
375
+ ].join(":");
376
+ return `${context.workContextId}-${shortHash(material)}`;
377
+ }
378
+ function shortHash(value) {
379
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
380
+ }
381
+ function sha256(value) {
382
+ return crypto.createHash("sha256").update(value).digest("hex");
383
+ }
384
+ async function ensurePrivateDir(dir) {
385
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
386
+ await chmodPrivate(dir, 0o700);
387
+ }
388
+ async function chmodPrivate(target, mode) {
389
+ if (process.platform === "win32")
390
+ return;
391
+ await fs.chmod(target, mode).catch(() => undefined);
392
+ }
393
+ function isSecretLikePath(value) {
394
+ return SECRET_FILE_SEGMENT_PATTERN.test(value);
395
+ }
@@ -408,6 +408,7 @@ async function runOnboard(command, io) {
408
408
  writeLine(io.stdout, `Facts: ${sync.event_count}`);
409
409
  writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
410
410
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
411
+ writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
411
412
  writeLine(io.stdout, "5/5 Status ready.");
412
413
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
413
414
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
@@ -566,6 +567,7 @@ async function runSync(command, io) {
566
567
  writeLine(io.stdout, `Context: ${result.work_context_id}`);
567
568
  writeLine(io.stdout, `Facts: ${result.event_count}`);
568
569
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
570
+ writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
569
571
  return 0;
570
572
  }
571
573
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
@@ -21,6 +21,10 @@ export async function installLocalCollector(options = {}) {
21
21
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
22
22
  const defaultRepoPaths = new Set(existingConfig?.default_repo_paths ?? []);
23
23
  defaultRepoPaths.add(repoRoot);
24
+ const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
25
+ existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
26
+ ? "remote_durable_opt_in"
27
+ : (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
24
28
  const config = LocalCollectorConfigSchema.parse({
25
29
  schema_version: "telemetry-core.v1",
26
30
  dashboard_url: options.dashboardUrl ?? existingConfig?.dashboard_url ?? DEFAULT_DASHBOARD_URL,
@@ -33,7 +37,7 @@ export async function installLocalCollector(options = {}) {
33
37
  claimed_owner_email: existingConfig?.claimed_owner_email,
34
38
  operator_id: existingConfig?.operator_id,
35
39
  default_repo_paths: [...defaultRepoPaths],
36
- raw_evidence_upload: existingConfig?.raw_evidence_upload ?? "disabled",
40
+ raw_evidence_upload: rawEvidenceUpload,
37
41
  session_file_path: paths.session_file,
38
42
  state_dir_path: paths.state_dir,
39
43
  });
@@ -120,6 +120,7 @@ function parseUploadSpoolEntry(value) {
120
120
  event_count: optionalNumber(record["event_count"]),
121
121
  source_scan_count: optionalNumber(record["source_scan_count"]),
122
122
  risk_flag_count: optionalNumber(record["risk_flag_count"]),
123
+ raw_evidence_file_count: optionalNumber(record["raw_evidence_file_count"]),
123
124
  failure_reason: failureReason,
124
125
  retry_command: retryCommand ?? "cockpit sync",
125
126
  };
package/dist/upload.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
2
+ import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContext, } from "./local-state.js";
4
5
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
@@ -46,6 +47,8 @@ export async function buildLocalAmbientEnvelope(options = {}) {
46
47
  sessionId: session.session_id,
47
48
  workContextId: uploadContext.work_context_id,
48
49
  activeWorkContext: activeContext,
50
+ rawEvidenceStateDir: paths.state_dir,
51
+ rawEvidenceSessionsDir: path.join(paths.home_dir, ".codex", "sessions"),
49
52
  now,
50
53
  });
51
54
  const binding = sourceCollection.binding;
@@ -67,6 +70,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
67
70
  gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
68
71
  gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
69
72
  carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
73
+ rawEvidenceFacts: sourceCollection.facts.raw_evidence,
70
74
  riskFlags: safeRiskFlags,
71
75
  }),
72
76
  ];
@@ -89,6 +93,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
89
93
  source_scan_count: envelope.source_scan_results.length,
90
94
  risk_flag_count: safeRiskFlags.length,
91
95
  repo_label: repoLabel,
96
+ raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
92
97
  };
93
98
  }
94
99
  export async function syncLocalAmbientEnvelope(options = {}) {
@@ -111,7 +116,15 @@ export async function syncLocalAmbientEnvelope(options = {}) {
111
116
  if (!fetchImpl) {
112
117
  throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
113
118
  }
119
+ let uploadedRawEvidenceObjects = [];
114
120
  try {
121
+ uploadedRawEvidenceObjects = await uploadRawEvidenceFiles({
122
+ fetchImpl,
123
+ dashboardUrl: built.dashboard_url,
124
+ deviceToken: built.device_token,
125
+ envelope: built.envelope,
126
+ files: built.raw_evidence_upload_files,
127
+ });
115
128
  const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
116
129
  method: "POST",
117
130
  headers: {
@@ -133,11 +146,22 @@ export async function syncLocalAmbientEnvelope(options = {}) {
133
146
  event_count: built.event_count,
134
147
  source_scan_count: built.source_scan_count,
135
148
  risk_flag_count: built.risk_flag_count,
149
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
136
150
  http_status: response.status,
137
151
  };
138
152
  }
139
153
  catch (error) {
140
154
  const failureReason = error instanceof Error ? error.message : String(error);
155
+ const cleanupFailureReason = await cleanupUploadedRawEvidenceObjects({
156
+ fetchImpl,
157
+ dashboardUrl: built.dashboard_url,
158
+ deviceToken: built.device_token,
159
+ envelope: built.envelope,
160
+ uploadedObjects: uploadedRawEvidenceObjects,
161
+ });
162
+ const spooledFailureReason = cleanupFailureReason
163
+ ? `${failureReason}; raw evidence cleanup failed: ${cleanupFailureReason}`
164
+ : failureReason;
141
165
  const entry = await recordUploadFailure(paths, {
142
166
  last_attempt_at: attemptedAt,
143
167
  dashboard_url: built.dashboard_url,
@@ -148,7 +172,8 @@ export async function syncLocalAmbientEnvelope(options = {}) {
148
172
  event_count: built.event_count,
149
173
  source_scan_count: built.source_scan_count,
150
174
  risk_flag_count: built.risk_flag_count,
151
- failure_reason: failureReason,
175
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
176
+ failure_reason: spooledFailureReason,
152
177
  retry_command: "cockpit sync",
153
178
  });
154
179
  return {
@@ -159,7 +184,8 @@ export async function syncLocalAmbientEnvelope(options = {}) {
159
184
  event_count: built.event_count,
160
185
  source_scan_count: built.source_scan_count,
161
186
  risk_flag_count: built.risk_flag_count,
162
- failure_reason: failureReason,
187
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
188
+ failure_reason: spooledFailureReason,
163
189
  spool_entry_id: entry.spool_id,
164
190
  retry_command: entry.retry_command,
165
191
  };
@@ -184,15 +210,23 @@ function makeSourceScanCompletedEvent(options) {
184
210
  if (!options.context.provenance) {
185
211
  throw new Error("Upload work context is missing provenance.");
186
212
  }
213
+ const rawEvidencePointers = options.rawEvidenceFacts?.pointers ?? [];
214
+ const hasRawEvidence = rawEvidencePointers.length > 0;
187
215
  return TelemetryIngestEventDtoSchema.parse({
188
216
  event_id: `ambient-sync:${options.context.work_context_id}:${options.generatedAt}`,
189
217
  event_type: "source_scan_completed",
190
218
  occurred_at: options.generatedAt,
191
219
  provenance: options.context.provenance,
192
- privacy_classification: "metadata",
220
+ privacy_classification: hasRawEvidence
221
+ ? "remote_durable_raw_evidence"
222
+ : "metadata",
193
223
  redaction: {
194
- privacy_classification: "metadata",
195
- redaction_status: "metadata_only",
224
+ privacy_classification: hasRawEvidence
225
+ ? "remote_durable_raw_evidence"
226
+ : "metadata",
227
+ redaction_status: hasRawEvidence
228
+ ? "raw_remote_durable"
229
+ : "metadata_only",
196
230
  redacted_fields: [
197
231
  "prompt_body",
198
232
  "response_body",
@@ -201,10 +235,14 @@ function makeSourceScanCompletedEvent(options) {
201
235
  "git.changed_paths",
202
236
  "local_file_paths",
203
237
  ],
204
- raw_evidence_pointer_ids: [],
205
- redacted_summary: "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
238
+ raw_evidence_pointer_ids: rawEvidencePointers.map((pointer) => pointer.raw_evidence_pointer_id),
239
+ redacted_summary: hasRawEvidence
240
+ ? "Collector uploaded raw evidence objects separately and sent only references, hashes, source scan, binding, and risk summaries to ingest."
241
+ : "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
206
242
  },
207
- redacted_summary: "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
243
+ redacted_summary: hasRawEvidence
244
+ ? "Collector uploaded raw evidence objects separately, then sent evidence references and metadata summaries."
245
+ : "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
208
246
  metrics: {
209
247
  git_changed_file_count: options.gitChangedFileCount,
210
248
  git_added_lines: options.gitAddedLines,
@@ -212,6 +250,9 @@ function makeSourceScanCompletedEvent(options) {
212
250
  car_open_ticket_count: options.carOpenTicketCount,
213
251
  source_scan_count: options.scans.length,
214
252
  risk_flag_count: options.riskFlags.length,
253
+ raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
254
+ raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
255
+ raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
215
256
  },
216
257
  attributes: {
217
258
  repo_label: options.context.repo,
@@ -221,12 +262,87 @@ function makeSourceScanCompletedEvent(options) {
221
262
  ticket_id: options.binding.selected_ticket_id ?? "unbound",
222
263
  source_adapters: options.scans.map((scan) => scan.adapter.adapter_name),
223
264
  source_statuses: options.scans.map((scan) => `${scan.adapter.adapter_name}:${scan.status}`),
224
- redaction_mode: "metadata_only",
265
+ redaction_mode: hasRawEvidence
266
+ ? "remote_durable_raw_evidence"
267
+ : "metadata_only",
225
268
  raw_payload_included: false,
226
269
  },
227
270
  ticket_binding: options.ticketBinding ?? undefined,
228
271
  risk_flags: options.riskFlags,
229
- raw_evidence_pointers: [],
272
+ raw_evidence_pointers: rawEvidencePointers,
273
+ });
274
+ }
275
+ async function uploadRawEvidenceFiles(options) {
276
+ if (options.files.length === 0)
277
+ return [];
278
+ const provenance = options.envelope.work_context.provenance;
279
+ if (!provenance) {
280
+ throw new Error("Raw evidence upload requires collector provenance.");
281
+ }
282
+ const files = await Promise.all(options.files.map(async (file) => ({
283
+ pointer: file.pointer,
284
+ content_base64: (await fs.readFile(file.local_path)).toString("base64"),
285
+ })));
286
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/upload`, {
287
+ method: "POST",
288
+ headers: {
289
+ "Authorization": `Bearer ${options.deviceToken}`,
290
+ "Content-Type": "application/json",
291
+ },
292
+ body: JSON.stringify({
293
+ schema_version: "ambient-raw-evidence-upload.v1",
294
+ generated_at: options.envelope.generated_at,
295
+ provenance,
296
+ files,
297
+ }),
298
+ });
299
+ const responseBody = await readResponseJson(response);
300
+ if (!response.ok) {
301
+ throw new Error(responseErrorMessage(responseBody, `Raw evidence upload failed with HTTP ${response.status}`));
302
+ }
303
+ return readUploadedRawEvidenceObjects(responseBody);
304
+ }
305
+ async function cleanupUploadedRawEvidenceObjects(options) {
306
+ if (options.uploadedObjects.length === 0)
307
+ return null;
308
+ const provenance = options.envelope.work_context.provenance;
309
+ if (!provenance)
310
+ return "missing collector provenance";
311
+ try {
312
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/upload`, {
313
+ method: "DELETE",
314
+ headers: {
315
+ "Authorization": `Bearer ${options.deviceToken}`,
316
+ "Content-Type": "application/json",
317
+ },
318
+ body: JSON.stringify({
319
+ schema_version: "ambient-raw-evidence-cleanup.v1",
320
+ generated_at: options.envelope.generated_at,
321
+ provenance,
322
+ object_keys: options.uploadedObjects.map((object) => object.object_key),
323
+ }),
324
+ });
325
+ const responseBody = await readResponseJson(response);
326
+ if (!response.ok) {
327
+ return responseErrorMessage(responseBody, `Raw evidence cleanup failed with HTTP ${response.status}`);
328
+ }
329
+ return null;
330
+ }
331
+ catch (error) {
332
+ return error instanceof Error ? error.message : String(error);
333
+ }
334
+ }
335
+ function readUploadedRawEvidenceObjects(value) {
336
+ if (!value || typeof value !== "object")
337
+ return [];
338
+ const uploaded = value.uploaded;
339
+ if (!Array.isArray(uploaded))
340
+ return [];
341
+ return uploaded.flatMap((entry) => {
342
+ if (!entry || typeof entry !== "object")
343
+ return [];
344
+ const objectKey = entry.object_key;
345
+ return typeof objectKey === "string" && objectKey ? [{ object_key: objectKey }] : [];
230
346
  });
231
347
  }
232
348
  function makeCollectorProvenance(options) {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
7
- "cockpit": "dist/cli.js"
7
+ "cockpit": "./dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist/",
@@ -25,6 +25,6 @@
25
25
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
26
26
  },
27
27
  "dependencies": {
28
- "@bli-cockpit/telemetry-core": "0.1.0"
28
+ "@bli-cockpit/telemetry-core": "0.1.1"
29
29
  }
30
30
  }