@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.
- package/README.md +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -24
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +103 -65
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +187 -289
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +34 -65
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/paths.ts +82 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -111
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +93 -51
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +138 -239
- package/src/control-plane/setup.ts +215 -130
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +52 -142
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +12 -28
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +86 -48
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseComposeStderr,
|
|
4
|
+
summarizeComposeStderr,
|
|
5
|
+
} from "./compose-errors.js";
|
|
6
|
+
|
|
7
|
+
describe("parseComposeStderr", () => {
|
|
8
|
+
it("returns empty for empty input", () => {
|
|
9
|
+
expect(parseComposeStderr("")).toEqual([]);
|
|
10
|
+
expect(parseComposeStderr("\n\n")).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("extracts pull access denied for a single service", () => {
|
|
14
|
+
const stderr = [
|
|
15
|
+
" Network openpalm_default Created",
|
|
16
|
+
" voice Pulling",
|
|
17
|
+
" voice Error pull access denied for openpalm/voice, repository does not exist or may require 'docker login'",
|
|
18
|
+
"Error response from daemon: pull access denied for openpalm/voice, repository does not exist or may require 'docker login': denied: requested access to the resource is denied",
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
const failures = parseComposeStderr(stderr);
|
|
22
|
+
expect(failures.length).toBeGreaterThanOrEqual(1);
|
|
23
|
+
expect(failures[0].service).toBe("voice");
|
|
24
|
+
expect(failures[0].reason).toMatch(/pull access denied/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("handles spinner / status prefix glyphs", () => {
|
|
28
|
+
const stderr = " ⠿ voice Error pull access denied for openpalm/voice";
|
|
29
|
+
const failures = parseComposeStderr(stderr);
|
|
30
|
+
expect(failures).toHaveLength(1);
|
|
31
|
+
expect(failures[0].service).toBe("voice");
|
|
32
|
+
expect(failures[0].reason).toMatch(/pull access denied/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("captures quoted Service failed lines", () => {
|
|
36
|
+
const stderr =
|
|
37
|
+
'Service "discord" failed to build: failed to solve: process did not complete';
|
|
38
|
+
const failures = parseComposeStderr(stderr);
|
|
39
|
+
expect(failures).toHaveLength(1);
|
|
40
|
+
expect(failures[0].service).toBe("discord");
|
|
41
|
+
expect(failures[0].reason).toMatch(/failed to solve/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("deduplicates identical (service, reason) pairs", () => {
|
|
45
|
+
const stderr = [
|
|
46
|
+
"voice Error pull access denied for openpalm/voice",
|
|
47
|
+
"voice Error pull access denied for openpalm/voice",
|
|
48
|
+
].join("\n");
|
|
49
|
+
const failures = parseComposeStderr(stderr);
|
|
50
|
+
expect(failures).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns multiple distinct failures", () => {
|
|
54
|
+
const stderr = [
|
|
55
|
+
"voice Error pull access denied for openpalm/voice",
|
|
56
|
+
"discord Error no such image: openpalm/discord:latest",
|
|
57
|
+
].join("\n");
|
|
58
|
+
const failures = parseComposeStderr(stderr);
|
|
59
|
+
expect(failures).toHaveLength(2);
|
|
60
|
+
expect(failures.map((f) => f.service).sort()).toEqual(["discord", "voice"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to image name when only daemon error is present", () => {
|
|
64
|
+
const stderr =
|
|
65
|
+
"Error response from daemon: pull access denied for openpalm/voice, repository does not exist";
|
|
66
|
+
const failures = parseComposeStderr(stderr);
|
|
67
|
+
expect(failures).toHaveLength(1);
|
|
68
|
+
expect(failures[0].service).toBe("openpalm/voice");
|
|
69
|
+
expect(failures[0].reason).toMatch(/pull access denied/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores non-error noise (Pulling/Created/Started)", () => {
|
|
73
|
+
const stderr = [
|
|
74
|
+
" Network openpalm_default Created",
|
|
75
|
+
" Container openpalm-guardian-1 Started",
|
|
76
|
+
" assistant Pulling",
|
|
77
|
+
].join("\n");
|
|
78
|
+
expect(parseComposeStderr(stderr)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not treat 'Error response from daemon' as a service name", () => {
|
|
82
|
+
const stderr = "Error response from daemon: something bad happened";
|
|
83
|
+
// No service-prefixed line, no pull access denied, no quoted service —
|
|
84
|
+
// parser should NOT invent a service called "Error".
|
|
85
|
+
expect(parseComposeStderr(stderr)).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("summarizeComposeStderr", () => {
|
|
90
|
+
it("returns first non-empty line", () => {
|
|
91
|
+
expect(summarizeComposeStderr("\n\n hello world \nnext line")).toBe(
|
|
92
|
+
"hello world"
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("truncates long lines", () => {
|
|
97
|
+
const long = "x".repeat(800);
|
|
98
|
+
const out = summarizeComposeStderr(long, 100);
|
|
99
|
+
expect(out.length).toBe(100);
|
|
100
|
+
expect(out.endsWith("…")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns empty string for empty input", () => {
|
|
104
|
+
expect(summarizeComposeStderr("")).toBe("");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse `docker compose` stderr for per-service failures.
|
|
3
|
+
*
|
|
4
|
+
* `docker compose up -d` reports its progress on stderr — one or more
|
|
5
|
+
* status lines per service, plus a daemon-level "Error response from daemon"
|
|
6
|
+
* summary. When a single addon service fails to pull or start, the rest of
|
|
7
|
+
* the stack often comes up fine, so the only signal that anything is wrong
|
|
8
|
+
* is whatever appears on stderr. This helper extracts the per-service
|
|
9
|
+
* failure messages so callers can surface them to operators.
|
|
10
|
+
*/
|
|
11
|
+
export type ComposeServiceFailure = {
|
|
12
|
+
service: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lines we recognise as per-service failure indicators. The compose CLI
|
|
18
|
+
* has rendered these in a few different shapes across versions:
|
|
19
|
+
*
|
|
20
|
+
* "voice Error pull access denied for openpalm/voice ..."
|
|
21
|
+
* " ⠿ voice Error pull access denied for openpalm/voice ..."
|
|
22
|
+
* "Service \"voice\" failed to build: ..."
|
|
23
|
+
*
|
|
24
|
+
* We also pick up the bare daemon error and attribute it to the service
|
|
25
|
+
* named in nearby lines when no service-prefixed line is present.
|
|
26
|
+
*/
|
|
27
|
+
const SERVICE_ERROR_RE = /^[\s⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠿✔✘×]*\s*([A-Za-z0-9._-]+)\s+(Error|Failed|failed)\s+(.+)$/;
|
|
28
|
+
const SERVICE_FAILED_QUOTED_RE = /Service\s+["']([A-Za-z0-9._-]+)["']\s+failed[^:]*:\s*(.+)$/i;
|
|
29
|
+
const SERVICE_NOT_FOUND_RE = /no such service:\s*([A-Za-z0-9._-]+)/i;
|
|
30
|
+
const PULL_ACCESS_DENIED_RE = /pull access denied for\s+([^\s,]+)/i;
|
|
31
|
+
|
|
32
|
+
function pushUnique(
|
|
33
|
+
failures: ComposeServiceFailure[],
|
|
34
|
+
entry: ComposeServiceFailure
|
|
35
|
+
): void {
|
|
36
|
+
const trimmed = { service: entry.service.trim(), reason: entry.reason.trim() };
|
|
37
|
+
if (!trimmed.service || !trimmed.reason) return;
|
|
38
|
+
const dup = failures.find(
|
|
39
|
+
(f) => f.service === trimmed.service && f.reason === trimmed.reason
|
|
40
|
+
);
|
|
41
|
+
if (!dup) failures.push(trimmed);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Best-effort extraction of failures from compose stderr.
|
|
46
|
+
*
|
|
47
|
+
* - Returns one entry per (service, reason) pair, in stderr order.
|
|
48
|
+
* - Does NOT fabricate service names: if a daemon error appears without
|
|
49
|
+
* any nearby service-prefixed line, the caller's intended-services list
|
|
50
|
+
* is used by the route, not this parser.
|
|
51
|
+
*/
|
|
52
|
+
export function parseComposeStderr(stderr: string): ComposeServiceFailure[] {
|
|
53
|
+
const failures: ComposeServiceFailure[] = [];
|
|
54
|
+
if (!stderr) return failures;
|
|
55
|
+
|
|
56
|
+
const lines = stderr.split(/\r?\n/);
|
|
57
|
+
|
|
58
|
+
for (const raw of lines) {
|
|
59
|
+
const line = raw.replace(/\s+$/, "");
|
|
60
|
+
if (!line.trim()) continue;
|
|
61
|
+
|
|
62
|
+
const quoted = SERVICE_FAILED_QUOTED_RE.exec(line);
|
|
63
|
+
if (quoted) {
|
|
64
|
+
pushUnique(failures, { service: quoted[1], reason: quoted[2] });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const m = SERVICE_ERROR_RE.exec(line);
|
|
69
|
+
if (m) {
|
|
70
|
+
// Skip generic prefixes that look like services but aren't
|
|
71
|
+
// (e.g. "Error response from daemon ..." would match if the parser
|
|
72
|
+
// is too lenient — the verb word would be the second token).
|
|
73
|
+
const candidate = m[1];
|
|
74
|
+
if (candidate.toLowerCase() === "error") continue;
|
|
75
|
+
pushUnique(failures, { service: candidate, reason: m[3] });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const notFound = SERVICE_NOT_FOUND_RE.exec(line);
|
|
80
|
+
if (notFound) {
|
|
81
|
+
pushUnique(failures, {
|
|
82
|
+
service: notFound[1],
|
|
83
|
+
reason: `no such service: ${notFound[1]}`,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// If we still found nothing but the stderr clearly mentions a pull
|
|
90
|
+
// access denied, surface the offending image as the "service" identifier
|
|
91
|
+
// — better than swallowing the failure entirely.
|
|
92
|
+
if (failures.length === 0) {
|
|
93
|
+
const denied = PULL_ACCESS_DENIED_RE.exec(stderr);
|
|
94
|
+
if (denied) {
|
|
95
|
+
pushUnique(failures, {
|
|
96
|
+
service: denied[1],
|
|
97
|
+
reason: `pull access denied for ${denied[1]}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return failures;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Summarise compose stderr in a single short line, suitable for log
|
|
107
|
+
* envelopes / API error messages when no per-service parse succeeded.
|
|
108
|
+
* Returns the first non-empty stderr line, capped.
|
|
109
|
+
*/
|
|
110
|
+
export function summarizeComposeStderr(stderr: string, maxLen = 500): string {
|
|
111
|
+
if (!stderr) return "";
|
|
112
|
+
const first = stderr
|
|
113
|
+
.split(/\r?\n/)
|
|
114
|
+
.map((l) => l.trim())
|
|
115
|
+
.find((l) => l.length > 0) ?? "";
|
|
116
|
+
return first.length > maxLen ? first.slice(0, maxLen - 1) + "…" : first;
|
|
117
|
+
}
|
|
@@ -6,67 +6,54 @@
|
|
|
6
6
|
* the rollback module (snapshot to ~/.cache/openpalm/rollback/).
|
|
7
7
|
*/
|
|
8
8
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
|
|
9
|
-
import {
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { parse as yamlParse } from "yaml";
|
|
11
|
+
import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
10
12
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
11
13
|
import { isChannelAddon } from "./channels.js";
|
|
12
|
-
import { readStackSpec } from "./stack-spec.js";
|
|
13
|
-
import { writeCapabilityVars } from "./spec-to-env.js";
|
|
14
14
|
import { listEnabledAddonIds } from "./registry.js";
|
|
15
15
|
|
|
16
|
-
import { generateRedactSchema } from "./redact-schema.js";
|
|
17
|
-
import { readStackEnv } from "./secrets.js";
|
|
18
16
|
import {
|
|
19
17
|
readCoreCompose,
|
|
20
|
-
ensureUserEnvSchema,
|
|
21
|
-
ensureSystemEnvSchema,
|
|
22
18
|
} from "./core-assets.js";
|
|
23
19
|
export { sha256, randomHex } from "./crypto.js";
|
|
24
20
|
import { sha256, randomHex } from "./crypto.js";
|
|
25
21
|
|
|
26
22
|
const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
|
|
27
23
|
|
|
28
|
-
// ── Stack Config (stack.yml) ─────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Check whether Ollama is enabled via active stack/addons/ overlay.
|
|
32
|
-
*/
|
|
33
|
-
export function isOllamaEnabled(state: ControlPlaneState): boolean {
|
|
34
|
-
return listEnabledAddonIds(state.homeDir).includes("ollama");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Check whether admin is enabled via active stack/addons/ overlay.
|
|
39
|
-
*/
|
|
40
|
-
export function isAdminEnabled(state: ControlPlaneState): boolean {
|
|
41
|
-
return listEnabledAddonIds(state.homeDir).includes("admin");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
24
|
// ── Env File Management ──────────────────────────────────────────────
|
|
45
25
|
|
|
46
26
|
/**
|
|
47
27
|
* Return the env files used for docker compose --env-file args.
|
|
48
28
|
* These are the live vault env files.
|
|
49
29
|
*
|
|
50
|
-
* Order: stack.env ->
|
|
30
|
+
* Order: stack.env -> guardian.env
|
|
31
|
+
*
|
|
32
|
+
* Note: `vault/user/user.env` is no longer a
|
|
33
|
+
* compose env_file. User-managed env secrets live in the akm
|
|
34
|
+
* `vault:user` store and are sourced by the assistant entrypoint at
|
|
35
|
+
* container startup. The legacy file is migrated into akm and deleted
|
|
36
|
+
* on upgrade; subsequent `docker compose` invocations must not reference
|
|
37
|
+
* it (compose interpolates `${VAR}` against the merged --env-file
|
|
38
|
+
* contents, and a stale user.env would shadow the akm-sourced values).
|
|
51
39
|
*/
|
|
52
40
|
export function buildEnvFiles(state: ControlPlaneState): string[] {
|
|
53
41
|
return [
|
|
54
|
-
`${state.
|
|
55
|
-
`${state.
|
|
56
|
-
`${state.vaultDir}/stack/guardian.env`,
|
|
42
|
+
`${state.stackDir}/stack.env`,
|
|
43
|
+
`${state.stackDir}/guardian.env`,
|
|
57
44
|
].filter(existsSync);
|
|
58
45
|
}
|
|
59
46
|
|
|
60
47
|
/**
|
|
61
|
-
* Write system-managed values to
|
|
48
|
+
* Write system-managed values to config/stack/stack.env.
|
|
62
49
|
*
|
|
63
50
|
* Channel HMAC secrets are NOT written here — they belong in guardian.env.
|
|
64
51
|
* Use writeChannelSecrets() for channel secrets.
|
|
65
52
|
*/
|
|
66
53
|
export function writeSystemEnv(state: ControlPlaneState): void {
|
|
67
|
-
mkdirSync(
|
|
54
|
+
mkdirSync(state.stackDir, { recursive: true });
|
|
68
55
|
|
|
69
|
-
const systemEnvPath = `${state.
|
|
56
|
+
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
70
57
|
|
|
71
58
|
let base = "";
|
|
72
59
|
if (existsSync(systemEnvPath)) {
|
|
@@ -98,18 +85,15 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
98
85
|
"# Auto-generated fallback.",
|
|
99
86
|
"",
|
|
100
87
|
"# ── Authentication ──────────────────────────────────────────────────",
|
|
101
|
-
`
|
|
102
|
-
`OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
|
|
88
|
+
`OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
|
|
103
89
|
"",
|
|
104
90
|
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
105
|
-
`OP_MEMORY_TOKEN=${process.env.OP_MEMORY_TOKEN ?? ""}`,
|
|
106
91
|
"OP_OPENCODE_PASSWORD=",
|
|
107
92
|
"",
|
|
108
93
|
"# ── Paths ──────────────────────────────────────────────────────────",
|
|
109
94
|
`OP_HOME=${state.homeDir}`,
|
|
110
95
|
`OP_UID=${uid}`,
|
|
111
96
|
`OP_GID=${gid}`,
|
|
112
|
-
`OP_DOCKER_SOCK=${process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock"}`,
|
|
113
97
|
"",
|
|
114
98
|
"# ── Images ──────────────────────────────────────────────────────────",
|
|
115
99
|
`OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
|
|
@@ -119,7 +103,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
119
103
|
`OP_ASSISTANT_PORT=3800`,
|
|
120
104
|
`OP_ADMIN_PORT=3880`,
|
|
121
105
|
`OP_ADMIN_OPENCODE_PORT=3881`,
|
|
122
|
-
`OP_MEMORY_PORT=3898`,
|
|
123
106
|
`OP_GUARDIAN_PORT=3899`,
|
|
124
107
|
""
|
|
125
108
|
].join("\n");
|
|
@@ -191,19 +174,19 @@ function extractChannelSecrets(parsed: Record<string, string>): Record<string, s
|
|
|
191
174
|
}
|
|
192
175
|
|
|
193
176
|
/**
|
|
194
|
-
* Read channel HMAC secrets from
|
|
177
|
+
* Read channel HMAC secrets from config/stack/guardian.env.
|
|
195
178
|
*/
|
|
196
|
-
export function readChannelSecrets(
|
|
197
|
-
return extractChannelSecrets(parseEnvFile(`${
|
|
179
|
+
export function readChannelSecrets(stackDir: string): Record<string, string> {
|
|
180
|
+
return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
|
|
198
181
|
}
|
|
199
182
|
|
|
200
183
|
/**
|
|
201
|
-
* Write channel HMAC secrets to
|
|
184
|
+
* Write channel HMAC secrets to state/guardian.env.
|
|
202
185
|
* Merges with existing content; does not overwrite unrelated entries.
|
|
203
186
|
*/
|
|
204
|
-
export function writeChannelSecrets(
|
|
205
|
-
const guardianPath = `${
|
|
206
|
-
mkdirSync(
|
|
187
|
+
export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
|
|
188
|
+
const guardianPath = `${stackDir}/guardian.env`;
|
|
189
|
+
mkdirSync(stackDir, { recursive: true });
|
|
207
190
|
|
|
208
191
|
let base = "";
|
|
209
192
|
if (existsSync(guardianPath)) {
|
|
@@ -223,48 +206,103 @@ export function writeChannelSecrets(vaultDir: string, secrets: Record<string, st
|
|
|
223
206
|
chmodSync(guardianPath, 0o600);
|
|
224
207
|
}
|
|
225
208
|
|
|
209
|
+
// ── Volume Mount Targets ───────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse all enabled compose files and pre-create every host-side volume
|
|
213
|
+
* mount target as the current user. This prevents Docker from creating
|
|
214
|
+
* them as root-owned, which causes EACCES inside non-root containers.
|
|
215
|
+
*
|
|
216
|
+
* For file mounts (basename contains a `.`), creates an empty file.
|
|
217
|
+
* For directory mounts (basename has no `.`), creates the directory.
|
|
218
|
+
*
|
|
219
|
+
* Heuristic: a basename containing a `.` is treated as a file. This
|
|
220
|
+
* intentionally includes leading-dot files (e.g. `.env`) because Docker
|
|
221
|
+
* bind mounts to them must be regular files. Bare directory names like
|
|
222
|
+
* `stack` or `addons` lack extensions and are created as directories.
|
|
223
|
+
*
|
|
224
|
+
* Only mount sources under `state.homeDir` are touched; external paths
|
|
225
|
+
* (e.g. `/var/run/docker.sock`) are left alone.
|
|
226
|
+
*/
|
|
227
|
+
export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
228
|
+
const composeFiles = discoverStackOverlays(`${state.homeDir}/stack`);
|
|
229
|
+
if (composeFiles.length === 0) return;
|
|
230
|
+
|
|
231
|
+
const envVars: Record<string, string> = {
|
|
232
|
+
...(process.env as Record<string, string>),
|
|
233
|
+
...parseEnvFile(`${state.stackDir}/stack.env`),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
for (const file of composeFiles) {
|
|
237
|
+
let doc: Record<string, unknown>;
|
|
238
|
+
try {
|
|
239
|
+
doc = yamlParse(readFileSync(file, 'utf-8')) as Record<string, unknown>;
|
|
240
|
+
} catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const services = doc?.services;
|
|
244
|
+
if (!services || typeof services !== 'object') continue;
|
|
245
|
+
|
|
246
|
+
for (const svc of Object.values(services as Record<string, unknown>)) {
|
|
247
|
+
if (!svc || typeof svc !== 'object') continue;
|
|
248
|
+
const svcRecord = svc as Record<string, unknown>;
|
|
249
|
+
if (!Array.isArray(svcRecord.volumes)) continue;
|
|
250
|
+
for (const vol of svcRecord.volumes as unknown[]) {
|
|
251
|
+
const volRecord = typeof vol === 'object' && vol !== null
|
|
252
|
+
? (vol as Record<string, unknown>)
|
|
253
|
+
: null;
|
|
254
|
+
const rawSource = typeof vol === 'string'
|
|
255
|
+
? vol.split(':')[0]
|
|
256
|
+
: String(volRecord?.source ?? '');
|
|
257
|
+
if (!rawSource) continue;
|
|
258
|
+
|
|
259
|
+
const hostPath = expandEnvVars(rawSource, envVars);
|
|
260
|
+
if (!hostPath || !hostPath.startsWith('/')) continue;
|
|
261
|
+
if (existsSync(hostPath)) continue;
|
|
262
|
+
|
|
263
|
+
// A basename containing a `.` (anywhere, including leading) is a file.
|
|
264
|
+
// Bare names like `stack` or `data` are directories.
|
|
265
|
+
const basename = hostPath.split('/').pop() ?? '';
|
|
266
|
+
const isFile = basename.includes('.');
|
|
267
|
+
|
|
268
|
+
if (isFile) {
|
|
269
|
+
mkdirSync(dirname(hostPath), { recursive: true });
|
|
270
|
+
writeFileSync(hostPath, '');
|
|
271
|
+
} else {
|
|
272
|
+
mkdirSync(hostPath, { recursive: true });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
226
279
|
// ── Persistence (direct-write to live paths) ────────────────────────
|
|
227
280
|
|
|
228
281
|
export function writeRuntimeFiles(
|
|
229
282
|
state: ControlPlaneState
|
|
230
283
|
): void {
|
|
231
|
-
// Write core compose to stack/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
writeFileSync(`${stackDir}/core.compose.yml`, state.artifacts.compose);
|
|
284
|
+
// Write core compose to config/stack/
|
|
285
|
+
mkdirSync(state.stackDir, { recursive: true });
|
|
286
|
+
writeFileSync(`${state.stackDir}/core.compose.yml`, state.artifacts.compose);
|
|
235
287
|
|
|
236
288
|
// Load persisted channel HMAC secrets from guardian.env,
|
|
237
289
|
// then generate new ones for new channel addons.
|
|
238
|
-
const channelSecrets = readChannelSecrets(state.
|
|
239
|
-
const addonStackDir = `${state.homeDir}/stack`;
|
|
290
|
+
const channelSecrets = readChannelSecrets(state.stackDir);
|
|
240
291
|
for (const addon of listEnabledAddonIds(state.homeDir)) {
|
|
241
|
-
const composePath = `${
|
|
292
|
+
const composePath = `${state.stackDir}/addons/${addon}/compose.yml`;
|
|
242
293
|
if (isChannelAddon(composePath) && !channelSecrets[addon]) {
|
|
243
294
|
channelSecrets[addon] = randomHex(16);
|
|
244
295
|
}
|
|
245
296
|
}
|
|
246
297
|
|
|
247
298
|
// Write channel secrets to guardian.env (the canonical source)
|
|
248
|
-
writeChannelSecrets(state.
|
|
299
|
+
writeChannelSecrets(state.stackDir, channelSecrets);
|
|
249
300
|
|
|
250
301
|
// Write system.env (no channel secrets — those live in guardian.env)
|
|
251
302
|
writeSystemEnv(state);
|
|
252
303
|
|
|
253
|
-
// Ensure
|
|
254
|
-
|
|
255
|
-
ensureSystemEnvSchema();
|
|
256
|
-
|
|
257
|
-
const spec = readStackSpec(state.configDir);
|
|
258
|
-
// Write OP_CAP_* capability vars to stack.env from stack spec
|
|
259
|
-
if (spec) {
|
|
260
|
-
writeCapabilityVars(spec, state.vaultDir);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Generate redact.env.schema from canonical mappings
|
|
264
|
-
const systemEnv = readStackEnv(state.vaultDir);
|
|
265
|
-
const redactDir = `${state.dataDir}/secrets`;
|
|
266
|
-
mkdirSync(redactDir, { recursive: true });
|
|
267
|
-
writeFileSync(`${redactDir}/redact.env.schema`, generateRedactSchema(systemEnv));
|
|
304
|
+
// Ensure state directory exists
|
|
305
|
+
mkdirSync(state.stateDir, { recursive: true });
|
|
268
306
|
|
|
269
307
|
state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
|
|
270
308
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { seedStashAssets } from "./core-assets.js";
|
|
6
|
+
|
|
7
|
+
describe("seedStashAssets", () => {
|
|
8
|
+
let homeDir: string;
|
|
9
|
+
const originalHome = process.env.OP_HOME;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
homeDir = mkdtempSync(join(tmpdir(), "stash-seed-test-"));
|
|
13
|
+
process.env.OP_HOME = homeDir;
|
|
14
|
+
mkdirSync(join(homeDir, "stash"), { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
process.env.OP_HOME = originalHome;
|
|
19
|
+
// Restore writable mode in case a test chmod'd the stash dir.
|
|
20
|
+
try {
|
|
21
|
+
chmodSync(join(homeDir, "stash"), 0o755);
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore — dir may not exist
|
|
24
|
+
}
|
|
25
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("writes every seed under stash/ on first run", () => {
|
|
29
|
+
const seeds = {
|
|
30
|
+
"skills/test-skill/SKILL.md": "---\nname: test-skill\ntype: skill\n---\nhello\n",
|
|
31
|
+
"commands/test-cmd.md": "---\nname: test-cmd\ntype: command\n---\nrun me\n",
|
|
32
|
+
};
|
|
33
|
+
const written = seedStashAssets(seeds);
|
|
34
|
+
|
|
35
|
+
expect(written.sort()).toEqual(Object.keys(seeds).sort());
|
|
36
|
+
for (const [rel, content] of Object.entries(seeds)) {
|
|
37
|
+
const target = join(homeDir, "stash", rel);
|
|
38
|
+
expect(existsSync(target)).toBe(true);
|
|
39
|
+
expect(readFileSync(target, "utf-8")).toBe(content);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not overwrite existing files (user edits win)", () => {
|
|
44
|
+
const seeds = { "skills/keep-mine/SKILL.md": "ORIGINAL SEED\n" };
|
|
45
|
+
const userEdit = "USER EDIT — must not be overwritten\n";
|
|
46
|
+
|
|
47
|
+
// Simulate a previous install: seed first.
|
|
48
|
+
seedStashAssets(seeds);
|
|
49
|
+
const target = join(homeDir, "stash/skills/keep-mine/SKILL.md");
|
|
50
|
+
expect(readFileSync(target, "utf-8")).toBe("ORIGINAL SEED\n");
|
|
51
|
+
|
|
52
|
+
// User edits the file.
|
|
53
|
+
writeFileSync(target, userEdit);
|
|
54
|
+
|
|
55
|
+
// Re-run: must return [] and leave the user's content intact.
|
|
56
|
+
const written = seedStashAssets(seeds);
|
|
57
|
+
expect(written).toEqual([]);
|
|
58
|
+
expect(readFileSync(target, "utf-8")).toBe(userEdit);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("creates nested directories under stash/ as needed", () => {
|
|
62
|
+
const seeds = { "skills/deep/nested/asset/SKILL.md": "x" };
|
|
63
|
+
seedStashAssets(seeds);
|
|
64
|
+
expect(existsSync(join(homeDir, "stash/skills/deep/nested/asset/SKILL.md"))).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns an empty list when called with no seeds", () => {
|
|
68
|
+
expect(seedStashAssets({})).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects seed keys that escape the stash directory", () => {
|
|
72
|
+
// Path-traversal guard: ../ sequences in keys must throw rather than
|
|
73
|
+
// silently writing outside stash/.
|
|
74
|
+
expect(() =>
|
|
75
|
+
seedStashAssets({ "../../etc/cron.d/evil": "owned\n" }),
|
|
76
|
+
).toThrow(/escapes stash dir/);
|
|
77
|
+
|
|
78
|
+
// Confirm the malicious payload was NOT written anywhere relative to
|
|
79
|
+
// the temp home.
|
|
80
|
+
expect(existsSync(join(homeDir, "..", "..", "etc", "cron.d", "evil"))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects seed keys that traverse through the stash dir back out", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
seedStashAssets({ "skills/../../../escape.md": "x" }),
|
|
86
|
+
).toThrow(/escapes stash dir/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("surfaces errors when the stash directory is read-only", () => {
|
|
90
|
+
// Skip when running as root (chmod is a no-op for the superuser).
|
|
91
|
+
const uid = process.getuid?.();
|
|
92
|
+
if (uid === 0) return;
|
|
93
|
+
|
|
94
|
+
const stashDir = join(homeDir, "stash");
|
|
95
|
+
chmodSync(stashDir, 0o555);
|
|
96
|
+
try {
|
|
97
|
+
expect(() =>
|
|
98
|
+
seedStashAssets({ "skills/readonly/SKILL.md": "nope\n" }),
|
|
99
|
+
).toThrow();
|
|
100
|
+
} finally {
|
|
101
|
+
chmodSync(stashDir, 0o755);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|