pi-harness-runtime 1.1.396 → 1.1.397

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 (204) hide show
  1. package/AGENTS.md +54 -7
  2. package/package.json +1 -1
  3. package/packages/a2a-adapter/package.json +1 -1
  4. package/packages/agent-policy/package.json +1 -1
  5. package/packages/architecture-generator/package.json +1 -1
  6. package/packages/auth/package.json +1 -1
  7. package/packages/autonomous-refactor/package.json +1 -1
  8. package/packages/autonomous-runtime/package.json +1 -1
  9. package/packages/cache-strategy/package.json +1 -1
  10. package/packages/capability-registry/package.json +1 -1
  11. package/packages/checkpoint/package.json +1 -1
  12. package/packages/cli-plugin-sdk/package.json +1 -1
  13. package/packages/clipboard/package.json +1 -1
  14. package/packages/clipboard-plugin/package.json +1 -1
  15. package/packages/code-generation/package.json +1 -1
  16. package/packages/code-review/package.json +1 -1
  17. package/packages/codex-adapter/package.json +1 -1
  18. package/packages/codex-watcher/package.json +1 -1
  19. package/packages/config-capture/package.json +1 -1
  20. package/packages/connection-runtime/package.json +1 -1
  21. package/packages/context-compiler/package.json +1 -1
  22. package/packages/context-discovery/package.json +1 -1
  23. package/packages/context-manager/package.json +1 -1
  24. package/packages/contracts/package.json +1 -1
  25. package/packages/cookie-sanitizer/package.json +1 -1
  26. package/packages/cost-optimizer/package.json +1 -1
  27. package/packages/dependency-analyzer/package.json +1 -1
  28. package/packages/django-plugin/package.json +1 -1
  29. package/packages/doc-generator/package.json +1 -1
  30. package/packages/durable-review/package.json +1 -1
  31. package/packages/e2e-tester/package.json +1 -1
  32. package/packages/evaluation-engine/package.json +1 -1
  33. package/packages/evaluation-runner/package.json +1 -1
  34. package/packages/event-bus/package.json +1 -1
  35. package/packages/event-sink/package.json +1 -1
  36. package/packages/event-store/package.json +1 -1
  37. package/packages/experience-replay/package.json +1 -1
  38. package/packages/feedback-collector/package.json +1 -1
  39. package/packages/file-copy-helper/package.json +1 -1
  40. package/packages/framework-detector/package.json +1 -1
  41. package/packages/framework-plugin-sdk/package.json +1 -1
  42. package/packages/frappe-plugin/package.json +1 -1
  43. package/packages/generic-web-plugin/package.json +1 -1
  44. package/packages/health-monitor/package.json +1 -1
  45. package/packages/intent-analyzer/package.json +1 -1
  46. package/packages/jev-judge/package.json +1 -1
  47. package/packages/knowledge-graph/package.json +1 -1
  48. package/packages/knowledge-retrieval/package.json +1 -1
  49. package/packages/laravel-plugin/package.json +1 -1
  50. package/packages/learning-engine/package.json +1 -1
  51. package/packages/logging/README.md +41 -6
  52. package/packages/logging/dist/classification.d.ts +65 -0
  53. package/packages/logging/dist/classification.d.ts.map +1 -0
  54. package/packages/logging/dist/classification.js +155 -0
  55. package/packages/logging/dist/classification.js.map +1 -0
  56. package/packages/logging/dist/config.d.ts +9 -0
  57. package/packages/logging/dist/config.d.ts.map +1 -1
  58. package/packages/logging/dist/config.js +40 -3
  59. package/packages/logging/dist/config.js.map +1 -1
  60. package/packages/logging/dist/index.d.ts +9 -1
  61. package/packages/logging/dist/index.d.ts.map +1 -1
  62. package/packages/logging/dist/index.js +10 -1
  63. package/packages/logging/dist/index.js.map +1 -1
  64. package/packages/logging/dist/ingest/schema.d.ts +60 -0
  65. package/packages/logging/dist/ingest/schema.d.ts.map +1 -0
  66. package/packages/logging/dist/ingest/schema.js +245 -0
  67. package/packages/logging/dist/ingest/schema.js.map +1 -0
  68. package/packages/logging/dist/installation.d.ts +32 -0
  69. package/packages/logging/dist/installation.d.ts.map +1 -0
  70. package/packages/logging/dist/installation.js +64 -0
  71. package/packages/logging/dist/installation.js.map +1 -0
  72. package/packages/logging/dist/sensitive-keys.d.ts +12 -0
  73. package/packages/logging/dist/sensitive-keys.d.ts.map +1 -0
  74. package/packages/logging/dist/sensitive-keys.js +39 -0
  75. package/packages/logging/dist/sensitive-keys.js.map +1 -0
  76. package/packages/logging/dist/server/credentials.d.ts +85 -0
  77. package/packages/logging/dist/server/credentials.d.ts.map +1 -0
  78. package/packages/logging/dist/server/credentials.js +147 -0
  79. package/packages/logging/dist/server/credentials.js.map +1 -0
  80. package/packages/logging/dist/server/http.d.ts +36 -0
  81. package/packages/logging/dist/server/http.d.ts.map +1 -0
  82. package/packages/logging/dist/server/http.js +139 -0
  83. package/packages/logging/dist/server/http.js.map +1 -0
  84. package/packages/logging/dist/server/index-store.d.ts +95 -0
  85. package/packages/logging/dist/server/index-store.d.ts.map +1 -0
  86. package/packages/logging/dist/server/index-store.js +190 -0
  87. package/packages/logging/dist/server/index-store.js.map +1 -0
  88. package/packages/logging/dist/server/index.d.ts +22 -0
  89. package/packages/logging/dist/server/index.d.ts.map +1 -0
  90. package/packages/logging/dist/server/index.js +15 -0
  91. package/packages/logging/dist/server/index.js.map +1 -0
  92. package/packages/logging/dist/server/keys.d.ts +22 -0
  93. package/packages/logging/dist/server/keys.d.ts.map +1 -0
  94. package/packages/logging/dist/server/keys.js +24 -0
  95. package/packages/logging/dist/server/keys.js.map +1 -0
  96. package/packages/logging/dist/server/outbox-worker.d.ts +55 -0
  97. package/packages/logging/dist/server/outbox-worker.d.ts.map +1 -0
  98. package/packages/logging/dist/server/outbox-worker.js +95 -0
  99. package/packages/logging/dist/server/outbox-worker.js.map +1 -0
  100. package/packages/logging/dist/server/outbox.d.ts +112 -0
  101. package/packages/logging/dist/server/outbox.d.ts.map +1 -0
  102. package/packages/logging/dist/server/outbox.js +247 -0
  103. package/packages/logging/dist/server/outbox.js.map +1 -0
  104. package/packages/logging/dist/server/service.d.ts +83 -0
  105. package/packages/logging/dist/server/service.d.ts.map +1 -0
  106. package/packages/logging/dist/server/service.js +144 -0
  107. package/packages/logging/dist/server/service.js.map +1 -0
  108. package/packages/logging/dist/shipper.d.ts +20 -4
  109. package/packages/logging/dist/shipper.d.ts.map +1 -1
  110. package/packages/logging/dist/shipper.js +31 -5
  111. package/packages/logging/dist/shipper.js.map +1 -1
  112. package/packages/logging/dist/singleton.d.ts.map +1 -1
  113. package/packages/logging/dist/singleton.js +3 -0
  114. package/packages/logging/dist/singleton.js.map +1 -1
  115. package/packages/logging/dist/types.d.ts +28 -1
  116. package/packages/logging/dist/types.d.ts.map +1 -1
  117. package/packages/logging/dist/types.js +8 -0
  118. package/packages/logging/dist/types.js.map +1 -1
  119. package/packages/logging/package.json +1 -1
  120. package/packages/logging/src/classification.ts +175 -0
  121. package/packages/logging/src/config.ts +60 -3
  122. package/packages/logging/src/index.ts +45 -1
  123. package/packages/logging/src/ingest/schema.ts +295 -0
  124. package/packages/logging/src/installation.ts +76 -0
  125. package/packages/logging/src/sensitive-keys.ts +40 -0
  126. package/packages/logging/src/server/credentials.ts +198 -0
  127. package/packages/logging/src/server/http.ts +170 -0
  128. package/packages/logging/src/server/index-store.ts +242 -0
  129. package/packages/logging/src/server/index.ts +56 -0
  130. package/packages/logging/src/server/keys.ts +32 -0
  131. package/packages/logging/src/server/outbox-worker.ts +124 -0
  132. package/packages/logging/src/server/outbox.ts +316 -0
  133. package/packages/logging/src/server/service.ts +196 -0
  134. package/packages/logging/src/shipper.ts +54 -8
  135. package/packages/logging/src/singleton.ts +3 -0
  136. package/packages/logging/src/types.ts +29 -1
  137. package/packages/logging/test/classification.test.ts +137 -0
  138. package/packages/logging/test/identity-transport.test.ts +197 -0
  139. package/packages/logging/test/ingest-schema.test.ts +184 -0
  140. package/packages/logging/test/integration.test.ts +9 -4
  141. package/packages/logging/test/log-http.test.ts +246 -0
  142. package/packages/logging/test/server.test.ts +444 -0
  143. package/packages/logging/test/shipper.test.ts +11 -11
  144. package/packages/logging/test/spool.test.ts +4 -1
  145. package/packages/mcp-adapter/package.json +1 -1
  146. package/packages/memory-engine/package.json +1 -1
  147. package/packages/milestone-manager/package.json +1 -1
  148. package/packages/model-registry/package.json +1 -1
  149. package/packages/nextjs-plugin/package.json +1 -1
  150. package/packages/notification/package.json +1 -1
  151. package/packages/observability/package.json +1 -1
  152. package/packages/okf-indexer/package.json +1 -1
  153. package/packages/paperclip-integration/package.json +1 -1
  154. package/packages/performance-optimizer/package.json +1 -1
  155. package/packages/permission-mode/package.json +1 -1
  156. package/packages/privilege-broker/package.json +1 -1
  157. package/packages/project-analyzer/package.json +1 -1
  158. package/packages/project-bootstrap/package.json +1 -1
  159. package/packages/projection-engine/package.json +1 -1
  160. package/packages/prompt-compiler/package.json +1 -1
  161. package/packages/prompt-versioning/package.json +1 -1
  162. package/packages/provider-adapter-sdk/package.json +1 -1
  163. package/packages/provider-router/package.json +1 -1
  164. package/packages/provider-selector/package.json +1 -1
  165. package/packages/providers/package.json +1 -1
  166. package/packages/qdrant-skills/package.json +1 -1
  167. package/packages/quota-manager/package.json +1 -1
  168. package/packages/rate-limiter/package.json +1 -1
  169. package/packages/react-vite-plugin/package.json +1 -1
  170. package/packages/relay/package.json +1 -1
  171. package/packages/release-manager/package.json +1 -1
  172. package/packages/requirement-compiler/package.json +1 -1
  173. package/packages/runtime/package.json +1 -1
  174. package/packages/scheduler/package.json +1 -1
  175. package/packages/scheduler-adapter/package.json +1 -1
  176. package/packages/session/package.json +1 -1
  177. package/packages/session-api/package.json +1 -1
  178. package/packages/session-export/package.json +1 -1
  179. package/packages/shared-context/package.json +1 -1
  180. package/packages/skill-mcp-client/package.json +1 -1
  181. package/packages/skill-registry/package.json +1 -1
  182. package/packages/skills/package.json +1 -1
  183. package/packages/sprint-planner/package.json +1 -1
  184. package/packages/subscription-engine/package.json +1 -1
  185. package/packages/task-compiler/package.json +1 -1
  186. package/packages/telegram/package.json +1 -1
  187. package/packages/tencentdb-memory/package.json +1 -1
  188. package/packages/tencentdb-sync/package.json +1 -1
  189. package/packages/test-data-generator/package.json +1 -1
  190. package/packages/test-generator/package.json +1 -1
  191. package/packages/todo-bd-sync/package.json +1 -1
  192. package/packages/token-estimation/package.json +1 -1
  193. package/packages/token-optimizer/package.json +1 -1
  194. package/packages/trajectory/package.json +1 -1
  195. package/packages/tui/package.json +1 -1
  196. package/packages/types/package.json +1 -1
  197. package/packages/voice-api/package.json +1 -1
  198. package/packages/webmcp-tester/package.json +1 -1
  199. package/packages/wiki/package.json +1 -1
  200. package/packages/workflow-events/package.json +1 -1
  201. package/packages/workspace-scanner/package.json +1 -1
  202. package/packages/worktree/package.json +1 -1
  203. package/packages/write-review/package.json +1 -1
  204. package/scripts/unified-webhook-server.ts +86 -81
@@ -0,0 +1,175 @@
1
+ /**
2
+ * What may leave the machine.
3
+ *
4
+ * The local log file keeps everything. The centralized archive keeps what an
5
+ * operator actually needs: service state, event codes, sanitized errors,
6
+ * durations and opaque identifiers.
7
+ *
8
+ * Two rules decide:
9
+ *
10
+ * 1. The event decides. A catch-all mirror of a local log line
11
+ * (`harness.log`) or anything that smells like a prompt, a response or
12
+ * command output never leaves the machine.
13
+ * 2. The payload decides. A message only travels when it looks like a
14
+ * sanitized summary: short, one line, no path and no URL with a query
15
+ * string. Fields with a private key name are dropped.
16
+ *
17
+ * The diagnostic mode (`PI_LOG_DIAGNOSTIC=1`) relaxes rule 2 for a user who
18
+ * explicitly asked for more, and nothing else.
19
+ */
20
+
21
+ import { isLocalOnlyFieldKey } from "./sensitive-keys.js";
22
+ import type { LogRecord } from "./types.js";
23
+
24
+ export interface ClassificationPolicy {
25
+ /** Events that may go remote. */
26
+ remoteEvents: RegExp[];
27
+ /** Events that never go remote, whatever the level is. */
28
+ localEvents: RegExp[];
29
+ /** Keep a message even when it does not look sanitized. */
30
+ allowRawMessages: boolean;
31
+ /** Maximum length of a message that is kept. */
32
+ maxMessageLength: number;
33
+ }
34
+
35
+ /**
36
+ * Events that stay local.
37
+ *
38
+ * `harness.log` is the mirror of the catch-all local log line - it carries
39
+ * whatever the harness printed, so it is never remote.
40
+ */
41
+ export const DEFAULT_LOCAL_EVENTS: RegExp[] = [
42
+ /^harness\.log$/,
43
+ /prompt/i,
44
+ /response/i,
45
+ /transcript/i,
46
+ /output/i,
47
+ /command/i,
48
+ /content/i,
49
+ /message\.text/i,
50
+ /notification\.text/i,
51
+ ];
52
+
53
+ /**
54
+ * Events that may go remote.
55
+ *
56
+ * They are structured: a service state change, a session lifecycle event, a
57
+ * validation tier, a provider or quota event.
58
+ */
59
+ export const DEFAULT_REMOTE_EVENTS: RegExp[] = [
60
+ /^harness\.service-diagnostics\./,
61
+ /^harness\.session\./,
62
+ /^harness\.startup\./,
63
+ /^harness\.shutdown\./,
64
+ /^harness\.planner\.decision$/,
65
+ /^harness\.glm-quota\.quota$/,
66
+ /^validation\./,
67
+ /^tier\./,
68
+ /^service\./,
69
+ /^session\./,
70
+ /^startup\./,
71
+ /^config\./,
72
+ /^provider\./,
73
+ /^quota\./,
74
+ /^logging\./,
75
+ /^jest\./,
76
+ ];
77
+
78
+ export const DEFAULT_CLASSIFICATION_POLICY: ClassificationPolicy = {
79
+ remoteEvents: DEFAULT_REMOTE_EVENTS,
80
+ localEvents: DEFAULT_LOCAL_EVENTS,
81
+ allowRawMessages: false,
82
+ maxMessageLength: 200,
83
+ };
84
+
85
+ const ABSOLUTE_PATH = /(?:^|\s)\/(?:[\w.-]+\/)+[\w.-]+/;
86
+ const URL_WITH_QUERY = /https?:\/\/\S+\?\S+/;
87
+
88
+ /**
89
+ * True when a message looks like a sanitized summary and may travel.
90
+ */
91
+ export function isSanitizedMessage(message: string, policy: ClassificationPolicy): boolean {
92
+ if (policy.allowRawMessages) return true;
93
+ if (message.length > policy.maxMessageLength) return false;
94
+ if (message.includes("\n")) return false;
95
+ if (ABSOLUTE_PATH.test(message)) return false;
96
+ if (URL_WITH_QUERY.test(message)) return false;
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Classify one record.
102
+ *
103
+ * "remote" means the record may be shipped; "local" means it must stay on
104
+ * the machine.
105
+ */
106
+ export function classifyRecord(
107
+ record: LogRecord,
108
+ policy: ClassificationPolicy = DEFAULT_CLASSIFICATION_POLICY,
109
+ ): "remote" | "local" {
110
+ for (const pattern of policy.localEvents) {
111
+ if (pattern.test(record.event)) return "local";
112
+ }
113
+ for (const pattern of policy.remoteEvents) {
114
+ if (pattern.test(record.event)) return "remote";
115
+ }
116
+ // Errors stay valuable even when the event is unknown: the message and
117
+ // the fields are sanitized anyway.
118
+ if (record.level === "warn" || record.level === "error") return "remote";
119
+ // Unknown events are local by default: an allowlist is safer than a
120
+ // denylist.
121
+ return "local";
122
+ }
123
+
124
+ /**
125
+ * Build the version of a record that may leave the machine.
126
+ *
127
+ * Returns null when the record must stay local.
128
+ */
129
+ export function prepareRemoteRecord(
130
+ record: LogRecord,
131
+ policy: ClassificationPolicy = DEFAULT_CLASSIFICATION_POLICY,
132
+ ): LogRecord | null {
133
+ if (classifyRecord(record, policy) === "local") return null;
134
+
135
+ const remote: LogRecord = {
136
+ ts: record.ts,
137
+ level: record.level,
138
+ source: record.source,
139
+ event: record.event,
140
+ };
141
+
142
+ if (record.message !== undefined && isSanitizedMessage(record.message, policy)) {
143
+ remote.message = record.message;
144
+ }
145
+ if (record.sessionId) remote.sessionId = record.sessionId;
146
+ if (typeof record.durationMs === "number") remote.durationMs = record.durationMs;
147
+
148
+ if (record.fields) {
149
+ const fields: Record<string, unknown> = {};
150
+ for (const [key, value] of Object.entries(record.fields)) {
151
+ if (isLocalOnlyFieldKey(key)) continue;
152
+ fields[key] = value;
153
+ }
154
+ if (Object.keys(fields).length > 0) remote.fields = fields;
155
+ }
156
+
157
+ if (record.error) {
158
+ remote.error = { ...record.error };
159
+ }
160
+
161
+ return remote;
162
+ }
163
+
164
+ /** Keep only the records that may leave the machine. */
165
+ export function filterRemoteRecords(
166
+ records: LogRecord[],
167
+ policy: ClassificationPolicy = DEFAULT_CLASSIFICATION_POLICY,
168
+ ): LogRecord[] {
169
+ const remote: LogRecord[] = [];
170
+ for (const record of records) {
171
+ const prepared = prepareRemoteRecord(record, policy);
172
+ if (prepared) remote.push(prepared);
173
+ }
174
+ return remote;
175
+ }
@@ -13,6 +13,11 @@ import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { createR2Transport } from "./transports/r2.js";
15
15
  import { createWebhookTransport } from "./transports/webhook.js";
16
+ import { resolveInstallationId } from "./installation.js";
17
+ import {
18
+ DEFAULT_CLASSIFICATION_POLICY,
19
+ type ClassificationPolicy,
20
+ } from "./classification.js";
16
21
  import type { LogBufferOptions } from "./buffer.js";
17
22
  import type { SamplingPolicy } from "./sampler.js";
18
23
  import type { LogTransport } from "./types.js";
@@ -24,6 +29,10 @@ export interface LogSinkConfig {
24
29
  enabled: boolean;
25
30
  /** Application name reported with every batch. */
26
31
  app: string;
32
+ /** Version of the emitting application. */
33
+ appVersion?: string;
34
+ /** Stable id of this installation. */
35
+ installationId: string;
27
36
  /** Transports in delivery priority order. */
28
37
  transports: LogTransport[];
29
38
  /** Directory of the local spool (undefined when logging is off). */
@@ -31,6 +40,8 @@ export interface LogSinkConfig {
31
40
  sampling: Partial<SamplingPolicy>;
32
41
  buffer: LogBufferOptions;
33
42
  flushIntervalMs: number;
43
+ /** What may leave the machine. */
44
+ classification: ClassificationPolicy;
34
45
  /** Human readable notes about what was enabled and why. */
35
46
  notes: string[];
36
47
  }
@@ -39,6 +50,8 @@ export interface ResolveConfigOptions {
39
50
  env?: Record<string, string | undefined>;
40
51
  /** Directory holding the credential key files. */
41
52
  keysDir?: string;
53
+ /** Overrides the installation id (tests, containers). */
54
+ installationId?: string;
42
55
  }
43
56
 
44
57
  function readKeyFile(keysDir: string, name: string): string | undefined {
@@ -67,6 +80,21 @@ export function defaultSpoolDir(): string {
67
80
  return join(homedir(), ".pi-harness-runtime", "logs", "spool");
68
81
  }
69
82
 
83
+ /**
84
+ * Build the classification policy.
85
+ *
86
+ * `PI_LOG_DIAGNOSTIC=1` is the explicit, user controlled diagnostic mode: it
87
+ * lets messages travel that the default policy keeps local. It never widens
88
+ * the event allowlist - only the message rule.
89
+ */
90
+ function classificationFor(
91
+ env: Record<string, string | undefined>,
92
+ ): ClassificationPolicy {
93
+ const diagnostic = env.PI_LOG_DIAGNOSTIC === "1" || env.PI_LOG_DIAGNOSTIC === "true";
94
+ if (!diagnostic) return DEFAULT_CLASSIFICATION_POLICY;
95
+ return { ...DEFAULT_CLASSIFICATION_POLICY, allowRawMessages: true };
96
+ }
97
+
70
98
  /**
71
99
  * Build the log sink configuration from the environment and the key files.
72
100
  */
@@ -76,14 +104,19 @@ export function resolveLogSinkConfig(options: ResolveConfigOptions = {}): LogSin
76
104
  const mode = (env.PI_LOG_SINK ?? "auto") as LogSinkMode;
77
105
  const notes: string[] = [];
78
106
 
107
+ const installationId =
108
+ options.installationId ?? env.PI_LOG_INSTALLATION_ID ?? resolveInstallationId();
109
+
79
110
  if (mode === "off") {
80
111
  return {
81
112
  enabled: false,
82
113
  app: env.PI_LOG_APP ?? "pi-harness",
114
+ installationId,
83
115
  transports: [],
84
116
  sampling: {},
85
117
  buffer: {},
86
118
  flushIntervalMs: 0,
119
+ classification: classificationFor(env),
87
120
  notes: ["logging disabled by PI_LOG_SINK=off"],
88
121
  };
89
122
  }
@@ -102,7 +135,21 @@ export function resolveLogSinkConfig(options: ResolveConfigOptions = {}): LogSin
102
135
  (accountId ? `https://${accountId}.r2.cloudflarestorage.com` : undefined);
103
136
 
104
137
  const r2Ready = Boolean(bucket && accessKeyId && secretAccessKey && endpoint);
105
- const wantR2 = mode === "r2" || mode === "r2+webhook" || (mode === "auto" && r2Ready);
138
+ const webhookUrl = env.PI_LOG_WEBHOOK_URL;
139
+
140
+ /*
141
+ * Transport selection.
142
+ *
143
+ * The transports form a FALLBACK chain, not a broadcast: the first
144
+ * transport that accepts the batch wins and the later ones are not
145
+ * called. `auto` therefore prefers the webhook ingress when one is
146
+ * configured: the server is the single writer to R2, so client machines
147
+ * never need R2 credentials at all.
148
+ */
149
+ const wantR2 =
150
+ mode === "r2" ||
151
+ mode === "r2+webhook" ||
152
+ (mode === "auto" && r2Ready && !webhookUrl);
106
153
 
107
154
  if (wantR2) {
108
155
  if (r2Ready) {
@@ -121,7 +168,6 @@ export function resolveLogSinkConfig(options: ResolveConfigOptions = {}): LogSin
121
168
  }
122
169
  }
123
170
 
124
- const webhookUrl = env.PI_LOG_WEBHOOK_URL;
125
171
  const wantWebhook =
126
172
  mode === "webhook" || mode === "r2+webhook" || (mode === "auto" && Boolean(webhookUrl));
127
173
 
@@ -134,6 +180,11 @@ export function resolveLogSinkConfig(options: ResolveConfigOptions = {}): LogSin
134
180
  }),
135
181
  );
136
182
  notes.push(`webhook enabled (${webhookUrl})`);
183
+ if (mode === "auto") {
184
+ notes.push(
185
+ "webhook is the client ingress point - the server writes to R2 (direct r2 transport skipped)",
186
+ );
187
+ }
137
188
  } else {
138
189
  notes.push("webhook requested but PI_LOG_WEBHOOK_URL is not set - transport skipped");
139
190
  }
@@ -143,9 +194,10 @@ export function resolveLogSinkConfig(options: ResolveConfigOptions = {}): LogSin
143
194
  notes.push("no transport configured - logs stay local");
144
195
  }
145
196
 
146
- return {
197
+ const config: LogSinkConfig = {
147
198
  enabled: true,
148
199
  app: env.PI_LOG_APP ?? "pi-harness",
200
+ installationId,
149
201
  transports,
150
202
  spoolDir: env.PI_LOG_SPOOL_DIR ?? defaultSpoolDir(),
151
203
  sampling: {
@@ -158,6 +210,11 @@ export function resolveLogSinkConfig(options: ResolveConfigOptions = {}): LogSin
158
210
  maxBatchSize: readNumber(env, "PI_LOG_BATCH_SIZE", 100),
159
211
  },
160
212
  flushIntervalMs: readNumber(env, "PI_LOG_FLUSH_INTERVAL_MS", 15_000),
213
+ classification: classificationFor(env),
161
214
  notes,
162
215
  };
216
+
217
+ if (env.PI_LOG_APP_VERSION) config.appVersion = env.PI_LOG_APP_VERSION;
218
+
219
+ return config;
163
220
  }
@@ -12,7 +12,7 @@
12
12
 
13
13
  // --- Types -----------------------------------------------------------------
14
14
 
15
- export { LOG_LEVEL_SEVERITY } from "./types.js";
15
+ export { LOG_LEVEL_SEVERITY, LOG_SCHEMA_VERSION } from "./types.js";
16
16
  export type {
17
17
  FlushStats,
18
18
  LogBatch,
@@ -77,6 +77,50 @@ export {
77
77
  } from "./singleton.js";
78
78
  export type { InitCentralLoggingOptions } from "./singleton.js";
79
79
 
80
+ // --- Installation identity --------------------------------------------------
81
+
82
+ export {
83
+ INSTALLATION_ID_FILE,
84
+ defaultInstallationIdPath,
85
+ isValidInstallationId,
86
+ resolveInstallationId,
87
+ } from "./installation.js";
88
+ export type { ResolveInstallationIdOptions } from "./installation.js";
89
+
90
+ // --- Classification (what may leave the machine) ----------------------------
91
+
92
+ export {
93
+ DEFAULT_CLASSIFICATION_POLICY,
94
+ DEFAULT_LOCAL_EVENTS,
95
+ DEFAULT_REMOTE_EVENTS,
96
+ classifyRecord,
97
+ filterRemoteRecords,
98
+ isSanitizedMessage,
99
+ prepareRemoteRecord,
100
+ } from "./classification.js";
101
+ export type { ClassificationPolicy } from "./classification.js";
102
+
103
+ export { LOCAL_ONLY_FIELD_KEYS, isLocalOnlyFieldKey } from "./sensitive-keys.js";
104
+
105
+ // --- Ingest contract (shared by client and server) -------------------------
106
+
107
+ export {
108
+ DEFAULT_BATCH_LIMITS,
109
+ LOG_LEVELS,
110
+ sanitizeRecord,
111
+ validateBatch,
112
+ } from "./ingest/schema.js";
113
+ export type {
114
+ BatchValidationError,
115
+ BatchValidationLimits,
116
+ BatchValidationResult,
117
+ SanitizeRecordResult,
118
+ } from "./ingest/schema.js";
119
+
120
+ // --- Server side ------------------------------------------------------------
121
+
122
+ export * from "./server/index.js";
123
+
80
124
  // --- Configuration ---------------------------------------------------------
81
125
 
82
126
  export {
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Ingest contract: strict validation and allowlisted redaction.
3
+ *
4
+ * The client and the server share this module, so both sides agree on what a
5
+ * batch is. The server never trusts the body:
6
+ *
7
+ * - the tenant comes from the authenticated credential, never from the JSON,
8
+ * - the batch is validated against a strict, versioned schema,
9
+ * - the records are rebuilt from an allowlist, so an unknown property can
10
+ * never smuggle data into the archive,
11
+ * - every string is redacted again on the server (defence in depth).
12
+ */
13
+
14
+ import { redactText, redactValue } from "../redact.js";
15
+ import { isLocalOnlyFieldKey } from "../sensitive-keys.js";
16
+ import { LOG_SCHEMA_VERSION, type LogBatch, type LogErrorInfo, type LogLevel, type LogRecord } from "../types.js";
17
+
18
+ export const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
19
+
20
+ export interface BatchValidationLimits {
21
+ /** Maximum number of records in one batch. */
22
+ maxRecords: number;
23
+ /** Maximum length of a single string value. */
24
+ maxStringLength: number;
25
+ /** Maximum number of structured fields per record. */
26
+ maxFieldKeys: number;
27
+ }
28
+
29
+ export const DEFAULT_BATCH_LIMITS: BatchValidationLimits = {
30
+ maxRecords: 1000,
31
+ maxStringLength: 8192,
32
+ maxFieldKeys: 64,
33
+ };
34
+
35
+ export interface BatchValidationError {
36
+ code: string;
37
+ message: string;
38
+ }
39
+
40
+ export type BatchValidationResult =
41
+ | { ok: true; batch: LogBatch; warnings: string[] }
42
+ | { ok: false; error: BatchValidationError };
43
+
44
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ function isIsoTimestamp(value: string): boolean {
49
+ return Number.isFinite(Date.parse(value));
50
+ }
51
+
52
+ /** Batch ids are opaque but must stay safe to use in an object key. */
53
+ const BATCH_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
54
+ const INSTALLATION_ID_PATTERN = /^[A-Za-z0-9._:-]{8,64}$/;
55
+
56
+ /**
57
+ * Validate an inbound batch.
58
+ *
59
+ * Unknown properties are NOT an error here - the sanitizer drops them. Only a
60
+ * structurally broken batch is rejected.
61
+ */
62
+ export function validateBatch(
63
+ input: unknown,
64
+ options: { limits?: Partial<BatchValidationLimits> } = {},
65
+ ): BatchValidationResult {
66
+ const limits = { ...DEFAULT_BATCH_LIMITS, ...options.limits };
67
+ const warnings: string[] = [];
68
+
69
+ if (!isPlainObject(input)) {
70
+ return { ok: false, error: { code: "invalid_batch", message: "batch must be an object" } };
71
+ }
72
+
73
+ let schemaVersion = input.schemaVersion;
74
+ if (schemaVersion === undefined) {
75
+ // A client that predates the version marker sends v1.
76
+ schemaVersion = 1;
77
+ warnings.push("batch without a schema version, treated as legacy v1");
78
+ }
79
+ if (typeof schemaVersion !== "number" || !Number.isFinite(schemaVersion)) {
80
+ return {
81
+ ok: false,
82
+ error: { code: "invalid_schema_version", message: "schemaVersion must be a number" },
83
+ };
84
+ }
85
+ if (schemaVersion < 1) {
86
+ return {
87
+ ok: false,
88
+ error: { code: "invalid_schema_version", message: "schemaVersion must be >= 1" },
89
+ };
90
+ }
91
+ if (schemaVersion > LOG_SCHEMA_VERSION) {
92
+ return {
93
+ ok: false,
94
+ error: {
95
+ code: "unsupported_schema_version",
96
+ message: `schemaVersion ${schemaVersion} is newer than the supported version ${LOG_SCHEMA_VERSION}`,
97
+ },
98
+ };
99
+ }
100
+ if (schemaVersion < LOG_SCHEMA_VERSION) {
101
+ warnings.push(`legacy schemaVersion ${schemaVersion}`);
102
+ }
103
+
104
+ const batchId = input.batchId;
105
+ if (typeof batchId !== "string" || !BATCH_ID_PATTERN.test(batchId)) {
106
+ return {
107
+ ok: false,
108
+ error: { code: "invalid_batch_id", message: "batchId is missing or malformed" },
109
+ };
110
+ }
111
+
112
+ const app = input.app;
113
+ if (typeof app !== "string" || app.length === 0 || app.length > 64) {
114
+ return { ok: false, error: { code: "invalid_app", message: "app is missing or malformed" } };
115
+ }
116
+
117
+ const installationId = input.installationId;
118
+ if (installationId !== undefined && typeof installationId !== "string") {
119
+ return {
120
+ ok: false,
121
+ error: { code: "invalid_installation_id", message: "installationId must be a string" },
122
+ };
123
+ }
124
+ if (schemaVersion >= 2) {
125
+ if (typeof installationId !== "string" || !INSTALLATION_ID_PATTERN.test(installationId)) {
126
+ return {
127
+ ok: false,
128
+ error: { code: "invalid_installation_id", message: "installationId is missing or malformed" },
129
+ };
130
+ }
131
+ } else if (!installationId) {
132
+ warnings.push("batch without an installation id");
133
+ }
134
+
135
+ const sentAt = input.sentAt;
136
+ if (typeof sentAt !== "string" || !isIsoTimestamp(sentAt)) {
137
+ return { ok: false, error: { code: "invalid_sent_at", message: "sentAt is not a timestamp" } };
138
+ }
139
+
140
+ const records = input.records;
141
+ if (!Array.isArray(records)) {
142
+ return { ok: false, error: { code: "invalid_records", message: "records must be an array" } };
143
+ }
144
+ if (records.length === 0) {
145
+ return { ok: false, error: { code: "empty_batch", message: "records must not be empty" } };
146
+ }
147
+ if (records.length > limits.maxRecords) {
148
+ return {
149
+ ok: false,
150
+ error: {
151
+ code: "too_many_records",
152
+ message: `records exceed the limit of ${limits.maxRecords}`,
153
+ },
154
+ };
155
+ }
156
+
157
+ const sanitized: LogRecord[] = [];
158
+ for (const record of records) {
159
+ const result = sanitizeRecord(record, limits);
160
+ if (!result.ok) {
161
+ return { ok: false, error: result.error };
162
+ }
163
+ sanitized.push(result.record);
164
+ }
165
+
166
+ const batch: LogBatch = {
167
+ schemaVersion,
168
+ batchId,
169
+ app,
170
+ installationId: typeof installationId === "string" ? installationId : "legacy",
171
+ sentAt,
172
+ records: sanitized,
173
+ };
174
+
175
+ if (typeof input.appVersion === "string") batch.appVersion = input.appVersion.slice(0, 32);
176
+ if (typeof input.hostLabel === "string") batch.hostLabel = input.hostLabel.slice(0, 128);
177
+ if (typeof input.sessionId === "string") batch.sessionId = input.sessionId.slice(0, 128);
178
+
179
+ return { ok: true, batch, warnings };
180
+ }
181
+
182
+ /** Record keys that are accepted. Everything else is dropped. */
183
+ const ALLOWED_RECORD_KEYS = new Set([
184
+ "ts",
185
+ "level",
186
+ "source",
187
+ "event",
188
+ "message",
189
+ "sessionId",
190
+ "durationMs",
191
+ "fields",
192
+ "error",
193
+ ]);
194
+
195
+ /**
196
+ * Copy only the allowed properties of a record.
197
+ *
198
+ * This is the allowlist in action: a property the contract does not know is
199
+ * dropped before anything else happens to the record.
200
+ */
201
+ function pickAllowedProperties(input: Record<string, unknown>): Record<string, unknown> {
202
+ const picked: Record<string, unknown> = {};
203
+ for (const key of ALLOWED_RECORD_KEYS) {
204
+ if (key in input) picked[key] = input[key];
205
+ }
206
+ return picked;
207
+ }
208
+
209
+ export type SanitizeRecordResult =
210
+ | { ok: true; record: LogRecord }
211
+ | { ok: false; error: BatchValidationError };
212
+
213
+ /**
214
+ * Rebuild one record from the allowlist and redact every string.
215
+ *
216
+ * Unknown properties are dropped, `error.name`, `error.message` and
217
+ * `error.stack` are redacted, and structured fields lose the keys that can
218
+ * carry private content.
219
+ */
220
+ export function sanitizeRecord(
221
+ input: unknown,
222
+ limits: Partial<BatchValidationLimits> = {},
223
+ ): SanitizeRecordResult {
224
+ const merged = { ...DEFAULT_BATCH_LIMITS, ...limits };
225
+ if (!isPlainObject(input)) {
226
+ return { ok: false, error: { code: "invalid_record", message: "record must be an object" } };
227
+ }
228
+
229
+ const allowed = pickAllowedProperties(input);
230
+
231
+ const ts = allowed.ts;
232
+ if (typeof ts !== "string" || !isIsoTimestamp(ts)) {
233
+ return { ok: false, error: { code: "invalid_ts", message: "ts is not a timestamp" } };
234
+ }
235
+
236
+ const level = allowed.level;
237
+ if (typeof level !== "string" || !LOG_LEVELS.includes(level as LogLevel)) {
238
+ return { ok: false, error: { code: "invalid_level", message: "level is not known" } };
239
+ }
240
+
241
+ const source = allowed.source;
242
+ if (typeof source !== "string" || source.length === 0 || source.length > 128) {
243
+ return { ok: false, error: { code: "invalid_source", message: "source is missing or malformed" } };
244
+ }
245
+
246
+ const event = allowed.event;
247
+ if (typeof event !== "string" || event.length === 0 || event.length > 128) {
248
+ return { ok: false, error: { code: "invalid_event", message: "event is missing or malformed" } };
249
+ }
250
+
251
+ const record: LogRecord = {
252
+ ts,
253
+ level: level as LogLevel,
254
+ source,
255
+ event,
256
+ };
257
+
258
+ if (typeof allowed.message === "string") {
259
+ record.message = redactText(allowed.message).slice(0, merged.maxStringLength);
260
+ }
261
+ if (typeof allowed.sessionId === "string") {
262
+ record.sessionId = allowed.sessionId.slice(0, 128);
263
+ }
264
+ if (typeof allowed.durationMs === "number" && Number.isFinite(allowed.durationMs)) {
265
+ record.durationMs = allowed.durationMs;
266
+ }
267
+
268
+ if (isPlainObject(allowed.fields)) {
269
+ const fields: Record<string, unknown> = {};
270
+ let kept = 0;
271
+ for (const [key, value] of Object.entries(allowed.fields)) {
272
+ if (kept >= merged.maxFieldKeys) break;
273
+ if (isLocalOnlyFieldKey(key)) continue;
274
+ fields[key] = redactValue(value);
275
+ kept++;
276
+ }
277
+ if (Object.keys(fields).length > 0) record.fields = fields;
278
+ }
279
+
280
+ if (isPlainObject(allowed.error)) {
281
+ const error: LogErrorInfo = { name: "Error", message: "" };
282
+ if (typeof allowed.error.name === "string") {
283
+ error.name = redactText(allowed.error.name).slice(0, 256);
284
+ }
285
+ if (typeof allowed.error.message === "string") {
286
+ error.message = redactText(allowed.error.message).slice(0, merged.maxStringLength);
287
+ }
288
+ if (typeof allowed.error.stack === "string") {
289
+ error.stack = redactText(allowed.error.stack).slice(0, merged.maxStringLength);
290
+ }
291
+ record.error = error;
292
+ }
293
+
294
+ return { ok: true, record };
295
+ }