@bli-cockpit/cli 0.1.0 → 0.1.2
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 +72 -7
- package/dist/adapters/local-sources.js +20 -2
- package/dist/adapters/raw-evidence.js +395 -0
- package/dist/commands/local.js +134 -11
- package/dist/local-state.js +6 -2
- package/dist/spool/local-spool.js +1 -0
- package/dist/upload.js +126 -10
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,25 +2,90 @@
|
|
|
2
2
|
|
|
3
3
|
Public BLI Cockpit command-line interface for approved operators and interns.
|
|
4
4
|
|
|
5
|
-
The
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
--
|
|
13
|
-
--
|
|
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. The signed-in intern approves that exact code, or an Admin approves it while
|
|
30
|
+
watching the same code.
|
|
31
|
+
5. The CLI starts general ambient capture, uploads private raw evidence objects
|
|
32
|
+
when present, then uploads one safe metadata/ref envelope.
|
|
33
|
+
6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
|
|
34
|
+
|
|
35
|
+
`--device-name` is just a readable label in Cockpit. It can be
|
|
36
|
+
`"Savina MacBook"`, `"Box VM 42"`, or the auto-filled macOS computer name.
|
|
37
|
+
On reused laptops or VMs, keep `--email <APPROVED_EMAIL>` in the command.
|
|
38
|
+
`cockpit onboard` skips pairing only when the existing valid session belongs to
|
|
39
|
+
that same email and dashboard URL; a different email or dashboard forces a new
|
|
40
|
+
approval.
|
|
41
|
+
|
|
42
|
+
Before the command runs, an Admin must create or approve the email in the
|
|
43
|
+
private dashboard. Default launch path is email + temporary password, handed
|
|
44
|
+
over out of band. Magic link remains available as fallback.
|
|
45
|
+
For temporary-password accounts, the intern signs in directly; there is no
|
|
46
|
+
separate invite acceptance step.
|
|
47
|
+
|
|
17
48
|
When ticket work starts later:
|
|
18
49
|
|
|
19
50
|
```bash
|
|
20
|
-
npm exec --yes --package=@bli-cockpit/cli -- cockpit start \
|
|
51
|
+
npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit start \
|
|
21
52
|
--ticket <ticket-id> \
|
|
22
53
|
--repo "$PWD"
|
|
54
|
+
|
|
55
|
+
npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit sync \
|
|
56
|
+
--dashboard-url <DASHBOARD_URL> \
|
|
57
|
+
--repo "$PWD" \
|
|
58
|
+
--json
|
|
23
59
|
```
|
|
24
60
|
|
|
61
|
+
No ticket is required for setup, chatting, planning, or general ambient capture.
|
|
62
|
+
Only pass `--ticket` when the work really belongs to a visible ticket.
|
|
63
|
+
|
|
64
|
+
## What gets saved
|
|
65
|
+
|
|
66
|
+
Local files:
|
|
67
|
+
|
|
68
|
+
- `~/.config/bli-cockpit/config.json`: dashboard URL and local install config.
|
|
69
|
+
- `~/.config/bli-cockpit/session.json`: normal paired device session.
|
|
70
|
+
- `~/.local/state/bli-cockpit/spool/`: safe retry records when upload fails.
|
|
71
|
+
- `.codex-autorunner/contextspace/active_context.md` in the work repo when a
|
|
72
|
+
work context is active.
|
|
73
|
+
|
|
74
|
+
Remote dashboard:
|
|
75
|
+
|
|
76
|
+
- approved user and device identity;
|
|
77
|
+
- repo, branch, optional ticket, source availability, risk flags, and upload
|
|
78
|
+
timestamps;
|
|
79
|
+
- raw evidence refs accepted by `/api/ambient/ingest`;
|
|
80
|
+
- durable private Storage objects accepted by
|
|
81
|
+
`/api/ambient/evidence/upload`.
|
|
82
|
+
|
|
83
|
+
Never provide Supabase service-role keys, raw DB URLs, root env files, cookies,
|
|
84
|
+
or deployment tokens to this CLI. The collector must never read env files.
|
|
85
|
+
|
|
86
|
+
## Public package boundary
|
|
87
|
+
|
|
25
88
|
This public package intentionally excludes Cockpit admin bootstrap commands,
|
|
26
89
|
service-role credential handling, source maps, tests, and internal runbooks.
|
|
90
|
+
Clean `npm pack` and `npm publish` run the public CLI build before packaging so
|
|
91
|
+
`dist/cli.js` is present in emergency releases.
|
|
@@ -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
|
+
}
|
package/dist/commands/local.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCollectorServer } from "../server.js";
|
|
2
|
-
import { DEFAULT_DASHBOARD_URL, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, startLocalWorkContext, } from "../local-state.js";
|
|
2
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
3
3
|
import { syncLocalAmbientEnvelope } from "../upload.js";
|
|
4
4
|
export const rootCommandNames = new Set([
|
|
5
5
|
"onboard",
|
|
@@ -13,6 +13,10 @@ export const rootCommandNames = new Set([
|
|
|
13
13
|
"serve",
|
|
14
14
|
]);
|
|
15
15
|
export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
16
|
+
if (isLocalHelpRequest(argv)) {
|
|
17
|
+
writeLine(io.stdout, localCommandHelp(argv[0]));
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
16
20
|
let command;
|
|
17
21
|
try {
|
|
18
22
|
command = parseLocalArgs(argv);
|
|
@@ -48,19 +52,98 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
48
52
|
return 1;
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
|
-
export function localCommandHelp() {
|
|
55
|
+
export function localCommandHelp(command) {
|
|
56
|
+
if (command)
|
|
57
|
+
return localSubcommandHelp(command);
|
|
52
58
|
return [
|
|
53
|
-
" cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>]",
|
|
54
|
-
" cockpit install [--dashboard-url <url>] [--repo <path>]",
|
|
55
|
-
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>]",
|
|
56
|
-
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>]",
|
|
59
|
+
" cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
60
|
+
" cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
61
|
+
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
62
|
+
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
57
63
|
" cockpit logout",
|
|
58
|
-
" cockpit start [--ticket <id>] [--repo <path>] [--branch <name>]",
|
|
59
|
-
" cockpit sync [--repo <path>] [--dashboard-url <url>]",
|
|
60
|
-
" cockpit status [--repo <path>]",
|
|
64
|
+
" cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
|
|
65
|
+
" cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
|
|
66
|
+
" cockpit status [--repo <path>] [--json]",
|
|
61
67
|
" cockpit serve [--port <port>] [--repo <path>]",
|
|
62
68
|
].join("\n");
|
|
63
69
|
}
|
|
70
|
+
function localSubcommandHelp(command) {
|
|
71
|
+
const helpByCommand = new Map([
|
|
72
|
+
[
|
|
73
|
+
"onboard",
|
|
74
|
+
[
|
|
75
|
+
"Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
76
|
+
"",
|
|
77
|
+
"Installs, pairs, starts a work context, syncs once, and prints readiness proof.",
|
|
78
|
+
"Use --email on shared or reused machines; mismatched existing sessions are re-paired.",
|
|
79
|
+
],
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
"install",
|
|
83
|
+
[
|
|
84
|
+
"Usage: cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
85
|
+
"",
|
|
86
|
+
"Writes local collector config. Pair with `cockpit login`, then run `cockpit start` when work begins.",
|
|
87
|
+
],
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
"login",
|
|
91
|
+
[
|
|
92
|
+
"Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
93
|
+
"",
|
|
94
|
+
"Starts dashboard device pairing and stores the approved local session.",
|
|
95
|
+
],
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
"pair",
|
|
99
|
+
[
|
|
100
|
+
"Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
101
|
+
"",
|
|
102
|
+
"Alias for `cockpit login`.",
|
|
103
|
+
],
|
|
104
|
+
],
|
|
105
|
+
["logout", ["Usage: cockpit logout [--json]", "", "Removes the local device session."]],
|
|
106
|
+
[
|
|
107
|
+
"start",
|
|
108
|
+
[
|
|
109
|
+
"Usage: cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
|
|
110
|
+
"",
|
|
111
|
+
"Starts local ambient capture. Add --ticket only when the work already has a visible ticket.",
|
|
112
|
+
],
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
"sync",
|
|
116
|
+
[
|
|
117
|
+
"Usage: cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
|
|
118
|
+
"",
|
|
119
|
+
"Uploads the latest local ambient envelope or spools a safe retry if blocked.",
|
|
120
|
+
],
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
"status",
|
|
124
|
+
[
|
|
125
|
+
"Usage: cockpit status [--repo <path>] [--json]",
|
|
126
|
+
"",
|
|
127
|
+
"Prints install, pairing, active work, upload, and retry state.",
|
|
128
|
+
],
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
"serve",
|
|
132
|
+
[
|
|
133
|
+
"Usage: cockpit serve [--port <port>] [--repo <path>]",
|
|
134
|
+
"",
|
|
135
|
+
"Starts the local collector HTTP status server.",
|
|
136
|
+
],
|
|
137
|
+
],
|
|
138
|
+
]);
|
|
139
|
+
return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
|
|
140
|
+
}
|
|
141
|
+
function isLocalHelpRequest(argv) {
|
|
142
|
+
const command = argv[0];
|
|
143
|
+
if (!command || !rootCommandNames.has(command))
|
|
144
|
+
return false;
|
|
145
|
+
return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
|
|
146
|
+
}
|
|
64
147
|
function parseLocalArgs(argv) {
|
|
65
148
|
const command = argv[0];
|
|
66
149
|
switch (command) {
|
|
@@ -311,7 +394,7 @@ async function runInstall(command, io) {
|
|
|
311
394
|
writeLine(io.stdout, `Config: ${result.paths.config_file}`);
|
|
312
395
|
writeLine(io.stdout, `Session: ${result.paths.session_file}`);
|
|
313
396
|
writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
|
|
314
|
-
writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start
|
|
397
|
+
writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
|
|
315
398
|
return 0;
|
|
316
399
|
}
|
|
317
400
|
async function runOnboard(command, io) {
|
|
@@ -340,12 +423,20 @@ async function runOnboard(command, io) {
|
|
|
340
423
|
repoRoot: command.repoRoot,
|
|
341
424
|
branch: command.branch,
|
|
342
425
|
});
|
|
343
|
-
|
|
426
|
+
const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
|
|
427
|
+
const canReuseInstalledSession = canReuseOnboardSession(installedSession, command.claimedOwnerEmail, command.dashboardUrl);
|
|
428
|
+
if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
|
|
344
429
|
if (!command.json) {
|
|
345
430
|
writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
|
|
346
431
|
}
|
|
347
432
|
}
|
|
348
433
|
else {
|
|
434
|
+
if (installedStatus.session_state === "valid" && !canReuseInstalledSession) {
|
|
435
|
+
await logoutLocalCollector({ homeDir: command.homeDir });
|
|
436
|
+
if (!command.json) {
|
|
437
|
+
writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
349
440
|
pair = await pairLocalCollector({
|
|
350
441
|
homeDir: command.homeDir,
|
|
351
442
|
dashboardUrl: command.dashboardUrl,
|
|
@@ -408,6 +499,7 @@ async function runOnboard(command, io) {
|
|
|
408
499
|
writeLine(io.stdout, `Facts: ${sync.event_count}`);
|
|
409
500
|
writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
|
|
410
501
|
writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
|
|
502
|
+
writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
|
|
411
503
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
412
504
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
413
505
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
@@ -438,6 +530,36 @@ async function runOnboard(command, io) {
|
|
|
438
530
|
return 1;
|
|
439
531
|
}
|
|
440
532
|
}
|
|
533
|
+
function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
|
|
534
|
+
if (session.session_state !== "valid")
|
|
535
|
+
return false;
|
|
536
|
+
const expectedEmail = normalizeEmailForComparison(claimedOwnerEmail);
|
|
537
|
+
if (expectedEmail && normalizeEmailForComparison(session.email) !== expectedEmail) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
const expectedDashboardUrl = normalizeUrlForComparison(dashboardUrl);
|
|
541
|
+
if (expectedDashboardUrl &&
|
|
542
|
+
normalizeUrlForComparison(session.dashboard_url) !== expectedDashboardUrl) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
function normalizeEmailForComparison(value) {
|
|
548
|
+
const normalized = value?.trim().toLowerCase();
|
|
549
|
+
return normalized ? normalized : null;
|
|
550
|
+
}
|
|
551
|
+
async function readOnboardSessionReuseCandidate(homeDir) {
|
|
552
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
553
|
+
try {
|
|
554
|
+
return await readLocalCollectorSessionFile(paths);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return readLocalSessionReference(paths);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function normalizeUrlForComparison(value) {
|
|
561
|
+
return value ? normalizeUrl(value) : null;
|
|
562
|
+
}
|
|
441
563
|
async function runLogin(command, io) {
|
|
442
564
|
const result = await pairLocalCollector({
|
|
443
565
|
homeDir: command.homeDir,
|
|
@@ -566,6 +688,7 @@ async function runSync(command, io) {
|
|
|
566
688
|
writeLine(io.stdout, `Context: ${result.work_context_id}`);
|
|
567
689
|
writeLine(io.stdout, `Facts: ${result.event_count}`);
|
|
568
690
|
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
691
|
+
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
569
692
|
return 0;
|
|
570
693
|
}
|
|
571
694
|
writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
|
package/dist/local-state.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
7
|
-
export const LOCAL_COLLECTOR_VERSION = "0.1.
|
|
7
|
+
export const LOCAL_COLLECTOR_VERSION = "0.1.2";
|
|
8
8
|
export const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:3100";
|
|
9
9
|
export function getCollectorRuntimePaths(homeDir = os.homedir()) {
|
|
10
10
|
const paths = getUserLocalCockpitPaths(homeDir);
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
220
|
+
privacy_classification: hasRawEvidence
|
|
221
|
+
? "remote_durable_raw_evidence"
|
|
222
|
+
: "metadata",
|
|
193
223
|
redaction: {
|
|
194
|
-
privacy_classification:
|
|
195
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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/",
|
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"prebuild": "npm run build --workspace=@bli-cockpit/local-collector",
|
|
21
21
|
"build": "node ../../scripts/build-public-cli.mjs",
|
|
22
|
+
"prepack": "npm run build",
|
|
22
23
|
"pretypecheck": "npm run build",
|
|
23
24
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
24
25
|
"pretest": "npm run build",
|
|
25
26
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.1"
|
|
29
30
|
}
|
|
30
31
|
}
|