@openpalm/lib 0.11.0-beta.10 → 0.11.0-beta.13
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 +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +97 -88
- package/src/control-plane/registry.ts +142 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +60 -0
- package/src/control-plane/secrets-files.ts +66 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +60 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
/// <reference types="bun-types" />
|
|
2
|
-
/**
|
|
3
|
-
* akm `vault:user` helpers.
|
|
4
|
-
*
|
|
5
|
-
* The akm-cli vault store at `${OP_HOME}/stash/vaults/user.env` is the
|
|
6
|
-
* canonical home for user-managed environment secrets. The assistant
|
|
7
|
-
* entrypoint sources this file directly at startup.
|
|
8
|
-
*
|
|
9
|
-
* `stack.env` and `guardian.env` are operator-managed and NOT mirrored
|
|
10
|
-
* into akm — mirroring them would break guardian's HMAC env_file
|
|
11
|
-
* hot-reload contract.
|
|
12
|
-
*
|
|
13
|
-
* SECURITY: every write into the akm vault is performed by spawning
|
|
14
|
-
* `akm vault set <ref> <key>` with the secret VALUE delivered via stdin
|
|
15
|
-
* (akm-cli >= 0.8.0). Values never appear in argv, so they cannot leak
|
|
16
|
-
* through `/proc/<pid>/cmdline`. The matching delete path uses
|
|
17
|
-
* `akm vault unset <ref> <key>` which is naturally argv-safe.
|
|
18
|
-
*
|
|
19
|
-
* Layout:
|
|
20
|
-
* stash/ — AKM_STASH_DIR: asset content (skills, vaults, knowledge, agents)
|
|
21
|
-
* state/akm/ — AKM_DATA_DIR / AKM_STATE_DIR / AKM_CONFIG_DIR: operational metadata
|
|
22
|
-
* cache/akm/ — AKM_CACHE_DIR: regenerable registry artifacts
|
|
23
|
-
*/
|
|
24
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
-
import { execFile as execFileCb } from "node:child_process";
|
|
26
|
-
import { promisify } from "node:util";
|
|
27
|
-
import { parseEnvFile } from "./env.js";
|
|
28
|
-
import { createLogger } from "../logger.js";
|
|
29
|
-
import type { ControlPlaneState } from "./types.js";
|
|
30
|
-
|
|
31
|
-
const execFile = promisify(execFileCb);
|
|
32
|
-
const logger = createLogger("akm-vault");
|
|
33
|
-
|
|
34
|
-
export const AKM_USER_VAULT_REF = "vault:user";
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Build the env that points akm at the shared OpenPalm stash. We mirror the
|
|
38
|
-
* layout that the assistant/admin containers use (see
|
|
39
|
-
* `.openpalm/config/stack/core.compose.yml`) so host-side and container-side
|
|
40
|
-
* runs resolve to the same vault file.
|
|
41
|
-
*
|
|
42
|
-
* AKM_CONFIG_DIR lives in config/ (alongside stack.env, auth.json) so
|
|
43
|
-
* operators can inspect and version-control akm setup alongside other config.
|
|
44
|
-
* AKM_CACHE_DIR lives in cache/ since registry index and downloaded artifacts
|
|
45
|
-
* are regenerable and should not be indexed alongside stash assets.
|
|
46
|
-
*/
|
|
47
|
-
export function buildAkmEnv(state: ControlPlaneState): NodeJS.ProcessEnv {
|
|
48
|
-
const akmOperational = `${state.stateDir}/akm`;
|
|
49
|
-
return {
|
|
50
|
-
...process.env,
|
|
51
|
-
AKM_STASH_DIR: state.stashDir,
|
|
52
|
-
AKM_CONFIG_DIR: `${state.configDir}/akm`,
|
|
53
|
-
AKM_DATA_DIR: `${akmOperational}/data`,
|
|
54
|
-
AKM_STATE_DIR: `${akmOperational}/state`,
|
|
55
|
-
AKM_CACHE_DIR: `${state.cacheDir}/akm`,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Per-invocation timeout (ms) for every akm subprocess we launch. The CLI is
|
|
61
|
-
* a local binary and these probes (`--version`, `vault create`, `vault path`,
|
|
62
|
-
* `vault set/unset`) complete in well under a second on a healthy host;
|
|
63
|
-
* anything longer means akm is wedged or unreachable. Bounding the call
|
|
64
|
-
* keeps `mirrorUserVaultToAkm` truly best-effort: a stuck akm binary cannot
|
|
65
|
-
* block install/upgrade.
|
|
66
|
-
*
|
|
67
|
-
* Why a wall-clock race instead of execFile's built-in `timeout` option:
|
|
68
|
-
* node's `child_process.execFile` in Bun is implemented on top of `Bun.spawn`,
|
|
69
|
-
* and its `timeout` option only fires once stdout/stderr are wired up. Test
|
|
70
|
-
* suites that stub `Bun.spawn` (e.g. `packages/cli/src/main.test.ts`
|
|
71
|
-
* `mockDockerCli`) return a fake child whose stdout never closes, so neither
|
|
72
|
-
* the underlying promise nor the timeout option ever resolves. A simple
|
|
73
|
-
* `Promise.race` against an unref'd setTimeout converts that failure mode
|
|
74
|
-
* into a fast rejection that `akmAvailable` swallows as "akm not on PATH",
|
|
75
|
-
* without changing behaviour on real hosts.
|
|
76
|
-
*/
|
|
77
|
-
const AKM_EXEC_TIMEOUT_MS = 2_000;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Race a promise against an unref'd setTimeout. If the timeout fires first,
|
|
81
|
-
* reject with `<label> timed out after <ms>ms`. The timer is always cleared
|
|
82
|
-
* in `finally` so it never keeps the event loop alive past resolution. The
|
|
83
|
-
* unref means the timer alone won't block process exit — the surrounding
|
|
84
|
-
* subprocess work owns the liveness.
|
|
85
|
-
*/
|
|
86
|
-
function raceWithTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
87
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
88
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
89
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
90
|
-
timer.unref?.();
|
|
91
|
-
});
|
|
92
|
-
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
93
|
-
if (timer) clearTimeout(timer);
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function execAkm(args: string[], env: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string }> {
|
|
98
|
-
return raceWithTimeout(
|
|
99
|
-
execFile("akm", args, { env }),
|
|
100
|
-
AKM_EXEC_TIMEOUT_MS,
|
|
101
|
-
`akm ${args[0] ?? "?"}`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function akmAvailable(env: NodeJS.ProcessEnv): Promise<boolean> {
|
|
106
|
-
try {
|
|
107
|
-
await execAkm(["--version"], env);
|
|
108
|
-
return true;
|
|
109
|
-
} catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Return the absolute path of the akm vault file, creating the vault if
|
|
116
|
-
* missing. Callers that have already built the akm env (via `buildAkmEnv`)
|
|
117
|
-
* can pass it in to avoid rebuilding — the result is identical either way.
|
|
118
|
-
*/
|
|
119
|
-
export async function ensureAkmUserVault(
|
|
120
|
-
state: ControlPlaneState,
|
|
121
|
-
env: NodeJS.ProcessEnv = buildAkmEnv(state),
|
|
122
|
-
): Promise<string | null> {
|
|
123
|
-
if (!(await akmAvailable(env))) {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
// `vault create` accepts only the ref on argv — no secret material crosses
|
|
128
|
-
// the process boundary here.
|
|
129
|
-
await execAkm(["vault", "create", AKM_USER_VAULT_REF], env);
|
|
130
|
-
} catch (err) {
|
|
131
|
-
// `create` is documented as a no-op when the vault already exists, but
|
|
132
|
-
// some build channels emit a non-zero exit. Probe `path` to distinguish
|
|
133
|
-
// a real failure from "already exists".
|
|
134
|
-
logger.debug("akm vault create returned non-zero", {
|
|
135
|
-
ref: AKM_USER_VAULT_REF,
|
|
136
|
-
error: err instanceof Error ? err.message : String(err),
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
try {
|
|
140
|
-
const { stdout } = await execAkm(["vault", "path", AKM_USER_VAULT_REF], env);
|
|
141
|
-
const path = stdout.trim();
|
|
142
|
-
return path.length > 0 ? path : null;
|
|
143
|
-
} catch (err) {
|
|
144
|
-
logger.warn("akm vault path failed", {
|
|
145
|
-
ref: AKM_USER_VAULT_REF,
|
|
146
|
-
error: err instanceof Error ? err.message : String(err),
|
|
147
|
-
});
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Spawn `akm vault set <ref> <key>` and feed the secret VALUE via stdin.
|
|
154
|
-
* The value never crosses argv, so it cannot leak through
|
|
155
|
-
* `/proc/<pid>/cmdline`. Bounded by AKM_EXEC_TIMEOUT_MS — a stuck akm
|
|
156
|
-
* binary cannot block the calling install/upgrade flow.
|
|
157
|
-
*/
|
|
158
|
-
async function akmVaultSetViaStdin(
|
|
159
|
-
ref: string,
|
|
160
|
-
key: string,
|
|
161
|
-
value: string,
|
|
162
|
-
env: NodeJS.ProcessEnv,
|
|
163
|
-
): Promise<void> {
|
|
164
|
-
// We use Bun.spawn directly because it supports an in-memory stdin pipe
|
|
165
|
-
// (a buffer/string stream) without dragging in an extra dependency, and
|
|
166
|
-
// because akm-cli on >= 0.8.0 reads the value from stdin when no
|
|
167
|
-
// positional `<value>` is provided. (The CLI silently switched stdin to
|
|
168
|
-
// the default in commit c50f9f4; explicit `--stdin` is still accepted
|
|
169
|
-
// for older binaries — but since we pin akm-cli >= 0.8.0-rc2 across all
|
|
170
|
-
// images via Dockerfile ARGs, the implicit form is enough.)
|
|
171
|
-
const child = Bun.spawn(["akm", "vault", "set", ref, key], {
|
|
172
|
-
env,
|
|
173
|
-
stdin: "pipe",
|
|
174
|
-
stdout: "pipe",
|
|
175
|
-
stderr: "pipe",
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// Feed the secret. `child.stdin` is a FileSink in Bun — write+end then
|
|
179
|
-
// wait for exit. We don't use `await child.stdin.end(value)` because
|
|
180
|
-
// some Bun versions return undefined here; explicit write+end is portable.
|
|
181
|
-
if (child.stdin) {
|
|
182
|
-
// child.stdin is typed as FileSink in Bun
|
|
183
|
-
const sink = child.stdin as { write: (data: string) => unknown; end: () => unknown };
|
|
184
|
-
sink.write(value);
|
|
185
|
-
sink.end();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Wall-clock bound — mirror of the execAkm pattern. On timeout we also
|
|
189
|
-
// SIGKILL the child so the orphaned subprocess doesn't outlive us.
|
|
190
|
-
let exitCode: number;
|
|
191
|
-
try {
|
|
192
|
-
exitCode = await raceWithTimeout(
|
|
193
|
-
child.exited,
|
|
194
|
-
AKM_EXEC_TIMEOUT_MS,
|
|
195
|
-
`akm vault set ${key}`,
|
|
196
|
-
);
|
|
197
|
-
} catch (err) {
|
|
198
|
-
try { child.kill(); } catch { /* best-effort */ }
|
|
199
|
-
throw err;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (exitCode !== 0) {
|
|
203
|
-
const stderrText = child.stderr ? await new Response(child.stderr).text() : "";
|
|
204
|
-
throw new Error(`akm vault set ${key} failed (exit ${exitCode}): ${stderrText.trim()}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Write a single key/value into the akm `vault:user` store via
|
|
210
|
-
* `akm vault set <ref> <key>` with the value delivered on stdin.
|
|
211
|
-
*
|
|
212
|
-
* Returns `true` on success, `false` when akm is unavailable or the vault
|
|
213
|
-
* could not be ensured. Throws on akm subprocess failures (non-zero exit
|
|
214
|
-
* with a captured stderr, or wall-clock timeout) so callers can surface
|
|
215
|
-
* the real error instead of silently dropping the write.
|
|
216
|
-
*/
|
|
217
|
-
export async function writeAkmVaultKey(
|
|
218
|
-
state: ControlPlaneState,
|
|
219
|
-
key: string,
|
|
220
|
-
value: string,
|
|
221
|
-
): Promise<boolean> {
|
|
222
|
-
// Build env once and thread it through both the ensure step and the
|
|
223
|
-
// subsequent `akm vault set`. Avoids a redundant `buildAkmEnv` call.
|
|
224
|
-
const env = buildAkmEnv(state);
|
|
225
|
-
const vaultPath = await ensureAkmUserVault(state, env);
|
|
226
|
-
if (!vaultPath) return false;
|
|
227
|
-
await akmVaultSetViaStdin(AKM_USER_VAULT_REF, key, value, env);
|
|
228
|
-
return true;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Remove a key from the akm `vault:user` store via `akm vault unset`.
|
|
233
|
-
* The key name is a normal identifier and crosses argv only — secret
|
|
234
|
-
* values are never involved. Returns `true` if the operation completed
|
|
235
|
-
* (whether or not the key was present), `false` when akm is unavailable.
|
|
236
|
-
*/
|
|
237
|
-
export async function deleteAkmVaultKey(
|
|
238
|
-
state: ControlPlaneState,
|
|
239
|
-
key: string,
|
|
240
|
-
): Promise<boolean> {
|
|
241
|
-
// Build env once and pass it into ensureAkmUserVault so we don't pay
|
|
242
|
-
// for two `buildAkmEnv` calls on a single delete.
|
|
243
|
-
const env = buildAkmEnv(state);
|
|
244
|
-
const vaultPath = await ensureAkmUserVault(state, env);
|
|
245
|
-
if (!vaultPath) return false;
|
|
246
|
-
try {
|
|
247
|
-
// --yes: newer akm versions require explicit confirmation for any
|
|
248
|
-
// destructive operation in non-interactive mode. Without this flag
|
|
249
|
-
// the command exits with NON_INTERACTIVE_REQUIRES_YES and our
|
|
250
|
-
// delete looks like a hard failure instead of an idempotent unset.
|
|
251
|
-
await execAkm(["vault", "unset", "--yes", AKM_USER_VAULT_REF, key], env);
|
|
252
|
-
} catch (err) {
|
|
253
|
-
// `unset` of a missing key is a benign no-op; many akm versions exit 0
|
|
254
|
-
// anyway. If akm hard-fails (non-zero, non-empty stderr) we surface it.
|
|
255
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
-
// Heuristic: tolerate "not found" / "no such" messages so re-running
|
|
257
|
-
// delete on an already-deleted key stays idempotent for callers.
|
|
258
|
-
if (/not\s*found|no\s+such|does\s+not\s+exist/i.test(message)) {
|
|
259
|
-
logger.debug("akm vault unset reported missing key", { key, message });
|
|
260
|
-
return true;
|
|
261
|
-
}
|
|
262
|
-
throw err;
|
|
263
|
-
}
|
|
264
|
-
return true;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Synchronously resolve the canonical akm `vault:user` file path for a given
|
|
269
|
-
* control-plane state. Used by sync read paths (e.g. plaintext secret backend
|
|
270
|
-
* `list`/`exists`) that cannot await `ensureAkmUserVault`.
|
|
271
|
-
*
|
|
272
|
-
* The path is deterministic: `buildAkmEnv` pins `AKM_STASH_DIR` to
|
|
273
|
-
* `state.stashDir`, and akm-cli (>= 0.8.0) materializes vault files
|
|
274
|
-
* at `${AKM_STASH_DIR}/vaults/<ref>.env`.
|
|
275
|
-
*
|
|
276
|
-
* Returns the path string regardless of whether the file currently exists —
|
|
277
|
-
* callers should `existsSync` if presence matters.
|
|
278
|
-
*/
|
|
279
|
-
export function akmUserVaultPathSync(state: ControlPlaneState): string {
|
|
280
|
-
return `${state.stashDir}/vaults/user.env`;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Read the user-managed env namespace from the akm `vault:user` store.
|
|
285
|
-
*
|
|
286
|
-
* Returns `{}` when the vault file does not exist yet. Pure sync — no subprocess spawn.
|
|
287
|
-
*/
|
|
288
|
-
export function readUserVaultSync(state: ControlPlaneState): Record<string, string> {
|
|
289
|
-
const akmPath = akmUserVaultPathSync(state);
|
|
290
|
-
if (existsSync(akmPath)) {
|
|
291
|
-
return readAkmUserVaultFile(akmPath);
|
|
292
|
-
}
|
|
293
|
-
return {};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** Return the parsed contents of the akm vault file (public API used by admin UI list endpoint). */
|
|
297
|
-
export function readAkmUserVaultFile(vaultPath: string): Record<string, string> {
|
|
298
|
-
if (!existsSync(vaultPath)) return {};
|
|
299
|
-
try {
|
|
300
|
-
return parseEnvFile(vaultPath);
|
|
301
|
-
} catch {
|
|
302
|
-
// Fallback: hand-parse if dotenv chokes (e.g. file with stray BOM).
|
|
303
|
-
const raw = readFileSync(vaultPath, "utf-8");
|
|
304
|
-
const out: Record<string, string> = {};
|
|
305
|
-
for (const line of raw.split("\n")) {
|
|
306
|
-
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
307
|
-
if (m) out[m[1]] = m[2];
|
|
308
|
-
}
|
|
309
|
-
return out;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
@@ -1,177 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
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
|
-
}
|