@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
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Tests for the 0.11.0 auth-migration shim.
3
+ */
4
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ chmodSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { migrateAuth0110 } from "./migrate-0110.js";
18
+ import type { ControlPlaneState } from "./types.js";
19
+
20
+ function makeState(homeDir: string): ControlPlaneState {
21
+ return {
22
+ homeDir,
23
+ configDir: join(homeDir, "config"),
24
+ stashDir: join(homeDir, "stash"),
25
+ workspaceDir: join(homeDir, "workspace"),
26
+ cacheDir: join(homeDir, "cache"),
27
+ stateDir: join(homeDir, "state"),
28
+ stackDir: join(homeDir, "config", "stack"),
29
+ services: {},
30
+ artifacts: { compose: "" },
31
+ artifactMeta: [],
32
+ };
33
+ }
34
+
35
+ function seedStackEnv(stackDir: string, content: string): string {
36
+ mkdirSync(stackDir, { recursive: true });
37
+ const path = join(stackDir, "stack.env");
38
+ writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
39
+ chmodSync(path, 0o600);
40
+ return path;
41
+ }
42
+
43
+ describe("migrateAuth0110", () => {
44
+ let homeDir: string;
45
+
46
+ beforeEach(() => {
47
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-migrate-0110-"));
48
+ });
49
+
50
+ afterEach(() => {
51
+ rmSync(homeDir, { recursive: true, force: true });
52
+ });
53
+
54
+ it("no-ops on a fresh install (no stack.env)", () => {
55
+ const state = makeState(homeDir);
56
+ const result = migrateAuth0110(state);
57
+ expect(result.migrated).toBe(false);
58
+ expect(result.reason).toContain("fresh install");
59
+ });
60
+
61
+ it("promotes OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD and removes legacy keys", () => {
62
+ const state = makeState(homeDir);
63
+ const stackEnvPath = seedStackEnv(
64
+ state.stackDir,
65
+ [
66
+ "# header",
67
+ "OP_UI_TOKEN=legacy-token-value",
68
+ "OP_ASSISTANT_TOKEN=some-assistant-token",
69
+ "OP_OPENCODE_PASSWORD=opencode-secret",
70
+ "",
71
+ ].join("\n"),
72
+ );
73
+
74
+ const result = migrateAuth0110(state);
75
+ expect(result.migrated).toBe(true);
76
+ expect(result.reason).toContain("promoted OP_UI_TOKEN");
77
+ expect(result.reason).toContain("removed OP_UI_TOKEN");
78
+ expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
79
+
80
+ const after = readFileSync(stackEnvPath, "utf-8");
81
+ expect(after).toContain("OP_UI_LOGIN_PASSWORD=legacy-token-value");
82
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
83
+ expect(after).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
84
+ // Unrelated keys preserved
85
+ expect(after).toContain("OP_OPENCODE_PASSWORD=opencode-secret");
86
+
87
+ // Perms preserved
88
+ expect(statSync(stackEnvPath).mode & 0o777).toBe(0o600);
89
+
90
+ // Migration log appended
91
+ const logPath = join(state.stateDir, "logs", "migration-0.11.0.log");
92
+ expect(existsSync(logPath)).toBe(true);
93
+ const log = readFileSync(logPath, "utf-8");
94
+ expect(log).toContain("migrate-auth-0110");
95
+ expect(log).toContain("promoted OP_UI_TOKEN");
96
+ });
97
+
98
+ it("does not overwrite an existing OP_UI_LOGIN_PASSWORD", () => {
99
+ const state = makeState(homeDir);
100
+ const stackEnvPath = seedStackEnv(
101
+ state.stackDir,
102
+ [
103
+ "OP_UI_LOGIN_PASSWORD=new-password",
104
+ "OP_UI_TOKEN=legacy-value",
105
+ "",
106
+ ].join("\n"),
107
+ );
108
+
109
+ const result = migrateAuth0110(state);
110
+ expect(result.migrated).toBe(true);
111
+ expect(result.reason).not.toContain("promoted");
112
+ expect(result.reason).toContain("removed OP_UI_TOKEN");
113
+
114
+ const after = readFileSync(stackEnvPath, "utf-8");
115
+ expect(after).toContain("OP_UI_LOGIN_PASSWORD=new-password");
116
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
117
+ });
118
+
119
+ it("removes OP_ASSISTANT_TOKEN even when only it is present", () => {
120
+ const state = makeState(homeDir);
121
+ const stackEnvPath = seedStackEnv(
122
+ state.stackDir,
123
+ [
124
+ "OP_UI_LOGIN_PASSWORD=pw",
125
+ "OP_ASSISTANT_TOKEN=stale",
126
+ "",
127
+ ].join("\n"),
128
+ );
129
+
130
+ const result = migrateAuth0110(state);
131
+ expect(result.migrated).toBe(true);
132
+ expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
133
+ expect(readFileSync(stackEnvPath, "utf-8")).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
134
+ });
135
+
136
+ it("is idempotent: second run reports already-migrated", () => {
137
+ const state = makeState(homeDir);
138
+ seedStackEnv(
139
+ state.stackDir,
140
+ [
141
+ "OP_UI_TOKEN=t",
142
+ "OP_ASSISTANT_TOKEN=t2",
143
+ "",
144
+ ].join("\n"),
145
+ );
146
+
147
+ const first = migrateAuth0110(state);
148
+ expect(first.migrated).toBe(true);
149
+
150
+ const second = migrateAuth0110(state);
151
+ expect(second.migrated).toBe(false);
152
+ expect(second.reason).toContain("already migrated");
153
+ });
154
+
155
+ it("treats an empty OP_UI_TOKEN value as not-set (no promotion)", () => {
156
+ const state = makeState(homeDir);
157
+ const stackEnvPath = seedStackEnv(
158
+ state.stackDir,
159
+ [
160
+ "OP_UI_TOKEN=",
161
+ "OP_ASSISTANT_TOKEN=foo",
162
+ "",
163
+ ].join("\n"),
164
+ );
165
+
166
+ const result = migrateAuth0110(state);
167
+ expect(result.migrated).toBe(true);
168
+ // Empty-string OP_UI_TOKEN should NOT be promoted as a password.
169
+ expect(result.reason).not.toContain("promoted");
170
+
171
+ const after = readFileSync(stackEnvPath, "utf-8");
172
+ // The empty OP_UI_TOKEN line is still removed.
173
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
174
+ // No OP_UI_LOGIN_PASSWORD added (would be an empty value).
175
+ expect(after).not.toMatch(/^OP_UI_LOGIN_PASSWORD=/m);
176
+ });
177
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * One-shot migration for the 0.11.0 auth refactor.
3
+ *
4
+ * Existing installs have OP_UI_TOKEN and OP_ASSISTANT_TOKEN in
5
+ * config/stack/stack.env. The 0.11.0 refactor (auth-and-proxy-refactor-plan.md)
6
+ * replaces them with a single OP_UI_LOGIN_PASSWORD. If we don't migrate,
7
+ * operators get locked out the moment they run the new UI build because the
8
+ * login route compares the cookie against process.env.OP_UI_LOGIN_PASSWORD,
9
+ * which is empty on existing installs.
10
+ *
11
+ * Migration logic (idempotent):
12
+ * - If OP_UI_LOGIN_PASSWORD is unset AND OP_UI_TOKEN is set, copy
13
+ * OP_UI_TOKEN's value into OP_UI_LOGIN_PASSWORD.
14
+ * - Remove OP_UI_TOKEN and OP_ASSISTANT_TOKEN from stack.env (they're
15
+ * no longer used).
16
+ * - Append a one-line summary to state/logs/migration-0.11.0.log.
17
+ * - If OP_UI_LOGIN_PASSWORD is already set, leave it alone — the operator
18
+ * already migrated or set up fresh.
19
+ *
20
+ * Called from ensureSecrets so it runs before any auth-required code path
21
+ * gets a chance to see the half-migrated state.
22
+ */
23
+ import {
24
+ existsSync,
25
+ readFileSync,
26
+ writeFileSync,
27
+ chmodSync,
28
+ appendFileSync,
29
+ mkdirSync,
30
+ } from "node:fs";
31
+ import { dirname } from "node:path";
32
+ import { parseEnvContent, removeEnvKey, upsertEnvValue } from "./env.js";
33
+ import { migration0110LogPath } from "./paths.js";
34
+ import type { ControlPlaneState } from "./types.js";
35
+
36
+ export type MigrateAuth0110Result = {
37
+ /** True if any change was written to stack.env. */
38
+ migrated: boolean;
39
+ /** Human-readable description of what changed (or why nothing did). */
40
+ reason: string;
41
+ };
42
+
43
+ export function migrateAuth0110(state: ControlPlaneState): MigrateAuth0110Result {
44
+ const stackEnvPath = `${state.stackDir}/stack.env`;
45
+ if (!existsSync(stackEnvPath)) {
46
+ return { migrated: false, reason: "no stack.env yet (fresh install)" };
47
+ }
48
+
49
+ const before = readFileSync(stackEnvPath, "utf-8");
50
+ const parsed = parseEnvContent(before);
51
+ const hasLoginPw = typeof parsed.OP_UI_LOGIN_PASSWORD === "string" && parsed.OP_UI_LOGIN_PASSWORD.length > 0;
52
+ const hasUiToken = typeof parsed.OP_UI_TOKEN === "string" && parsed.OP_UI_TOKEN.length > 0;
53
+ const hasAssistantToken = "OP_ASSISTANT_TOKEN" in parsed;
54
+ const hasUiTokenLine = "OP_UI_TOKEN" in parsed;
55
+
56
+ if (hasLoginPw && !hasUiTokenLine && !hasAssistantToken) {
57
+ return { migrated: false, reason: "already migrated" };
58
+ }
59
+
60
+ let content = before;
61
+ const changes: string[] = [];
62
+
63
+ if (!hasLoginPw && hasUiToken) {
64
+ content = upsertEnvValue(content, "OP_UI_LOGIN_PASSWORD", parsed.OP_UI_TOKEN);
65
+ changes.push("promoted OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD");
66
+ }
67
+ if (hasUiTokenLine) {
68
+ content = removeEnvKey(content, "OP_UI_TOKEN");
69
+ changes.push("removed OP_UI_TOKEN");
70
+ }
71
+ if (hasAssistantToken) {
72
+ content = removeEnvKey(content, "OP_ASSISTANT_TOKEN");
73
+ changes.push("removed OP_ASSISTANT_TOKEN");
74
+ }
75
+
76
+ if (changes.length === 0) {
77
+ return { migrated: false, reason: "no changes needed" };
78
+ }
79
+
80
+ // Preserve the 0600 mode the existing file should already have.
81
+ writeFileSync(stackEnvPath, content, { encoding: "utf-8", mode: 0o600 });
82
+ try { chmodSync(stackEnvPath, 0o600); } catch { /* best-effort */ }
83
+
84
+ // Best-effort audit line. The migration log is small and append-only;
85
+ // if it fails (perm error, fs full), we don't roll back the migration.
86
+ try {
87
+ const logPath = migration0110LogPath(state);
88
+ mkdirSync(dirname(logPath), { recursive: true });
89
+ appendFileSync(
90
+ logPath,
91
+ `${new Date().toISOString()} migrate-auth-0110 ${changes.join("; ")}\n`,
92
+ "utf-8",
93
+ );
94
+ } catch {
95
+ /* best-effort */
96
+ }
97
+
98
+ return { migrated: true, reason: changes.join("; ") };
99
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Authoritative path resolution for the OpenPalm control plane.
3
+ *
4
+ * Every consumer imports from here instead of concatenating paths inline.
5
+ * When the directory layout changes, update this file only.
6
+ *
7
+ * Layout:
8
+ * config/ — user-editable config + system config files (auth.json, akm/)
9
+ * config/stack/ — compose runtime + stack config (stack.env, guardian.env, stack.yml, addons/)
10
+ * cache/ — regenerable/semi-persistent data (akm cache, guardian cache, rollback)
11
+ * state/ — persistent service data (assistant, admin, guardian, logs, backups, registry)
12
+ * stash/ — akm knowledge (skills, vaults, agents)
13
+ * workspace/ — shared work area
14
+ */
15
+ import type { ControlPlaneState } from "./types.js";
16
+
17
+ // ── Config directory — user + system config ─────────────────────────────────
18
+
19
+ /** OpenCode auth token store */
20
+ export const authJsonPath = (s: ControlPlaneState): string => `${s.configDir}/auth.json`;
21
+ /** akm setup config directory (AKM_CONFIG_DIR) */
22
+ export const akmConfigDir = (s: ControlPlaneState): string => `${s.configDir}/akm`;
23
+ /** akm setup config file (written by admin on capability save) */
24
+ export const akmConfigPath = (s: ControlPlaneState): string => `${s.configDir}/akm/config.json`;
25
+ export const tasksDir = (s: ControlPlaneState): string => `${s.stashDir}/tasks`;
26
+ export const assistantConfigDir = (s: ControlPlaneState): string => `${s.configDir}/assistant`;
27
+
28
+ // ── Config/stack directory — compose runtime + stack config ─────────────────
29
+
30
+ /** System env: capabilities, secrets, tokens */
31
+ export const stackEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/stack.env`;
32
+ /** Guardian HMAC channel secrets */
33
+ export const guardianEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/guardian.env`;
34
+ /** Stack spec: capability assignments */
35
+ export const stackSpecFilePath = (s: ControlPlaneState): string => `${s.stackDir}/stack.yml`;
36
+
37
+ // ── Cache directory — regenerable/semi-persistent ───────────────────────────
38
+
39
+ export const akmCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm`;
40
+ export const guardianCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/guardian`;
41
+ export const rollbackDir = (s: ControlPlaneState): string => `${s.cacheDir}/rollback`;
42
+
43
+ // ── State directory — persistent service data ───────────────────────────────
44
+
45
+ export const assistantServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/assistant`;
46
+ export const adminServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/admin`;
47
+ export const guardianServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian`;
48
+ export const guardianStashDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/stash`;
49
+ export const guardianAkmDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/akm`;
50
+ /** Shared akm operational data (data/, state/ — NOT config, which lives in config/akm/) */
51
+ export const akmStateDir = (s: ControlPlaneState): string => `${s.stateDir}/akm`;
52
+ export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
53
+ export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
54
+ export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
55
+ /**
56
+ * Guardian's own audit log of channel ingress (HMAC verify, replay, rate
57
+ * limit). Phase 6 of the auth/proxy refactor removed the OpenPalm-side
58
+ * `admin-audit.jsonl` — OpenCode session logs are the audit trail for
59
+ * chat + tool activity.
60
+ */
61
+ export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
62
+ /** One-shot 0.11.0 migration log (OP_UI_TOKEN → OPENCODE_SERVER_PASSWORD, endpoints.json move) */
63
+ export const migration0110LogPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/migration-0.11.0.log`;
64
+ export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
65
+ export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
66
+ export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
67
+ export const registryAutomationsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/automations`;
68
+ export const secretsDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets`;
69
+ export const secretProviderPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/provider.json`;
70
+ export const secretsIndexPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/plaintext-index.json`;
71
+ export const passStoreDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets/pass-store`;
72
+
73
+ // ── Stash directory ─────────────────────────────────────────────────────────
74
+
75
+ /** akm vault:user file — lives in the stash */
76
+ export const akmUserVaultPath = (s: ControlPlaneState): string => `${s.stashDir}/vaults/user.env`;
77
+
78
+ // ── Stack directory ─────────────────────────────────────────────────────────
79
+
80
+ export const coreComposePath = (s: ControlPlaneState): string => `${s.stackDir}/core.compose.yml`;
81
+ export const addonsStackDir = (s: ControlPlaneState): string => `${s.stackDir}/addons`;
82
+ export const addonComposePath = (s: ControlPlaneState, name: string): string => `${s.stackDir}/addons/${name}/compose.yml`;
@@ -8,7 +8,7 @@ export type SecretProviderConfig = {
8
8
  };
9
9
 
10
10
  function providerConfigPath(state: ControlPlaneState): string {
11
- return `${state.dataDir}/secrets/provider.json`;
11
+ return `${state.stateDir}/secrets/provider.json`;
12
12
  }
13
13
 
14
14
  export function readSecretProviderConfig(state: ControlPlaneState): SecretProviderConfig | null {
@@ -28,7 +28,7 @@ export function readSecretProviderConfig(state: ControlPlaneState): SecretProvid
28
28
  }
29
29
 
30
30
  export function writeSecretProviderConfig(state: ControlPlaneState, config: SecretProviderConfig): void {
31
- const dir = `${state.dataDir}/secrets`;
31
+ const dir = `${state.stateDir}/secrets`;
32
32
  mkdirSync(dir, { recursive: true });
33
33
  writeFileSync(providerConfigPath(state), JSON.stringify(config, null, 2) + '\n');
34
34
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Provider model discovery and API key resolution.
3
+ *
4
+ * Used by the admin capabilities test endpoint and the CLI setup wizard
5
+ * to enumerate the models a configured provider exposes.
6
+ */
7
+ import { readStackEnv } from "./secrets.js";
8
+ import { PROVIDER_DEFAULT_URLS } from "../provider-constants.js";
9
+
10
+ /** Static model list for Anthropic (no listing API available). */
11
+ const ANTHROPIC_MODELS = [
12
+ "claude-opus-4-6",
13
+ "claude-sonnet-4-6",
14
+ "claude-opus-4-20250514",
15
+ "claude-sonnet-4-20250514",
16
+ "claude-haiku-4-5-20251001",
17
+ "claude-3-5-sonnet-20241022",
18
+ "claude-3-5-haiku-20241022",
19
+ ];
20
+
21
+
22
+ /**
23
+ * Resolve an API key reference.
24
+ *
25
+ * - Empty input → empty string.
26
+ * - `env:NAME` form → looks up `NAME` in `process.env` first, then falls back
27
+ * to `config/stack/stack.env` resolved against `stackDir`.
28
+ * - Anything else → returned verbatim (treated as a literal key value).
29
+ */
30
+ function resolveApiKey(apiKeyRef: string, stackDir: string): string {
31
+ if (!apiKeyRef) return "";
32
+ if (!apiKeyRef.startsWith("env:")) return apiKeyRef;
33
+
34
+ const varName = apiKeyRef.slice(4);
35
+ if (process.env[varName]) return process.env[varName]!;
36
+
37
+ const secrets = readStackEnv(stackDir);
38
+ return secrets[varName] ?? "";
39
+ }
40
+
41
+
42
+ export type ModelDiscoveryReason =
43
+ | 'none'
44
+ | 'provider_static'
45
+ | 'provider_http'
46
+ | 'missing_base_url'
47
+ | 'timeout'
48
+ | 'network';
49
+
50
+ export type ProviderModelsResult = {
51
+ models: string[];
52
+ status: 'ok' | 'recoverable_error';
53
+ reason: ModelDiscoveryReason;
54
+ error?: string;
55
+ };
56
+
57
+ const HTTP_STATUS_LABELS: Record<number, string> = {
58
+ 401: 'Invalid or missing API key',
59
+ 403: 'Access denied — check API key permissions',
60
+ 404: 'Endpoint not found — verify the base URL',
61
+ 429: 'Rate limited — try again shortly',
62
+ 500: 'Provider internal error',
63
+ 502: 'Provider returned a bad gateway error',
64
+ 503: 'Provider is temporarily unavailable',
65
+ };
66
+
67
+ /**
68
+ * Enumerate available models for a provider. Returns an `ok` result with a
69
+ * sorted model list when the provider responds successfully, or a
70
+ * `recoverable_error` with a structured reason otherwise. Network and timeout
71
+ * failures are caught and mapped to a result rather than thrown.
72
+ */
73
+ export async function fetchProviderModels(
74
+ provider: string,
75
+ apiKeyRef: string,
76
+ baseUrl: string,
77
+ stackDir: string
78
+ ): Promise<ProviderModelsResult> {
79
+ try {
80
+ if (provider === "anthropic") {
81
+ return { models: [...ANTHROPIC_MODELS], status: 'ok', reason: 'provider_static' };
82
+ }
83
+
84
+ const resolvedKey = resolveApiKey(apiKeyRef, stackDir);
85
+
86
+ if (provider === "ollama") {
87
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS.ollama;
88
+ const url = `${base.replace(/\/+$/, "")}/api/tags`;
89
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
90
+ if (!res.ok) {
91
+ return {
92
+ models: [],
93
+ status: 'recoverable_error',
94
+ reason: 'provider_http',
95
+ error: `Ollama API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
96
+ };
97
+ }
98
+ const data = (await res.json()) as { models?: { name: string }[] };
99
+ const models = (data.models ?? []).map((m) => m.name).sort();
100
+ return { models, status: 'ok', reason: 'none' };
101
+ }
102
+
103
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS[provider] || "";
104
+ if (!base) {
105
+ return {
106
+ models: [],
107
+ status: 'recoverable_error',
108
+ reason: 'missing_base_url',
109
+ error: `No base URL configured for provider "${provider}"`,
110
+ };
111
+ }
112
+ const url = `${base.replace(/\/+$/, "")}/v1/models`;
113
+
114
+ const headers: Record<string, string> = {};
115
+ if (resolvedKey) {
116
+ headers["Authorization"] = `Bearer ${resolvedKey}`;
117
+ }
118
+
119
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
120
+ if (!res.ok) {
121
+ let detail = '';
122
+ try {
123
+ const json = JSON.parse(await res.text()) as Record<string, unknown>;
124
+ const errObj = json.error as Record<string, unknown> | string | undefined;
125
+ detail = (typeof errObj === 'object' && errObj !== null && typeof errObj.message === 'string') ? errObj.message
126
+ : typeof errObj === 'string' ? errObj
127
+ : typeof json.message === 'string' ? json.message
128
+ : typeof json.detail === 'string' ? json.detail : '';
129
+ } catch { /* ignore parse errors */ }
130
+ return {
131
+ models: [],
132
+ status: 'recoverable_error',
133
+ reason: 'provider_http',
134
+ error: detail
135
+ ? `Provider API returned ${res.status}: ${detail}`
136
+ : `Provider API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
137
+ };
138
+ }
139
+ const data = (await res.json()) as { data?: { id: string }[] };
140
+ const models = (data.data ?? []).map((m) => m.id).sort();
141
+ return { models, status: 'ok', reason: 'none' };
142
+ } catch (err) {
143
+ const message =
144
+ err instanceof Error && err.name === "TimeoutError"
145
+ ? "Request timed out after 5s"
146
+ : String(err);
147
+ return {
148
+ models: [],
149
+ status: 'recoverable_error',
150
+ reason: err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network',
151
+ error: message,
152
+ };
153
+ }
154
+ }