@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18

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 (66) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. package/src/control-plane/stack-spec.ts +0 -67
@@ -16,10 +16,9 @@ function makeState(homeDir: string): ControlPlaneState {
16
16
  return {
17
17
  homeDir,
18
18
  configDir: join(homeDir, "config"),
19
- stashDir: join(homeDir, "stash"),
19
+ stashDir: join(homeDir, "knowledge"),
20
20
  workspaceDir: join(homeDir, "workspace"),
21
- cacheDir: join(homeDir, "cache"),
22
- stateDir: join(homeDir, "state"),
21
+ dataDir: join(homeDir, "data"),
23
22
  stackDir: join(homeDir, "config/stack"),
24
23
  services: {},
25
24
  artifacts: { compose: "" },
@@ -104,6 +103,48 @@ describe("detectHostOpenCode", () => {
104
103
  expect(status.providerCount).toBe(0);
105
104
  });
106
105
  });
106
+
107
+ it("returns modelPreferences when model and small_model are set", () => {
108
+ const configDir = join(xdgRoot, "config", "opencode");
109
+ mkdirSync(configDir, { recursive: true });
110
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
111
+ provider: { groq: {} },
112
+ model: "groq/llama-3.3-70b-versatile",
113
+ small_model: "groq/llama-3.1-8b-instant",
114
+ }));
115
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
116
+ const status = detectHostOpenCode();
117
+ expect(status.modelPreferences).toBeDefined();
118
+ expect(status.modelPreferences?.model).toBe("groq/llama-3.3-70b-versatile");
119
+ expect(status.modelPreferences?.small_model).toBe("groq/llama-3.1-8b-instant");
120
+ });
121
+ });
122
+
123
+ it("omits modelPreferences when no model fields are set", () => {
124
+ const configDir = join(xdgRoot, "config", "opencode");
125
+ mkdirSync(configDir, { recursive: true });
126
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
127
+ provider: { groq: {} },
128
+ }));
129
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
130
+ const status = detectHostOpenCode();
131
+ expect(status.modelPreferences).toBeUndefined();
132
+ });
133
+ });
134
+
135
+ it("returns partial modelPreferences when only model is set", () => {
136
+ const configDir = join(xdgRoot, "config", "opencode");
137
+ mkdirSync(configDir, { recursive: true });
138
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
139
+ provider: { anthropic: {} },
140
+ model: "anthropic/claude-sonnet-4-5",
141
+ }));
142
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
143
+ const status = detectHostOpenCode();
144
+ expect(status.modelPreferences?.model).toBe("anthropic/claude-sonnet-4-5");
145
+ expect(status.modelPreferences?.small_model).toBeUndefined();
146
+ });
147
+ });
107
148
  });
108
149
 
109
150
  // ── importHostOpenCode ────────────────────────────────────────────────────────
@@ -132,6 +173,8 @@ describe("importHostOpenCode", () => {
132
173
  writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
133
174
  provider: { anthropic: { name: "Anthropic" }, groq: {} },
134
175
  model: "anthropic/claude-3-5-sonnet",
176
+ small_model: "openai/gpt-4o-mini",
177
+ disabled_providers: ["groq"],
135
178
  // These should be stripped:
136
179
  plugin: [{ module: "some-plugin" }],
137
180
  mcp: { server: {} },
@@ -153,14 +196,16 @@ describe("importHostOpenCode", () => {
153
196
  const destConfig = JSON.parse(readFileSync(join(opHome, "config", "assistant", "opencode.json"), "utf-8"));
154
197
  expect(destConfig.provider).toEqual({ anthropic: { name: "Anthropic" }, groq: {} });
155
198
  expect(destConfig.model).toBe("anthropic/claude-3-5-sonnet");
199
+ expect(destConfig.small_model).toBe("openai/gpt-4o-mini");
200
+ expect(destConfig.disabled_providers).toEqual(["groq"]);
156
201
  expect(destConfig.plugin).toBeUndefined();
157
202
  expect(destConfig.mcp).toBeUndefined();
158
203
 
159
204
  // Verify auth.json was written
160
- expect(existsSync(join(opHome, "config", "auth.json"))).toBe(true);
205
+ expect(existsSync(join(opHome, "knowledge", "secrets", "auth.json"))).toBe(true);
161
206
 
162
207
  // Verify auth.json permissions are 0o600
163
- const authStat = statSync(join(opHome, "config", "auth.json"));
208
+ const authStat = statSync(join(opHome, "knowledge", "secrets", "auth.json"));
164
209
  // On Linux, mode & 0o777 extracts permission bits
165
210
  expect(authStat.mode & 0o777).toBe(0o600);
166
211
  });
@@ -217,6 +262,34 @@ describe("importHostOpenCode", () => {
217
262
  expect(written.provider.anthropic.name).toBe("Host Anthropic");
218
263
  });
219
264
 
265
+ it("keeps existing model defaults and fills only missing host fields", () => {
266
+ const hostConfigDir = join(xdgRoot, "config", "opencode");
267
+ mkdirSync(hostConfigDir, { recursive: true });
268
+ writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
269
+ provider: { openai: { name: "Host OpenAI" } },
270
+ model: "openai/gpt-4.1",
271
+ small_model: "openai/gpt-4.1-mini",
272
+ disabled_providers: ["groq"],
273
+ }));
274
+
275
+ const state = makeState(opHome);
276
+ const destDir = join(opHome, "config", "assistant");
277
+ mkdirSync(destDir, { recursive: true });
278
+ writeFileSync(join(destDir, "opencode.json"), JSON.stringify({
279
+ provider: { anthropic: { name: "Existing Anthropic" } },
280
+ model: "anthropic/claude-sonnet-4",
281
+ }));
282
+
283
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
284
+ importHostOpenCode(state);
285
+ });
286
+
287
+ const written = JSON.parse(readFileSync(join(destDir, "opencode.json"), "utf-8"));
288
+ expect(written.model).toBe("anthropic/claude-sonnet-4");
289
+ expect(written.small_model).toBe("openai/gpt-4.1-mini");
290
+ expect(written.disabled_providers).toEqual(["groq"]);
291
+ });
292
+
220
293
  it("returns zero counts when no host config is present", () => {
221
294
  const state = makeState(opHome);
222
295
  withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
@@ -228,10 +301,9 @@ describe("importHostOpenCode", () => {
228
301
  });
229
302
 
230
303
  it("partial-merge auth: does not overwrite existing credential, adds new one", () => {
231
- // Pre-seed OP_HOME/config/auth.json with one existing credential
232
- const opConfigDir = join(opHome, "config");
233
- mkdirSync(opConfigDir, { recursive: true });
234
- writeFileSync(join(opConfigDir, "auth.json"), JSON.stringify({
304
+ // Pre-seed OP_HOME/knowledge/secrets/auth.json with one existing credential
305
+ mkdirSync(join(opHome, "knowledge", "secrets"), { recursive: true });
306
+ writeFileSync(join(opHome, "knowledge", "secrets", "auth.json"), JSON.stringify({
235
307
  azure: { type: "api", key: "existing" },
236
308
  }));
237
309
 
@@ -252,7 +324,7 @@ describe("importHostOpenCode", () => {
252
324
  });
253
325
 
254
326
  // Verify azure key was NOT overwritten
255
- const written = JSON.parse(readFileSync(join(opConfigDir, "auth.json"), "utf-8")) as Record<string, { key: string }>;
327
+ const written = JSON.parse(readFileSync(join(opHome, "knowledge", "secrets", "auth.json"), "utf-8")) as Record<string, { key: string }>;
256
328
  expect(written.azure.key).toBe("existing");
257
329
  // Verify groq was added
258
330
  expect(written.groq.key).toBe("gsk-host");
@@ -11,12 +11,14 @@
11
11
  * - auth.json is copied byte-for-byte and chmodded 0o600. Its contents
12
12
  * are never parsed, logged, or returned to callers.
13
13
  * - opencode.json is parsed to strip plugin/mcp/permission keys before
14
- * writing; only provider/model/small_model/disabled_providers are kept.
14
+ * writing; provider definitions are always kept, and top-level model
15
+ * defaults are imported only when OP_HOME does not already define them.
15
16
  * - Conflict detection compares provider IDs; existing credentials are
16
17
  * preserved unless overwriteConflicts=true.
17
18
  */
18
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
19
20
  import { homedir } from "node:os";
21
+ import { dirname } from "node:path";
20
22
  import type { ControlPlaneState } from "./types.js";
21
23
  import { authJsonPath, assistantConfigDir } from "./paths.js";
22
24
 
@@ -31,6 +33,8 @@ export type HostOpenCodeStatus = {
31
33
  providerCount: number;
32
34
  /** Number of credential entries in auth.json (0 when not found) */
33
35
  credentialCount: number;
36
+ /** Model preferences from the host's opencode.json, if present */
37
+ modelPreferences?: { model?: string; small_model?: string };
34
38
  };
35
39
 
36
40
  export type HostImportResult = {
@@ -64,7 +68,7 @@ function hostAuthJsonPath(): string {
64
68
 
65
69
  // ── opencode.json parsing ────────────────────────────────────────────────────
66
70
 
67
- /** Keys that are safe to import from host opencode.json into OP_HOME config */
71
+ /** Keys that are safe to import from host opencode.json into OP_HOME config. */
68
72
  const ALLOWED_CONFIG_KEYS = new Set(["$schema", "provider", "model", "small_model", "disabled_providers"]);
69
73
 
70
74
  type OpenCodeJson = Record<string, unknown>;
@@ -78,8 +82,18 @@ function readJsonFileSafe(path: string): OpenCodeJson | null {
78
82
  }
79
83
 
80
84
  function stripDisallowedKeys(obj: OpenCodeJson): OpenCodeJson {
85
+ const next: OpenCodeJson = {};
86
+ if (typeof obj.$schema === 'string') next.$schema = obj.$schema;
87
+ if (obj.provider && typeof obj.provider === 'object' && !Array.isArray(obj.provider)) {
88
+ next.provider = obj.provider;
89
+ }
90
+ if (typeof obj.model === 'string') next.model = obj.model;
91
+ if (typeof obj.small_model === 'string') next.small_model = obj.small_model;
92
+ if (Array.isArray(obj.disabled_providers) && obj.disabled_providers.every((entry) => typeof entry === 'string')) {
93
+ next.disabled_providers = obj.disabled_providers;
94
+ }
81
95
  return Object.fromEntries(
82
- Object.entries(obj).filter(([k]) => ALLOWED_CONFIG_KEYS.has(k))
96
+ Object.entries(next).filter(([k]) => ALLOWED_CONFIG_KEYS.has(k))
83
97
  );
84
98
  }
85
99
 
@@ -116,9 +130,16 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
116
130
  }
117
131
 
118
132
  let providerCount = 0;
133
+ let modelPreferences: { model?: string; small_model?: string } | undefined;
119
134
  if (configExists) {
120
135
  const parsed = readJsonFileSafe(configPath);
121
136
  providerCount = parsed ? countProviders(parsed) : 0;
137
+ if (parsed) {
138
+ const prefs: { model?: string; small_model?: string } = {};
139
+ if (typeof parsed.model === 'string' && parsed.model) prefs.model = parsed.model;
140
+ if (typeof parsed.small_model === 'string' && parsed.small_model) prefs.small_model = parsed.small_model;
141
+ if (prefs.model || prefs.small_model) modelPreferences = prefs;
142
+ }
122
143
  }
123
144
 
124
145
  let credentialCount = 0;
@@ -131,6 +152,7 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
131
152
  authPath: authExists ? authPath : undefined,
132
153
  providerCount,
133
154
  credentialCount,
155
+ ...(modelPreferences ? { modelPreferences } : {}),
134
156
  };
135
157
  }
136
158
 
@@ -181,21 +203,28 @@ export function importHostOpenCode(
181
203
  }
182
204
  }
183
205
 
184
- const merged: OpenCodeJson = {
185
- ...existing,
186
- ...sanitized,
187
- ...(Object.keys(mergedProviders).length > 0 ? { provider: mergedProviders } : {}),
188
- };
189
-
190
- writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
191
- }
206
+ const merged: OpenCodeJson = {
207
+ ...existing,
208
+ ...(typeof existing.$schema === 'undefined' && typeof sanitized.$schema !== 'undefined'
209
+ ? { $schema: sanitized.$schema }
210
+ : {}),
211
+ ...(Object.keys(mergedProviders).length > 0 ? { provider: mergedProviders } : {}),
212
+ };
213
+
214
+ for (const key of ["model", "small_model", "disabled_providers"] as const) {
215
+ if (typeof merged[key] === 'undefined' && typeof sanitized[key] !== 'undefined') {
216
+ merged[key] = sanitized[key];
217
+ }
218
+ }
219
+
220
+ writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
221
+ }
192
222
  }
193
223
 
194
224
  // ── auth.json ──────────────────────────────────────────────────────────
195
225
  if (status.authPath) {
196
226
  const destPath = authJsonPath(state);
197
- const destDir = state.configDir;
198
- mkdirSync(destDir, { recursive: true });
227
+ mkdirSync(dirname(destPath), { recursive: true, mode: 0o700 });
199
228
 
200
229
  if (existsSync(destPath) && !overwriteConflicts) {
201
230
  // Merge: copy only keys that do not already exist in OP_HOME auth.json