@vellumai/credential-executor 0.7.0 → 0.7.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/package.json +3 -2
- package/src/__tests__/ces-migrations-002-api-keys.test.ts +185 -0
- package/src/__tests__/ces-migrations-runner.test.ts +227 -0
- package/src/__tests__/cli.test.ts +139 -0
- package/src/__tests__/command-executor.test.ts +70 -41
- package/src/__tests__/local-token-refresh.test.ts +65 -38
- package/src/__tests__/toolstore.test.ts +65 -20
- package/src/__tests__/transport.test.ts +12 -3
- package/src/cli.ts +158 -0
- package/src/http/__tests__/credential-routes-normalization.test.ts +202 -0
- package/src/http/credential-routes.ts +53 -7
- package/src/main.ts +120 -50
- package/src/managed-main.ts +6 -0
- package/src/materializers/local-oauth-lookup.ts +7 -6
- package/src/materializers/local-token-refresh.ts +25 -15
- package/src/migrations/001-no-op.ts +19 -0
- package/src/migrations/002-api-keys-to-credentials.ts +60 -0
- package/src/migrations/registry.ts +15 -0
- package/src/migrations/runner.ts +146 -0
- package/src/migrations/types.ts +54 -0
- package/src/paths.ts +15 -11
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
5
|
+
|
|
6
|
+
import { getLogger } from "../logger.js";
|
|
7
|
+
import type { CesMigration, CesMigrationStatus } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const log = getLogger("ces-migrations");
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Checkpoint file
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
type CheckpointFile = {
|
|
16
|
+
applied: Record<string, { appliedAt: string; status?: CesMigrationStatus }>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function getCheckpointPath(cesDataRoot: string): string {
|
|
20
|
+
return join(cesDataRoot, ".ces-migrations.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadCheckpoints(cesDataRoot: string): CheckpointFile {
|
|
24
|
+
const path = getCheckpointPath(cesDataRoot);
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return { applied: {} };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(path, "utf-8");
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
if (
|
|
32
|
+
typeof data === "object" &&
|
|
33
|
+
data != null &&
|
|
34
|
+
typeof data.applied === "object" &&
|
|
35
|
+
data.applied != null
|
|
36
|
+
) {
|
|
37
|
+
return data as CheckpointFile;
|
|
38
|
+
}
|
|
39
|
+
log.warn(
|
|
40
|
+
"CES migration checkpoint file has unexpected structure; treating as fresh state",
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
log.warn(
|
|
44
|
+
"CES migration checkpoint file is malformed; treating as fresh state",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return { applied: {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveCheckpoints(
|
|
51
|
+
cesDataRoot: string,
|
|
52
|
+
checkpoints: CheckpointFile,
|
|
53
|
+
): void {
|
|
54
|
+
const path = getCheckpointPath(cesDataRoot);
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
+
const tmpPath = path + ".tmp";
|
|
57
|
+
writeFileSync(tmpPath, JSON.stringify(checkpoints, null, 2) + "\n", "utf-8");
|
|
58
|
+
renameSync(tmpPath, path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Runner
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run all pending CES migrations in registry order.
|
|
67
|
+
*
|
|
68
|
+
* - Skips migrations that already have a checkpoint entry (`"completed"` or
|
|
69
|
+
* `"failed"`). Only `"started"` and `"rolling_back"` entries are cleared
|
|
70
|
+
* and re-run on the next startup (crash recovery — migrations must be
|
|
71
|
+
* idempotent).
|
|
72
|
+
* - Marks failed migrations as `"failed"` and continues startup; a failed
|
|
73
|
+
* migration does not block the RPC server from starting.
|
|
74
|
+
*
|
|
75
|
+
* @param cesDataRoot The CES-private data root (from `getCesDataRoot(mode)`).
|
|
76
|
+
* The checkpoint file is stored here as `.ces-migrations.json`.
|
|
77
|
+
* @param backend The active `SecureKeyBackend` instance, passed directly to
|
|
78
|
+
* each migration's `run()` function.
|
|
79
|
+
* @param migrations Ordered list of migrations from the registry.
|
|
80
|
+
*/
|
|
81
|
+
export async function runCesMigrations(
|
|
82
|
+
cesDataRoot: string,
|
|
83
|
+
backend: SecureKeyBackend,
|
|
84
|
+
migrations: CesMigration[],
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
// Validate uniqueness.
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
for (const m of migrations) {
|
|
89
|
+
if (seen.has(m.id)) {
|
|
90
|
+
throw new Error(`Duplicate CES migration id: "${m.id}"`);
|
|
91
|
+
}
|
|
92
|
+
seen.add(m.id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const checkpoints = loadCheckpoints(cesDataRoot);
|
|
96
|
+
|
|
97
|
+
// Clear any interrupted checkpoints so they re-run.
|
|
98
|
+
for (const [id, entry] of Object.entries(checkpoints.applied)) {
|
|
99
|
+
if (entry.status === "started" || entry.status === "rolling_back") {
|
|
100
|
+
log.warn(
|
|
101
|
+
`CES migration "${id}" was interrupted during a previous run; will re-run`,
|
|
102
|
+
);
|
|
103
|
+
delete checkpoints.applied[id];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const migration of migrations) {
|
|
108
|
+
if (checkpoints.applied[migration.id]) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
log.info(
|
|
113
|
+
`Running CES migration: ${migration.id} — ${migration.description}`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Mark as started before executing (crash recovery observability).
|
|
117
|
+
checkpoints.applied[migration.id] = {
|
|
118
|
+
appliedAt: new Date().toISOString(),
|
|
119
|
+
status: "started",
|
|
120
|
+
};
|
|
121
|
+
saveCheckpoints(cesDataRoot, checkpoints);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await migration.run(backend);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
log.error(
|
|
127
|
+
{ migrationId: migration.id, error },
|
|
128
|
+
`CES migration failed: ${migration.id} — marking as failed and continuing`,
|
|
129
|
+
);
|
|
130
|
+
checkpoints.applied[migration.id] = {
|
|
131
|
+
appliedAt: new Date().toISOString(),
|
|
132
|
+
status: "failed",
|
|
133
|
+
};
|
|
134
|
+
saveCheckpoints(cesDataRoot, checkpoints);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
checkpoints.applied[migration.id] = {
|
|
139
|
+
appliedAt: new Date().toISOString(),
|
|
140
|
+
status: "completed",
|
|
141
|
+
};
|
|
142
|
+
saveCheckpoints(cesDataRoot, checkpoints);
|
|
143
|
+
|
|
144
|
+
log.info(`CES migration completed: ${migration.id}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single CES data migration.
|
|
5
|
+
*
|
|
6
|
+
* Migrations run at CES startup — once per installation, tracked via a
|
|
7
|
+
* checkpoint file in the CES-private data root. They are the right place
|
|
8
|
+
* for one-time transformations of the credential store (key renames,
|
|
9
|
+
* format changes, etc.) that must happen before the RPC server accepts
|
|
10
|
+
* connections.
|
|
11
|
+
*
|
|
12
|
+
* **Idempotency**: `run` and `down` must both be safe to re-run. The
|
|
13
|
+
* runner re-executes any migration whose checkpoint was left in `"started"`
|
|
14
|
+
* state (i.e. the process crashed mid-migration).
|
|
15
|
+
*
|
|
16
|
+
* **Ordering**: Migrations are executed in registry order. Never reorder
|
|
17
|
+
* or remove an entry from the registry once it has been released.
|
|
18
|
+
*/
|
|
19
|
+
export interface CesMigration {
|
|
20
|
+
/**
|
|
21
|
+
* Unique identifier used as the checkpoint key.
|
|
22
|
+
* Convention: `"NNN-short-description"`, e.g. `"001-no-op"`.
|
|
23
|
+
* Must be unique across all registered migrations.
|
|
24
|
+
*/
|
|
25
|
+
id: string;
|
|
26
|
+
|
|
27
|
+
/** Human-readable description logged when the migration runs. */
|
|
28
|
+
description: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Apply the migration.
|
|
32
|
+
*
|
|
33
|
+
* Receives the active `SecureKeyBackend` so the migration can read,
|
|
34
|
+
* write, and delete credential store entries without re-opening the
|
|
35
|
+
* encrypted store.
|
|
36
|
+
*/
|
|
37
|
+
run(backend: SecureKeyBackend): void | Promise<void>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reverse the migration (best-effort).
|
|
41
|
+
*
|
|
42
|
+
* Some migrations are forward-only (e.g. dropping a key that is no
|
|
43
|
+
* longer used). In those cases, implement `down` as a no-op and document
|
|
44
|
+
* why reversal is not meaningful.
|
|
45
|
+
*/
|
|
46
|
+
down(backend: SecureKeyBackend): void | Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Checkpoint status values written to `.ces-migrations.json`. */
|
|
50
|
+
export type CesMigrationStatus =
|
|
51
|
+
| "started"
|
|
52
|
+
| "completed"
|
|
53
|
+
| "rolling_back"
|
|
54
|
+
| "failed";
|
package/src/paths.ts
CHANGED
|
@@ -43,12 +43,19 @@ export function getCesMode(): CesMode {
|
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Resolve the
|
|
47
|
-
*
|
|
46
|
+
* Resolve the CES security directory.
|
|
47
|
+
*
|
|
48
|
+
* Priority:
|
|
49
|
+
* 1. `CREDENTIAL_SECURITY_DIR` env var (set by the platform template for the
|
|
50
|
+
* CES container — `/ces-security` in managed mode)
|
|
51
|
+
* 2. Default: `~/.vellum/protected` (local mode shares the filesystem with
|
|
52
|
+
* the gateway)
|
|
48
53
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
export function getSecurityDir(): string {
|
|
55
|
+
return (
|
|
56
|
+
process.env["CREDENTIAL_SECURITY_DIR"]?.trim() ||
|
|
57
|
+
join(homedir(), ".vellum", "protected")
|
|
58
|
+
);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -62,18 +69,15 @@ const DEFAULT_MANAGED_CES_DATA_ROOT = "/ces-data";
|
|
|
62
69
|
/**
|
|
63
70
|
* Return the CES-private data root.
|
|
64
71
|
*
|
|
65
|
-
* - Local: `<
|
|
72
|
+
* - Local: `<securityDir>/credential-executor/`
|
|
66
73
|
* - Managed: `CES_DATA_DIR` env var, or `/ces-data` by default
|
|
67
74
|
*/
|
|
68
75
|
export function getCesDataRoot(mode?: CesMode): string {
|
|
69
76
|
const resolvedMode = mode ?? getCesMode();
|
|
70
77
|
if (resolvedMode === "managed") {
|
|
71
|
-
return
|
|
72
|
-
process.env["CES_DATA_DIR"] ??
|
|
73
|
-
DEFAULT_MANAGED_CES_DATA_ROOT
|
|
74
|
-
);
|
|
78
|
+
return process.env["CES_DATA_DIR"] ?? DEFAULT_MANAGED_CES_DATA_ROOT;
|
|
75
79
|
}
|
|
76
|
-
return join(
|
|
80
|
+
return join(getSecurityDir(), "credential-executor");
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
// ---------------------------------------------------------------------------
|