@bli-cockpit/cli 0.1.9 → 0.1.12

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
@@ -4,8 +4,9 @@ Public BLI Cockpit command-line interface for approved operators and interns.
4
4
 
5
5
  The npm package is public; the Cockpit backend and admin tooling are not. The
6
6
  CLI pairs a local laptop with the private Cockpit dashboard, records safe
7
- work context, uploads consented raw Codex/diff evidence to durable private
8
- storage, and uploads metadata refs after dashboard-approved
7
+ work context, uploads consented raw Codex/Claude/diff evidence and explicit
8
+ images attached into agent sessions to durable private storage, and uploads
9
+ metadata refs after dashboard-approved
9
10
  device pairing.
10
11
 
11
12
  ## One-paste install
@@ -14,8 +15,8 @@ Run this from the repo where work will happen:
14
15
 
15
16
  ```bash
16
17
  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> \
18
+ npm install -g @bli-cockpit/cli@latest
19
+ cockpit onboard \
19
20
  --email <APPROVED_EMAIL> \
20
21
  --device-name "$COCKPIT_DEVICE_NAME" \
21
22
  --repo "$PWD"
@@ -26,13 +27,20 @@ If a work folder contains multiple repos, run the same command from the parent:
26
27
  ```bash
27
28
  cd ~/Downloads/bluepearl-workspace
28
29
  COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
29
- npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit onboard \
30
- --dashboard-url <DASHBOARD_URL> \
30
+ npm install -g @bli-cockpit/cli@latest
31
+ cockpit onboard \
31
32
  --email <APPROVED_EMAIL> \
32
33
  --device-name "$COCKPIT_DEVICE_NAME" \
33
34
  --repo "$PWD"
34
35
  ```
35
36
 
37
+ The CLI defaults to the production dashboard. Normal intern/operator setup,
38
+ updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
39
+ staging, a custom dashboard, or deliberately forcing a different dashboard
40
+ pairing. Already-onboarded users update with
41
+ `npm install -g @bli-cockpit/cli@latest`, then run
42
+ `cockpit sync --repo "$PWD" --json`.
43
+
36
44
  Parent mode scans child git repos/worktrees (3 folder levels deep, up to 50
37
45
  repos by default — tune with `--max-depth` / `--max-repos`; a warning prints
38
46
  if the repo cap truncates discovery), creates one stable work context per
@@ -47,7 +55,7 @@ being duplicated across repos or dropped.
47
55
 
48
56
  What happens:
49
57
 
50
- 1. npm downloads the public `@bli-cockpit/cli` package.
58
+ 1. npm installs or updates the public `@bli-cockpit/cli` package.
51
59
  2. `cockpit onboard` writes local user config.
52
60
  3. Cockpit prints a dashboard pairing URL and code.
53
61
  4. Admin approves the pending request from Ambient -> Collector approvals, or
@@ -62,8 +70,8 @@ What happens:
62
70
  `"Savina MacBook"`, `"Box VM 42"`, or the auto-filled macOS computer name.
63
71
  On reused laptops or VMs, keep `--email <APPROVED_EMAIL>` in the command.
64
72
  `cockpit onboard` skips pairing only when the existing valid session belongs to
65
- that same email and dashboard URL; a different email or dashboard forces a new
66
- approval.
73
+ that same email and dashboard URL (the production default unless overridden); a
74
+ different email or dashboard forces a new approval.
67
75
 
68
76
  Before the command runs, an Admin must create or approve the email in the
69
77
  private dashboard. Default launch path is email + temporary password, handed
@@ -74,12 +82,11 @@ separate invite acceptance step.
74
82
  When ticket work starts later:
75
83
 
76
84
  ```bash
77
- npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit start \
85
+ cockpit start \
78
86
  --ticket <ticket-id> \
79
87
  --repo "$PWD"
80
88
 
81
- npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit sync \
82
- --dashboard-url <DASHBOARD_URL> \
89
+ cockpit sync \
83
90
  --repo "$PWD" \
84
91
  --json
85
92
  ```
@@ -91,7 +98,8 @@ Only pass `--ticket` when the work really belongs to a visible ticket.
91
98
 
92
99
  Local files:
93
100
 
94
- - `~/.config/bli-cockpit/config.json`: dashboard URL and local install config.
101
+ - `~/.config/bli-cockpit/config.json`: dashboard URL (production default unless
102
+ overridden) and local install config.
95
103
  - `~/.config/bli-cockpit/session.json`: normal paired device session.
96
104
  - `~/.local/state/bli-cockpit/spool/`: safe retry records when upload fails.
97
105
  - `~/.local/state/bli-cockpit/cursors/raw-evidence.json`: hashes and labels of
@@ -109,11 +117,14 @@ Remote dashboard:
109
117
  - durable private Storage objects accepted by the chunked
110
118
  `/api/ambient/evidence/upload/begin|chunk|commit` endpoints, tracked in a
111
119
  durable per-object upload ledger;
120
+ - attached image artifact metadata accepted by `/api/ambient/agent-artifacts`
121
+ for screenshots/images explicitly attached into Codex or Claude sessions;
112
122
  - Codex session attribution records (session id, file hash, attribution state
113
123
  and reason labels, scores) accepted by `/api/ambient/codex-sessions`.
114
124
 
115
125
  Never provide Supabase service-role keys, raw DB URLs, root env files, cookies,
116
- or deployment tokens to this CLI. The collector must never read env files.
126
+ or deployment tokens to this CLI. The collector must never read env files and
127
+ does not collect random desktop screenshots or screen recordings.
117
128
 
118
129
  ## Public package boundary
119
130
 
@@ -0,0 +1,97 @@
1
+ import { SECRET_FILE_SEGMENT_PATTERN } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { AGENT_IMAGE_MAX_BYTES, validateAgentImageBytes, } from "./agent-image-validation.js";
6
+ import { imageCandidatesFromRecord, } from "./agent-image-records.js";
7
+ export async function collectAgentImageEvidenceFromJsonlFile(options) {
8
+ const stat = await fs.stat(options.filePath).catch(() => null);
9
+ if (!stat?.isFile())
10
+ return { images: [], skipped: [] };
11
+ if (stat.size > AGENT_IMAGE_MAX_BYTES) {
12
+ return {
13
+ images: [],
14
+ skipped: [{ label: "transcript", reason: "file_too_large" }],
15
+ };
16
+ }
17
+ const content = await fs.readFile(options.filePath, "utf8").catch(() => null);
18
+ if (content === null)
19
+ return { images: [], skipped: [] };
20
+ const images = [];
21
+ const skipped = [];
22
+ let lineIndex = 0;
23
+ for (const line of content.split("\n")) {
24
+ if (!line.trim())
25
+ continue;
26
+ lineIndex += 1;
27
+ let record;
28
+ try {
29
+ record = JSON.parse(line);
30
+ }
31
+ catch {
32
+ continue;
33
+ }
34
+ for (const candidate of imageCandidatesFromRecord(record, {
35
+ source: options.source,
36
+ sessionId: options.sessionId,
37
+ sidecarId: options.sidecarId,
38
+ fallbackOccurredAt: new Date(stat.mtimeMs).toISOString(),
39
+ lineIndex,
40
+ })) {
41
+ const result = await loadCandidate(candidate, path.dirname(options.filePath));
42
+ if ("reason" in result) {
43
+ skipped.push({ label: candidate.label, reason: result.reason });
44
+ }
45
+ else {
46
+ images.push(result);
47
+ }
48
+ }
49
+ }
50
+ return { images, skipped };
51
+ }
52
+ async function loadCandidate(candidate, transcriptDir) {
53
+ let bytes;
54
+ if (candidate.dataValue) {
55
+ bytes = Buffer.from(candidate.dataValue, "base64");
56
+ }
57
+ else if (candidate.pathValue) {
58
+ const imagePath = path.isAbsolute(candidate.pathValue)
59
+ ? candidate.pathValue
60
+ : path.resolve(transcriptDir, candidate.pathValue);
61
+ if (SECRET_FILE_SEGMENT_PATTERN.test(imagePath)) {
62
+ return { reason: "secret_like_file_name" };
63
+ }
64
+ bytes = await fs.readFile(imagePath).catch(() => Buffer.alloc(0));
65
+ if (bytes.byteLength === 0)
66
+ return { reason: "file_read_failed" };
67
+ }
68
+ else {
69
+ return { reason: "missing_image_bytes" };
70
+ }
71
+ const validation = validateAgentImageBytes(bytes, candidate.declaredMediaType);
72
+ if ("reason" in validation)
73
+ return validation;
74
+ return {
75
+ bytes,
76
+ extension: validation.extension,
77
+ label: candidate.label,
78
+ metadata: {
79
+ agent_source: candidate.source,
80
+ source_session_id: candidate.sessionId,
81
+ ...(candidate.sourceMessageId ? { source_message_id: candidate.sourceMessageId } : {}),
82
+ ...(candidate.sidecarId ? { source_sidecar_id: candidate.sidecarId } : {}),
83
+ ...(candidate.turnIndex !== undefined ? { turn_index: candidate.turnIndex } : {}),
84
+ occurred_at: candidate.occurredAt,
85
+ artifact_kind: candidate.artifactKind,
86
+ capture_origin: "agent_session_attachment",
87
+ width: validation.width,
88
+ height: validation.height,
89
+ media_type: validation.mediaType,
90
+ content_hash_sha256: sha256(bytes),
91
+ byte_size: bytes.byteLength,
92
+ },
93
+ };
94
+ }
95
+ function sha256(value) {
96
+ return crypto.createHash("sha256").update(value).digest("hex");
97
+ }
@@ -0,0 +1,147 @@
1
+ export function imageCandidatesFromRecord(record, context) {
2
+ if (!isRecord(record))
3
+ return [];
4
+ const payload = recordOf(record["payload"]);
5
+ const message = recordOf(record["message"]) ?? recordOf(payload?.["message"]);
6
+ const sourceMessageId = stringFrom(record, ["id", "message_id", "messageId", "uuid"]) ??
7
+ stringFrom(payload, ["id", "message_id", "messageId", "uuid"]);
8
+ const turnIndex = numberFrom(record, ["turn_index", "turnIndex"]) ??
9
+ numberFrom(payload, ["turn_index", "turnIndex"]);
10
+ const occurredAt = isoFrom(stringFrom(record, ["timestamp", "created_at", "createdAt", "time"]) ??
11
+ stringFrom(payload, ["timestamp", "created_at", "createdAt", "time"])) ?? context.fallbackOccurredAt;
12
+ const candidates = [];
13
+ const base = {
14
+ source: context.source,
15
+ sessionId: context.sessionId,
16
+ sidecarId: context.sidecarId,
17
+ sourceMessageId,
18
+ turnIndex,
19
+ occurredAt,
20
+ lineIndex: context.lineIndex,
21
+ };
22
+ for (const value of explicitAttachmentObjects(record, payload, message)) {
23
+ const candidate = candidateFromObject(value, base, true);
24
+ if (candidate)
25
+ candidates.push(candidate);
26
+ }
27
+ for (const value of explicitImageBlocks(record, payload, message)) {
28
+ const candidate = candidateFromObject(value, base, false);
29
+ if (candidate)
30
+ candidates.push(candidate);
31
+ }
32
+ return candidates;
33
+ }
34
+ function explicitAttachmentObjects(record, payload, message) {
35
+ const out = [];
36
+ if (isAttachmentRecord(record))
37
+ out.push(record);
38
+ if (payload && isAttachmentRecord(payload))
39
+ out.push(payload);
40
+ for (const holder of [record, payload, message]) {
41
+ if (!holder)
42
+ continue;
43
+ const single = holder["attachment"];
44
+ if (isRecord(single))
45
+ out.push(single);
46
+ const attachments = holder["attachments"];
47
+ if (Array.isArray(attachments))
48
+ out.push(...attachments);
49
+ }
50
+ return out;
51
+ }
52
+ function explicitImageBlocks(record, payload, message) {
53
+ const out = [];
54
+ for (const holder of [record, payload, message]) {
55
+ const content = holder?.["content"];
56
+ if (Array.isArray(content))
57
+ out.push(...content.filter((value) => isRecord(value)));
58
+ }
59
+ return out;
60
+ }
61
+ function candidateFromObject(value, base, attachmentEnvelope) {
62
+ if (!isRecord(value))
63
+ return null;
64
+ const nestedSource = recordOf(value["source"]);
65
+ const imageUrl = recordOf(value["image_url"]);
66
+ const typeValue = stringFrom(value, ["type", "kind", "artifact_kind"]);
67
+ const declaredMediaType = stringFrom(value, ["media_type", "mime_type", "mimeType"]) ??
68
+ stringFrom(nestedSource, ["media_type", "mime_type", "mimeType"]);
69
+ const parsedDataUrl = parseDataUrl(stringFrom(imageUrl, ["url"]));
70
+ const pathValue = stringFrom(value, ["path", "local_path", "file_path", "filePath"]) ??
71
+ stringFrom(nestedSource, ["path", "local_path", "file_path", "filePath"]);
72
+ const dataValue = parsedDataUrl?.data ??
73
+ stringFrom(value, ["data_base64", "base64", "content_base64", "data"]) ??
74
+ stringFrom(nestedSource, ["data_base64", "base64", "content_base64", "data"]);
75
+ const mediaType = parsedDataUrl?.mediaType ?? declaredMediaType;
76
+ if (!pathValue && !dataValue)
77
+ return null;
78
+ if (!attachmentEnvelope && !isImageBlockType(typeValue))
79
+ return null;
80
+ if (attachmentEnvelope && !isImageish(typeValue, mediaType))
81
+ return null;
82
+ const objectId = stringFrom(value, ["id", "attachment_id", "attachmentId", "uuid"]);
83
+ return {
84
+ source: base.source,
85
+ sessionId: base.sessionId,
86
+ sidecarId: base.sidecarId,
87
+ sourceMessageId: objectId ?? base.sourceMessageId,
88
+ turnIndex: base.turnIndex,
89
+ occurredAt: base.occurredAt,
90
+ artifactKind: isScreenshot(typeValue) ? "screenshot" : "image_attachment",
91
+ declaredMediaType: mediaType,
92
+ pathValue,
93
+ dataValue,
94
+ label: objectId ?? base.sourceMessageId ?? `line-${base.lineIndex}`,
95
+ };
96
+ }
97
+ function parseDataUrl(value) {
98
+ const match = value?.match(/^data:([^;,]+);base64,(.+)$/i);
99
+ return match ? { mediaType: match[1] ?? "", data: match[2] ?? "" } : null;
100
+ }
101
+ function isAttachmentRecord(value) {
102
+ return ["attachment", "image_attachment", "screenshot"].includes(String(value["type"] ?? "").toLowerCase());
103
+ }
104
+ function isImageBlockType(value) {
105
+ return ["image", "input_image", "image_attachment", "screenshot"].includes(value?.toLowerCase() ?? "");
106
+ }
107
+ function isImageish(typeValue, mediaType) {
108
+ return isImageBlockType(typeValue) || Boolean(normalizeMediaType(mediaType));
109
+ }
110
+ function isScreenshot(typeValue) {
111
+ return typeValue?.toLowerCase().includes("screenshot") ?? false;
112
+ }
113
+ function normalizeMediaType(value) {
114
+ return ["image/png", "image/jpeg", "image/jpg"].includes(value?.trim().toLowerCase() ?? "");
115
+ }
116
+ function isoFrom(value) {
117
+ if (!value)
118
+ return null;
119
+ const date = new Date(value);
120
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
121
+ }
122
+ function stringFrom(record, keys) {
123
+ if (!record)
124
+ return undefined;
125
+ for (const key of keys) {
126
+ const value = record[key];
127
+ if (typeof value === "string" && value.trim())
128
+ return value.trim();
129
+ }
130
+ return undefined;
131
+ }
132
+ function numberFrom(record, keys) {
133
+ if (!record)
134
+ return undefined;
135
+ for (const key of keys) {
136
+ const value = record[key];
137
+ if (Number.isInteger(value))
138
+ return value;
139
+ }
140
+ return undefined;
141
+ }
142
+ function recordOf(value) {
143
+ return isRecord(value) ? value : undefined;
144
+ }
145
+ function isRecord(value) {
146
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
147
+ }
@@ -0,0 +1,88 @@
1
+ export const AGENT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
2
+ export const AGENT_IMAGE_MAX_DIMENSION_PX = 16_384;
3
+ export const AGENT_IMAGE_MAX_PIXELS = 50_000_000;
4
+ export function validateAgentImageBytes(bytes, declaredMediaType) {
5
+ if (bytes.byteLength > AGENT_IMAGE_MAX_BYTES)
6
+ return { reason: "file_too_large" };
7
+ if (declaredMediaType && !normalizeMediaType(declaredMediaType)) {
8
+ return { reason: "unsupported_media_type" };
9
+ }
10
+ const detected = detectImage(bytes);
11
+ if (!detected)
12
+ return { reason: "invalid_image_magic" };
13
+ if (detected.width <= 0 ||
14
+ detected.height <= 0 ||
15
+ detected.width > AGENT_IMAGE_MAX_DIMENSION_PX ||
16
+ detected.height > AGENT_IMAGE_MAX_DIMENSION_PX ||
17
+ detected.width * detected.height > AGENT_IMAGE_MAX_PIXELS) {
18
+ return { reason: "image_pixel_limit_exceeded" };
19
+ }
20
+ return {
21
+ ...detected,
22
+ extension: detected.mediaType === "image/png" ? "png" : "jpg",
23
+ };
24
+ }
25
+ function detectImage(bytes) {
26
+ const png = parsePngDimensions(bytes);
27
+ if (png)
28
+ return { mediaType: "image/png", ...png };
29
+ const jpeg = parseJpegDimensions(bytes);
30
+ if (jpeg)
31
+ return { mediaType: "image/jpeg", ...jpeg };
32
+ return null;
33
+ }
34
+ function parsePngDimensions(bytes) {
35
+ const signature = "89504e470d0a1a0a";
36
+ if (bytes.byteLength < 24 || bytes.subarray(0, 8).toString("hex") !== signature) {
37
+ return null;
38
+ }
39
+ if (bytes.subarray(12, 16).toString("ascii") !== "IHDR")
40
+ return null;
41
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
42
+ }
43
+ function parseJpegDimensions(bytes) {
44
+ if (bytes.byteLength < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
45
+ return null;
46
+ let offset = 2;
47
+ while (offset + 3 < bytes.byteLength) {
48
+ while (offset < bytes.byteLength && bytes[offset] === 0xff)
49
+ offset += 1;
50
+ if (offset + 2 > bytes.byteLength)
51
+ return null;
52
+ const marker = bytes[offset];
53
+ if (marker === undefined)
54
+ return null;
55
+ offset += 1;
56
+ if (marker === 0xd9 || marker === 0xda)
57
+ break;
58
+ if (marker !== undefined && marker >= 0xd0 && marker <= 0xd7)
59
+ continue;
60
+ if (offset + 2 > bytes.byteLength)
61
+ return null;
62
+ const length = bytes.readUInt16BE(offset);
63
+ if (length < 2 || offset + length > bytes.byteLength)
64
+ return null;
65
+ if (isJpegSofMarker(marker) && offset + 7 <= bytes.byteLength) {
66
+ return {
67
+ height: bytes.readUInt16BE(offset + 3),
68
+ width: bytes.readUInt16BE(offset + 5),
69
+ };
70
+ }
71
+ offset += length;
72
+ }
73
+ return null;
74
+ }
75
+ function isJpegSofMarker(marker) {
76
+ return (marker !== undefined &&
77
+ marker >= 0xc0 &&
78
+ marker <= 0xcf &&
79
+ ![0xc4, 0xc8, 0xcc].includes(marker));
80
+ }
81
+ function normalizeMediaType(value) {
82
+ const normalized = value?.trim().toLowerCase();
83
+ if (normalized === "image/png")
84
+ return "image/png";
85
+ if (normalized === "image/jpeg" || normalized === "image/jpg")
86
+ return "image/jpeg";
87
+ return null;
88
+ }