@oxygen-agent/cli 1.1012.2 → 1.1013.15

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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/dist/customer-operation-commands.js +6 -2
  3. package/dist/diagnostic-upload-main.d.ts +1 -0
  4. package/dist/diagnostic-upload-main.js +2 -0
  5. package/dist/diagnostic-uploader.d.ts +3 -0
  6. package/dist/diagnostic-uploader.js +68 -0
  7. package/dist/diagnostics.d.ts +22 -0
  8. package/dist/diagnostics.js +219 -0
  9. package/dist/http-client.d.ts +7 -0
  10. package/dist/http-client.js +12 -2
  11. package/dist/index.js +15 -3
  12. package/node_modules/@oxygen/shared/dist/capability-discovery.js +4 -4
  13. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -1
  14. package/node_modules/@oxygen/shared/dist/contracts/geo.js +30 -8
  15. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +1 -0
  16. package/node_modules/@oxygen/shared/dist/diagnostic-context.d.ts +3 -0
  17. package/node_modules/@oxygen/shared/dist/diagnostic-context.js +17 -0
  18. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +4 -0
  19. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +12 -0
  20. package/node_modules/@oxygen/shared/dist/diagnostic-lock.d.ts +2 -0
  21. package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +60 -0
  22. package/node_modules/@oxygen/shared/dist/diagnostic-receipt.d.ts +6 -0
  23. package/node_modules/@oxygen/shared/dist/diagnostic-receipt.js +22 -0
  24. package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.d.ts +2 -0
  25. package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +39 -0
  26. package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +31 -0
  27. package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +233 -0
  28. package/node_modules/@oxygen/shared/dist/diagnostics.d.ts +49 -0
  29. package/node_modules/@oxygen/shared/dist/diagnostics.js +253 -0
  30. package/node_modules/@oxygen/shared/dist/geo.d.ts +17 -0
  31. package/node_modules/@oxygen/shared/dist/geo.js +31 -3
  32. package/node_modules/@oxygen/shared/dist/onboarding-first-leads.d.ts +19 -0
  33. package/node_modules/@oxygen/shared/dist/onboarding-first-leads.js +25 -0
  34. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +1 -1
  35. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +4 -0
  36. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +2 -0
  37. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +2 -0
  38. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -0
  39. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  40. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  41. package/node_modules/@oxygen/shared/dist/version.js +3 -0
  42. package/package.json +1 -1
@@ -0,0 +1,17 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { diagnosticId } from "./diagnostics.js";
3
+ const context = new AsyncLocalStorage();
4
+ export function enterDiagnosticInvocation(value) {
5
+ const invocationId = diagnosticId(value);
6
+ if (!invocationId)
7
+ return;
8
+ const current = context.getStore();
9
+ if (current)
10
+ current.invocationId = invocationId;
11
+ else
12
+ context.enterWith({ invocationId });
13
+ }
14
+ export function withDiagnosticInvocation(value, task) {
15
+ return context.run({ invocationId: value }, task);
16
+ }
17
+ export function activeDiagnosticInvocation() { return context.getStore()?.invocationId; }
@@ -0,0 +1,4 @@
1
+ /** Credential-bearing tool contracts already excluded by MCP analytics. */
2
+ export declare const MCP_ARGUMENT_DENYLIST_TOOLS: ReadonlySet<string>;
3
+ export declare const MCP_RESPONSE_DENYLIST_TOOLS: ReadonlySet<string>;
4
+ export declare function credentialBearingOperation(operation: string, direction: "input" | "output"): boolean;
@@ -0,0 +1,12 @@
1
+ /** Credential-bearing tool contracts already excluded by MCP analytics. */
2
+ export const MCP_ARGUMENT_DENYLIST_TOOLS = new Set([
3
+ "oxygen_integrations_connect", "oxygen_custom_integrations_apply", "oxygen_senders_checkpoint_solve",
4
+ ]);
5
+ export const MCP_RESPONSE_DENYLIST_TOOLS = new Set([
6
+ "oxygen_senders_connect", "oxygen_whatsapp_connect", "oxygen_mailboxes_connect_oauth", "oxygen_integrations_connect",
7
+ ]);
8
+ export function credentialBearingOperation(operation, direction) {
9
+ if (direction === "input")
10
+ return MCP_ARGUMENT_DENYLIST_TOOLS.has(operation) || /(?:senders|linkedin).*checkpoint.*solve|custom.integrations.apply/.test(operation);
11
+ return MCP_RESPONSE_DENYLIST_TOOLS.has(operation) || /^(?:senders|whatsapp|mailboxes|integrations) (?:connect|connect-oauth)$/.test(operation);
12
+ }
@@ -0,0 +1,2 @@
1
+ /** Private spool lock. A dead owner is recoverable; age never evicts a live writer. */
2
+ export declare function acquireDiagnosticLock(path: string): Promise<(() => Promise<void>) | null>;
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, open, readFile, unlink } from "node:fs/promises";
3
+ /** Private spool lock. A dead owner is recoverable; age never evicts a live writer. */
4
+ export async function acquireDiagnosticLock(path) {
5
+ for (let attempt = 0; attempt < 2; attempt++) {
6
+ // Publish only an initialized owner. A crash between exclusive creation and
7
+ // writing a PID must not leave an empty, permanently unknowable lock.
8
+ const temporary = path + `.${randomUUID()}.tmp`;
9
+ const contents = JSON.stringify({ pid: process.pid, nonce: randomUUID(), start: await processStart(process.pid) });
10
+ const handle = await open(temporary, "wx", 0o600).catch(() => null);
11
+ if (!handle)
12
+ return null;
13
+ let published = false;
14
+ try {
15
+ await handle.writeFile(contents);
16
+ await handle.sync();
17
+ published = await link(temporary, path).then(() => true, () => false);
18
+ }
19
+ finally {
20
+ await unlink(temporary).catch(() => undefined);
21
+ if (!published)
22
+ await handle.close();
23
+ }
24
+ if (published) {
25
+ return async () => {
26
+ await handle.close();
27
+ if (await readFile(path, "utf8").catch(() => null) === contents)
28
+ await unlink(path).catch(() => undefined);
29
+ };
30
+ }
31
+ const ownerContents = await readFile(path, "utf8").catch(() => null);
32
+ if (!ownerContents)
33
+ return null; // Unknown/unfinished owner stays protected.
34
+ try {
35
+ const owner = JSON.parse(ownerContents);
36
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0)
37
+ return null;
38
+ let alive = true;
39
+ try {
40
+ process.kill(owner.pid, 0);
41
+ }
42
+ catch (error) {
43
+ alive = error.code !== "ESRCH";
44
+ }
45
+ const start = alive ? await processStart(owner.pid) : null;
46
+ if (alive && (!owner.start || !start || owner.start === start))
47
+ return null;
48
+ if (await readFile(path, "utf8").catch(() => null) === ownerContents)
49
+ await unlink(path).catch(() => undefined);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ async function processStart(pid) {
58
+ const value = await readFile(`/proc/${pid}/stat`, "utf8").catch(() => null);
59
+ return value ? value.slice(value.lastIndexOf(")") + 2).split(" ")[19] ?? null : null;
60
+ }
@@ -0,0 +1,6 @@
1
+ import { type DiagnosticRecord } from "./diagnostics.js";
2
+ import type { DiagnosticStoredReceipt } from "./diagnostic-storage.js";
3
+ import type { ProductAnalyticsEventInput } from "./product-analytics-core.js";
4
+ export declare function diagnosticTraffic(record: DiagnosticRecord): "internal" | "customer" | "unknown";
5
+ /** Private contents stay in storage; stable receipt UUIDs deduplicate retries. */
6
+ export declare function diagnosticReceiptEvent(receipt: DiagnosticStoredReceipt): ProductAnalyticsEventInput;
@@ -0,0 +1,22 @@
1
+ import { diagnosticSummary } from "./diagnostics.js";
2
+ import { PRODUCT_EVENTS } from "./product-analytics-events.js";
3
+ export function diagnosticTraffic(record) {
4
+ const internal = new Set((process.env.OXYGEN_DIAGNOSTICS_INTERNAL_ORGANIZATIONS ?? "").split(",").map(value => value.trim()).filter(Boolean));
5
+ if (internal.has(record.organization_id ?? "") || internal.has(String(record.metadata.uploader_organization_id ?? "")))
6
+ return "internal";
7
+ return record.identity_verified ? "customer" : "unknown";
8
+ }
9
+ /** Private contents stay in storage; stable receipt UUIDs deduplicate retries. */
10
+ export function diagnosticReceiptEvent(receipt) {
11
+ const { record } = receipt;
12
+ return { event: PRODUCT_EVENTS.DIAGNOSTIC_CAPTURE, organizationId: record.organization_id,
13
+ userId: record.user_id, uuid: record.id, timestamp: new Date(record.ended_at ?? record.started_at),
14
+ properties: { ...diagnosticSummary(record), request_id: record.id, diagnostic_key: receipt.key, payload_bytes: receipt.bytes,
15
+ payload_sha256: receipt.sha256, diagnostic_url: `/api/cli/admin/diagnostics/${record.id}?organization=${record.organization_id ?? "unknown"}`,
16
+ traffic_kind: diagnosticTraffic(record),
17
+ ...(record.layer === "worker" ? {
18
+ execution_id: typeof record.metadata.execution_id === "string" ? record.metadata.execution_id : null,
19
+ execution_attempt: typeof record.metadata.attempt === "number" ? record.metadata.attempt : null,
20
+ } : {}),
21
+ } };
22
+ }
@@ -0,0 +1,2 @@
1
+ /** Server mutex for shared host volumes; kernel locks span container PID namespaces. */
2
+ export declare function acquireDiagnosticStorageLock(path: string, waitMs?: number): Promise<(() => Promise<void>) | null>;
@@ -0,0 +1,39 @@
1
+ import { chmod } from "node:fs/promises";
2
+ /** Server mutex for shared host volumes; kernel locks span container PID namespaces. */
3
+ export async function acquireDiagnosticStorageLock(path, waitMs = 0) {
4
+ // Ask the supported Node runtime directly: server bundlers rewrite even
5
+ // createRequire calls to an unsupported URL external for node:sqlite.
6
+ // Browser-side Server Action analysis never needs to resolve this built-in.
7
+ const { DatabaseSync } = process.getBuiltinModule("node:sqlite");
8
+ const databasePath = path + ".sqlite";
9
+ const deadline = performance.now() + waitMs;
10
+ let database;
11
+ while (true) {
12
+ try {
13
+ // SQLite owns every descriptor: closing a separate descriptor for this inode
14
+ // would release POSIX locks held by another connection in this process.
15
+ database = new DatabaseSync(databasePath, { timeout: 0 });
16
+ await chmod(databasePath, 0o600);
17
+ database.exec("BEGIN EXCLUSIVE");
18
+ break;
19
+ }
20
+ catch (error) {
21
+ database?.close();
22
+ database = undefined;
23
+ const busy = error && typeof error === "object" && "errcode" in error && (error.errcode === 5 || error.errcode === 6);
24
+ if (!busy || performance.now() >= deadline)
25
+ return null;
26
+ // A synchronous SQLite busy timeout would block the same-process owner
27
+ // from finishing its asynchronous fsync and releasing this lock.
28
+ await new Promise(resolve => setTimeout(resolve, Math.min(20, Math.max(1, deadline - performance.now()))));
29
+ }
30
+ }
31
+ const held = database;
32
+ let closed = false;
33
+ return async () => {
34
+ if (!closed) {
35
+ closed = true;
36
+ held.close();
37
+ }
38
+ };
39
+ }
@@ -0,0 +1,31 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ import { type DiagnosticRecord } from "./diagnostics.js";
3
+ export type DiagnosticStoredReceipt = {
4
+ key: string;
5
+ sha256: string;
6
+ bytes: number;
7
+ compressed_bytes: number;
8
+ record: DiagnosticRecord;
9
+ };
10
+ type Store = {
11
+ client: S3Client;
12
+ bucket: string;
13
+ };
14
+ export declare function observeDiagnosticStorage(observer: (receipt: DiagnosticStoredReceipt) => Promise<void>): void;
15
+ export declare function startDiagnosticRetry(): void;
16
+ export declare function diagnosticsConfigured(): boolean;
17
+ export declare function diagnosticStore(): Store;
18
+ export declare function diagnosticObjectKey(record: Pick<DiagnosticRecord, "id" | "organization_id">): string;
19
+ export declare function encodeDiagnostic(record: DiagnosticRecord): {
20
+ bytes: Buffer;
21
+ sha256: string;
22
+ compressed: Buffer;
23
+ };
24
+ export declare function decodeDiagnostic(compressed: Uint8Array, expectedHash?: string): DiagnosticRecord;
25
+ export declare function readDiagnostic(id: string, organizationId: string | null): Promise<DiagnosticStoredReceipt>;
26
+ /** Durable local write precedes upload. Retries replay telemetry bytes only. */
27
+ export declare function storeDiagnostic(record: DiagnosticRecord): Promise<DiagnosticStoredReceipt | null>;
28
+ export declare function flushDiagnosticSpool(): Promise<void>;
29
+ /** A metadata-only fallback for an unsupported value never claims full capture. */
30
+ export declare function partialDiagnostic(record: DiagnosticRecord, reason: string): DiagnosticRecord;
31
+ export {};
@@ -0,0 +1,233 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { gzipSync, gunzipSync } from "node:zlib";
5
+ import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
6
+ import { createDiagnosticRecord, credentialSafeDiagnostic, DIAGNOSTIC_MAX_BYTES, DIAGNOSTIC_RECORD_MAX_BYTES, DIAGNOSTIC_RETENTION_MS, diagnosticId, diagnosticSummary, parseDiagnosticRecord, sanitizeDiagnosticContent } from "./diagnostics.js";
7
+ import { acquireDiagnosticStorageLock } from "./diagnostic-storage-lock.js";
8
+ import { log } from "./log.js";
9
+ import { recordTelemetryCounter } from "./telemetry.js";
10
+ let cached;
11
+ let warned = false;
12
+ let flushing = null;
13
+ let timer;
14
+ let onStored;
15
+ export function observeDiagnosticStorage(observer) {
16
+ onStored = observer;
17
+ startDiagnosticRetry();
18
+ void flushDiagnosticSpool();
19
+ }
20
+ export function startDiagnosticRetry() {
21
+ if (!timer && diagnosticsConfigured()) {
22
+ timer = setInterval(() => { void flushDiagnosticSpool(); }, 30_000);
23
+ timer.unref();
24
+ }
25
+ }
26
+ export function diagnosticsConfigured() {
27
+ return process.env.OXYGEN_DIAGNOSTICS_ENABLED !== "0" && Boolean(process.env.OXYGEN_DIAGNOSTICS_S3_ENDPOINT
28
+ && process.env.OXYGEN_DIAGNOSTICS_S3_BUCKET && process.env.OXYGEN_DIAGNOSTICS_S3_ACCESS_KEY_ID
29
+ && process.env.OXYGEN_DIAGNOSTICS_S3_SECRET_ACCESS_KEY && process.env.OXYGEN_DIAGNOSTICS_SPOOL_DIR);
30
+ }
31
+ export function diagnosticStore() {
32
+ if (!diagnosticsConfigured())
33
+ throw new Error("Diagnostics storage is not configured");
34
+ const endpoint = process.env.OXYGEN_DIAGNOSTICS_S3_ENDPOINT;
35
+ const bucket = process.env.OXYGEN_DIAGNOSTICS_S3_BUCKET;
36
+ const accessKeyId = process.env.OXYGEN_DIAGNOSTICS_S3_ACCESS_KEY_ID;
37
+ const secretAccessKey = process.env.OXYGEN_DIAGNOSTICS_S3_SECRET_ACCESS_KEY;
38
+ const key = createHash("sha256").update([endpoint, bucket, accessKeyId, secretAccessKey].join("\0")).digest("hex");
39
+ if (cached?.key !== key)
40
+ cached = { key, store: { bucket, client: new S3Client({
41
+ endpoint, region: "us-east-1", forcePathStyle: true, maxAttempts: 2,
42
+ credentials: { accessKeyId, secretAccessKey },
43
+ }) } };
44
+ return cached.store;
45
+ }
46
+ export function diagnosticObjectKey(record) {
47
+ if (!diagnosticId(record.id) || (record.organization_id !== null && !diagnosticId(record.organization_id)))
48
+ throw new Error("Invalid diagnostic scope");
49
+ return `records/${record.organization_id ?? "unknown"}/${record.id}.json.gz`;
50
+ }
51
+ export function encodeDiagnostic(record) {
52
+ const bytes = Buffer.from(JSON.stringify(sanitizeDiagnosticContent(credentialSafeDiagnostic(parseDiagnosticRecord(record)))));
53
+ if (bytes.length > DIAGNOSTIC_RECORD_MAX_BYTES)
54
+ throw new Error("Diagnostic record capacity exceeded");
55
+ return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), compressed: gzipSync(bytes) };
56
+ }
57
+ export function decodeDiagnostic(compressed, expectedHash) {
58
+ const bytes = gunzipSync(compressed, { maxOutputLength: DIAGNOSTIC_RECORD_MAX_BYTES });
59
+ if (expectedHash && createHash("sha256").update(bytes).digest("hex") !== expectedHash)
60
+ throw new Error("Diagnostic integrity mismatch");
61
+ const record = parseDiagnosticRecord(JSON.parse(bytes.toString("utf8")));
62
+ if (Date.now() - Date.parse(record.started_at) >= DIAGNOSTIC_RETENTION_MS)
63
+ throw Object.assign(new Error("Diagnostic content has expired"), { code: "diagnostic_content_expired" });
64
+ return record;
65
+ }
66
+ export async function readDiagnostic(id, organizationId) {
67
+ const { client, bucket } = diagnosticStore();
68
+ const key = diagnosticObjectKey({ id, organization_id: organizationId });
69
+ const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }), { abortSignal: AbortSignal.timeout(15_000) });
70
+ if (!result.Body)
71
+ throw new Error("Diagnostic body missing");
72
+ const compressed = await result.Body.transformToByteArray();
73
+ if (!result.Metadata?.sha256 || !/^[0-9a-f]{64}$/.test(result.Metadata.sha256))
74
+ throw new Error("Diagnostic integrity manifest missing");
75
+ const record = decodeDiagnostic(compressed, result.Metadata.sha256);
76
+ const bytes = gunzipSync(compressed, { maxOutputLength: DIAGNOSTIC_RECORD_MAX_BYTES }).length;
77
+ if (String(bytes) !== result.Metadata.bytes)
78
+ throw new Error("Diagnostic byte manifest mismatch");
79
+ if (record.id !== id || record.organization_id !== organizationId)
80
+ throw new Error("Diagnostic scope mismatch");
81
+ return { key, sha256: result.Metadata.sha256, bytes, compressed_bytes: compressed.length, record };
82
+ }
83
+ /** Durable local write precedes upload. Retries replay telemetry bytes only. */
84
+ export async function storeDiagnostic(record) {
85
+ recordTelemetryCounter("oxygen.diagnostics.attempted", 1, { source: record.surface, layer: record.layer });
86
+ if (!diagnosticsConfigured()) {
87
+ if (!warned && process.env.NODE_ENV !== "test") {
88
+ warned = true;
89
+ log("warn", "diagnostics.not_configured", { capture_state: "unavailable" });
90
+ }
91
+ return null;
92
+ }
93
+ const directory = process.env.OXYGEN_DIAGNOSTICS_SPOOL_DIR;
94
+ let path;
95
+ try {
96
+ let encoded;
97
+ try {
98
+ encoded = encodeDiagnostic(record);
99
+ }
100
+ catch {
101
+ record = partialDiagnostic(record, "unsupported_or_oversized_content");
102
+ encoded = encodeDiagnostic(record);
103
+ }
104
+ record = parseDiagnosticRecord(JSON.parse(encoded.bytes.toString("utf8")));
105
+ await mkdir(directory, { recursive: true, mode: 0o700 });
106
+ // Admission and expiry share one lock across web/worker processes on a host.
107
+ const lockPath = join(directory, ".admission.lock");
108
+ const release = await acquireDiagnosticStorageLock(lockPath, 5_000);
109
+ if (!release)
110
+ throw new Error("Diagnostic spool admission busy");
111
+ try {
112
+ const { used } = await inspectDiagnosticSpool(directory);
113
+ if (used + encoded.compressed.length > DIAGNOSTIC_MAX_BYTES)
114
+ throw new Error("Diagnostic spool is full");
115
+ path = join(directory, `${record.id}.json.gz`);
116
+ const temporary = join(directory, `${record.id}.${randomUUID()}.tmp`);
117
+ const handle = await open(temporary, "wx", 0o600);
118
+ try {
119
+ await handle.writeFile(encoded.compressed);
120
+ await handle.sync();
121
+ }
122
+ finally {
123
+ await handle.close();
124
+ }
125
+ await rename(temporary, path);
126
+ }
127
+ finally {
128
+ await release();
129
+ }
130
+ startDiagnosticRetry();
131
+ const receipt = await uploadEncodedDiagnostic(record, encoded);
132
+ await unlink(path).catch(() => undefined);
133
+ return receipt;
134
+ }
135
+ catch {
136
+ // Neither storage credentials nor upstream exception text belong in logs.
137
+ log("warn", path ? "diagnostics.capture_deferred" : "diagnostics.capture_lost", { diagnostic_id: record.id, reason: path ? "upload_failed" : "spool_admission_failed" });
138
+ recordTelemetryCounter(path ? "oxygen.diagnostics.deferred" : "oxygen.diagnostics.lost", 1, { source: record.surface });
139
+ return null;
140
+ }
141
+ }
142
+ async function uploadEncodedDiagnostic(record, encoded) {
143
+ const captured = Date.parse(record.started_at);
144
+ const now = Date.now();
145
+ if (now - captured >= 72 * 60 * 60 * 1000)
146
+ throw new Error("Diagnostic upload has expired");
147
+ // S3 day-based expiry rounds to the next UTC midnight. Tagged age bands
148
+ // leave that final day inside the 30-day deadline measured from capture.
149
+ const retentionDays = Math.min(29, 29 - (Math.floor(now / 86_400_000) - Math.floor(captured / 86_400_000)));
150
+ const { client, bucket } = diagnosticStore();
151
+ const key = diagnosticObjectKey(record);
152
+ await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: encoded.compressed,
153
+ ContentType: "application/gzip",
154
+ Tagging: `diagnostic_retention_days=${retentionDays}`,
155
+ Metadata: { sha256: encoded.sha256, bytes: String(encoded.bytes.length), expires_at: new Date(Date.parse(record.started_at) + DIAGNOSTIC_RETENTION_MS).toISOString() },
156
+ }), { abortSignal: AbortSignal.timeout(15_000) });
157
+ log("info", "diagnostics.capture_stored", { ...diagnosticSummary(record), org_id: record.organization_id, user_id: record.user_id,
158
+ diagnostic_key: key, bytes: encoded.bytes.length, compressed_bytes: encoded.compressed.length, sha256: encoded.sha256 });
159
+ recordTelemetryCounter("oxygen.diagnostics.stored", 1, { source: record.surface, layer: record.layer });
160
+ const receipt = { key, sha256: encoded.sha256, bytes: encoded.bytes.length, compressed_bytes: encoded.compressed.length, record };
161
+ if (onStored)
162
+ await onStored(receipt);
163
+ return receipt;
164
+ }
165
+ export function flushDiagnosticSpool() {
166
+ if (flushing)
167
+ return flushing;
168
+ flushing = (async () => {
169
+ if (!diagnosticsConfigured())
170
+ return;
171
+ const directory = process.env.OXYGEN_DIAGNOSTICS_SPOOL_DIR;
172
+ const release = await acquireDiagnosticStorageLock(join(directory, ".upload.lock"));
173
+ if (!release)
174
+ return;
175
+ try {
176
+ // Idle workers enforce the same expiry as new admissions. The admission
177
+ // lock protects cleanup against a writer replacing an old record path.
178
+ const admission = await acquireDiagnosticStorageLock(join(directory, ".admission.lock"));
179
+ if (!admission)
180
+ return;
181
+ let files;
182
+ try {
183
+ files = (await inspectDiagnosticSpool(directory)).files.slice(0, 20);
184
+ }
185
+ finally {
186
+ await admission();
187
+ }
188
+ for (const name of files) {
189
+ const path = join(directory, name);
190
+ try {
191
+ const compressed = await readFile(path);
192
+ const record = decodeDiagnostic(compressed);
193
+ await uploadEncodedDiagnostic(record, encodeDiagnostic(record));
194
+ await unlink(path).catch(() => undefined);
195
+ }
196
+ catch { /* retain failed uploads; admission expires them visibly */ }
197
+ }
198
+ }
199
+ finally {
200
+ await release();
201
+ }
202
+ })().catch(() => undefined).finally(() => { flushing = null; });
203
+ return flushing;
204
+ }
205
+ /** Caller holds admission lock; count retained bytes and expire actual filenames. */
206
+ async function inspectDiagnosticSpool(directory) {
207
+ let used = 0;
208
+ const files = [];
209
+ for (const name of await readdir(directory)) {
210
+ const record = /^[0-9a-f-]+\.json\.gz$/.test(name);
211
+ if (!record && !/^[0-9a-f-]+(?:\.[0-9a-f-]+)?\.tmp$/.test(name))
212
+ continue;
213
+ const path = join(directory, name);
214
+ const info = await stat(path).catch(() => null);
215
+ if (!info)
216
+ continue;
217
+ if (Date.now() - info.mtimeMs >= 72 * 60 * 60 * 1000) {
218
+ await unlink(path);
219
+ log("warn", "diagnostics.capture_lost", { reason: "spool_expired", bytes: info.size });
220
+ recordTelemetryCounter("oxygen.diagnostics.lost", 1, { reason: "spool_expired" });
221
+ }
222
+ else {
223
+ used += info.size;
224
+ if (record)
225
+ files.push(name);
226
+ }
227
+ }
228
+ return { used, files };
229
+ }
230
+ /** A metadata-only fallback for an unsupported value never claims full capture. */
231
+ export function partialDiagnostic(record, reason) {
232
+ return createDiagnosticRecord({ ...record, input: null, output: null, capture_state: "partial", metadata: { capture_reason: reason } });
233
+ }
@@ -0,0 +1,49 @@
1
+ /** Internal diagnostic evidence, distinct from customer run state and usage counts. */
2
+ export declare const DIAGNOSTIC_VERSION = 1;
3
+ export declare const DIAGNOSTIC_RETENTION_MS: number;
4
+ export declare const DIAGNOSTIC_INVOCATION_HEADER = "X-Oxygen-Invocation-Id";
5
+ export declare const DIAGNOSTIC_UPLOAD_CHUNK_BYTES: number;
6
+ export declare const DIAGNOSTIC_MAX_BYTES: number;
7
+ export declare const DIAGNOSTIC_RECORD_MAX_BYTES: number;
8
+ export declare const DIAGNOSTIC_UPLOAD_MAX_CHUNKS = 17;
9
+ export type DiagnosticOutcome = "ok" | "invalid_input" | "refused" | "platform_error" | "network_error" | "cancelled" | "pending";
10
+ export type DiagnosticRecord = {
11
+ version: 1;
12
+ id: string;
13
+ invocation_id: string;
14
+ parent_id: string | null;
15
+ layer: "invocation" | "api" | "worker" | "protocol";
16
+ surface: "cli" | "mcp" | "web" | "worker";
17
+ operation: string;
18
+ started_at: string;
19
+ ended_at: string | null;
20
+ outcome: DiagnosticOutcome;
21
+ error_code: string | null;
22
+ status: number | null;
23
+ organization_id: string | null;
24
+ user_id: string | null;
25
+ identity_verified: boolean;
26
+ client_version: string | null;
27
+ trace_id: string | null;
28
+ traceparent: string | null;
29
+ capture_state: "complete" | "partial";
30
+ input: unknown;
31
+ output: unknown;
32
+ metadata: Record<string, unknown>;
33
+ };
34
+ export declare function diagnosticId(value: unknown): string | null;
35
+ export declare function diagnosticOutcome(ok: boolean, status?: number | null, code?: string | null): DiagnosticOutcome;
36
+ export declare function createDiagnosticRecord(input: {
37
+ [K in keyof DiagnosticRecord]?: DiagnosticRecord[K] | undefined;
38
+ } & Pick<DiagnosticRecord, "surface" | "operation">): DiagnosticRecord;
39
+ export declare function sanitizeDiagnosticText(value: string, knownSecrets?: readonly string[]): string;
40
+ /** No business-content, array, string, field-count or depth truncation. */
41
+ export declare function sanitizeDiagnosticContent(value: unknown, knownSecrets?: readonly string[]): unknown;
42
+ export declare function sanitizeDiagnosticArgv(argv: string[]): string[];
43
+ export declare function parseDiagnosticRecord(value: unknown): DiagnosticRecord;
44
+ export declare function diagnosticSummary(record: DiagnosticRecord): Record<string, string | number | boolean | null>;
45
+ export declare function credentialSafeDiagnostic(record: DiagnosticRecord): DiagnosticRecord;
46
+ /** Bound the diagnostic branch of a response tee without delaying its consumer. */
47
+ export declare function readDiagnosticProtocolText(body: ReadableStream<Uint8Array> | null): Promise<string | null>;
48
+ /** Finite POST SSE is decoded before credential/binary filtering, like JSON. */
49
+ export declare function parseDiagnosticProtocolBody(text: string | null, contentType?: string | null): unknown;