@openpalm/lib 0.10.2 → 0.11.0-beta.10

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 (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  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 +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  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 +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  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/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -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,25 +10,27 @@
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
- OptionalServiceName,
23
- AccessScope,
24
28
  ChannelInfo,
25
29
  CallerType,
26
30
  ArtifactMeta,
27
- AuditEntry,
28
31
  } from "./control-plane/types.js";
29
32
  export {
30
33
  CORE_SERVICES,
31
- OPTIONAL_SERVICES,
32
34
  } from "./control-plane/types.js";
33
35
 
34
36
  // ── Backups ───────────────────────────────────────────────────────────────
@@ -39,6 +41,8 @@ export {
39
41
  // ── Registry Catalog ─────────────────────────────────────────────────────
40
42
  export type {
41
43
  AddonMutationResult,
44
+ AddonProfile,
45
+ AddonProfileAvailability,
42
46
  RegistryAutomationEntry,
43
47
  RegistryComponentEntry,
44
48
  RegistryAddonConfig,
@@ -53,39 +57,47 @@ export {
53
57
  getRegistryAutomation,
54
58
  getRegistryAddonConfig,
55
59
  getAddonServiceNames,
60
+ getAddonProfiles,
61
+ getAddonProfileAvailability,
62
+ annotateAddonProfileAvailability,
63
+ getAddonProfileSelection,
64
+ setAddonProfileSelection,
56
65
  listAvailableAddonIds,
57
66
  listEnabledAddonIds,
58
- enableAddon,
59
- disableAddonByName,
60
67
  setAddonEnabled,
61
68
  installAutomationFromRegistry,
62
69
  uninstallAutomation,
63
70
  } from "./control-plane/registry.js";
64
71
 
65
- // ── Home Layout (v0.10.0) ───────────────────────────────────────────────
72
+ // ── Home Layout (v0.11.0) ───────────────────────────────────────────────
66
73
  export {
67
74
  resolveOpenPalmHome,
68
75
  resolveConfigDir,
69
- resolveVaultDir,
70
- resolveDataDir,
76
+ resolveStashDir,
77
+ resolveWorkspaceDir,
78
+ resolveCacheDir,
79
+ resolveStateDir,
80
+ resolveStackDir,
71
81
  resolveLogsDir,
72
- resolveCacheHome,
73
- resolveRegistryDir,
74
- resolveRegistryAddonsDir,
75
- resolveRegistryAutomationsDir,
76
82
  ensureHomeDirs,
77
83
  } from "./control-plane/home.js";
78
84
 
85
+ // ── Path Resolution ─────────────────────────────────────────────────────
86
+ export * from "./control-plane/paths.js";
87
+
79
88
  // ── Env ─────────────────────────────────────────────────────────────────
80
89
  export {
81
90
  parseEnvContent,
82
91
  parseEnvFile,
92
+ expandEnvVars,
83
93
  mergeEnvContent,
94
+ removeEnvKey,
95
+ upsertEnvValue,
96
+ resolveRequestedImageTag,
97
+ reconcileStackEnvImageTag,
98
+ RELEASE_TAG_REGEX,
84
99
  } from "./control-plane/env.js";
85
100
 
86
- // ── Audit ───────────────────────────────────────────────────────────────
87
- export { appendAudit } from "./control-plane/audit.js";
88
-
89
101
  // ── OpenCode Client ─────────────────────────────────────────────────────
90
102
  export { createOpenCodeClient } from "./control-plane/opencode-client.js";
91
103
  export type { ProxyResult, OpenCodeProvider } from "./control-plane/opencode-client.js";
@@ -95,15 +107,14 @@ export {
95
107
  PLAIN_CONFIG_KEYS,
96
108
  ensureSecrets,
97
109
  updateSecretsEnv,
110
+ writeAuthJsonProviderKeys,
98
111
  readStackEnv,
99
112
  patchSecretsEnvFile,
100
113
  maskSecretValue,
101
114
  ensureOpenCodeConfig,
102
115
  } from "./control-plane/secrets.js";
103
- export {
104
- detectSecretBackend,
105
- validatePassEntryName,
106
- } from "./control-plane/secret-backend.js";
116
+ export { migrateAuth0110 } from "./control-plane/migrate-0110.js";
117
+ export type { MigrateAuth0110Result } from "./control-plane/migrate-0110.js";
107
118
  // ── Setup Status ────────────────────────────────────────────────────────
108
119
  export {
109
120
  isSetupComplete,
@@ -114,35 +125,25 @@ export {
114
125
  discoverChannels,
115
126
  isAllowedService,
116
127
  isValidChannel,
117
- isChannelAddon,
118
128
  } from "./control-plane/channels.js";
119
129
 
120
- // ── Memory Config ───────────────────────────────────────────────────────
130
+ // ── Provider Model Discovery ────────────────────────────────────────────
121
131
  export type {
122
- MemoryConfig,
123
- } from "./control-plane/memory-config.js";
132
+ ProviderModelsResult,
133
+ ModelDiscoveryReason,
134
+ } from "./control-plane/provider-models.js";
124
135
  export {
125
- EMBED_PROVIDERS,
126
- resolveApiKey,
127
136
  fetchProviderModels,
128
- getDefaultConfig,
129
- readMemoryConfig,
130
- writeMemoryConfig,
131
- ensureMemoryConfig,
132
- checkVectorDimensions,
133
- resetVectorStore,
134
- provisionMemoryUser,
135
- } from "./control-plane/memory-config.js";
137
+ } from "./control-plane/provider-models.js";
136
138
 
137
139
  // ── Core Assets ─────────────────────────────────────────────────────────
138
140
  export {
139
- ensureUserEnvSchema,
140
- ensureSystemEnvSchema,
141
- ensureMemoryDir,
142
141
  ensureCoreCompose,
143
142
  readCoreCompose,
144
143
  ensureOpenCodeSystemConfig,
145
144
  refreshCoreAssets,
145
+ seedStashAssets,
146
+ seedAssistantPersonaFiles,
146
147
  } from "./control-plane/core-assets.js";
147
148
 
148
149
  // ── Configuration Persistence ────────────────────────────────────────────
@@ -157,6 +158,7 @@ export {
157
158
  writeSystemEnv,
158
159
  readChannelSecrets,
159
160
  writeChannelSecrets,
161
+ ensureComposeVolumeTargets,
160
162
  } from "./control-plane/config-persistence.js";
161
163
 
162
164
  // ── Rollback ─────────────────────────────────────────────────────────────
@@ -179,12 +181,12 @@ export {
179
181
  applyUninstall,
180
182
  applyUpgrade,
181
183
  performUpgrade,
184
+ applyTagChange,
182
185
  updateStackEnvToLatestImageTag,
183
186
  buildComposeFileList,
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,30 +232,36 @@ 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
 
258
+ // ── Operator UID/GID Detection ──────────────────────────────────────────
259
+ export type { OperatorIds } from "./control-plane/operator-ids.js";
260
+ export {
261
+ resolveOperatorIds,
262
+ hasUsableOperatorId,
263
+ } from "./control-plane/operator-ids.js";
264
+
259
265
  // ── Setup ────────────────────────────────────────────────────────────────
260
266
  export type {
261
267
  SetupSpec,
@@ -265,3 +271,43 @@ export type {
265
271
  export {
266
272
  performSetup,
267
273
  } from "./control-plane/setup.js";
274
+
275
+ // ── Install Lock (shared between performSetup and startDeploy) ───────────
276
+ export type { InstallLockHandle } from "./control-plane/install-lock.js";
277
+ export {
278
+ acquireInstallLock,
279
+ releaseInstallLock,
280
+ } from "./control-plane/install-lock.js";
281
+
282
+ // ── Host OpenCode Import ─────────────────────────────────────────────────
283
+ export type {
284
+ HostOpenCodeStatus,
285
+ HostImportResult,
286
+ } from "./control-plane/host-opencode.js";
287
+ export {
288
+ detectHostOpenCode,
289
+ importHostOpenCode,
290
+ } from "./control-plane/host-opencode.js";
291
+
292
+ // ── AKM Vault ────────────────────────────────────────────────────────────
293
+ export {
294
+ AKM_USER_VAULT_REF,
295
+ buildAkmEnv,
296
+ ensureAkmUserVault,
297
+ writeAkmVaultKey,
298
+ deleteAkmVaultKey,
299
+ readAkmUserVaultFile,
300
+ readUserVaultSync,
301
+ } from "./control-plane/akm-vault.js";
302
+
303
+ // ── UI asset seeding and resolution ─────────────────────────────────────────
304
+ export type { UiBuildUpdateResult } from "./control-plane/ui-assets.js";
305
+ export {
306
+ resolveLocalOpenpalmDir,
307
+ seedOpenPalmDir,
308
+ resolveLocalUiBuild,
309
+ resolveUiBuildDir,
310
+ seedUiBuild,
311
+ checkAndUpdateUiBuild,
312
+ readCurrentUiBuildVersion,
313
+ } 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('OP_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('OP_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
+ OP_OWNER_NAME: 'alice',
85
+ });
86
+ expect(result).toEqual({
87
+ OPENAI_API_KEY: '***REDACTED***',
88
+ OP_OWNER_NAME: 'alice',
89
+ });
90
+ });
91
+
92
+ test('walks nested objects', () => {
93
+ const result = redactExtra({
94
+ env: {
95
+ OPENAI_API_KEY: 'sk-abc',
96
+ OP_OWNER_NAME: 'alice',
97
+ },
98
+ });
99
+ expect(result).toEqual({
100
+ env: {
101
+ OPENAI_API_KEY: '***REDACTED***',
102
+ OP_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
+ { OP_OWNER_NAME: 'bob' },
112
+ ],
113
+ });
114
+ expect(result).toEqual({
115
+ items: [
116
+ { OPENAI_API_KEY: '***REDACTED***' },
117
+ { OP_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
+ OP_OWNER_NAME: 'alice',
133
+ });
134
+ expect(result).toEqual({
135
+ OP_UI_TOKEN: '***REDACTED***',
136
+ OPENAI_API_KEY: '***REDACTED***',
137
+ OP_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', OP_OWNER_NAME: 'alice' });
185
+ expect(logged.length).toBe(1);
186
+ expect(logged[0]).toContain('"OPENAI_API_KEY":"***REDACTED***"');
187
+ expect(logged[0]).toContain('"OP_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
+ });