@openpalm/lib 0.9.9 → 0.10.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.
- package/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +158 -886
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- package/src/control-plane/staging.ts +0 -399
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
2
|
+
import { createOpenCodeClient } from "./opencode-client.ts";
|
|
3
|
+
|
|
4
|
+
// ── Test server helper ──────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
7
|
+
let serverPort = 0;
|
|
8
|
+
|
|
9
|
+
function startMockServer(handler: (req: Request) => Response | Promise<Response>) {
|
|
10
|
+
server = Bun.serve({
|
|
11
|
+
port: 0,
|
|
12
|
+
hostname: "127.0.0.1",
|
|
13
|
+
fetch: handler,
|
|
14
|
+
});
|
|
15
|
+
serverPort = server.port;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (server) { server.stop(true); server = null; }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── Tests ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe("createOpenCodeClient", () => {
|
|
25
|
+
test("proxy returns ok:true for 200 responses", async () => {
|
|
26
|
+
startMockServer(() => new Response(JSON.stringify({ hello: "world" }), {
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
31
|
+
const result = await client.proxy("/test");
|
|
32
|
+
expect(result.ok).toBe(true);
|
|
33
|
+
if (result.ok) expect(result.data).toEqual({ hello: "world" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("proxy returns ok:false for non-200 responses", async () => {
|
|
37
|
+
startMockServer(() => new Response(JSON.stringify({ message: "bad" }), {
|
|
38
|
+
status: 400,
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
43
|
+
const result = await client.proxy("/test");
|
|
44
|
+
expect(result.ok).toBe(false);
|
|
45
|
+
if (!result.ok) {
|
|
46
|
+
expect(result.status).toBe(400);
|
|
47
|
+
expect(result.message).toBe("bad");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("proxy returns unavailable for gateway errors", async () => {
|
|
52
|
+
startMockServer(() => new Response("bad gateway", { status: 502 }));
|
|
53
|
+
|
|
54
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
55
|
+
const result = await client.proxy("/test");
|
|
56
|
+
expect(result.ok).toBe(false);
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
expect(result.code).toBe("opencode_unavailable");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("proxy returns unavailable on network error", async () => {
|
|
63
|
+
const client = createOpenCodeClient({ baseUrl: "http://127.0.0.1:1", /* unreachable port */ });
|
|
64
|
+
const result = await client.proxy("/test");
|
|
65
|
+
expect(result.ok).toBe(false);
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
expect(result.code).toBe("opencode_unavailable");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("getProviders returns array from data.all", async () => {
|
|
72
|
+
startMockServer(() => new Response(JSON.stringify({
|
|
73
|
+
all: [{ id: "openai", name: "OpenAI" }, { id: "groq", name: "Groq" }],
|
|
74
|
+
}), { headers: { "Content-Type": "application/json" } }));
|
|
75
|
+
|
|
76
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
77
|
+
const providers = await client.getProviders();
|
|
78
|
+
expect(providers).toHaveLength(2);
|
|
79
|
+
expect(providers[0].id).toBe("openai");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("getProviders returns empty array on error", async () => {
|
|
83
|
+
startMockServer(() => new Response("error", { status: 500 }));
|
|
84
|
+
|
|
85
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
86
|
+
const providers = await client.getProviders();
|
|
87
|
+
expect(providers).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("getProviderAuth returns auth map", async () => {
|
|
91
|
+
startMockServer(() => new Response(JSON.stringify({
|
|
92
|
+
openai: [{ type: "api", label: "API Key" }],
|
|
93
|
+
}), { headers: { "Content-Type": "application/json" } }));
|
|
94
|
+
|
|
95
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
96
|
+
const auth = await client.getProviderAuth();
|
|
97
|
+
expect(auth.openai).toHaveLength(1);
|
|
98
|
+
expect(auth.openai[0].type).toBe("api");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("setProviderApiKey sends PUT with correct body", async () => {
|
|
102
|
+
let receivedBody: any = null;
|
|
103
|
+
let receivedMethod = "";
|
|
104
|
+
let receivedPath = "";
|
|
105
|
+
|
|
106
|
+
startMockServer(async (req) => {
|
|
107
|
+
receivedMethod = req.method;
|
|
108
|
+
receivedPath = new URL(req.url).pathname;
|
|
109
|
+
receivedBody = await req.json();
|
|
110
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
111
|
+
headers: { "Content-Type": "application/json" },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
116
|
+
const result = await client.setProviderApiKey("openai", "sk-test-123");
|
|
117
|
+
|
|
118
|
+
expect(result.ok).toBe(true);
|
|
119
|
+
expect(receivedMethod).toBe("PUT");
|
|
120
|
+
expect(receivedPath).toBe("/auth/openai");
|
|
121
|
+
expect(receivedBody).toEqual({ type: "api", key: "sk-test-123" });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("isAvailable returns true when /provider responds 200", async () => {
|
|
125
|
+
startMockServer(() => new Response(JSON.stringify({ all: [] }), {
|
|
126
|
+
headers: { "Content-Type": "application/json" },
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
130
|
+
expect(await client.isAvailable()).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("isAvailable returns false when unreachable", async () => {
|
|
134
|
+
const client = createOpenCodeClient({ baseUrl: "http://127.0.0.1:1", /* unreachable port */ });
|
|
135
|
+
expect(await client.isAvailable()).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("getConfig returns config object on success", async () => {
|
|
139
|
+
startMockServer(() => new Response(JSON.stringify({ model: "gpt-4o" }), {
|
|
140
|
+
headers: { "Content-Type": "application/json" },
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
144
|
+
const config = await client.getConfig();
|
|
145
|
+
expect(config).toEqual({ model: "gpt-4o" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("getConfig returns null on error", async () => {
|
|
149
|
+
startMockServer(() => new Response("error", { status: 500 }));
|
|
150
|
+
|
|
151
|
+
const client = createOpenCodeClient({ baseUrl: `http://127.0.0.1:${serverPort}` });
|
|
152
|
+
expect(await client.getConfig()).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OpenCode REST API client.
|
|
3
|
+
*
|
|
4
|
+
* Factory function that returns typed accessors for an OpenCode server
|
|
5
|
+
* at a configurable base URL. Used by both the admin (container) and
|
|
6
|
+
* CLI (host subprocess) to talk to OpenCode.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type OpenCodeClientOpts = {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ProxyResult =
|
|
14
|
+
| { ok: true; data: unknown }
|
|
15
|
+
| { ok: false; status: number; code: string; message: string };
|
|
16
|
+
|
|
17
|
+
export type OpenCodeProvider = {
|
|
18
|
+
id: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function createOpenCodeClient(opts: OpenCodeClientOpts) {
|
|
24
|
+
const { baseUrl } = opts;
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
async function proxy(path: string, options?: RequestInit): Promise<ProxyResult> {
|
|
29
|
+
try {
|
|
30
|
+
const signal = options?.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
|
|
31
|
+
const res = await fetch(`${baseUrl}${path}`, { ...options, signal });
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const body = await res.json().catch((e: unknown) => {
|
|
34
|
+
console.warn('[opencode-client] Failed to parse error response:', e);
|
|
35
|
+
return {} as Record<string, unknown>;
|
|
36
|
+
});
|
|
37
|
+
const message = typeof (body as Record<string, unknown>).message === 'string'
|
|
38
|
+
? (body as Record<string, unknown>).message as string
|
|
39
|
+
: `OpenCode returned ${res.status}`;
|
|
40
|
+
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
|
41
|
+
return { ok: false, status: res.status, code: 'opencode_unavailable', message };
|
|
42
|
+
}
|
|
43
|
+
return { ok: false, status: res.status >= 500 ? 502 : res.status, code: 'opencode_error', message };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, data: await res.json() };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.warn('[opencode-client] OpenCode request failed:', path, e);
|
|
48
|
+
return { ok: false, status: 503, code: 'opencode_unavailable', message: 'OpenCode is not reachable' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getProviders(): Promise<OpenCodeProvider[]> {
|
|
53
|
+
const result = await proxy('/provider');
|
|
54
|
+
if (!result.ok) return [];
|
|
55
|
+
const data = result.data as Record<string, unknown>;
|
|
56
|
+
if (data && Array.isArray(data.all)) return data.all as OpenCodeProvider[];
|
|
57
|
+
if (Array.isArray(result.data)) return result.data as OpenCodeProvider[];
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function getProviderAuth(): Promise<Record<string, Array<{ type: string; label: string }>>> {
|
|
62
|
+
const result = await proxy('/provider/auth');
|
|
63
|
+
if (!result.ok) return {};
|
|
64
|
+
return result.data as Record<string, Array<{ type: string; label: string }>>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function setProviderApiKey(providerID: string, apiKey: string): Promise<ProxyResult> {
|
|
68
|
+
return proxy(`/auth/${encodeURIComponent(providerID)}`, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ type: 'api', key: apiKey }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function startProviderOAuth(providerID: string, methodIndex: number): Promise<ProxyResult> {
|
|
76
|
+
return proxy(`/provider/${encodeURIComponent(providerID)}/oauth/authorize`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ method: methodIndex }),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function completeProviderOAuth(providerID: string, methodIndex: number, code?: string): Promise<ProxyResult> {
|
|
84
|
+
return proxy(`/provider/${encodeURIComponent(providerID)}/oauth/callback`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ method: methodIndex, ...(code ? { code } : {}) }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function getConfig(): Promise<Record<string, unknown> | null> {
|
|
92
|
+
const result = await proxy('/config');
|
|
93
|
+
if (!result.ok) return null;
|
|
94
|
+
return result.data as Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function isAvailable(): Promise<boolean> {
|
|
98
|
+
// OpenCode has no /health endpoint — check /provider instead
|
|
99
|
+
const result = await proxy('/provider');
|
|
100
|
+
return result.ok;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
proxy,
|
|
105
|
+
getProviders,
|
|
106
|
+
getProviderAuth,
|
|
107
|
+
setProviderApiKey,
|
|
108
|
+
startProviderOAuth,
|
|
109
|
+
completeProviderOAuth,
|
|
110
|
+
getConfig,
|
|
111
|
+
isAvailable,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type { ControlPlaneState } from './types.js';
|
|
3
|
+
|
|
4
|
+
export type SecretProviderConfig = {
|
|
5
|
+
provider: 'plaintext' | 'pass';
|
|
6
|
+
passwordStoreDir?: string;
|
|
7
|
+
passPrefix?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function providerConfigPath(state: ControlPlaneState): string {
|
|
11
|
+
return `${state.dataDir}/secrets/provider.json`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readSecretProviderConfig(state: ControlPlaneState): SecretProviderConfig | null {
|
|
15
|
+
const path = providerConfigPath(state);
|
|
16
|
+
if (!existsSync(path)) return null;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as SecretProviderConfig;
|
|
20
|
+
if (parsed?.provider === 'plaintext' || parsed?.provider === 'pass') {
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore malformed provider config and fall back to schema detection
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeSecretProviderConfig(state: ControlPlaneState, config: SecretProviderConfig): void {
|
|
31
|
+
const dir = `${state.dataDir}/secrets`;
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
writeFileSync(providerConfigPath(state), JSON.stringify(config, null, 2) + '\n');
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generates a `redact.env.schema` from the canonical secret mappings.
|
|
3
|
+
*
|
|
4
|
+
* This ensures that every env var carrying a secret is marked for redaction
|
|
5
|
+
* by varlock, without requiring manual maintenance of the schema file.
|
|
6
|
+
*/
|
|
7
|
+
import { getCoreSecretMappings } from './secret-mappings.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a redact.env.schema string from the canonical secret mappings.
|
|
11
|
+
*
|
|
12
|
+
* @param systemEnv - The current system env (used to discover dynamic channel secrets)
|
|
13
|
+
* @returns A complete `@env-spec` schema suitable for varlock redaction
|
|
14
|
+
*/
|
|
15
|
+
export function generateRedactSchema(systemEnv: Record<string, string>): string {
|
|
16
|
+
const lines: string[] = [
|
|
17
|
+
'# OpenPalm — Runtime Redaction Schema (auto-generated)',
|
|
18
|
+
'# Marks env vars as @sensitive so varlock redacts their values from',
|
|
19
|
+
'# stdout/stderr before they reach docker compose logs.',
|
|
20
|
+
'#',
|
|
21
|
+
'# @defaultSensitive=true',
|
|
22
|
+
'# @defaultRequired=false',
|
|
23
|
+
'# ---',
|
|
24
|
+
'',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const envKeys = new Set<string>();
|
|
28
|
+
for (const mapping of getCoreSecretMappings(systemEnv)) {
|
|
29
|
+
envKeys.add(mapping.envKey);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Include container-runtime env names that differ from env-file keys
|
|
33
|
+
// (compose maps OP_MEMORY_TOKEN -> MEMORY_AUTH_TOKEN, etc.)
|
|
34
|
+
envKeys.add('ADMIN_TOKEN');
|
|
35
|
+
envKeys.add('MEMORY_AUTH_TOKEN');
|
|
36
|
+
envKeys.add('OPENCODE_SERVER_PASSWORD');
|
|
37
|
+
|
|
38
|
+
// Resolved capability API keys (written to stack.env by spec-to-env)
|
|
39
|
+
envKeys.add('OP_CAP_LLM_API_KEY');
|
|
40
|
+
envKeys.add('OP_CAP_EMBEDDINGS_API_KEY');
|
|
41
|
+
envKeys.add('OP_CAP_TTS_API_KEY');
|
|
42
|
+
envKeys.add('OP_CAP_STT_API_KEY');
|
|
43
|
+
envKeys.add('OP_CAP_SLM_API_KEY');
|
|
44
|
+
|
|
45
|
+
for (const key of [...envKeys].sort()) {
|
|
46
|
+
lines.push(`${key}=`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return lines.join('\n') + '\n';
|
|
50
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the registry component directory format.
|
|
3
|
+
*
|
|
4
|
+
* Validates that all components in .openpalm/registry/addons/ follow the
|
|
5
|
+
* component conventions: compose.yml with required labels, .env.schema
|
|
6
|
+
* with documented variables, proper service naming, and no security
|
|
7
|
+
* violations.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Resolve path from repo root */
|
|
20
|
+
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
|
|
21
|
+
const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/registry/addons");
|
|
22
|
+
|
|
23
|
+
/** List all component directories in the registry */
|
|
24
|
+
function listComponentDirs(): string[] {
|
|
25
|
+
if (!existsSync(REGISTRY_DIR)) return [];
|
|
26
|
+
return readdirSync(REGISTRY_DIR, { withFileTypes: true })
|
|
27
|
+
.filter((d) => d.isDirectory())
|
|
28
|
+
.map((d) => d.name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read a file from a component directory */
|
|
32
|
+
function readComponentFile(componentId: string, filename: string): string {
|
|
33
|
+
return readFileSync(join(REGISTRY_DIR, componentId, filename), "utf-8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse .env.schema into { variable, annotations, defaultValue, comments } entries */
|
|
37
|
+
function parseEnvSchema(content: string): Array<{
|
|
38
|
+
variable: string;
|
|
39
|
+
defaultValue: string;
|
|
40
|
+
annotations: string[];
|
|
41
|
+
comments: string[];
|
|
42
|
+
}> {
|
|
43
|
+
const entries: Array<{
|
|
44
|
+
variable: string;
|
|
45
|
+
defaultValue: string;
|
|
46
|
+
annotations: string[];
|
|
47
|
+
comments: string[];
|
|
48
|
+
}> = [];
|
|
49
|
+
|
|
50
|
+
const lines = content.split("\n");
|
|
51
|
+
let pendingComments: string[] = [];
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
|
|
56
|
+
if (trimmed.startsWith("#")) {
|
|
57
|
+
pendingComments.push(trimmed);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (trimmed === "" || trimmed === "---") {
|
|
62
|
+
// Blank line or section separator — keep accumulating comments
|
|
63
|
+
// for the next variable.
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)/);
|
|
68
|
+
if (match) {
|
|
69
|
+
const variable = match[1];
|
|
70
|
+
const defaultValue = match[2];
|
|
71
|
+
|
|
72
|
+
// Extract @annotations from pending comments
|
|
73
|
+
const annotations: string[] = [];
|
|
74
|
+
for (const c of pendingComments) {
|
|
75
|
+
const annots = c.match(/@[a-z]+/g);
|
|
76
|
+
if (annots) annotations.push(...annots);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
entries.push({
|
|
80
|
+
variable,
|
|
81
|
+
defaultValue,
|
|
82
|
+
annotations,
|
|
83
|
+
comments: [...pendingComments],
|
|
84
|
+
});
|
|
85
|
+
pendingComments = [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return entries;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Discovery Tests ──────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("registry component discovery", () => {
|
|
95
|
+
const componentIds = listComponentDirs();
|
|
96
|
+
|
|
97
|
+
it("finds at least one component in the registry", () => {
|
|
98
|
+
expect(componentIds.length).toBeGreaterThan(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("contains the expected core components", () => {
|
|
102
|
+
expect(componentIds).toContain("chat");
|
|
103
|
+
expect(componentIds).toContain("api");
|
|
104
|
+
expect(componentIds).toContain("discord");
|
|
105
|
+
expect(componentIds).toContain("slack");
|
|
106
|
+
expect(componentIds).toContain("voice");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("component IDs are valid (lowercase alphanumeric + hyphens)", () => {
|
|
110
|
+
const validIdRe = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
111
|
+
for (const id of componentIds) {
|
|
112
|
+
expect(validIdRe.test(id)).toBe(true);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Required Files Tests ─────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("registry component required files", () => {
|
|
120
|
+
const componentIds = listComponentDirs();
|
|
121
|
+
|
|
122
|
+
for (const id of componentIds) {
|
|
123
|
+
it(`${id}: has compose.yml`, () => {
|
|
124
|
+
expect(existsSync(join(REGISTRY_DIR, id, "compose.yml"))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it(`${id}: has .env.schema`, () => {
|
|
128
|
+
expect(existsSync(join(REGISTRY_DIR, id, ".env.schema"))).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── Compose Overlay Validation Tests ─────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe("registry compose.yml validation", () => {
|
|
136
|
+
const componentIds = listComponentDirs();
|
|
137
|
+
|
|
138
|
+
for (const id of componentIds) {
|
|
139
|
+
describe(id, () => {
|
|
140
|
+
const compose = readComponentFile(id, "compose.yml");
|
|
141
|
+
|
|
142
|
+
it("has openpalm.name label", () => {
|
|
143
|
+
expect(compose).toMatch(/openpalm\.name:/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("has openpalm.description label", () => {
|
|
147
|
+
expect(compose).toMatch(/openpalm\.description:/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("uses static service name (no INSTANCE_ID)", () => {
|
|
151
|
+
expect(compose).not.toContain("${INSTANCE_ID}");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("does not use container_name", () => {
|
|
155
|
+
expect(compose).not.toMatch(/container_name:/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does not reference INSTANCE_DIR", () => {
|
|
159
|
+
expect(compose).not.toContain("${INSTANCE_DIR}");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("joins a valid stack network", () => {
|
|
163
|
+
const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
|
|
164
|
+
expect(hasValidNetwork).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("has restart policy", () => {
|
|
168
|
+
expect(compose).toMatch(/restart:\s/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("has healthcheck", () => {
|
|
172
|
+
expect(compose).toMatch(/healthcheck:/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("does not mount vault directory (single-file mounts allowed)", () => {
|
|
176
|
+
// Directory-level vault mounts are a security violation — only admin gets full vault access.
|
|
177
|
+
// Single-file mounts like vault/user/ov.conf are allowed (the source must end with a filename).
|
|
178
|
+
const lines = compose.split("\n");
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
if (line.match(/^\s*-\s+.*vault.*:/)) {
|
|
181
|
+
// Extract the source portion (before first colon that follows a path)
|
|
182
|
+
const match = line.match(/^\s*-\s+(.+?):/);
|
|
183
|
+
if (match) {
|
|
184
|
+
const source = match[1];
|
|
185
|
+
// Allow single-file vault mounts (path ends with a file, i.e. has an extension or
|
|
186
|
+
// a non-directory final segment). Block bare vault/ or vault/<dir>/ mounts.
|
|
187
|
+
if (/vault\b/i.test(source) && !/vault\/.*\.[a-z]+$/i.test(source)) {
|
|
188
|
+
throw new Error(`Vault directory mount detected: ${line.trim()}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not mount docker socket", () => {
|
|
196
|
+
// admin component is exempt — docker-socket-proxy IS the docker socket accessor by design
|
|
197
|
+
if (id === "admin") return;
|
|
198
|
+
expect(compose).not.toContain("/var/run/docker.sock");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("has a comment header describing the component", () => {
|
|
202
|
+
expect(compose.startsWith("#")).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── .env.schema Validation Tests ─────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("registry .env.schema validation", () => {
|
|
211
|
+
const componentIds = listComponentDirs();
|
|
212
|
+
|
|
213
|
+
for (const id of componentIds) {
|
|
214
|
+
describe(id, () => {
|
|
215
|
+
const schema = readComponentFile(id, ".env.schema");
|
|
216
|
+
const entries = parseEnvSchema(schema);
|
|
217
|
+
|
|
218
|
+
it("is non-empty", () => {
|
|
219
|
+
expect(schema.length).toBeGreaterThan(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("has at least one variable definition", () => {
|
|
223
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("does not include INSTANCE_ID (removed)", () => {
|
|
227
|
+
const names = entries.map((e) => e.variable);
|
|
228
|
+
expect(names).not.toContain("INSTANCE_ID");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not include INSTANCE_DIR (removed)", () => {
|
|
232
|
+
const names = entries.map((e) => e.variable);
|
|
233
|
+
expect(names).not.toContain("INSTANCE_DIR");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("has at least one @required variable", () => {
|
|
237
|
+
const requiredEntries = entries.filter((e) =>
|
|
238
|
+
e.annotations.includes("@required")
|
|
239
|
+
);
|
|
240
|
+
expect(requiredEntries.length).toBeGreaterThan(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("variable names are valid (uppercase with underscores)", () => {
|
|
244
|
+
const validVarRe = /^[A-Z_][A-Z0-9_]*$/;
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
expect(validVarRe.test(entry.variable)).toBe(true);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("every variable has at least one comment line above it", () => {
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
expect(entry.comments.length).toBeGreaterThan(0);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("does not contain vault references", () => {
|
|
257
|
+
expect(schema.toLowerCase()).not.toContain("vault/");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ── Sensitive Fields Tests ───────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
describe("registry component sensitive fields", () => {
|
|
266
|
+
const componentIds = listComponentDirs();
|
|
267
|
+
|
|
268
|
+
for (const id of componentIds) {
|
|
269
|
+
it(`${id}: has at least one @sensitive field (channel secret)`, () => {
|
|
270
|
+
// ollama is a local inference server — no channel secret or API key needed
|
|
271
|
+
if (id === "ollama") return;
|
|
272
|
+
const schema = readComponentFile(id, ".env.schema");
|
|
273
|
+
const entries = parseEnvSchema(schema);
|
|
274
|
+
const sensitiveEntries = entries.filter((e) =>
|
|
275
|
+
e.annotations.includes("@sensitive")
|
|
276
|
+
);
|
|
277
|
+
expect(sensitiveEntries.length).toBeGreaterThan(0);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── Cross-Component Consistency Tests ────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
describe("cross-component consistency", () => {
|
|
285
|
+
const componentIds = listComponentDirs();
|
|
286
|
+
|
|
287
|
+
it("no duplicate openpalm.name labels across components", () => {
|
|
288
|
+
const names = new Set<string>();
|
|
289
|
+
for (const id of componentIds) {
|
|
290
|
+
const compose = readComponentFile(id, "compose.yml");
|
|
291
|
+
const nameMatch = compose.match(/openpalm\.name:\s*(.+)/);
|
|
292
|
+
expect(nameMatch).not.toBeNull();
|
|
293
|
+
const name = nameMatch![1].trim();
|
|
294
|
+
expect(names.has(name)).toBe(false);
|
|
295
|
+
names.add(name);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("all components join a valid stack network", () => {
|
|
300
|
+
for (const id of componentIds) {
|
|
301
|
+
const compose = readComponentFile(id, "compose.yml");
|
|
302
|
+
const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
|
|
303
|
+
expect(hasValidNetwork).toBe(true);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("no compose file uses INSTANCE_ID anywhere", () => {
|
|
308
|
+
for (const id of componentIds) {
|
|
309
|
+
const compose = readComponentFile(id, "compose.yml");
|
|
310
|
+
expect(compose).not.toContain("INSTANCE_ID");
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|