@openpalm/lib 0.10.2 → 0.11.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/redact-schema.ts +0 -50
@@ -1,61 +1,34 @@
1
1
  /**
2
2
  * Runtime configuration validation for the OpenPalm control plane.
3
3
  *
4
- * Proposed changes are validated against temp copies before writing
5
- * to live paths.
4
+ * Validation is a presence check on the canonical env keys we expect in
5
+ * the live config/stack/stack.env file. The
6
+ * historical schema files and external validation binary were retired in
7
+ * #391; everything advisory is surfaced as a non-blocking warning. The
8
+ * function never shells out and never reads schemas.
6
9
  */
7
- import { existsSync, copyFileSync, mkdirSync, rmSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { mkdtempSync } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { execFile } from "node:child_process";
12
- import { promisify } from "node:util";
10
+ import { existsSync } from "node:fs";
11
+ import { readStackEnv } from "./secrets.js";
12
+ import { getCoreSecretMappings } from "./secret-mappings.js";
13
13
  import type { ControlPlaneState } from "./types.js";
14
14
 
15
- const execFileAsync = promisify(execFile);
16
-
17
- /** Resolve the varlock binary path — honours VARLOCK_BIN for dev environments. */
18
- const envVarlockBin = process.env.VARLOCK_BIN;
19
- let VARLOCK_BIN = "varlock";
20
- if (envVarlockBin) {
21
- if (envVarlockBin === "varlock" || envVarlockBin.startsWith("/")) {
22
- VARLOCK_BIN = envVarlockBin;
23
- }
24
- }
25
-
26
- function sanitizeVarlockMessage(msg: string): string {
27
- return msg
28
- .replace(/sk-[A-Za-z0-9]{20,}/g, "[REDACTED]")
29
- .replace(/gsk_[A-Za-z0-9]{30,}/g, "[REDACTED]")
30
- .replace(/AIza[A-Za-z0-9_\-]{35}/g, "[REDACTED]")
31
- .replace(/[0-9a-f]{32,}/gi, "[REDACTED]")
32
- .replace(/value '([^']*)'/g, "value '[REDACTED]'");
33
- }
34
-
35
- async function runVarlockLoad(
36
- schemaFile: string,
37
- envFile: string,
38
- ): Promise<void> {
39
- const tmpDir = mkdtempSync(join(tmpdir(), "varlock-"));
40
- try {
41
- copyFileSync(schemaFile, join(tmpDir, ".env.schema"));
42
- copyFileSync(envFile, join(tmpDir, ".env"));
43
- await execFileAsync(
44
- VARLOCK_BIN,
45
- ["load", "--path", `${tmpDir}/`],
46
- { timeout: 10000 },
47
- );
48
- } finally {
49
- rmSync(tmpDir, { recursive: true, force: true });
50
- }
51
- }
15
+ // Stack-scoped env keys that must always exist and carry a non-empty value
16
+ // for the platform to boot. Keep this list small — anything optional
17
+ // belongs in the warning bucket instead.
18
+ const REQUIRED_STACK_KEYS = ["OP_UI_LOGIN_PASSWORD"] as const;
52
19
 
53
20
  /**
54
- * Validate the current live configuration files in place.
21
+ * Validate the live configuration files.
55
22
  *
56
23
  * Checks:
57
- * 1. vault/user/user.env against vault/user/user.env.schema
58
- * 2. vault/stack/stack.env against vault/stack/stack.env.schema
24
+ * 1. config/stack/stack.env exists and carries every required key with a
25
+ * non-empty value.
26
+ * 2. Every secret env key in getCoreSecretMappings() is present (key only
27
+ * — blank values are warned about, never erred on, because operators
28
+ * may opt out of providers they don't use).
29
+ *
30
+ * Errors fail the result. Warnings do not. The function never reads
31
+ * schema files and never spawns subprocesses.
59
32
  */
60
33
  export async function validateProposedState(state: ControlPlaneState): Promise<{
61
34
  ok: boolean;
@@ -64,44 +37,36 @@ export async function validateProposedState(state: ControlPlaneState): Promise<{
64
37
  }> {
65
38
  const errors: string[] = [];
66
39
  const warnings: string[] = [];
67
- let anyFailed = false;
68
40
 
69
- function collectOutput(stderr: string): void {
70
- for (const line of stderr.split("\n")) {
71
- const trimmed = sanitizeVarlockMessage(line.trim());
72
- if (!trimmed) continue;
73
- if (trimmed.includes("ERROR")) errors.push(trimmed);
74
- else if (trimmed.includes("WARN")) warnings.push(trimmed);
75
- }
41
+ const stackEnvPath = `${state.stackDir}/stack.env`;
42
+
43
+ if (!existsSync(stackEnvPath)) {
44
+ errors.push(`ERROR: stack env file missing at ${stackEnvPath}`);
45
+ return { ok: false, errors, warnings };
76
46
  }
77
47
 
78
- // Validate user.env
79
- const userEnvSchema = `${state.vaultDir}/user/user.env.schema`;
80
- const userEnv = `${state.vaultDir}/user/user.env`;
81
- if (existsSync(userEnvSchema) && existsSync(userEnv)) {
82
- try {
83
- await runVarlockLoad(userEnvSchema, userEnv);
84
- } catch (err: unknown) {
85
- anyFailed = true;
86
- if (err && typeof err === "object" && "stderr" in err) {
87
- collectOutput(String((err as { stderr: string }).stderr));
88
- }
48
+ const stackEnv = readStackEnv(state.stackDir);
49
+ const userEnv: Record<string, string> = {};
50
+
51
+ for (const key of REQUIRED_STACK_KEYS) {
52
+ const value = stackEnv[key];
53
+ if (!value || value.trim().length === 0) {
54
+ errors.push(`ERROR: required key ${key} is missing or empty in config/stack/stack.env`);
89
55
  }
90
56
  }
91
57
 
92
- // Validate stack.env
93
- const systemEnvSchema = `${state.vaultDir}/stack/stack.env.schema`;
94
- const systemEnv = `${state.vaultDir}/stack/stack.env`;
95
- if (existsSync(systemEnvSchema) && existsSync(systemEnv)) {
96
- try {
97
- await runVarlockLoad(systemEnvSchema, systemEnv);
98
- } catch (err: unknown) {
99
- anyFailed = true;
100
- if (err && typeof err === "object" && "stderr" in err) {
101
- collectOutput(String((err as { stderr: string }).stderr));
102
- }
58
+ // Every canonical secret should at least appear as a key somewhere in
59
+ // the env files so the operator sees the slot. Missing slots warn (not
60
+ // error) since not every provider is in use on every install.
61
+ for (const mapping of getCoreSecretMappings(stackEnv)) {
62
+ const inStack = Object.prototype.hasOwnProperty.call(stackEnv, mapping.envKey);
63
+ const inUser = Object.prototype.hasOwnProperty.call(userEnv, mapping.envKey);
64
+ if (!inStack && !inUser) {
65
+ warnings.push(
66
+ `WARN: ${mapping.envKey} (akm ${mapping.secretKey}) is not declared in config/stack/stack.env`,
67
+ );
103
68
  }
104
69
  }
105
70
 
106
- return { ok: !anyFailed && errors.length === 0, errors, warnings };
71
+ return { ok: errors.length === 0, errors, warnings };
107
72
  }
package/src/index.ts CHANGED
@@ -10,21 +10,25 @@
10
10
  export {
11
11
  LLM_PROVIDERS,
12
12
  EMBEDDING_DIMS,
13
+ lookupEmbeddingDims,
13
14
  } from "./provider-constants.js";
14
15
 
15
16
  // ── Logger ──────────────────────────────────────────────────────────────
16
- export { createLogger } from "./logger.js";
17
+ export {
18
+ createLogger,
19
+ isSensitiveEnvKey,
20
+ redactValue,
21
+ redactExtra,
22
+ } from "./logger.js";
17
23
 
18
24
  // ── Types ───────────────────────────────────────────────────────────────
19
25
  export type {
20
26
  ControlPlaneState,
21
27
  CoreServiceName,
22
28
  OptionalServiceName,
23
- AccessScope,
24
29
  ChannelInfo,
25
30
  CallerType,
26
31
  ArtifactMeta,
27
- AuditEntry,
28
32
  } from "./control-plane/types.js";
29
33
  export {
30
34
  CORE_SERVICES,
@@ -62,30 +66,35 @@ export {
62
66
  uninstallAutomation,
63
67
  } from "./control-plane/registry.js";
64
68
 
65
- // ── Home Layout (v0.10.0) ───────────────────────────────────────────────
69
+ // ── Home Layout (v0.11.0) ───────────────────────────────────────────────
66
70
  export {
67
71
  resolveOpenPalmHome,
68
72
  resolveConfigDir,
69
- resolveVaultDir,
70
- resolveDataDir,
73
+ resolveStashDir,
74
+ resolveWorkspaceDir,
75
+ resolveCacheDir,
76
+ resolveStateDir,
77
+ resolveStackDir,
71
78
  resolveLogsDir,
72
- resolveCacheHome,
73
- resolveRegistryDir,
74
- resolveRegistryAddonsDir,
75
- resolveRegistryAutomationsDir,
76
79
  ensureHomeDirs,
77
80
  } from "./control-plane/home.js";
78
81
 
82
+ // ── Path Resolution ─────────────────────────────────────────────────────
83
+ export * from "./control-plane/paths.js";
84
+
79
85
  // ── Env ─────────────────────────────────────────────────────────────────
80
86
  export {
81
87
  parseEnvContent,
82
88
  parseEnvFile,
89
+ expandEnvVars,
83
90
  mergeEnvContent,
91
+ removeEnvKey,
92
+ upsertEnvValue,
93
+ resolveRequestedImageTag,
94
+ reconcileStackEnvImageTag,
95
+ RELEASE_TAG_REGEX,
84
96
  } from "./control-plane/env.js";
85
97
 
86
- // ── Audit ───────────────────────────────────────────────────────────────
87
- export { appendAudit } from "./control-plane/audit.js";
88
-
89
98
  // ── OpenCode Client ─────────────────────────────────────────────────────
90
99
  export { createOpenCodeClient } from "./control-plane/opencode-client.js";
91
100
  export type { ProxyResult, OpenCodeProvider } from "./control-plane/opencode-client.js";
@@ -95,15 +104,19 @@ export {
95
104
  PLAIN_CONFIG_KEYS,
96
105
  ensureSecrets,
97
106
  updateSecretsEnv,
107
+ writeAuthJsonProviderKeys,
98
108
  readStackEnv,
99
109
  patchSecretsEnvFile,
100
110
  maskSecretValue,
101
111
  ensureOpenCodeConfig,
102
112
  } from "./control-plane/secrets.js";
113
+ export { migrateAuth0110 } from "./control-plane/migrate-0110.js";
114
+ export type { MigrateAuth0110Result } from "./control-plane/migrate-0110.js";
103
115
  export {
104
116
  detectSecretBackend,
105
117
  validatePassEntryName,
106
118
  } from "./control-plane/secret-backend.js";
119
+ export type { SecretBackend } from "./control-plane/secret-backend.js";
107
120
  // ── Setup Status ────────────────────────────────────────────────────────
108
121
  export {
109
122
  isSetupComplete,
@@ -114,35 +127,24 @@ export {
114
127
  discoverChannels,
115
128
  isAllowedService,
116
129
  isValidChannel,
117
- isChannelAddon,
118
130
  } from "./control-plane/channels.js";
119
131
 
120
- // ── Memory Config ───────────────────────────────────────────────────────
132
+ // ── Provider Model Discovery ────────────────────────────────────────────
121
133
  export type {
122
- MemoryConfig,
123
- } from "./control-plane/memory-config.js";
134
+ ProviderModelsResult,
135
+ ModelDiscoveryReason,
136
+ } from "./control-plane/provider-models.js";
124
137
  export {
125
- EMBED_PROVIDERS,
126
- resolveApiKey,
127
138
  fetchProviderModels,
128
- getDefaultConfig,
129
- readMemoryConfig,
130
- writeMemoryConfig,
131
- ensureMemoryConfig,
132
- checkVectorDimensions,
133
- resetVectorStore,
134
- provisionMemoryUser,
135
- } from "./control-plane/memory-config.js";
139
+ } from "./control-plane/provider-models.js";
136
140
 
137
141
  // ── Core Assets ─────────────────────────────────────────────────────────
138
142
  export {
139
- ensureUserEnvSchema,
140
- ensureSystemEnvSchema,
141
- ensureMemoryDir,
142
143
  ensureCoreCompose,
143
144
  readCoreCompose,
144
145
  ensureOpenCodeSystemConfig,
145
146
  refreshCoreAssets,
147
+ seedStashAssets,
146
148
  } from "./control-plane/core-assets.js";
147
149
 
148
150
  // ── Configuration Persistence ────────────────────────────────────────────
@@ -157,6 +159,7 @@ export {
157
159
  writeSystemEnv,
158
160
  readChannelSecrets,
159
161
  writeChannelSecrets,
162
+ ensureComposeVolumeTargets,
160
163
  } from "./control-plane/config-persistence.js";
161
164
 
162
165
  // ── Rollback ─────────────────────────────────────────────────────────────
@@ -184,7 +187,6 @@ export {
184
187
  buildManagedServices,
185
188
  normalizeCaller,
186
189
  } from "./control-plane/lifecycle.js";
187
- export type { UpgradeResult } from "./control-plane/lifecycle.js";
188
190
 
189
191
  // ── Docker ──────────────────────────────────────────────────────────────
190
192
  export type { DockerResult } from "./control-plane/docker.js";
@@ -204,22 +206,20 @@ export {
204
206
  composePull,
205
207
  composeStats,
206
208
  getDockerEvents,
207
- selfRecreateAdmin,
209
+ inspectContainerStatus,
208
210
  } from "./control-plane/docker.js";
209
211
 
210
212
  // ── Scheduler ───────────────────────────────────────────────────────────
211
213
  export type {
212
- ActionType,
213
- AutomationAction,
214
214
  AutomationConfig,
215
- ExecutionLogEntry,
215
+ AutomationRunResult,
216
216
  } from "./control-plane/scheduler.js";
217
217
  export {
218
218
  SCHEDULE_PRESETS,
219
- resolveSchedule,
220
- parseAutomationYaml,
221
219
  loadAutomations,
222
- executeAction,
220
+ executeAutomation,
221
+ syncAutomations,
222
+ readAutomationLogs,
223
223
  } from "./control-plane/scheduler.js";
224
224
 
225
225
  // ── Model Runner (local provider detection) ─────────────────────────────
@@ -232,28 +232,27 @@ export {
232
232
  buildComposeCliArgs,
233
233
  } from "./control-plane/compose-args.js";
234
234
 
235
+ // ── Compose Error Parsing ────────────────────────────────────────────────
236
+ export type { ComposeServiceFailure } from "./control-plane/compose-errors.js";
237
+ export {
238
+ parseComposeStderr,
239
+ summarizeComposeStderr,
240
+ } from "./control-plane/compose-errors.js";
241
+
235
242
  // ── Stack Spec (v2) ──────────────────────────────────────────────────────
236
243
  export type {
237
244
  StackSpec,
238
- StackSpecCapabilities,
239
- StackSpecEmbeddings,
240
- StackSpecMemory,
241
- StackSpecTts,
242
- StackSpecStt,
243
- StackSpecReranker,
244
245
  } from "./control-plane/stack-spec.js";
245
246
  export {
246
247
  STACK_SPEC_FILENAME,
247
248
  writeStackSpec,
248
249
  readStackSpec,
249
- updateCapability,
250
- parseCapabilityString,
251
- formatCapabilityString,
252
250
  } from "./control-plane/stack-spec.js";
253
251
 
254
252
  // ── Spec-to-Env Derivation ──────────────────────────────────────────────
253
+ export type { VoiceVarsConfig } from "./control-plane/spec-to-env.js";
255
254
  export {
256
- writeCapabilityVars,
255
+ writeVoiceVars,
257
256
  } from "./control-plane/spec-to-env.js";
258
257
 
259
258
  // ── Setup ────────────────────────────────────────────────────────────────
@@ -265,3 +264,42 @@ export type {
265
264
  export {
266
265
  performSetup,
267
266
  } from "./control-plane/setup.js";
267
+
268
+ // ── Install Lock (shared between performSetup and startDeploy) ───────────
269
+ export type { InstallLockHandle } from "./control-plane/install-lock.js";
270
+ export {
271
+ acquireInstallLock,
272
+ releaseInstallLock,
273
+ } from "./control-plane/install-lock.js";
274
+
275
+ // ── Host OpenCode Import ─────────────────────────────────────────────────
276
+ export type {
277
+ HostOpenCodeStatus,
278
+ HostImportResult,
279
+ } from "./control-plane/host-opencode.js";
280
+ export {
281
+ detectHostOpenCode,
282
+ importHostOpenCode,
283
+ } from "./control-plane/host-opencode.js";
284
+
285
+ // ── AKM Vault ────────────────────────────────────────────────────────────
286
+ export {
287
+ AKM_USER_VAULT_REF,
288
+ buildAkmEnv,
289
+ ensureAkmUserVault,
290
+ writeAkmVaultKey,
291
+ deleteAkmVaultKey,
292
+ readAkmUserVaultFile,
293
+ readUserVaultSync,
294
+ } from "./control-plane/akm-vault.js";
295
+
296
+ // ── UI asset seeding and resolution ─────────────────────────────────────────
297
+ export type { UiBuildUpdateResult } from "./control-plane/ui-assets.js";
298
+ export {
299
+ resolveLocalOpenpalmDir,
300
+ seedOpenPalmDir,
301
+ resolveLocalUiBuild,
302
+ resolveUiBuildDir,
303
+ seedUiBuild,
304
+ checkAndUpdateUiBuild,
305
+ } from "./control-plane/ui-assets.js";
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Tests for the in-house log redactor introduced in #391 (replacing varlock).
3
+ *
4
+ * The contract:
5
+ * - keys matching the word-bounded pattern
6
+ * `(^|_)(TOKEN|SECRET|KEY|PASSWORD|HMAC)(_|$)` (case-insensitive)
7
+ * have their value replaced with `'***REDACTED***'`.
8
+ * - substring false positives (e.g. `MONKEY`, `PACKET_SIZE`) are NOT redacted.
9
+ * - non-string sensitive values (numbers, booleans) are still redacted.
10
+ * - non-secret keys are passed through unchanged.
11
+ * - nested objects and arrays are walked recursively.
12
+ * - createLogger() applies the same masking before writing to stdout/stderr
13
+ * at every log level (debug/info/warn/error).
14
+ */
15
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
16
+ import {
17
+ createLogger,
18
+ isSensitiveEnvKey,
19
+ redactValue,
20
+ redactExtra,
21
+ } from './logger.js';
22
+
23
+ describe('redactValue', () => {
24
+ test('masks values for sensitive key suffixes', () => {
25
+ expect(redactValue('OPENAI_API_KEY', 'sk-abc')).toBe('***REDACTED***');
26
+ expect(redactValue('SLACK_BOT_TOKEN', 'xoxb-123')).toBe('***REDACTED***');
27
+ expect(redactValue('CHANNEL_DISCORD_SECRET', 'hmac-bytes')).toBe('***REDACTED***');
28
+ expect(redactValue('OP_OPENCODE_PASSWORD', 'hunter2')).toBe('***REDACTED***');
29
+ });
30
+
31
+ test('matches case-insensitively', () => {
32
+ expect(redactValue('openai_api_key', 'sk-xyz')).toBe('***REDACTED***');
33
+ expect(redactValue('My_Token', 'abc')).toBe('***REDACTED***');
34
+ });
35
+
36
+ test('leaves non-secret values alone', () => {
37
+ expect(redactValue('OWNER_NAME', 'alice')).toBe('alice');
38
+ expect(redactValue('OP_HOME', '/openpalm')).toBe('/openpalm');
39
+ expect(redactValue('OP_ASSISTANT_PORT', '3800')).toBe('3800');
40
+ });
41
+ });
42
+
43
+ describe('isSensitiveEnvKey', () => {
44
+ test('returns true for token/secret/key/password/hmac suffix keys', () => {
45
+ expect(isSensitiveEnvKey('FOO_TOKEN')).toBe(true);
46
+ expect(isSensitiveEnvKey('FOO_SECRET')).toBe(true);
47
+ expect(isSensitiveEnvKey('FOO_KEY')).toBe(true);
48
+ expect(isSensitiveEnvKey('FOO_PASSWORD')).toBe(true);
49
+ expect(isSensitiveEnvKey('CHANNEL_FOO_HMAC')).toBe(true);
50
+ expect(isSensitiveEnvKey('OP_UI_TOKEN')).toBe(true);
51
+ expect(isSensitiveEnvKey('CHANNEL_API_KEY')).toBe(true);
52
+ });
53
+
54
+ test('returns true for bare or prefix forms', () => {
55
+ expect(isSensitiveEnvKey('TOKEN')).toBe(true);
56
+ expect(isSensitiveEnvKey('SECRET')).toBe(true);
57
+ expect(isSensitiveEnvKey('KEY')).toBe(true);
58
+ expect(isSensitiveEnvKey('HMAC_KEY')).toBe(true);
59
+ expect(isSensitiveEnvKey('PASSWORD_HASH')).toBe(true);
60
+ });
61
+
62
+ test('returns false for substring false positives', () => {
63
+ // MONKEY contains the substring KEY but no underscore boundary.
64
+ expect(isSensitiveEnvKey('MONKEY')).toBe(false);
65
+ // PACKET_SIZE: KET is not one of the words; ET_SIZE is unrelated.
66
+ expect(isSensitiveEnvKey('PACKET_SIZE')).toBe(false);
67
+ // MARKETING_KEYWORD: KEYWORD does not have a trailing underscore or EOL.
68
+ expect(isSensitiveEnvKey('MARKETING_KEYWORD')).toBe(false);
69
+ // KEYBOARD: starts with KEY but not followed by underscore or EOL.
70
+ expect(isSensitiveEnvKey('KEYBOARD')).toBe(false);
71
+ });
72
+
73
+ test('returns false for ordinary keys', () => {
74
+ expect(isSensitiveEnvKey('OWNER_NAME')).toBe(false);
75
+ expect(isSensitiveEnvKey('OP_HOME')).toBe(false);
76
+ expect(isSensitiveEnvKey('OP_ASSISTANT_PORT')).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe('redactExtra', () => {
81
+ test('masks top-level secret string values', () => {
82
+ const result = redactExtra({
83
+ OPENAI_API_KEY: 'sk-abc',
84
+ OWNER_NAME: 'alice',
85
+ });
86
+ expect(result).toEqual({
87
+ OPENAI_API_KEY: '***REDACTED***',
88
+ OWNER_NAME: 'alice',
89
+ });
90
+ });
91
+
92
+ test('walks nested objects', () => {
93
+ const result = redactExtra({
94
+ env: {
95
+ OPENAI_API_KEY: 'sk-abc',
96
+ OWNER_NAME: 'alice',
97
+ },
98
+ });
99
+ expect(result).toEqual({
100
+ env: {
101
+ OPENAI_API_KEY: '***REDACTED***',
102
+ OWNER_NAME: 'alice',
103
+ },
104
+ });
105
+ });
106
+
107
+ test('handles arrays of objects', () => {
108
+ const result = redactExtra({
109
+ items: [
110
+ { OPENAI_API_KEY: 'sk-1' },
111
+ { OWNER_NAME: 'bob' },
112
+ ],
113
+ });
114
+ expect(result).toEqual({
115
+ items: [
116
+ { OPENAI_API_KEY: '***REDACTED***' },
117
+ { OWNER_NAME: 'bob' },
118
+ ],
119
+ });
120
+ });
121
+
122
+ test('returns primitive inputs unchanged', () => {
123
+ expect(redactExtra('plain')).toBe('plain');
124
+ expect(redactExtra(42)).toBe(42);
125
+ expect(redactExtra(null)).toBe(null);
126
+ });
127
+
128
+ test('redacts non-string sensitive values (numbers, booleans)', () => {
129
+ const result = redactExtra({
130
+ OP_UI_TOKEN: 12345,
131
+ OPENAI_API_KEY: true,
132
+ OWNER_NAME: 'alice',
133
+ });
134
+ expect(result).toEqual({
135
+ OP_UI_TOKEN: '***REDACTED***',
136
+ OPENAI_API_KEY: '***REDACTED***',
137
+ OWNER_NAME: 'alice',
138
+ });
139
+ });
140
+
141
+ test('does not redact substring false positives', () => {
142
+ const result = redactExtra({
143
+ MONKEY: 'banana',
144
+ PACKET_SIZE: 1500,
145
+ MARKETING_KEYWORD: 'free',
146
+ OPENAI_API_KEY: 'sk-leak',
147
+ });
148
+ expect(result).toEqual({
149
+ MONKEY: 'banana',
150
+ PACKET_SIZE: 1500,
151
+ MARKETING_KEYWORD: 'free',
152
+ OPENAI_API_KEY: '***REDACTED***',
153
+ });
154
+ });
155
+
156
+ test('redacts CHANNEL_FOO_HMAC values', () => {
157
+ const result = redactExtra({ CHANNEL_DISCORD_HMAC: 'hmac-bytes' });
158
+ expect(result).toEqual({ CHANNEL_DISCORD_HMAC: '***REDACTED***' });
159
+ });
160
+ });
161
+
162
+ describe('createLogger', () => {
163
+ const origLog = console.log;
164
+ const origErr = console.error;
165
+ let logged: string[] = [];
166
+
167
+ beforeEach(() => {
168
+ logged = [];
169
+ console.log = (...args: unknown[]) => {
170
+ logged.push(args.map((a) => String(a)).join(' '));
171
+ };
172
+ console.error = (...args: unknown[]) => {
173
+ logged.push(args.map((a) => String(a)).join(' '));
174
+ };
175
+ });
176
+
177
+ afterEach(() => {
178
+ console.log = origLog;
179
+ console.error = origErr;
180
+ });
181
+
182
+ test('redacts sensitive keys in the extra payload before writing the log line', () => {
183
+ const logger = createLogger('test');
184
+ logger.info('msg', { OPENAI_API_KEY: 'sk-leak', OWNER_NAME: 'alice' });
185
+ expect(logged.length).toBe(1);
186
+ expect(logged[0]).toContain('"OPENAI_API_KEY":"***REDACTED***"');
187
+ expect(logged[0]).toContain('"OWNER_NAME":"alice"');
188
+ expect(logged[0]).not.toContain('sk-leak');
189
+ });
190
+
191
+ test('error level still goes through redaction', () => {
192
+ const logger = createLogger('test');
193
+ logger.error('boom', { OP_UI_TOKEN: 'tok-leak' });
194
+ expect(logged[0]).toContain('"OP_UI_TOKEN":"***REDACTED***"');
195
+ expect(logged[0]).not.toContain('tok-leak');
196
+ });
197
+
198
+ test('warn level applies redaction', () => {
199
+ const logger = createLogger('test');
200
+ logger.warn('caution', { CHANNEL_API_KEY: 'warn-leak' });
201
+ expect(logged.length).toBe(1);
202
+ expect(logged[0]).toContain('"CHANNEL_API_KEY":"***REDACTED***"');
203
+ expect(logged[0]).not.toContain('warn-leak');
204
+ });
205
+
206
+ test('debug level applies redaction', () => {
207
+ const logger = createLogger('test');
208
+ logger.debug('detail', { CHANNEL_FOO_HMAC: 'debug-leak' });
209
+ expect(logged.length).toBe(1);
210
+ expect(logged[0]).toContain('"CHANNEL_FOO_HMAC":"***REDACTED***"');
211
+ expect(logged[0]).not.toContain('debug-leak');
212
+ });
213
+
214
+ test('substring false positives are not redacted at log time', () => {
215
+ const logger = createLogger('test');
216
+ logger.info('msg', { MONKEY: 'banana', PACKET_SIZE: 1500 });
217
+ expect(logged[0]).toContain('"MONKEY":"banana"');
218
+ expect(logged[0]).toContain('"PACKET_SIZE":1500');
219
+ expect(logged[0]).not.toContain('***REDACTED***');
220
+ });
221
+
222
+ test('non-string sensitive values are redacted at log time', () => {
223
+ const logger = createLogger('test');
224
+ logger.info('msg', { OP_UI_TOKEN: 12345 });
225
+ expect(logged[0]).toContain('"OP_UI_TOKEN":"***REDACTED***"');
226
+ expect(logged[0]).not.toContain('12345');
227
+ });
228
+ });