@principles/host-runtime 0.1.0 → 0.1.1

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.
@@ -0,0 +1,18 @@
1
+ /** Adapter-owned facts consumed by the non-bypassable RuleCode promotion gate. */
2
+ export const OPENCLAW_HOST_LIVENESS_CONTRACT = {
3
+ version: 'openclaw-legacy@1',
4
+ supportsShadowEvidence: true,
5
+ outOfBandControls: ['activation_deactivate', 'global_rulecode_pause', 'owner_review_console'],
6
+ protectedCapabilities: [
7
+ { capabilityId: 'pd_status', hostToolAliases: ['bash', 'exec_command'] },
8
+ { capabilityId: 'rulecode_deactivate', hostToolAliases: ['bash', 'exec_command'] },
9
+ { capabilityId: 'rulecode_global_pause', hostToolAliases: ['bash', 'exec_command'] },
10
+ { capabilityId: 'owner_review_access', hostToolAliases: ['owner_review_access'] },
11
+ ],
12
+ neutralProbes: [
13
+ { probeId: 'probe-pd-status', capabilityId: 'pd_status', toolName: 'bash', params: { command: 'pd status' }, expectedDecision: 'allow' },
14
+ { probeId: 'probe-rule-deactivate', capabilityId: 'rulecode_deactivate', toolName: 'bash', params: { command: 'pd activation deactivate --activation-id test' }, expectedDecision: 'allow' },
15
+ { probeId: 'probe-global-pause', capabilityId: 'rulecode_global_pause', toolName: 'bash', params: { command: 'pd activation emergency-pause' }, expectedDecision: 'allow' },
16
+ { probeId: 'probe-owner-review', capabilityId: 'owner_review_access', toolName: 'owner_review_access', params: {}, expectedDecision: 'allow' },
17
+ ],
18
+ };
package/dist/index.d.ts CHANGED
@@ -8,6 +8,13 @@ export * from './pd-config.js';
8
8
  export * from './production-rulehost-gate.js';
9
9
  export * from './rule-implementation-runtime.js';
10
10
  export * from './production-pain-evidence.js';
11
+ export * from './governance-observation-store.js';
12
+ export * from './host-liveness-contract.js';
13
+ export * from './product-telemetry/consent-store.js';
14
+ export * from './product-telemetry/eligibility.js';
15
+ export * from './product-telemetry/exporter.js';
16
+ export * from './product-telemetry/milestone-readers.js';
17
+ export * from './product-telemetry/service.js';
11
18
  export declare const HOST_RUNTIME_ROUTES: readonly ['before_prompt_build', 'before_tool_call', 'after_tool_call'];
12
19
  export type HostRuntimeRoute = (typeof HOST_RUNTIME_ROUTES)[number];
13
20
  export type HostRuntimePort = (event: HostEvent) => HostEventResult | Promise<HostEventResult>;
package/dist/index.js CHANGED
@@ -7,6 +7,15 @@ export * from './pd-config.js';
7
7
  export * from './production-rulehost-gate.js';
8
8
  export * from './rule-implementation-runtime.js';
9
9
  export * from './production-pain-evidence.js';
10
+ export * from './governance-observation-store.js';
11
+ export * from './host-liveness-contract.js';
12
+ // Anonymous Product Telemetry v1 (PRI-595~603) — opt-in, default-off,
13
+ // read-only with respect to all PD governance facts.
14
+ export * from './product-telemetry/consent-store.js';
15
+ export * from './product-telemetry/eligibility.js';
16
+ export * from './product-telemetry/exporter.js';
17
+ export * from './product-telemetry/milestone-readers.js';
18
+ export * from './product-telemetry/service.js';
10
19
  export const HOST_RUNTIME_ROUTES = [
11
20
  'before_prompt_build',
12
21
  'before_tool_call',
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Retired RuleHost contract symbol scan — host-runtime local copy.
3
+ *
4
+ * The shared canonical scanner lives in @principles/core
5
+ * (internalization/legacy-rule-contract-scanner.ts) and is used by the
6
+ * plugin, console, installer, and pd-cli. This module deliberately does NOT
7
+ * import it: the published codex-adapter bundle installs host-runtime
8
+ * against the CURRENTLY PUBLISHED @principles/core from the npm registry,
9
+ * and a new runtime import would break clean installs until the next core
10
+ * release (see tests/published-codex-adapter-bundle.test.ts — it enforces
11
+ * exactly this constraint). Keep the symbol patterns in sync with the core
12
+ * scanner; the host-runtime tests cover the same detection behavior.
13
+ */
14
+ /**
15
+ * Conservative scan of persisted RuleCode source for retired RuleHost
16
+ * contract symbols (field reads and helper calls). A hit means the rule must
17
+ * NOT execute against the current contract — its reads would silently
18
+ * resolve to undefined and change owner-approved behavior.
19
+ */
20
+ export declare function scanRetiredContractSymbols(implementationCode: string): string[];
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Retired RuleHost contract symbol scan — host-runtime local copy.
3
+ *
4
+ * The shared canonical scanner lives in @principles/core
5
+ * (internalization/legacy-rule-contract-scanner.ts) and is used by the
6
+ * plugin, console, installer, and pd-cli. This module deliberately does NOT
7
+ * import it: the published codex-adapter bundle installs host-runtime
8
+ * against the CURRENTLY PUBLISHED @principles/core from the npm registry,
9
+ * and a new runtime import would break clean installs until the next core
10
+ * release (see tests/published-codex-adapter-bundle.test.ts — it enforces
11
+ * exactly this constraint). Keep the symbol patterns in sync with the core
12
+ * scanner; the host-runtime tests cover the same detection behavior.
13
+ */
14
+ // Helper-call form is listed before the bare-word form; the field pattern's
15
+ // negative lookahead prevents one hasPlanFile( call reporting twice.
16
+ const RETIRED_SYMBOL_PATTERNS = [
17
+ { symbol: 'recentThinking', pattern: /\brecentThinking\b/ },
18
+ { symbol: 'planStatus', pattern: /\bplanStatus\b/ },
19
+ { symbol: 'getPlanStatus', pattern: /\bgetPlanStatus\s*\(/ },
20
+ { symbol: 'hasPlanFileHelper', pattern: /\bhasPlanFile\s*\(/ },
21
+ { symbol: 'hasPlanFile', pattern: /\bhasPlanFile\b(?!\s*\()/ },
22
+ ];
23
+ function maskNonExecutableText(source) {
24
+ let state = 'code';
25
+ let templateExpressionDepth = 0;
26
+ let masked = '';
27
+ for (let index = 0; index < source.length; index += 1) {
28
+ const char = source[index] ?? '';
29
+ const next = source[index + 1] ?? '';
30
+ if (state === 'code') {
31
+ if (templateExpressionDepth > 0 && char === '{') {
32
+ templateExpressionDepth += 1;
33
+ masked += char;
34
+ }
35
+ else if (templateExpressionDepth > 0 && char === '}') {
36
+ templateExpressionDepth -= 1;
37
+ masked += char;
38
+ if (templateExpressionDepth === 0)
39
+ state = 'template';
40
+ }
41
+ else if (char === '/' && next === '/') {
42
+ masked += ' ';
43
+ index += 1;
44
+ state = 'line_comment';
45
+ }
46
+ else if (char === '/' && next === '*') {
47
+ masked += ' ';
48
+ index += 1;
49
+ state = 'block_comment';
50
+ }
51
+ else if (char === "'") {
52
+ masked += ' ';
53
+ state = 'single_quote';
54
+ }
55
+ else if (char === '"') {
56
+ masked += ' ';
57
+ state = 'double_quote';
58
+ }
59
+ else if (char === '`') {
60
+ masked += ' ';
61
+ state = 'template';
62
+ }
63
+ else {
64
+ masked += char;
65
+ }
66
+ continue;
67
+ }
68
+ if (state === 'line_comment') {
69
+ masked += char === '\n' || char === '\r' ? char : ' ';
70
+ if (char === '\n' || char === '\r')
71
+ state = 'code';
72
+ continue;
73
+ }
74
+ if (state === 'block_comment') {
75
+ if (char === '*' && next === '/') {
76
+ masked += ' ';
77
+ index += 1;
78
+ state = 'code';
79
+ }
80
+ else {
81
+ masked += char === '\n' || char === '\r' ? char : ' ';
82
+ }
83
+ continue;
84
+ }
85
+ if (state === 'template' && char === '$' && next === '{') {
86
+ masked += ' {';
87
+ index += 1;
88
+ templateExpressionDepth = 1;
89
+ state = 'code';
90
+ continue;
91
+ }
92
+ if (char === '\\') {
93
+ masked += ' ';
94
+ if (index + 1 < source.length) {
95
+ masked += source[index + 1] === '\n' || source[index + 1] === '\r' ? source[index + 1] : ' ';
96
+ index += 1;
97
+ }
98
+ continue;
99
+ }
100
+ const closesLiteral = (state === 'single_quote' && char === "'") ||
101
+ (state === 'double_quote' && char === '"') ||
102
+ (state === 'template' && char === '`');
103
+ masked += char === '\n' || char === '\r' ? char : ' ';
104
+ if (closesLiteral)
105
+ state = 'code';
106
+ }
107
+ return masked;
108
+ }
109
+ /**
110
+ * Conservative scan of persisted RuleCode source for retired RuleHost
111
+ * contract symbols (field reads and helper calls). A hit means the rule must
112
+ * NOT execute against the current contract — its reads would silently
113
+ * resolve to undefined and change owner-approved behavior.
114
+ */
115
+ export function scanRetiredContractSymbols(implementationCode) {
116
+ const executableSource = maskNonExecutableText(implementationCode);
117
+ const symbols = [];
118
+ for (const { symbol, pattern } of RETIRED_SYMBOL_PATTERNS) {
119
+ if (pattern.test(executableSource)) {
120
+ symbols.push(symbol);
121
+ }
122
+ }
123
+ return symbols;
124
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Product telemetry control-state store — Anonymous Product Telemetry v1
3
+ * (PRI-597, SPEC §44; review remediation: workspace-scoped export state).
4
+ *
5
+ * Persists the telemetry control state to `~/.pd/product-telemetry.json`.
6
+ * This file is Telemetry Control State — it never enters
7
+ * Principle/Pain/receipt/governance stores, and the secret never leaves the
8
+ * machine.
9
+ *
10
+ * Scope model (schema v2, review remediation P1-1):
11
+ * - MACHINE scope: consent, consentVersion, telemetrySecret.
12
+ * - WORKSPACE scope: `workspaceExports[scopeId]` — per-workspace dedup,
13
+ * retry, and attempt bookkeeping keyed by the opaque local scope ID
14
+ * (HMAC(secret, canonical workspace path); never uploaded). One
15
+ * workspace succeeding must never suppress another workspace's export.
16
+ *
17
+ * Migration from schema v1: consent, consentVersion, and telemetrySecret are
18
+ * preserved; legacy machine-global export bookkeeping (lastSucceededAt,
19
+ * retry state, attempt counters) is DISCARDED — it cannot be attributed to a
20
+ * workspace, and it is operational state, not a governance fact. Dropping it
21
+ * can cause at most one extra same-day snapshot per installation.
22
+ */
23
+ export declare const PRODUCT_TELEMETRY_STATE_FILENAME = "product-telemetry.json";
24
+ export declare const PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION = "2";
25
+ /** v1 files are still read (then migrated in memory and rewritten as v2). */
26
+ export declare const PRODUCT_TELEMETRY_LEGACY_CONTROL_SCHEMA_VERSION = "1";
27
+ /** Hard bound on tracked workspaces so the file cannot grow unboundedly. */
28
+ export declare const MAX_WORKSPACE_EXPORT_ENTRIES = 200;
29
+ /**
30
+ * Entries untouched for this long are pruned on the next write. This is local
31
+ * telemetry operational state only — no workspace history is retained.
32
+ */
33
+ export declare const WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS = 30;
34
+ export type ProductTelemetryConsent = 'unset' | 'granted' | 'denied';
35
+ /** Per-workspace export bookkeeping (operational state; never exported). */
36
+ export interface WorkspaceExportState {
37
+ lastAttemptedAt?: string;
38
+ lastSucceededAt?: string;
39
+ /** Coarse failure code (TelemetryFailureCode). No response bodies. */
40
+ lastFailureCode?: string;
41
+ nextRetryAt?: string;
42
+ /** Failed export attempts in the current attemptBucketDate (0–99; hard cap enforced by the service). */
43
+ dailyAttemptCount?: number;
44
+ /** UTC date bucket the dailyAttemptCount belongs to (resets on day change). */
45
+ attemptBucketDate?: string;
46
+ }
47
+ export interface ProductTelemetryControlState {
48
+ consent: ProductTelemetryConsent;
49
+ consentVersion: string;
50
+ /** Cryptographically random hex secret. Never uploaded. */
51
+ telemetrySecret?: string;
52
+ /** Per-workspace export bookkeeping keyed by opaque local scope ID. */
53
+ workspaceExports?: Record<string, WorkspaceExportState>;
54
+ schemaVersion: string;
55
+ }
56
+ export type ControlStateRead = {
57
+ ok: true;
58
+ state: ProductTelemetryControlState;
59
+ existed: boolean;
60
+ } | {
61
+ ok: false;
62
+ reason: string;
63
+ nextAction: string;
64
+ };
65
+ export type ControlStateWrite = {
66
+ ok: true;
67
+ } | {
68
+ ok: false;
69
+ reason: string;
70
+ nextAction: string;
71
+ };
72
+ export declare function defaultProductTelemetryControlState(): ProductTelemetryControlState;
73
+ export declare function getProductTelemetryStatePath(homeDir: string): string;
74
+ /**
75
+ * Read and validate the control state. A missing file is the normal
76
+ * never-configured case (defaults, existed=false). A malformed file is a
77
+ * loud failure — silently treating it as "unset" could re-prompt or re-export
78
+ * against the user's recorded decision (rc-3/rc-9).
79
+ *
80
+ * v1 files are migrated in memory (export bookkeeping dropped, consent
81
+ * identity preserved); the migration is persisted on the next write.
82
+ */
83
+ export declare function readProductTelemetryControlState(homeDir: string): ControlStateRead;
84
+ /**
85
+ * Drop `workspaceExports` entries whose most recent activity (attempt or
86
+ * success) is older than WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS. Bounded local
87
+ * operational state only — this is not a workspace history database.
88
+ */
89
+ export declare function pruneWorkspaceExports(state: ProductTelemetryControlState, nowMs: number): ProductTelemetryControlState;
90
+ /** Atomic write (temp file + rename) so a crash never truncates the state. */
91
+ export declare function writeProductTelemetryControlState(homeDir: string, state: ProductTelemetryControlState): ControlStateWrite;
92
+ /** State after `pd telemetry enable`: explicit granted consent + secret. */
93
+ export declare function grantedControlState(previous: ProductTelemetryControlState): ProductTelemetryControlState;
94
+ /**
95
+ * State after `pd telemetry disable`: consent denied and all export identity
96
+ * removed (SPEC §19). The explicit `denied` choice is preserved so PD never
97
+ * re-prompts.
98
+ */
99
+ export declare function deniedControlState(): ProductTelemetryControlState;
100
+ /**
101
+ * State after `pd telemetry reset` (SPEC §18): secret and export status are
102
+ * deleted — no future daily ID relates to previous ones. The consent choice
103
+ * is preserved; a fresh secret is generated only while telemetry remains
104
+ * enabled. Workspace bookkeeping keyed under the OLD secret's scope IDs is
105
+ * dropped with it (the IDs are meaningless under a new secret).
106
+ */
107
+ export declare function resetControlState(previous: ProductTelemetryControlState): ProductTelemetryControlState;
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Product telemetry control-state store — Anonymous Product Telemetry v1
3
+ * (PRI-597, SPEC §44; review remediation: workspace-scoped export state).
4
+ *
5
+ * Persists the telemetry control state to `~/.pd/product-telemetry.json`.
6
+ * This file is Telemetry Control State — it never enters
7
+ * Principle/Pain/receipt/governance stores, and the secret never leaves the
8
+ * machine.
9
+ *
10
+ * Scope model (schema v2, review remediation P1-1):
11
+ * - MACHINE scope: consent, consentVersion, telemetrySecret.
12
+ * - WORKSPACE scope: `workspaceExports[scopeId]` — per-workspace dedup,
13
+ * retry, and attempt bookkeeping keyed by the opaque local scope ID
14
+ * (HMAC(secret, canonical workspace path); never uploaded). One
15
+ * workspace succeeding must never suppress another workspace's export.
16
+ *
17
+ * Migration from schema v1: consent, consentVersion, and telemetrySecret are
18
+ * preserved; legacy machine-global export bookkeeping (lastSucceededAt,
19
+ * retry state, attempt counters) is DISCARDED — it cannot be attributed to a
20
+ * workspace, and it is operational state, not a governance fact. Dropping it
21
+ * can cause at most one extra same-day snapshot per installation.
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { generateTelemetrySecretHex, isValidBucketDate, isValidTelemetrySecretHex, PRODUCT_TELEMETRY_CONSENT_VERSION, } from '@principles/core/runtime-v2';
26
+ export const PRODUCT_TELEMETRY_STATE_FILENAME = 'product-telemetry.json';
27
+ export const PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION = '2';
28
+ /** v1 files are still read (then migrated in memory and rewritten as v2). */
29
+ export const PRODUCT_TELEMETRY_LEGACY_CONTROL_SCHEMA_VERSION = '1';
30
+ /** Hard bound on tracked workspaces so the file cannot grow unboundedly. */
31
+ export const MAX_WORKSPACE_EXPORT_ENTRIES = 200;
32
+ /**
33
+ * Entries untouched for this long are pruned on the next write. This is local
34
+ * telemetry operational state only — no workspace history is retained.
35
+ */
36
+ export const WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS = 30;
37
+ export function defaultProductTelemetryControlState() {
38
+ return { consent: 'unset', consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION, schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION };
39
+ }
40
+ export function getProductTelemetryStatePath(homeDir) {
41
+ return path.join(path.resolve(homeDir), '.pd', PRODUCT_TELEMETRY_STATE_FILENAME);
42
+ }
43
+ function isConsent(value) {
44
+ return value === 'unset' || value === 'granted' || value === 'denied';
45
+ }
46
+ /**
47
+ * Optional timestamp fields must be parseable dates, not just short strings —
48
+ * a "garbage" lastSucceededAt/nextRetryAt would otherwise slip validation and
49
+ * turn into NaN inside Date.parse, silently skipping same-day dedup or retry
50
+ * backoff (fail-loud contract, review round 2).
51
+ */
52
+ function isOptionalIsoString(value) {
53
+ if (value === undefined)
54
+ return true;
55
+ if (typeof value !== 'string' || value.length === 0 || value.length > 40)
56
+ return false;
57
+ return !Number.isNaN(Date.parse(value));
58
+ }
59
+ function isOptionalAttemptBucketDate(value) {
60
+ if (value === undefined)
61
+ return true;
62
+ return isValidBucketDate(value);
63
+ }
64
+ function isOptionalAttemptCount(value) {
65
+ return value === undefined || (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 99);
66
+ }
67
+ function isOptionalFailureCode(value) {
68
+ return value === undefined || (typeof value === 'string' && value.length > 0 && value.length <= 40);
69
+ }
70
+ function isScopeKey(value) {
71
+ return typeof value === 'string' && /^[0-9a-f]{4,32}$/.test(value);
72
+ }
73
+ /** Validate one `workspaceExports` entry; collect errors under `prefix`. */
74
+ function workspaceExportErrors(entry, prefix) {
75
+ const errors = [];
76
+ for (const key of Object.keys(entry)) {
77
+ if (!['lastAttemptedAt', 'lastSucceededAt', 'lastFailureCode', 'nextRetryAt', 'dailyAttemptCount', 'attemptBucketDate'].includes(key)) {
78
+ errors.push(`${prefix}: unknown field '${key}'`);
79
+ }
80
+ }
81
+ if (!isOptionalIsoString(entry.lastAttemptedAt) || !isOptionalIsoString(entry.lastSucceededAt) || !isOptionalIsoString(entry.nextRetryAt)) {
82
+ errors.push(`${prefix}: timestamps must be parseable ISO-8601 strings (≤40 chars) when present`);
83
+ }
84
+ if (!isOptionalAttemptCount(entry.dailyAttemptCount))
85
+ errors.push(`${prefix}: dailyAttemptCount must be an integer 0–99 when present`);
86
+ if (!isOptionalAttemptBucketDate(entry.attemptBucketDate))
87
+ errors.push(`${prefix}: attemptBucketDate must be a valid YYYY-MM-DD UTC date when present`);
88
+ if (!isOptionalFailureCode(entry.lastFailureCode))
89
+ errors.push(`${prefix}: lastFailureCode must be a short non-empty string when present`);
90
+ return errors;
91
+ }
92
+ function isPlainRecord(value) {
93
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
94
+ }
95
+ /**
96
+ * Read and validate the control state. A missing file is the normal
97
+ * never-configured case (defaults, existed=false). A malformed file is a
98
+ * loud failure — silently treating it as "unset" could re-prompt or re-export
99
+ * against the user's recorded decision (rc-3/rc-9).
100
+ *
101
+ * v1 files are migrated in memory (export bookkeeping dropped, consent
102
+ * identity preserved); the migration is persisted on the next write.
103
+ */
104
+ export function readProductTelemetryControlState(homeDir) {
105
+ const filePath = getProductTelemetryStatePath(homeDir);
106
+ let raw;
107
+ try {
108
+ raw = fs.readFileSync(filePath, 'utf8');
109
+ }
110
+ catch (error) {
111
+ // Guarded code access (rc-1/rc-2: runtime check before use; rc-5:
112
+ // Object.hasOwn on untrusted objects; no NodeJS namespace in this config).
113
+ const codeValue = typeof error === 'object' && error !== null && Object.hasOwn(error, 'code')
114
+ ? error.code
115
+ : undefined;
116
+ const code = typeof codeValue === 'string' ? codeValue : undefined;
117
+ if (code === 'ENOENT') {
118
+ return { ok: true, state: defaultProductTelemetryControlState(), existed: false };
119
+ }
120
+ return {
121
+ ok: false,
122
+ reason: `product_telemetry_state_unreadable: ${code ?? String(error)}`,
123
+ nextAction: `Check permissions on ${filePath}`,
124
+ };
125
+ }
126
+ let parsed;
127
+ try {
128
+ parsed = JSON.parse(raw);
129
+ }
130
+ catch {
131
+ return {
132
+ ok: false,
133
+ reason: 'product_telemetry_state_malformed_json',
134
+ nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
135
+ };
136
+ }
137
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
138
+ return {
139
+ ok: false,
140
+ reason: 'product_telemetry_state_malformed_shape',
141
+ nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
142
+ };
143
+ }
144
+ const obj = parsed;
145
+ const { schemaVersion } = obj;
146
+ const isV1 = schemaVersion === PRODUCT_TELEMETRY_LEGACY_CONTROL_SCHEMA_VERSION;
147
+ const isV2 = schemaVersion === PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION;
148
+ if (!isV1 && !isV2) {
149
+ return {
150
+ ok: false,
151
+ reason: `product_telemetry_state_malformed: schemaVersion must be '${PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION}' or legacy '${PRODUCT_TELEMETRY_LEGACY_CONTROL_SCHEMA_VERSION}'`,
152
+ nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
153
+ };
154
+ }
155
+ // v1 machine-global export fields exist only in v1 files; v2 files carry
156
+ // workspaceExports instead. Unknown keys are rejected per shape.
157
+ const allowedKeys = isV1
158
+ ? ['consent', 'consentVersion', 'telemetrySecret', 'lastAttemptedAt', 'lastSucceededAt', 'lastFailureCode', 'nextRetryAt', 'dailyAttemptCount', 'attemptBucketDate', 'schemaVersion']
159
+ : ['consent', 'consentVersion', 'telemetrySecret', 'workspaceExports', 'schemaVersion'];
160
+ const errors = [];
161
+ for (const key of Object.keys(obj)) {
162
+ if (!allowedKeys.includes(key)) {
163
+ errors.push(`unknown field '${key}'`);
164
+ }
165
+ }
166
+ if (!isConsent(obj.consent))
167
+ errors.push('consent must be unset|granted|denied');
168
+ if (typeof obj.consentVersion !== 'string' || obj.consentVersion.length === 0 || obj.consentVersion.length > 8)
169
+ errors.push('consentVersion must be a short non-empty string');
170
+ if (!isValidTelemetrySecretHex(obj.telemetrySecret) && obj.telemetrySecret !== undefined)
171
+ errors.push('telemetrySecret must be 64 hex chars when present');
172
+ if (isV1) {
173
+ // v1 legacy export fields are migrated by DISCARDING them — validating
174
+ // values that are about to be dropped would only turn a smooth migration
175
+ // into a hard failure (the identity fields carry the user's decision).
176
+ // Only the field-NAME allowlist above still applies to v1 files.
177
+ }
178
+ else {
179
+ if (obj.workspaceExports !== undefined) {
180
+ if (typeof obj.workspaceExports !== 'object' || obj.workspaceExports === null || Array.isArray(obj.workspaceExports)) {
181
+ errors.push('workspaceExports must be an object');
182
+ }
183
+ else {
184
+ const entries = obj.workspaceExports;
185
+ const scopeKeys = Object.keys(entries);
186
+ if (scopeKeys.length > MAX_WORKSPACE_EXPORT_ENTRIES) {
187
+ errors.push(`workspaceExports must track at most ${MAX_WORKSPACE_EXPORT_ENTRIES} workspaces`);
188
+ }
189
+ for (const scopeKey of scopeKeys) {
190
+ if (!isScopeKey(scopeKey)) {
191
+ errors.push(`workspaceExports key '${scopeKey}' must be 4–32 hex chars`);
192
+ continue;
193
+ }
194
+ const entry = entries[scopeKey];
195
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
196
+ errors.push(`workspaceExports['${scopeKey}'] must be an object`);
197
+ continue;
198
+ }
199
+ errors.push(...workspaceExportErrors(entry, `workspaceExports['${scopeKey}']`));
200
+ }
201
+ }
202
+ }
203
+ }
204
+ if (errors.length > 0) {
205
+ return {
206
+ ok: false,
207
+ reason: `product_telemetry_state_malformed: ${errors.join('; ')}`,
208
+ nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
209
+ };
210
+ }
211
+ // Post-validation reconstruction from guard-narrowed fields — no `as` on
212
+ // the untrusted parsed object (rc-2). isConsent is a type guard; the
213
+ // optional-field spreads carry only guard-passing values. v1 export
214
+ // bookkeeping is deliberately NOT carried over (see module doc).
215
+ const state = {
216
+ consent: isConsent(obj.consent) ? obj.consent : 'unset',
217
+ consentVersion: typeof obj.consentVersion === 'string' ? obj.consentVersion : PRODUCT_TELEMETRY_CONSENT_VERSION,
218
+ ...(isValidTelemetrySecretHex(obj.telemetrySecret) ? { telemetrySecret: obj.telemetrySecret } : {}),
219
+ ...(isV2 && isPlainRecord(obj.workspaceExports)
220
+ ? {
221
+ workspaceExports: Object.fromEntries(Object.entries(obj.workspaceExports)
222
+ .filter(([scopeKey, entry]) => isScopeKey(scopeKey) && isPlainRecord(entry))
223
+ .map(([scopeKey, entry]) => {
224
+ const record = entry;
225
+ return [
226
+ scopeKey,
227
+ {
228
+ ...(isOptionalIsoString(record.lastAttemptedAt) && record.lastAttemptedAt !== undefined ? { lastAttemptedAt: record.lastAttemptedAt } : {}),
229
+ ...(isOptionalIsoString(record.lastSucceededAt) && record.lastSucceededAt !== undefined ? { lastSucceededAt: record.lastSucceededAt } : {}),
230
+ ...(isOptionalIsoString(record.nextRetryAt) && record.nextRetryAt !== undefined ? { nextRetryAt: record.nextRetryAt } : {}),
231
+ ...(isOptionalFailureCode(record.lastFailureCode) && record.lastFailureCode !== undefined ? { lastFailureCode: record.lastFailureCode } : {}),
232
+ ...(isOptionalAttemptCount(record.dailyAttemptCount) && record.dailyAttemptCount !== undefined ? { dailyAttemptCount: record.dailyAttemptCount } : {}),
233
+ ...(isOptionalAttemptBucketDate(record.attemptBucketDate) && record.attemptBucketDate !== undefined ? { attemptBucketDate: record.attemptBucketDate } : {}),
234
+ },
235
+ ];
236
+ })),
237
+ }
238
+ : {}),
239
+ schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION,
240
+ };
241
+ if (state.workspaceExports !== undefined && Object.keys(state.workspaceExports).length === 0) {
242
+ delete state.workspaceExports;
243
+ }
244
+ return { ok: true, state, existed: true };
245
+ }
246
+ /**
247
+ * Drop `workspaceExports` entries whose most recent activity (attempt or
248
+ * success) is older than WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS. Bounded local
249
+ * operational state only — this is not a workspace history database.
250
+ */
251
+ export function pruneWorkspaceExports(state, nowMs) {
252
+ const exports = state.workspaceExports;
253
+ if (exports === undefined)
254
+ return state;
255
+ const cutoff = nowMs - WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
256
+ const kept = {};
257
+ for (const [scopeKey, entry] of Object.entries(exports)) {
258
+ const stamps = [entry.lastAttemptedAt, entry.lastSucceededAt].filter((s) => s !== undefined).map((s) => Date.parse(s));
259
+ const latest = stamps.length > 0 ? Math.max(...stamps) : Number.NEGATIVE_INFINITY;
260
+ if (Number.isNaN(latest) || latest < cutoff)
261
+ continue;
262
+ kept[scopeKey] = entry;
263
+ }
264
+ const next = { ...state };
265
+ if (Object.keys(kept).length > 0)
266
+ next.workspaceExports = kept;
267
+ else
268
+ delete next.workspaceExports;
269
+ return next;
270
+ }
271
+ /** Atomic write (temp file + rename) so a crash never truncates the state. */
272
+ export function writeProductTelemetryControlState(homeDir, state) {
273
+ const filePath = getProductTelemetryStatePath(homeDir);
274
+ const dir = path.dirname(filePath);
275
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
276
+ try {
277
+ fs.mkdirSync(dir, { recursive: true });
278
+ fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
279
+ fs.renameSync(tmpPath, filePath);
280
+ return { ok: true };
281
+ }
282
+ catch (error) {
283
+ const message = error instanceof Error ? error.message : String(error);
284
+ try {
285
+ fs.rmSync(tmpPath, { force: true });
286
+ }
287
+ catch {
288
+ // best-effort cleanup; the write failure below is the loud signal
289
+ }
290
+ return {
291
+ ok: false,
292
+ reason: `product_telemetry_state_write_failed: ${message}`,
293
+ nextAction: `Check permissions on ${dir}`,
294
+ };
295
+ }
296
+ }
297
+ /** State after `pd telemetry enable`: explicit granted consent + secret. */
298
+ export function grantedControlState(previous) {
299
+ return {
300
+ consent: 'granted',
301
+ consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION,
302
+ telemetrySecret: isValidTelemetrySecretHex(previous.telemetrySecret) ? previous.telemetrySecret : generateTelemetrySecretHex(),
303
+ ...(previous.workspaceExports !== undefined ? { workspaceExports: previous.workspaceExports } : {}),
304
+ schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION,
305
+ };
306
+ }
307
+ /**
308
+ * State after `pd telemetry disable`: consent denied and all export identity
309
+ * removed (SPEC §19). The explicit `denied` choice is preserved so PD never
310
+ * re-prompts.
311
+ */
312
+ export function deniedControlState() {
313
+ return { consent: 'denied', consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION, schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION };
314
+ }
315
+ /**
316
+ * State after `pd telemetry reset` (SPEC §18): secret and export status are
317
+ * deleted — no future daily ID relates to previous ones. The consent choice
318
+ * is preserved; a fresh secret is generated only while telemetry remains
319
+ * enabled. Workspace bookkeeping keyed under the OLD secret's scope IDs is
320
+ * dropped with it (the IDs are meaningless under a new secret).
321
+ */
322
+ export function resetControlState(previous) {
323
+ return {
324
+ consent: previous.consent,
325
+ consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION,
326
+ ...(previous.consent === 'granted' ? { telemetrySecret: generateTelemetrySecretHex() } : {}),
327
+ schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION,
328
+ };
329
+ }