pi-harness-runtime 1.1.396 → 1.1.398
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/AGENTS.md +54 -7
- package/harness/notification-events.ts +6 -6
- package/index.ts +1 -1
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/README.md +41 -6
- package/packages/logging/dist/classification.d.ts +65 -0
- package/packages/logging/dist/classification.d.ts.map +1 -0
- package/packages/logging/dist/classification.js +155 -0
- package/packages/logging/dist/classification.js.map +1 -0
- package/packages/logging/dist/config.d.ts +9 -0
- package/packages/logging/dist/config.d.ts.map +1 -1
- package/packages/logging/dist/config.js +40 -3
- package/packages/logging/dist/config.js.map +1 -1
- package/packages/logging/dist/index.d.ts +9 -1
- package/packages/logging/dist/index.d.ts.map +1 -1
- package/packages/logging/dist/index.js +10 -1
- package/packages/logging/dist/index.js.map +1 -1
- package/packages/logging/dist/ingest/schema.d.ts +60 -0
- package/packages/logging/dist/ingest/schema.d.ts.map +1 -0
- package/packages/logging/dist/ingest/schema.js +245 -0
- package/packages/logging/dist/ingest/schema.js.map +1 -0
- package/packages/logging/dist/installation.d.ts +32 -0
- package/packages/logging/dist/installation.d.ts.map +1 -0
- package/packages/logging/dist/installation.js +64 -0
- package/packages/logging/dist/installation.js.map +1 -0
- package/packages/logging/dist/sensitive-keys.d.ts +12 -0
- package/packages/logging/dist/sensitive-keys.d.ts.map +1 -0
- package/packages/logging/dist/sensitive-keys.js +39 -0
- package/packages/logging/dist/sensitive-keys.js.map +1 -0
- package/packages/logging/dist/server/credentials.d.ts +85 -0
- package/packages/logging/dist/server/credentials.d.ts.map +1 -0
- package/packages/logging/dist/server/credentials.js +147 -0
- package/packages/logging/dist/server/credentials.js.map +1 -0
- package/packages/logging/dist/server/http.d.ts +36 -0
- package/packages/logging/dist/server/http.d.ts.map +1 -0
- package/packages/logging/dist/server/http.js +139 -0
- package/packages/logging/dist/server/http.js.map +1 -0
- package/packages/logging/dist/server/index-store.d.ts +95 -0
- package/packages/logging/dist/server/index-store.d.ts.map +1 -0
- package/packages/logging/dist/server/index-store.js +190 -0
- package/packages/logging/dist/server/index-store.js.map +1 -0
- package/packages/logging/dist/server/index.d.ts +22 -0
- package/packages/logging/dist/server/index.d.ts.map +1 -0
- package/packages/logging/dist/server/index.js +15 -0
- package/packages/logging/dist/server/index.js.map +1 -0
- package/packages/logging/dist/server/keys.d.ts +22 -0
- package/packages/logging/dist/server/keys.d.ts.map +1 -0
- package/packages/logging/dist/server/keys.js +24 -0
- package/packages/logging/dist/server/keys.js.map +1 -0
- package/packages/logging/dist/server/outbox-worker.d.ts +55 -0
- package/packages/logging/dist/server/outbox-worker.d.ts.map +1 -0
- package/packages/logging/dist/server/outbox-worker.js +95 -0
- package/packages/logging/dist/server/outbox-worker.js.map +1 -0
- package/packages/logging/dist/server/outbox.d.ts +112 -0
- package/packages/logging/dist/server/outbox.d.ts.map +1 -0
- package/packages/logging/dist/server/outbox.js +247 -0
- package/packages/logging/dist/server/outbox.js.map +1 -0
- package/packages/logging/dist/server/service.d.ts +83 -0
- package/packages/logging/dist/server/service.d.ts.map +1 -0
- package/packages/logging/dist/server/service.js +144 -0
- package/packages/logging/dist/server/service.js.map +1 -0
- package/packages/logging/dist/shipper.d.ts +20 -4
- package/packages/logging/dist/shipper.d.ts.map +1 -1
- package/packages/logging/dist/shipper.js +31 -5
- package/packages/logging/dist/shipper.js.map +1 -1
- package/packages/logging/dist/singleton.d.ts.map +1 -1
- package/packages/logging/dist/singleton.js +3 -0
- package/packages/logging/dist/singleton.js.map +1 -1
- package/packages/logging/dist/types.d.ts +28 -1
- package/packages/logging/dist/types.d.ts.map +1 -1
- package/packages/logging/dist/types.js +8 -0
- package/packages/logging/dist/types.js.map +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/logging/src/classification.ts +175 -0
- package/packages/logging/src/config.ts +60 -3
- package/packages/logging/src/index.ts +45 -1
- package/packages/logging/src/ingest/schema.ts +295 -0
- package/packages/logging/src/installation.ts +76 -0
- package/packages/logging/src/sensitive-keys.ts +40 -0
- package/packages/logging/src/server/credentials.ts +198 -0
- package/packages/logging/src/server/http.ts +170 -0
- package/packages/logging/src/server/index-store.ts +242 -0
- package/packages/logging/src/server/index.ts +56 -0
- package/packages/logging/src/server/keys.ts +32 -0
- package/packages/logging/src/server/outbox-worker.ts +124 -0
- package/packages/logging/src/server/outbox.ts +316 -0
- package/packages/logging/src/server/service.ts +196 -0
- package/packages/logging/src/shipper.ts +54 -8
- package/packages/logging/src/singleton.ts +3 -0
- package/packages/logging/src/types.ts +29 -1
- package/packages/logging/test/classification.test.ts +137 -0
- package/packages/logging/test/identity-transport.test.ts +197 -0
- package/packages/logging/test/ingest-schema.test.ts +184 -0
- package/packages/logging/test/integration.test.ts +9 -4
- package/packages/logging/test/log-http.test.ts +246 -0
- package/packages/logging/test/server.test.ts +444 -0
- package/packages/logging/test/shipper.test.ts +11 -11
- package/packages/logging/test/spool.test.ts +4 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/dist/adapters/detail-format.d.ts +34 -0
- package/packages/notification/dist/adapters/detail-format.d.ts.map +1 -0
- package/packages/notification/dist/adapters/detail-format.js +55 -0
- package/packages/notification/dist/adapters/detail-format.js.map +1 -0
- package/packages/notification/dist/adapters/email-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/email-adapter.js +3 -2
- package/packages/notification/dist/adapters/email-adapter.js.map +1 -1
- package/packages/notification/dist/adapters/line-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/line-adapter.js +3 -2
- package/packages/notification/dist/adapters/line-adapter.js.map +1 -1
- package/packages/notification/dist/adapters/ntfy-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/ntfy-adapter.js +3 -2
- package/packages/notification/dist/adapters/ntfy-adapter.js.map +1 -1
- package/packages/notification/dist/adapters/telegram-adapter.d.ts +14 -0
- package/packages/notification/dist/adapters/telegram-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/telegram-adapter.js +83 -67
- package/packages/notification/dist/adapters/telegram-adapter.js.map +1 -1
- package/packages/notification/dist/notification-center.d.ts.map +1 -1
- package/packages/notification/dist/notification-center.js +1 -0
- package/packages/notification/dist/notification-center.js.map +1 -1
- package/packages/notification/dist/types.d.ts +3 -0
- package/packages/notification/dist/types.d.ts.map +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/notification/src/adapters/detail-format.ts +74 -0
- package/packages/notification/src/adapters/email-adapter.ts +3 -2
- package/packages/notification/src/adapters/line-adapter.ts +3 -2
- package/packages/notification/src/adapters/ntfy-adapter.ts +3 -2
- package/packages/notification/src/adapters/telegram-adapter.ts +92 -74
- package/packages/notification/src/notification-center.ts +1 -0
- package/packages/notification/src/types.ts +3 -0
- package/packages/notification/test/detail-format.test.ts +106 -0
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/unified-webhook-server.ts +86 -81
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What may leave the machine.
|
|
3
|
+
*
|
|
4
|
+
* The local log file keeps everything; the centralized archive keeps the
|
|
5
|
+
* structured, sanitized events an operator needs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_CLASSIFICATION_POLICY,
|
|
11
|
+
classifyRecord,
|
|
12
|
+
isSanitizedMessage,
|
|
13
|
+
prepareRemoteRecord,
|
|
14
|
+
} from "../src/classification.js";
|
|
15
|
+
import { LogShipper } from "../src/shipper.js";
|
|
16
|
+
import type { LogBatch, LogRecord, LogTransport } from "../src/types.js";
|
|
17
|
+
|
|
18
|
+
function record(overrides: Partial<LogRecord> = {}): LogRecord {
|
|
19
|
+
return {
|
|
20
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
21
|
+
level: "info",
|
|
22
|
+
source: "harness.test",
|
|
23
|
+
event: "service.started",
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("event classification", () => {
|
|
29
|
+
test("keeps structured service events remote", () => {
|
|
30
|
+
expect(classifyRecord(record({ event: "service.started" }))).toBe("remote");
|
|
31
|
+
expect(classifyRecord(record({ event: "harness.service-diagnostics.check" }))).toBe("remote");
|
|
32
|
+
expect(classifyRecord(record({ event: "validation.tier.run" }))).toBe("remote");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("keeps the catch-all local log mirror local", () => {
|
|
36
|
+
expect(classifyRecord(record({ event: "harness.log" }))).toBe("local");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("keeps prompt and response events local", () => {
|
|
40
|
+
expect(classifyRecord(record({ event: "prompt.received" }))).toBe("local");
|
|
41
|
+
expect(classifyRecord(record({ event: "assistant.response" }))).toBe("local");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("keeps unknown events local by default", () => {
|
|
45
|
+
expect(classifyRecord(record({ event: "something.new" }))).toBe("local");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("message policy", () => {
|
|
50
|
+
test("keeps a short sanitized message", () => {
|
|
51
|
+
expect(isSanitizedMessage("service started in 12 ms", DEFAULT_CLASSIFICATION_POLICY)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("drops a multi line message", () => {
|
|
55
|
+
expect(isSanitizedMessage("line one\nline two", DEFAULT_CLASSIFICATION_POLICY)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("drops a message with an absolute path", () => {
|
|
59
|
+
expect(
|
|
60
|
+
isSanitizedMessage("failed to read /home/frappe/project/file.ts", DEFAULT_CLASSIFICATION_POLICY),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("drops a message with a URL that has a query string", () => {
|
|
65
|
+
expect(
|
|
66
|
+
isSanitizedMessage("GET https://api.example.com/v1?token=abc failed", DEFAULT_CLASSIFICATION_POLICY),
|
|
67
|
+
).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("drops a very long message", () => {
|
|
71
|
+
expect(isSanitizedMessage("x".repeat(500), DEFAULT_CLASSIFICATION_POLICY)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("remote preparation", () => {
|
|
76
|
+
test("removes the fields with a private key name", () => {
|
|
77
|
+
const prepared = prepareRemoteRecord(
|
|
78
|
+
record({
|
|
79
|
+
fields: { durationMs: 12, prompt: "private", output: "private", reason: "ok" },
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(prepared?.fields?.prompt).toBeUndefined();
|
|
84
|
+
expect(prepared?.fields?.output).toBeUndefined();
|
|
85
|
+
expect(prepared?.fields?.durationMs).toBe(12);
|
|
86
|
+
expect(prepared?.fields?.reason).toBe("ok");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("returns null for a local event", () => {
|
|
90
|
+
expect(prepareRemoteRecord(record({ event: "harness.log", message: "anything" }))).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("drops the message but keeps the event", () => {
|
|
94
|
+
const prepared = prepareRemoteRecord(
|
|
95
|
+
record({ event: "service.started", message: "failed at /home/frappe/x/y.ts" }),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(prepared?.message).toBeUndefined();
|
|
99
|
+
expect(prepared?.event).toBe("service.started");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("shipper integration", () => {
|
|
104
|
+
test("does not ship a local-only record", async () => {
|
|
105
|
+
const batches: LogBatch[] = [];
|
|
106
|
+
const transport: LogTransport = {
|
|
107
|
+
name: "recorder",
|
|
108
|
+
async send(batch: LogBatch): Promise<void> {
|
|
109
|
+
batches.push(batch);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const shipper = new LogShipper({ transports: [transport], flushIntervalMs: 0 });
|
|
113
|
+
|
|
114
|
+
shipper.info("harness", "log", "a private local log line");
|
|
115
|
+
await shipper.flush();
|
|
116
|
+
|
|
117
|
+
expect(batches).toHaveLength(0);
|
|
118
|
+
expect(shipper.getStats().localOnly).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("ships a structured record", async () => {
|
|
122
|
+
const batches: LogBatch[] = [];
|
|
123
|
+
const transport: LogTransport = {
|
|
124
|
+
name: "recorder",
|
|
125
|
+
async send(batch: LogBatch): Promise<void> {
|
|
126
|
+
batches.push(batch);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const shipper = new LogShipper({ transports: [transport], flushIntervalMs: 0 });
|
|
130
|
+
|
|
131
|
+
shipper.info("harness.service-diagnostics", "service.check", "service is up", { durationMs: 3 });
|
|
132
|
+
await shipper.flush();
|
|
133
|
+
|
|
134
|
+
expect(batches).toHaveLength(1);
|
|
135
|
+
expect(batches[0].records[0].event).toBe("service.check");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client side identity and transport semantics.
|
|
3
|
+
*
|
|
4
|
+
* The transports form a FALLBACK chain, not a broadcast: the first transport
|
|
5
|
+
* that accepts a batch wins and the later ones are never called. This file
|
|
6
|
+
* documents that behaviour explicitly, because a silent "broadcast" would
|
|
7
|
+
* duplicate every batch.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { LogShipper } from "../src/shipper.js";
|
|
15
|
+
import { resolveInstallationId } from "../src/installation.js";
|
|
16
|
+
import { resolveLogSinkConfig } from "../src/config.js";
|
|
17
|
+
import { LOG_SCHEMA_VERSION, type LogBatch, type LogTransport } from "../src/types.js";
|
|
18
|
+
|
|
19
|
+
const created: string[] = [];
|
|
20
|
+
|
|
21
|
+
function tempDir(): string {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "log-identity-"));
|
|
23
|
+
created.push(dir);
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
while (created.length > 0) {
|
|
29
|
+
rmSync(created.pop() as string, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function recorder(name: string): { transport: LogTransport; batches: LogBatch[] } {
|
|
34
|
+
const batches: LogBatch[] = [];
|
|
35
|
+
return {
|
|
36
|
+
batches,
|
|
37
|
+
transport: {
|
|
38
|
+
name,
|
|
39
|
+
async send(batch: LogBatch): Promise<void> {
|
|
40
|
+
batches.push(batch);
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("transport semantics", () => {
|
|
47
|
+
test("stops at the first successful transport - the chain is not a broadcast", async () => {
|
|
48
|
+
const first = recorder("first");
|
|
49
|
+
const second = recorder("second");
|
|
50
|
+
const shipper = new LogShipper({
|
|
51
|
+
transports: [first.transport, second.transport],
|
|
52
|
+
flushIntervalMs: 0,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
shipper.info("unit", "service.started");
|
|
56
|
+
await shipper.flush();
|
|
57
|
+
|
|
58
|
+
expect(first.batches).toHaveLength(1);
|
|
59
|
+
expect(second.batches).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("uses the next transport only when the first one fails", async () => {
|
|
63
|
+
const failing = recorder("first");
|
|
64
|
+
failing.transport = {
|
|
65
|
+
name: "first",
|
|
66
|
+
async send(): Promise<void> {
|
|
67
|
+
throw new Error("transport down");
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const second = recorder("second");
|
|
71
|
+
const shipper = new LogShipper({
|
|
72
|
+
transports: [failing.transport, second.transport],
|
|
73
|
+
flushIntervalMs: 0,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
shipper.info("unit", "service.started");
|
|
77
|
+
await shipper.flush();
|
|
78
|
+
|
|
79
|
+
expect(second.batches).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("batch schema v2", () => {
|
|
84
|
+
test("carries the schema version, the installation id and the app version", async () => {
|
|
85
|
+
const transport = recorder("only");
|
|
86
|
+
const shipper = new LogShipper({
|
|
87
|
+
app: "pi-harness",
|
|
88
|
+
appVersion: "9.9.9",
|
|
89
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
90
|
+
hostLabel: "laptop",
|
|
91
|
+
transports: [transport.transport],
|
|
92
|
+
flushIntervalMs: 0,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
shipper.info("unit", "service.started");
|
|
96
|
+
await shipper.flush();
|
|
97
|
+
|
|
98
|
+
const batch = transport.batches[0];
|
|
99
|
+
expect(batch.schemaVersion).toBe(LOG_SCHEMA_VERSION);
|
|
100
|
+
expect(batch.installationId).toBe("11111111-2222-3333-4444-555555555555");
|
|
101
|
+
expect(batch.appVersion).toBe("9.9.9");
|
|
102
|
+
expect(batch.hostLabel).toBe("laptop");
|
|
103
|
+
expect(batch.batchId.length).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("does not send the hostname as an identity", async () => {
|
|
107
|
+
const transport = recorder("only");
|
|
108
|
+
const shipper = new LogShipper({
|
|
109
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
110
|
+
transports: [transport.transport],
|
|
111
|
+
flushIntervalMs: 0,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
shipper.info("unit", "service.started");
|
|
115
|
+
await shipper.flush();
|
|
116
|
+
|
|
117
|
+
const batch = transport.batches[0];
|
|
118
|
+
// The hostname may travel as a display label, but the identity is the
|
|
119
|
+
// installation id.
|
|
120
|
+
expect(batch.installationId).not.toBe(batch.hostLabel);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("installation identity", () => {
|
|
125
|
+
test("creates the id once and reuses it", () => {
|
|
126
|
+
const dir = tempDir();
|
|
127
|
+
const path = join(dir, "installation-id.txt");
|
|
128
|
+
|
|
129
|
+
const first = resolveInstallationId({ path });
|
|
130
|
+
const second = resolveInstallationId({ path });
|
|
131
|
+
|
|
132
|
+
expect(first).toBe(second);
|
|
133
|
+
expect(existsSync(path)).toBe(true);
|
|
134
|
+
expect(readFileSync(path, "utf8").trim()).toBe(first);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("replaces an invalid id file", () => {
|
|
138
|
+
const dir = tempDir();
|
|
139
|
+
const path = join(dir, "installation-id.txt");
|
|
140
|
+
writeFileSync(path, "not-a-uuid\n");
|
|
141
|
+
|
|
142
|
+
const id = resolveInstallationId({ path });
|
|
143
|
+
|
|
144
|
+
expect(id).not.toBe("not-a-uuid");
|
|
145
|
+
expect(readFileSync(path, "utf8").trim()).toBe(id);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("transport selection", () => {
|
|
150
|
+
const env = {
|
|
151
|
+
PI_LOG_R2_BUCKET: "bucket",
|
|
152
|
+
PI_LOG_R2_ACCESS_KEY_ID: "ak",
|
|
153
|
+
PI_LOG_R2_SECRET_ACCESS_KEY: "sk",
|
|
154
|
+
PI_LOG_R2_ENDPOINT: "https://example.invalid",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
test("auto prefers the webhook ingress so only the server writes to R2", () => {
|
|
158
|
+
const config = resolveLogSinkConfig({
|
|
159
|
+
env: { ...env, PI_LOG_WEBHOOK_URL: "https://logs.example.invalid/api/logs" },
|
|
160
|
+
keysDir: join(tempDir(), "keys"),
|
|
161
|
+
installationId: "install",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(config.transports.map((transport) => transport.name)).toEqual(["webhook"]);
|
|
165
|
+
expect(config.notes.join(" ")).toContain("client ingress point");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("auto falls back to R2 when no webhook is configured", () => {
|
|
169
|
+
const config = resolveLogSinkConfig({
|
|
170
|
+
env,
|
|
171
|
+
keysDir: join(tempDir(), "keys"),
|
|
172
|
+
installationId: "install",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(config.transports.map((transport) => transport.name)).toEqual(["r2"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("r2+webhook keeps the explicit order (R2 first, webhook as fallback)", () => {
|
|
179
|
+
const config = resolveLogSinkConfig({
|
|
180
|
+
env: { ...env, PI_LOG_SINK: "r2+webhook", PI_LOG_WEBHOOK_URL: "https://logs.example.invalid/api/logs" },
|
|
181
|
+
keysDir: join(tempDir(), "keys"),
|
|
182
|
+
installationId: "install",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(config.transports.map((transport) => transport.name)).toEqual(["r2", "webhook"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("the installation id reaches the resolved config", () => {
|
|
189
|
+
const config = resolveLogSinkConfig({
|
|
190
|
+
env: { PI_LOG_SINK: "off" },
|
|
191
|
+
keysDir: join(tempDir(), "keys"),
|
|
192
|
+
installationId: "install",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(config.installationId).toBe("install");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingest contract: strict validation and allowlisted redaction.
|
|
3
|
+
*
|
|
4
|
+
* The acceptance rule is simple: a secret must not survive the trip, no
|
|
5
|
+
* matter where it hides - in the message, in a structured field, in the
|
|
6
|
+
* error message or in the stack.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { sanitizeRecord, validateBatch } from "../src/ingest/schema.js";
|
|
11
|
+
import { LOG_SCHEMA_VERSION } from "../src/types.js";
|
|
12
|
+
|
|
13
|
+
function batchWith(records: unknown[], overrides: Record<string, unknown> = {}): unknown {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: LOG_SCHEMA_VERSION,
|
|
16
|
+
batchId: "batch-1",
|
|
17
|
+
app: "pi-harness",
|
|
18
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
19
|
+
sentAt: "2026-10-24T09:00:00.000Z",
|
|
20
|
+
records,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SECRET = "sk-abcdefghijklmnopqrstuvwxyz012345";
|
|
26
|
+
|
|
27
|
+
describe("validation", () => {
|
|
28
|
+
test("accepts a well formed batch", () => {
|
|
29
|
+
const result = validateBatch(
|
|
30
|
+
batchWith([
|
|
31
|
+
{
|
|
32
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
33
|
+
level: "info",
|
|
34
|
+
source: "harness.test",
|
|
35
|
+
event: "service.started",
|
|
36
|
+
},
|
|
37
|
+
]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(result.ok).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("accepts a legacy batch without a schema version", () => {
|
|
44
|
+
const result = validateBatch({
|
|
45
|
+
batchId: "batch-legacy",
|
|
46
|
+
app: "pi-harness",
|
|
47
|
+
sentAt: "2026-10-24T09:00:00.000Z",
|
|
48
|
+
records: [
|
|
49
|
+
{
|
|
50
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
51
|
+
level: "info",
|
|
52
|
+
source: "harness.test",
|
|
53
|
+
event: "service.started",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result.ok).toBe(true);
|
|
59
|
+
if (result.ok) {
|
|
60
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
61
|
+
expect(result.batch.installationId).toBe("legacy");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("rejects a newer schema version", () => {
|
|
66
|
+
const result = validateBatch(batchWith([], { schemaVersion: LOG_SCHEMA_VERSION + 1 }));
|
|
67
|
+
|
|
68
|
+
expect(result.ok).toBe(false);
|
|
69
|
+
if (!result.ok) expect(result.error.code).toBe("unsupported_schema_version");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("rejects too many records", () => {
|
|
73
|
+
const records = Array.from({ length: 5 }, () => ({
|
|
74
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
75
|
+
level: "info",
|
|
76
|
+
source: "s",
|
|
77
|
+
event: "e",
|
|
78
|
+
}));
|
|
79
|
+
const result = validateBatch(batchWith(records), { limits: { maxRecords: 2 } });
|
|
80
|
+
|
|
81
|
+
expect(result.ok).toBe(false);
|
|
82
|
+
if (!result.ok) expect(result.error.code).toBe("too_many_records");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("rejects an unknown level", () => {
|
|
86
|
+
const result = validateBatch(
|
|
87
|
+
batchWith([
|
|
88
|
+
{ ts: "2026-10-24T09:00:00.000Z", level: "verbose", source: "s", event: "e" },
|
|
89
|
+
]),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(result.ok).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("allowlist", () => {
|
|
97
|
+
test("drops unknown record properties", () => {
|
|
98
|
+
const result = sanitizeRecord({
|
|
99
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
100
|
+
level: "info",
|
|
101
|
+
source: "harness.test",
|
|
102
|
+
event: "service.started",
|
|
103
|
+
secretPayload: "should not survive",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.ok).toBe(true);
|
|
107
|
+
if (!result.ok) return;
|
|
108
|
+
expect((result.record as Record<string, unknown>).secretPayload).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("drops field keys that can carry private content", () => {
|
|
112
|
+
const result = sanitizeRecord({
|
|
113
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
114
|
+
level: "info",
|
|
115
|
+
source: "harness.test",
|
|
116
|
+
event: "service.started",
|
|
117
|
+
fields: {
|
|
118
|
+
prompt: "the user asked something private",
|
|
119
|
+
output: "command output",
|
|
120
|
+
durationMs: 12,
|
|
121
|
+
reason: "timeout",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result.ok).toBe(true);
|
|
126
|
+
if (!result.ok) return;
|
|
127
|
+
expect(result.record.fields?.prompt).toBeUndefined();
|
|
128
|
+
expect(result.record.fields?.output).toBeUndefined();
|
|
129
|
+
expect(result.record.fields?.durationMs).toBe(12);
|
|
130
|
+
expect(result.record.fields?.reason).toBe("timeout");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("redaction", () => {
|
|
135
|
+
test("redacts a secret in the message", () => {
|
|
136
|
+
const result = sanitizeRecord({
|
|
137
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
138
|
+
level: "info",
|
|
139
|
+
source: "harness.test",
|
|
140
|
+
event: "service.started",
|
|
141
|
+
message: `using ${SECRET} to authenticate`,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
if (!result.ok) return;
|
|
146
|
+
expect(result.record.message).not.toContain(SECRET);
|
|
147
|
+
expect(result.record.message).toContain("[REDACTED]");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("redacts a secret in a structured field", () => {
|
|
151
|
+
const result = sanitizeRecord({
|
|
152
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
153
|
+
level: "info",
|
|
154
|
+
source: "harness.test",
|
|
155
|
+
event: "service.started",
|
|
156
|
+
fields: { token: SECRET, count: 3 },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(result.ok).toBe(true);
|
|
160
|
+
if (!result.ok) return;
|
|
161
|
+
expect(String(result.record.fields?.token)).not.toContain(SECRET);
|
|
162
|
+
expect(result.record.fields?.count).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("redacts the error message and the stack", () => {
|
|
166
|
+
const result = sanitizeRecord({
|
|
167
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
168
|
+
level: "error",
|
|
169
|
+
source: "harness.test",
|
|
170
|
+
event: "service.failed",
|
|
171
|
+
error: {
|
|
172
|
+
name: "AuthError",
|
|
173
|
+
message: `token ${SECRET} rejected`,
|
|
174
|
+
stack: `Error: token ${SECRET} rejected\n at run (app.js:1:1)`,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.ok).toBe(true);
|
|
179
|
+
if (!result.ok) return;
|
|
180
|
+
expect(result.record.error?.message).not.toContain(SECRET);
|
|
181
|
+
expect(result.record.error?.stack).not.toContain(SECRET);
|
|
182
|
+
expect(result.record.error?.name).toBe("AuthError");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
shutdownCentralLogging,
|
|
11
11
|
} from "../src/singleton.js";
|
|
12
12
|
import type { LogBatch } from "../src/types.js";
|
|
13
|
+
import { LOG_SCHEMA_VERSION } from "../src/types.js";
|
|
13
14
|
|
|
14
15
|
const cleanup: string[] = [];
|
|
15
16
|
|
|
@@ -49,9 +50,11 @@ describe("webhook transport", () => {
|
|
|
49
50
|
try {
|
|
50
51
|
const transport = createWebhookTransport({ url: collector.url });
|
|
51
52
|
await transport.send({
|
|
53
|
+
schemaVersion: LOG_SCHEMA_VERSION,
|
|
52
54
|
batchId: "batch-1",
|
|
53
55
|
app: "test",
|
|
54
|
-
|
|
56
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
57
|
+
hostLabel: "localhost",
|
|
55
58
|
sentAt: new Date().toISOString(),
|
|
56
59
|
records: [
|
|
57
60
|
{
|
|
@@ -83,9 +86,11 @@ describe("webhook transport", () => {
|
|
|
83
86
|
const transport = createWebhookTransport({ url: `http://127.0.0.1:${port}/api/logs` });
|
|
84
87
|
await expect(
|
|
85
88
|
transport.send({
|
|
89
|
+
schemaVersion: LOG_SCHEMA_VERSION,
|
|
86
90
|
batchId: "batch-2",
|
|
87
91
|
app: "test",
|
|
88
|
-
|
|
92
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
93
|
+
hostLabel: "localhost",
|
|
89
94
|
sentAt: new Date().toISOString(),
|
|
90
95
|
records: [
|
|
91
96
|
{
|
|
@@ -123,11 +128,11 @@ describe("initCentralLogging", () => {
|
|
|
123
128
|
expect(config?.enabled).toBe(true);
|
|
124
129
|
expect(config?.transports).toHaveLength(1);
|
|
125
130
|
|
|
126
|
-
getCentralShipper()?.info("unit", "central", "hello");
|
|
131
|
+
getCentralShipper()?.info("unit", "service.central", "hello");
|
|
127
132
|
await getCentralShipper()?.flush();
|
|
128
133
|
|
|
129
134
|
expect(collector.batches).toHaveLength(1);
|
|
130
|
-
expect(collector.batches[0].records[0].event).toBe("central");
|
|
135
|
+
expect(collector.batches[0].records[0].event).toBe("service.central");
|
|
131
136
|
} finally {
|
|
132
137
|
collector.server.close();
|
|
133
138
|
}
|