@openpalm/lib 0.10.1 → 0.11.0-beta.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.
Files changed (55) 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 +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. 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_TOKEN", "OP_ASSISTANT_TOKEN"] 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,17 +10,22 @@
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,
@@ -62,25 +67,33 @@ export {
62
67
  uninstallAutomation,
63
68
  } from "./control-plane/registry.js";
64
69
 
65
- // ── Home Layout (v0.10.0) ───────────────────────────────────────────────
70
+ // ── Home Layout (v0.11.0) ───────────────────────────────────────────────
66
71
  export {
67
72
  resolveOpenPalmHome,
68
73
  resolveConfigDir,
69
- resolveVaultDir,
70
- resolveDataDir,
74
+ resolveStashDir,
75
+ resolveWorkspaceDir,
76
+ resolveCacheDir,
77
+ resolveStateDir,
78
+ resolveStackDir,
71
79
  resolveLogsDir,
72
- resolveCacheHome,
73
- resolveRegistryDir,
74
- resolveRegistryAddonsDir,
75
- resolveRegistryAutomationsDir,
76
80
  ensureHomeDirs,
77
81
  } from "./control-plane/home.js";
78
82
 
83
+ // ── Path Resolution ─────────────────────────────────────────────────────
84
+ export * from "./control-plane/paths.js";
85
+
79
86
  // ── Env ─────────────────────────────────────────────────────────────────
80
87
  export {
81
88
  parseEnvContent,
82
89
  parseEnvFile,
90
+ expandEnvVars,
83
91
  mergeEnvContent,
92
+ removeEnvKey,
93
+ upsertEnvValue,
94
+ resolveRequestedImageTag,
95
+ reconcileStackEnvImageTag,
96
+ RELEASE_TAG_REGEX,
84
97
  } from "./control-plane/env.js";
85
98
 
86
99
  // ── Audit ───────────────────────────────────────────────────────────────
@@ -95,6 +108,7 @@ export {
95
108
  PLAIN_CONFIG_KEYS,
96
109
  ensureSecrets,
97
110
  updateSecretsEnv,
111
+ writeAuthJsonProviderKeys,
98
112
  readStackEnv,
99
113
  patchSecretsEnvFile,
100
114
  maskSecretValue,
@@ -104,6 +118,7 @@ export {
104
118
  detectSecretBackend,
105
119
  validatePassEntryName,
106
120
  } from "./control-plane/secret-backend.js";
121
+ export type { SecretBackend } from "./control-plane/secret-backend.js";
107
122
  // ── Setup Status ────────────────────────────────────────────────────────
108
123
  export {
109
124
  isSetupComplete,
@@ -114,35 +129,24 @@ export {
114
129
  discoverChannels,
115
130
  isAllowedService,
116
131
  isValidChannel,
117
- isChannelAddon,
118
132
  } from "./control-plane/channels.js";
119
133
 
120
- // ── Memory Config ───────────────────────────────────────────────────────
134
+ // ── Provider Model Discovery ────────────────────────────────────────────
121
135
  export type {
122
- MemoryConfig,
123
- } from "./control-plane/memory-config.js";
136
+ ProviderModelsResult,
137
+ ModelDiscoveryReason,
138
+ } from "./control-plane/provider-models.js";
124
139
  export {
125
- EMBED_PROVIDERS,
126
- resolveApiKey,
127
140
  fetchProviderModels,
128
- getDefaultConfig,
129
- readMemoryConfig,
130
- writeMemoryConfig,
131
- ensureMemoryConfig,
132
- checkVectorDimensions,
133
- resetVectorStore,
134
- provisionMemoryUser,
135
- } from "./control-plane/memory-config.js";
141
+ } from "./control-plane/provider-models.js";
136
142
 
137
143
  // ── Core Assets ─────────────────────────────────────────────────────────
138
144
  export {
139
- ensureUserEnvSchema,
140
- ensureSystemEnvSchema,
141
- ensureMemoryDir,
142
145
  ensureCoreCompose,
143
146
  readCoreCompose,
144
147
  ensureOpenCodeSystemConfig,
145
148
  refreshCoreAssets,
149
+ seedStashAssets,
146
150
  } from "./control-plane/core-assets.js";
147
151
 
148
152
  // ── Configuration Persistence ────────────────────────────────────────────
@@ -157,6 +161,7 @@ export {
157
161
  writeSystemEnv,
158
162
  readChannelSecrets,
159
163
  writeChannelSecrets,
164
+ ensureComposeVolumeTargets,
160
165
  } from "./control-plane/config-persistence.js";
161
166
 
162
167
  // ── Rollback ─────────────────────────────────────────────────────────────
@@ -184,7 +189,6 @@ export {
184
189
  buildManagedServices,
185
190
  normalizeCaller,
186
191
  } from "./control-plane/lifecycle.js";
187
- export type { UpgradeResult } from "./control-plane/lifecycle.js";
188
192
 
189
193
  // ── Docker ──────────────────────────────────────────────────────────────
190
194
  export type { DockerResult } from "./control-plane/docker.js";
@@ -204,22 +208,20 @@ export {
204
208
  composePull,
205
209
  composeStats,
206
210
  getDockerEvents,
207
- selfRecreateAdmin,
211
+ inspectContainerStatus,
208
212
  } from "./control-plane/docker.js";
209
213
 
210
214
  // ── Scheduler ───────────────────────────────────────────────────────────
211
215
  export type {
212
- ActionType,
213
- AutomationAction,
214
216
  AutomationConfig,
215
- ExecutionLogEntry,
217
+ AutomationRunResult,
216
218
  } from "./control-plane/scheduler.js";
217
219
  export {
218
220
  SCHEDULE_PRESETS,
219
- resolveSchedule,
220
- parseAutomationYaml,
221
221
  loadAutomations,
222
- executeAction,
222
+ executeAutomation,
223
+ syncAutomations,
224
+ readAutomationLogs,
223
225
  } from "./control-plane/scheduler.js";
224
226
 
225
227
  // ── Model Runner (local provider detection) ─────────────────────────────
@@ -235,25 +237,17 @@ export {
235
237
  // ── Stack Spec (v2) ──────────────────────────────────────────────────────
236
238
  export type {
237
239
  StackSpec,
238
- StackSpecCapabilities,
239
- StackSpecEmbeddings,
240
- StackSpecMemory,
241
- StackSpecTts,
242
- StackSpecStt,
243
- StackSpecReranker,
244
240
  } from "./control-plane/stack-spec.js";
245
241
  export {
246
242
  STACK_SPEC_FILENAME,
247
243
  writeStackSpec,
248
244
  readStackSpec,
249
- updateCapability,
250
- parseCapabilityString,
251
- formatCapabilityString,
252
245
  } from "./control-plane/stack-spec.js";
253
246
 
254
247
  // ── Spec-to-Env Derivation ──────────────────────────────────────────────
248
+ export type { VoiceVarsConfig } from "./control-plane/spec-to-env.js";
255
249
  export {
256
- writeCapabilityVars,
250
+ writeVoiceVars,
257
251
  } from "./control-plane/spec-to-env.js";
258
252
 
259
253
  // ── Setup ────────────────────────────────────────────────────────────────
@@ -265,3 +259,42 @@ export type {
265
259
  export {
266
260
  performSetup,
267
261
  } from "./control-plane/setup.js";
262
+
263
+ // ── Install Lock (shared between performSetup and startDeploy) ───────────
264
+ export type { InstallLockHandle } from "./control-plane/install-lock.js";
265
+ export {
266
+ acquireInstallLock,
267
+ releaseInstallLock,
268
+ } from "./control-plane/install-lock.js";
269
+
270
+ // ── Host OpenCode Import ─────────────────────────────────────────────────
271
+ export type {
272
+ HostOpenCodeStatus,
273
+ HostImportResult,
274
+ } from "./control-plane/host-opencode.js";
275
+ export {
276
+ detectHostOpenCode,
277
+ importHostOpenCode,
278
+ } from "./control-plane/host-opencode.js";
279
+
280
+ // ── AKM Vault ────────────────────────────────────────────────────────────
281
+ export {
282
+ AKM_USER_VAULT_REF,
283
+ buildAkmEnv,
284
+ ensureAkmUserVault,
285
+ writeAkmVaultKey,
286
+ deleteAkmVaultKey,
287
+ readAkmUserVaultFile,
288
+ readUserVaultSync,
289
+ } from "./control-plane/akm-vault.js";
290
+
291
+ // ── UI asset seeding and resolution ─────────────────────────────────────────
292
+ export type { UiBuildUpdateResult } from "./control-plane/ui-assets.js";
293
+ export {
294
+ resolveLocalOpenpalmDir,
295
+ seedOpenPalmDir,
296
+ resolveLocalUiBuild,
297
+ resolveUiBuildDir,
298
+ seedUiBuild,
299
+ checkAndUpdateUiBuild,
300
+ } 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
+ });