@seekrit/cli 1.4.0 → 1.6.0
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/dist/index.js +2296 -1369
- package/dist/{mcp-BOawfW_U.js → mcp-CjHg4P-V.js} +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dirname, join, parse, resolve } from "node:path";
|
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
+
import { connect } from "node:net";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { request } from "node:http";
|
|
13
14
|
/** Default lifetime of a published bundle (7 days), in seconds. */
|
|
@@ -5052,7 +5053,7 @@ async function createAgentTaskToken() {
|
|
|
5052
5053
|
}
|
|
5053
5054
|
//#endregion
|
|
5054
5055
|
//#region package.json
|
|
5055
|
-
var version = "1.
|
|
5056
|
+
var version = "1.6.0";
|
|
5056
5057
|
//#endregion
|
|
5057
5058
|
//#region ../../packages/api-client/src/index.ts
|
|
5058
5059
|
var SeekritApiError = class extends Error {
|
|
@@ -7058,185 +7059,647 @@ function registerPolicyCommands(policy) {
|
|
|
7058
7059
|
});
|
|
7059
7060
|
});
|
|
7060
7061
|
}
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
return {
|
|
7074
|
-
principalType: "service_token",
|
|
7075
|
-
principalId: tokenId,
|
|
7076
|
-
publicKeyJwk: JSON.stringify(pub)
|
|
7077
|
-
};
|
|
7078
|
-
}
|
|
7079
|
-
const { user } = await ctx.client.me();
|
|
7080
|
-
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
7062
|
+
/** Domain separators — must match `crates/seekrit-cache`. */
|
|
7063
|
+
const KEY_DOMAIN = "seekrit-lkg-cache-v1";
|
|
7064
|
+
const TOKEN_DOMAIN = "seekrit-token-fp-v1";
|
|
7065
|
+
/** 24h: long enough to ride out an outage, short enough to bound the tail. */
|
|
7066
|
+
const DEFAULT_MAX_AGE_MS = 1440 * 60 * 1e3;
|
|
7067
|
+
/**
|
|
7068
|
+
* Derive the key for one resolve request. Overrides are sorted, so flag order
|
|
7069
|
+
* never splits the cache. Byte-for-byte identical to `CacheKey::new` in
|
|
7070
|
+
* `crates/seekrit-cache`.
|
|
7071
|
+
*/
|
|
7072
|
+
function cacheKey(apiUrl, token, branch, overrides = {}) {
|
|
7073
|
+
const pairs = Object.entries(overrides).map(([group, env]) => `${group}:${env}`).sort();
|
|
7081
7074
|
return {
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7075
|
+
key: createHash("sha256").update([
|
|
7076
|
+
KEY_DOMAIN,
|
|
7077
|
+
apiUrl.replace(/\/+$/, ""),
|
|
7078
|
+
token,
|
|
7079
|
+
branch ?? "",
|
|
7080
|
+
pairs.join(",")
|
|
7081
|
+
].join("\n")).digest("hex"),
|
|
7082
|
+
tokenFingerprint: tokenFingerprint(token)
|
|
7085
7083
|
};
|
|
7086
7084
|
}
|
|
7087
|
-
/**
|
|
7088
|
-
|
|
7089
|
-
|
|
7090
|
-
|
|
7091
|
-
|
|
7092
|
-
|
|
7093
|
-
|
|
7085
|
+
/** The hex SHA-256 identifying a token without storing it. */
|
|
7086
|
+
function tokenFingerprint(token) {
|
|
7087
|
+
return createHash("sha256").update(`${TOKEN_DOMAIN}\n${token}`).digest("hex");
|
|
7088
|
+
}
|
|
7089
|
+
/**
|
|
7090
|
+
* `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit` — beside the config
|
|
7091
|
+
* directory holding the credential, never a shared temp directory.
|
|
7092
|
+
*/
|
|
7093
|
+
function defaultCacheDir() {
|
|
7094
|
+
return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "seekrit");
|
|
7095
|
+
}
|
|
7096
|
+
/** Render an age for a log line, rounded to its largest whole unit. */
|
|
7097
|
+
function humanize(ms) {
|
|
7098
|
+
const secs = Math.floor(ms / 1e3);
|
|
7099
|
+
if (secs < 60) return `${secs}s`;
|
|
7100
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
|
7101
|
+
if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
|
|
7102
|
+
return `${Math.floor(secs / 86400)}d`;
|
|
7103
|
+
}
|
|
7104
|
+
/**
|
|
7105
|
+
* Whether a failed resolve means the API was *unreachable* (the cache may stand
|
|
7106
|
+
* in) rather than *refusing us* (it must not). A refusal is an answer, and
|
|
7107
|
+
* revocation is supposed to take effect the moment it arrives.
|
|
7108
|
+
*/
|
|
7109
|
+
function mayFallBack(err) {
|
|
7110
|
+
if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
|
|
7111
|
+
return true;
|
|
7112
|
+
}
|
|
7113
|
+
/** An opened cache, bound to one resolve request. */
|
|
7114
|
+
var LkgCache = class {
|
|
7115
|
+
dir;
|
|
7116
|
+
maxAgeMs;
|
|
7117
|
+
id;
|
|
7118
|
+
constructor(id, options = {}) {
|
|
7119
|
+
this.id = id;
|
|
7120
|
+
this.dir = options.dir ?? defaultCacheDir();
|
|
7121
|
+
this.maxAgeMs = options.maxAgeMs ?? 864e5;
|
|
7122
|
+
}
|
|
7123
|
+
path() {
|
|
7124
|
+
return join(this.dir, `${this.id.key}.json`);
|
|
7125
|
+
}
|
|
7126
|
+
read() {
|
|
7127
|
+
const path = this.path();
|
|
7128
|
+
if (!existsSync(path)) return { kind: "missing" };
|
|
7129
|
+
let envelope;
|
|
7130
|
+
try {
|
|
7131
|
+
envelope = JSON.parse(readFileSync(path, "utf8"));
|
|
7132
|
+
} catch (err) {
|
|
7133
|
+
return {
|
|
7134
|
+
kind: "unusable",
|
|
7135
|
+
reason: `corrupt cache entry: ${message(err)}`
|
|
7136
|
+
};
|
|
7137
|
+
}
|
|
7138
|
+
if (envelope.version !== 1) return {
|
|
7139
|
+
kind: "unusable",
|
|
7140
|
+
reason: `cache entry is format v${envelope.version}, this build reads v1`
|
|
7141
|
+
};
|
|
7142
|
+
if (envelope.tokenFingerprint !== this.id.tokenFingerprint) return {
|
|
7143
|
+
kind: "unusable",
|
|
7144
|
+
reason: "cache entry belongs to a different token"
|
|
7145
|
+
};
|
|
7146
|
+
if (typeof envelope.body !== "string") return {
|
|
7147
|
+
kind: "unusable",
|
|
7148
|
+
reason: "cache entry has no body"
|
|
7149
|
+
};
|
|
7150
|
+
const ageMs = Math.max(0, Date.now() - envelope.fetchedAt * 1e3);
|
|
7151
|
+
if (ageMs >= this.maxAgeMs) return {
|
|
7152
|
+
kind: "expired",
|
|
7153
|
+
ageMs
|
|
7154
|
+
};
|
|
7094
7155
|
return {
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7156
|
+
kind: "hit",
|
|
7157
|
+
body: envelope.body,
|
|
7158
|
+
ageMs
|
|
7098
7159
|
};
|
|
7099
7160
|
}
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7161
|
+
/**
|
|
7162
|
+
* Record a freshly-fetched response. Written to a temporary file and renamed,
|
|
7163
|
+
* so a concurrent reader sees either the old entry or the new one — never a
|
|
7164
|
+
* half-written file. Throws only on genuinely unexpected I/O; callers treat a
|
|
7165
|
+
* failed write as a warning, never as a failed resolve.
|
|
7166
|
+
*/
|
|
7167
|
+
write(body) {
|
|
7168
|
+
mkdirSync(this.dir, {
|
|
7169
|
+
recursive: true,
|
|
7170
|
+
mode: 448
|
|
7171
|
+
});
|
|
7172
|
+
const envelope = {
|
|
7173
|
+
version: 1,
|
|
7174
|
+
fetchedAt: Math.floor(Date.now() / 1e3),
|
|
7175
|
+
tokenFingerprint: this.id.tokenFingerprint,
|
|
7176
|
+
body
|
|
7108
7177
|
};
|
|
7178
|
+
const tmp = join(this.dir, `${this.id.key}.${process.pid}.tmp`);
|
|
7179
|
+
try {
|
|
7180
|
+
writeFileSync(tmp, JSON.stringify(envelope), { mode: 384 });
|
|
7181
|
+
renameSync(tmp, this.path());
|
|
7182
|
+
} catch (err) {
|
|
7183
|
+
rmSync(tmp, { force: true });
|
|
7184
|
+
throw err;
|
|
7185
|
+
}
|
|
7186
|
+
this.pruneExpired();
|
|
7109
7187
|
}
|
|
7110
|
-
|
|
7188
|
+
/** Drop this entry — used when the API refuses the token. */
|
|
7189
|
+
invalidate() {
|
|
7190
|
+
rmSync(this.path(), { force: true });
|
|
7191
|
+
}
|
|
7192
|
+
/**
|
|
7193
|
+
* Delete entries past their usefulness (a rotated token or a changed
|
|
7194
|
+
* `--with` leaves one nothing will read again). Best-effort and silent, and
|
|
7195
|
+
* it only ever touches files this cache named: `<64 hex>.json`.
|
|
7196
|
+
*/
|
|
7197
|
+
pruneExpired() {
|
|
7198
|
+
let names;
|
|
7199
|
+
try {
|
|
7200
|
+
names = readdirSync(this.dir);
|
|
7201
|
+
} catch {
|
|
7202
|
+
return;
|
|
7203
|
+
}
|
|
7204
|
+
for (const name of names) {
|
|
7205
|
+
if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
|
|
7206
|
+
const path = join(this.dir, name);
|
|
7207
|
+
let expired = true;
|
|
7208
|
+
try {
|
|
7209
|
+
const envelope = JSON.parse(readFileSync(path, "utf8"));
|
|
7210
|
+
expired = Math.max(0, Date.now() - envelope.fetchedAt * 1e3) >= this.maxAgeMs;
|
|
7211
|
+
} catch {}
|
|
7212
|
+
if (expired) rmSync(path, { force: true });
|
|
7213
|
+
}
|
|
7214
|
+
}
|
|
7215
|
+
};
|
|
7216
|
+
function message(err) {
|
|
7217
|
+
return err instanceof Error ? err.message : String(err);
|
|
7111
7218
|
}
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7219
|
+
/**
|
|
7220
|
+
* Fetch + decrypt every secret in a single environment.
|
|
7221
|
+
*
|
|
7222
|
+
* `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
|
|
7223
|
+
* `interpolate`) unless `raw` is set. Only this environment's own secrets are in
|
|
7224
|
+
* scope here — a reference to a secret inherited from a composed group is left
|
|
7225
|
+
* literal, because the group layers aren't fetched. `materializeEnv` is the
|
|
7226
|
+
* fully-layered view.
|
|
7227
|
+
*/
|
|
7228
|
+
async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
|
|
7229
|
+
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
7230
|
+
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
7231
|
+
return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
|
|
7117
7232
|
}
|
|
7118
|
-
/**
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
return {
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7233
|
+
/**
|
|
7234
|
+
* Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
|
|
7235
|
+
* the CLI's standard fatal exit — it is a config bug with no correct value to
|
|
7236
|
+
* emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
|
|
7237
|
+
*/
|
|
7238
|
+
function interpolateValues(values, enabled = true) {
|
|
7239
|
+
if (!enabled) return {
|
|
7240
|
+
values,
|
|
7241
|
+
interpolated: [],
|
|
7242
|
+
unresolvedRefs: []
|
|
7128
7243
|
};
|
|
7244
|
+
try {
|
|
7245
|
+
const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
|
|
7246
|
+
return {
|
|
7247
|
+
values: expandedValues,
|
|
7248
|
+
interpolated: expanded,
|
|
7249
|
+
unresolvedRefs: unresolved
|
|
7250
|
+
};
|
|
7251
|
+
} catch (err) {
|
|
7252
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
7253
|
+
}
|
|
7129
7254
|
}
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7255
|
+
/**
|
|
7256
|
+
* Decrypt one historical version of a secret. Ciphertext is bound to
|
|
7257
|
+
* `(envId, name)` as AAD and neither changes across versions, so an old blob
|
|
7258
|
+
* opens with the environment's current data key — no special handling needed.
|
|
7259
|
+
*/
|
|
7260
|
+
async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
|
|
7261
|
+
const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
|
|
7262
|
+
const row = versions.find((v) => v.version === version);
|
|
7263
|
+
if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
|
|
7264
|
+
return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
|
|
7265
|
+
}
|
|
7266
|
+
async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
7267
|
+
const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
|
|
7268
|
+
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
7269
|
+
}
|
|
7270
|
+
/**
|
|
7271
|
+
* Encrypt and store many secrets into one environment. The DEK is fetched once
|
|
7272
|
+
* (so user auth prompts for the passphrase a single time, not per variable),
|
|
7273
|
+
* then each value is encrypted locally and written. Existing names are
|
|
7274
|
+
* overwritten; the result splits them into created vs. updated for a summary.
|
|
7275
|
+
* Callers validate the names first — a rejected name aborts before any write.
|
|
7276
|
+
*/
|
|
7277
|
+
async function importSecrets(ctx, orgId, envId, entries) {
|
|
7278
|
+
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
7279
|
+
const existing = new Set(secrets.map((s) => s.name));
|
|
7280
|
+
const result = {
|
|
7281
|
+
created: [],
|
|
7282
|
+
updated: []
|
|
7283
|
+
};
|
|
7284
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
7285
|
+
const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
|
|
7286
|
+
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
7287
|
+
(existing.has(name) ? result.updated : result.created).push(name);
|
|
7288
|
+
}
|
|
7289
|
+
return result;
|
|
7290
|
+
}
|
|
7291
|
+
/**
|
|
7292
|
+
* Resolve the full, layered environment for a running app: composed group
|
|
7293
|
+
* secrets (lowest precedence) → the app env's own secrets → `.env` files.
|
|
7294
|
+
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
7295
|
+
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
7296
|
+
* that spawn a process layer it on top so the live shell always wins.
|
|
7297
|
+
*
|
|
7298
|
+
* `${OTHER_SECRET}` references are expanded last, against the merged set, so a
|
|
7299
|
+
* reference always resolves to whichever layer won the name.
|
|
7300
|
+
*/
|
|
7301
|
+
async function materializeEnv(ctx, opts) {
|
|
7302
|
+
const query = {};
|
|
7303
|
+
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
7304
|
+
if (opts.branch) query.branch = opts.branch;
|
|
7305
|
+
if (!isTokenAuth(ctx)) {
|
|
7306
|
+
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
7307
|
+
query.env = opts.envId;
|
|
7308
|
+
}
|
|
7309
|
+
const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
|
|
7310
|
+
const privateKey = await getPrivateKey(ctx);
|
|
7311
|
+
const values = {};
|
|
7312
|
+
const provenance = {};
|
|
7313
|
+
for (const layer of layers) {
|
|
7314
|
+
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
7315
|
+
let label;
|
|
7316
|
+
if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
|
|
7317
|
+
else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
|
|
7318
|
+
else label = `app:${scope.appSlug}/${layer.slug}`;
|
|
7319
|
+
for (const secret of layer.secrets) {
|
|
7320
|
+
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
7321
|
+
provenance[secret.name] = label;
|
|
7157
7322
|
}
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7323
|
+
}
|
|
7324
|
+
const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
|
|
7325
|
+
return {
|
|
7326
|
+
...interpolateValues(values, opts.interpolate !== false),
|
|
7327
|
+
provenance,
|
|
7328
|
+
scope,
|
|
7329
|
+
loadedEnvFiles
|
|
7330
|
+
};
|
|
7331
|
+
}
|
|
7332
|
+
/**
|
|
7333
|
+
* Resolve, going through the last-known-good cache when one is configured.
|
|
7334
|
+
*
|
|
7335
|
+
* Always live first: the cache exists for when the call cannot land, not to
|
|
7336
|
+
* save a round trip, so a recovered network is picked up on the very next
|
|
7337
|
+
* invocation. A *refused* resolve (401/403/…) drops the entry rather than
|
|
7338
|
+
* falling back to it — otherwise revoking a token would keep working offline
|
|
7339
|
+
* until the entry aged out.
|
|
7340
|
+
*/
|
|
7341
|
+
async function resolveWithCache(ctx, query, cache) {
|
|
7342
|
+
if (!cache) return ctx.client.resolve(query);
|
|
7343
|
+
try {
|
|
7344
|
+
const response = await ctx.client.resolve(query);
|
|
7345
|
+
try {
|
|
7346
|
+
cache.write(JSON.stringify(response));
|
|
7347
|
+
} catch (err) {
|
|
7348
|
+
warn(`could not update the cache: ${errorMessage(err)}`);
|
|
7172
7349
|
}
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
...groupId ? { groupId } : {},
|
|
7179
|
-
...publicKeyJwk ? { publicKeyJwk } : {},
|
|
7180
|
-
grants
|
|
7181
|
-
};
|
|
7182
|
-
const { key } = await ctx.client.createKmsKey(org.id, input);
|
|
7183
|
-
console.error(`created ${key.purpose} key ${key.name} (${key.id}), ${grants.length} grant(s)`);
|
|
7184
|
-
});
|
|
7185
|
-
kms.command("ls").description("list keys you can see").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
7186
|
-
const ctx = buildContext();
|
|
7187
|
-
const org = await resolveOrg(ctx, options.org);
|
|
7188
|
-
const { keys } = await ctx.client.listKmsKeys(org.id);
|
|
7189
|
-
if (keys.length === 0) {
|
|
7190
|
-
console.error("(no keys)");
|
|
7191
|
-
return;
|
|
7350
|
+
return response;
|
|
7351
|
+
} catch (err) {
|
|
7352
|
+
if (!mayFallBack(err)) {
|
|
7353
|
+
cache.invalidate();
|
|
7354
|
+
throw err;
|
|
7192
7355
|
}
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7356
|
+
const found = cache.read();
|
|
7357
|
+
if (found.kind === "hit") {
|
|
7358
|
+
warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
|
|
7359
|
+
return JSON.parse(found.body);
|
|
7197
7360
|
}
|
|
7361
|
+
if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
|
|
7362
|
+
else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
|
|
7363
|
+
throw err;
|
|
7364
|
+
}
|
|
7365
|
+
}
|
|
7366
|
+
function warn(text) {
|
|
7367
|
+
process.stderr.write(`seekrit: ${text}\n`);
|
|
7368
|
+
}
|
|
7369
|
+
function errorMessage(err) {
|
|
7370
|
+
return err instanceof Error ? err.message : String(err);
|
|
7371
|
+
}
|
|
7372
|
+
/**
|
|
7373
|
+
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
7374
|
+
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
7375
|
+
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
7376
|
+
* managed secrets are unavailable but `.env` should still apply.
|
|
7377
|
+
*/
|
|
7378
|
+
function overlayEnvFiles(values, provenance, envFiles) {
|
|
7379
|
+
const loaded = [];
|
|
7380
|
+
for (const file of envFiles) {
|
|
7381
|
+
if (!existsSync(file)) continue;
|
|
7382
|
+
loaded.push(file);
|
|
7383
|
+
for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
|
|
7384
|
+
values[name] = value;
|
|
7385
|
+
provenance[name] = `dotenv:${file}`;
|
|
7386
|
+
}
|
|
7387
|
+
}
|
|
7388
|
+
return loaded;
|
|
7389
|
+
}
|
|
7390
|
+
/**
|
|
7391
|
+
* Print a name → source table to stderr (never the secret values). Names whose
|
|
7392
|
+
* value had references expanded are marked, and dangling references are called
|
|
7393
|
+
* out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
|
|
7394
|
+
* deliberately passed through as literal text.
|
|
7395
|
+
*/
|
|
7396
|
+
function printExplain(provenance, refs = {}) {
|
|
7397
|
+
const interpolated = new Set(refs.interpolated ?? []);
|
|
7398
|
+
const names = Object.keys(provenance).sort();
|
|
7399
|
+
const width = names.reduce((w, n) => Math.max(w, n.length), 0);
|
|
7400
|
+
for (const name of names) {
|
|
7401
|
+
const marker = interpolated.has(name) ? " (interpolated)" : "";
|
|
7402
|
+
process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
|
|
7403
|
+
}
|
|
7404
|
+
if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
|
|
7405
|
+
}
|
|
7406
|
+
//#endregion
|
|
7407
|
+
//#region src/env-diff.ts
|
|
7408
|
+
/**
|
|
7409
|
+
* The comparison itself, kept pure so it can be tested without a network or a
|
|
7410
|
+
* key. Rows come back sorted by name: a diff people read top-to-bottom should
|
|
7411
|
+
* not reorder itself because the API returned rows in a different order.
|
|
7412
|
+
*/
|
|
7413
|
+
function diffSecrets(from, to, values) {
|
|
7414
|
+
const byName = (rows) => new Map(rows.map((r) => [r.name, r]));
|
|
7415
|
+
const left = byName(from);
|
|
7416
|
+
const right = byName(to);
|
|
7417
|
+
return [.../* @__PURE__ */ new Set([...left.keys(), ...right.keys()])].sort().map((name) => {
|
|
7418
|
+
const a = left.get(name) ?? null;
|
|
7419
|
+
const b = right.get(name) ?? null;
|
|
7420
|
+
let value = "unknown";
|
|
7421
|
+
if (values && a && b) value = values.from[name] === values.to[name] ? "identical" : "differs";
|
|
7422
|
+
return {
|
|
7423
|
+
name,
|
|
7424
|
+
from: a,
|
|
7425
|
+
to: b,
|
|
7426
|
+
value
|
|
7427
|
+
};
|
|
7198
7428
|
});
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7429
|
+
}
|
|
7430
|
+
/**
|
|
7431
|
+
* Resolve `--from` / `--to`, which accept an environment *or* a branch.
|
|
7432
|
+
*
|
|
7433
|
+
* Branch slugs share the application's environment namespace but are excluded
|
|
7434
|
+
* from the environment listing, so naming one would otherwise land on "no
|
|
7435
|
+
* environment …" — and diffing a branch against its parent is the main thing
|
|
7436
|
+
* this command is for.
|
|
7437
|
+
*/
|
|
7438
|
+
async function resolveSide(ctx, app, ref) {
|
|
7439
|
+
const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
|
|
7440
|
+
const env = environments.find((e) => e.slug === ref || e.id === ref);
|
|
7441
|
+
if (env) return {
|
|
7442
|
+
id: env.id,
|
|
7443
|
+
slug: env.slug,
|
|
7444
|
+
isBranch: false,
|
|
7445
|
+
parentSlug: null
|
|
7446
|
+
};
|
|
7447
|
+
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
7448
|
+
const branch = branches.find((b) => b.slug === ref || b.id === ref);
|
|
7449
|
+
if (!branch) fail(`no environment or branch "${ref}" in ${app.slug}`);
|
|
7450
|
+
const parent = environments.find((e) => e.id === branch.parentEnvironmentId);
|
|
7451
|
+
return {
|
|
7452
|
+
id: branch.id,
|
|
7453
|
+
slug: branch.slug,
|
|
7454
|
+
isBranch: true,
|
|
7455
|
+
parentSlug: parent?.slug ?? null
|
|
7456
|
+
};
|
|
7457
|
+
}
|
|
7458
|
+
/** `v3 → v5`, or the one side that exists. */
|
|
7459
|
+
function versions(row) {
|
|
7460
|
+
if (row.from && row.to) return `v${row.from.version} → v${row.to.version}`;
|
|
7461
|
+
const only = row.from ?? row.to;
|
|
7462
|
+
return only ? `v${only.version}` : "";
|
|
7463
|
+
}
|
|
7464
|
+
function registerEnvDiff(env) {
|
|
7465
|
+
env.command("diff").description("compare the secrets declared on two environments (or a branch and its parent)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--from <slug>", "environment or branch to compare from").requiredOption("--to <slug>", "environment or branch to compare to").option("--values", "also decrypt both sides to report identical/differs (values are never printed)").option("--json", "print the comparison as JSON (metadata only — never values)").action(async (options) => {
|
|
7215
7466
|
const ctx = buildContext();
|
|
7216
|
-
const
|
|
7217
|
-
const
|
|
7218
|
-
|
|
7219
|
-
await
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7467
|
+
const app = await resolveApp(ctx, options);
|
|
7468
|
+
const [from, to] = await Promise.all([resolveSide(ctx, app, options.from), resolveSide(ctx, app, options.to)]);
|
|
7469
|
+
if (from.id === to.id) fail(`--from and --to are the same environment (${from.slug})`);
|
|
7470
|
+
const [fromRows, toRows] = await Promise.all([ctx.client.listSecrets(app.orgId, from.id), ctx.client.listSecrets(app.orgId, to.id)]);
|
|
7471
|
+
const facts = (rows) => rows.secrets.map(({ name, version, updatedAt }) => ({
|
|
7472
|
+
name,
|
|
7473
|
+
version,
|
|
7474
|
+
updatedAt
|
|
7475
|
+
}));
|
|
7476
|
+
const values = options.values ? {
|
|
7477
|
+
from: await fetchDecryptedSecrets(ctx, app.orgId, from.id, { raw: true }),
|
|
7478
|
+
to: await fetchDecryptedSecrets(ctx, app.orgId, to.id, { raw: true })
|
|
7479
|
+
} : void 0;
|
|
7480
|
+
const rows = diffSecrets(facts(fromRows), facts(toRows), values);
|
|
7481
|
+
const onlyFrom = rows.filter((r) => r.from && !r.to);
|
|
7482
|
+
const onlyTo = rows.filter((r) => !r.from && r.to);
|
|
7483
|
+
const both = rows.filter((r) => r.from && r.to);
|
|
7484
|
+
const differs = both.filter((r) => r.value === "differs");
|
|
7485
|
+
const identical = both.filter((r) => r.value === "identical");
|
|
7486
|
+
emit(options, {
|
|
7487
|
+
from: {
|
|
7488
|
+
slug: from.slug,
|
|
7489
|
+
isBranch: from.isBranch,
|
|
7490
|
+
parentSlug: from.parentSlug
|
|
7491
|
+
},
|
|
7492
|
+
to: {
|
|
7493
|
+
slug: to.slug,
|
|
7494
|
+
isBranch: to.isBranch,
|
|
7495
|
+
parentSlug: to.parentSlug
|
|
7496
|
+
},
|
|
7497
|
+
comparedValues: Boolean(options.values),
|
|
7498
|
+
secrets: rows
|
|
7499
|
+
}, () => {
|
|
7500
|
+
for (const side of [from, to]) if (side.isBranch) console.log(`note: ${side.slug} is a branch — it declares only its own overrides; everything else it reads comes from ${side.parentSlug ?? "its parent"}`);
|
|
7501
|
+
section(`only in ${from.slug}`);
|
|
7502
|
+
printTable(onlyFrom, [col("name", (r) => r.name), col("version", versions)], `nothing declared only in ${from.slug}`);
|
|
7503
|
+
section(`only in ${to.slug}`);
|
|
7504
|
+
printTable(onlyTo, [col("name", (r) => r.name), col("version", versions)], `nothing declared only in ${to.slug}`);
|
|
7505
|
+
if (options.values) {
|
|
7506
|
+
section("different values");
|
|
7507
|
+
printTable(differs, [col("name", (r) => r.name), col("version", versions)], "no shared name holds a different value");
|
|
7508
|
+
section("identical values");
|
|
7509
|
+
printTable(identical, [col("name", (r) => r.name), col("version", versions)], "no shared name holds the same value");
|
|
7510
|
+
if (identical.length > 0) console.log(`\nnote: ${identical.length} name(s) hold the same value in both — a credential shared across two environments is one revocation that hits both.`);
|
|
7511
|
+
} else {
|
|
7512
|
+
section("declared in both");
|
|
7513
|
+
printTable(both, [
|
|
7514
|
+
col("name", (r) => r.name),
|
|
7515
|
+
col("version", versions),
|
|
7516
|
+
col("updated", (r) => r.to?.updatedAt ?? "")
|
|
7517
|
+
], "no name is declared on both");
|
|
7518
|
+
console.log("\nnote: values were not compared — pass --values to decrypt both sides and report identical/differs.");
|
|
7519
|
+
}
|
|
7520
|
+
console.log("note: this compares secrets declared on each environment, not the composed view — a secret contributed by a group is not listed here.");
|
|
7223
7521
|
});
|
|
7224
|
-
console.error(`revoked ${recipient.principalId} from ${key.name}`);
|
|
7225
7522
|
});
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
}
|
|
7238
|
-
const {
|
|
7239
|
-
|
|
7523
|
+
}
|
|
7524
|
+
//#endregion
|
|
7525
|
+
//#region src/kms.ts
|
|
7526
|
+
/** Collect a repeatable option into a list. */
|
|
7527
|
+
function collect$8(value, acc = []) {
|
|
7528
|
+
acc.push(value);
|
|
7529
|
+
return acc;
|
|
7530
|
+
}
|
|
7531
|
+
/** The calling principal's identity + public key (for a self-grant). */
|
|
7532
|
+
async function kmsCallerIdentity(ctx) {
|
|
7533
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
7534
|
+
const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
|
|
7535
|
+
const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
7536
|
+
return {
|
|
7537
|
+
principalType: "service_token",
|
|
7538
|
+
principalId: tokenId,
|
|
7539
|
+
publicKeyJwk: JSON.stringify(pub)
|
|
7540
|
+
};
|
|
7541
|
+
}
|
|
7542
|
+
const { user } = await ctx.client.me();
|
|
7543
|
+
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
7544
|
+
return {
|
|
7545
|
+
principalType: "user",
|
|
7546
|
+
principalId: user.id,
|
|
7547
|
+
publicKeyJwk: user.publicKeyJwk
|
|
7548
|
+
};
|
|
7549
|
+
}
|
|
7550
|
+
/** Look up an org member (by email) or service token (by id) as a grant recipient. */
|
|
7551
|
+
async function kmsResolveRecipient(ctx, orgId, who) {
|
|
7552
|
+
if (who.user) {
|
|
7553
|
+
const { members } = await ctx.client.listMembers(orgId);
|
|
7554
|
+
const m = members.find((x) => x.email === who.user);
|
|
7555
|
+
if (!m) fail(`no member ${who.user}`);
|
|
7556
|
+
if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
|
|
7557
|
+
return {
|
|
7558
|
+
principalType: "user",
|
|
7559
|
+
principalId: m.userId,
|
|
7560
|
+
publicKeyJwk: m.publicKeyJwk
|
|
7561
|
+
};
|
|
7562
|
+
}
|
|
7563
|
+
if (who.token) {
|
|
7564
|
+
const { tokens } = await ctx.client.listTokens(orgId);
|
|
7565
|
+
const t = tokens.find((x) => x.id === who.token);
|
|
7566
|
+
if (!t) fail(`no service token ${who.token}`);
|
|
7567
|
+
return {
|
|
7568
|
+
principalType: "service_token",
|
|
7569
|
+
principalId: t.id,
|
|
7570
|
+
publicKeyJwk: t.publicKeyJwk
|
|
7571
|
+
};
|
|
7572
|
+
}
|
|
7573
|
+
fail("specify --user <email> or --token <id>");
|
|
7574
|
+
}
|
|
7575
|
+
async function kmsResolveKey(ctx, orgId, ref) {
|
|
7576
|
+
const { keys } = await ctx.client.listKmsKeys(orgId);
|
|
7577
|
+
const key = keys.find((k) => k.id === ref || k.name === ref);
|
|
7578
|
+
if (!key) fail(`no KMS key "${ref}"`);
|
|
7579
|
+
return key;
|
|
7580
|
+
}
|
|
7581
|
+
/** Recover a key's material for one version (default: current), for the caller. */
|
|
7582
|
+
async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
|
|
7583
|
+
const mat = await ctx.client.getMyKmsKey(orgId, keyId);
|
|
7584
|
+
const v = version ?? mat.currentVersion;
|
|
7585
|
+
const grant = mat.grants.find((g) => g.version === v);
|
|
7586
|
+
if (!grant) fail(`no grant for version ${v} of this key`);
|
|
7587
|
+
return {
|
|
7588
|
+
material: await unwrapDek(grant.wrappedKey, await getPrivateKey(ctx)),
|
|
7589
|
+
version: v,
|
|
7590
|
+
currentVersion: mat.currentVersion
|
|
7591
|
+
};
|
|
7592
|
+
}
|
|
7593
|
+
function registerKmsCommands(program) {
|
|
7594
|
+
const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
|
|
7595
|
+
kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$8, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$8, []).action(async (options) => {
|
|
7596
|
+
if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
|
|
7597
|
+
if (options.app && options.group) fail("pass at most one of --app or --group");
|
|
7598
|
+
const ctx = buildContext();
|
|
7599
|
+
const org = await resolveOrg(ctx, options.org);
|
|
7600
|
+
let toWrap;
|
|
7601
|
+
let publicKeyJwk;
|
|
7602
|
+
if (options.purpose === "encrypt") toWrap = generateEncryptKeyMaterial();
|
|
7603
|
+
else {
|
|
7604
|
+
const km = await generateSigningKeyMaterial();
|
|
7605
|
+
toWrap = km.privateKeyPkcs8;
|
|
7606
|
+
publicKeyJwk = km.publicKeyJwk;
|
|
7607
|
+
}
|
|
7608
|
+
let applicationId;
|
|
7609
|
+
let groupId;
|
|
7610
|
+
if (options.app) {
|
|
7611
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
7612
|
+
const app = apps.find((a) => a.slug === options.app || a.id === options.app);
|
|
7613
|
+
if (!app) fail(`no app "${options.app}" in ${org.slug}`);
|
|
7614
|
+
applicationId = app.id;
|
|
7615
|
+
} else if (options.group) {
|
|
7616
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
7617
|
+
const group = groups.find((g) => g.slug === options.group || g.id === options.group);
|
|
7618
|
+
if (!group) fail(`no group "${options.group}" in ${org.slug}`);
|
|
7619
|
+
groupId = group.id;
|
|
7620
|
+
}
|
|
7621
|
+
const recipients = [await kmsCallerIdentity(ctx)];
|
|
7622
|
+
for (const email of options.grantUser) recipients.push(await kmsResolveRecipient(ctx, org.id, { user: email }));
|
|
7623
|
+
for (const tokenId of options.grantToken) recipients.push(await kmsResolveRecipient(ctx, org.id, { token: tokenId }));
|
|
7624
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7625
|
+
const grants = [];
|
|
7626
|
+
for (const r of recipients) {
|
|
7627
|
+
const dedupeKey = `${r.principalType}:${r.principalId}`;
|
|
7628
|
+
if (seen.has(dedupeKey)) continue;
|
|
7629
|
+
seen.add(dedupeKey);
|
|
7630
|
+
grants.push({
|
|
7631
|
+
principalType: r.principalType,
|
|
7632
|
+
principalId: r.principalId,
|
|
7633
|
+
wrappedKey: await wrapDek(toWrap, r.publicKeyJwk)
|
|
7634
|
+
});
|
|
7635
|
+
}
|
|
7636
|
+
const input = {
|
|
7637
|
+
name: options.name,
|
|
7638
|
+
purpose: options.purpose,
|
|
7639
|
+
spec: options.purpose === "sign" ? "ecdsa-p256" : "aes-256-gcm",
|
|
7640
|
+
...applicationId ? { applicationId } : {},
|
|
7641
|
+
...groupId ? { groupId } : {},
|
|
7642
|
+
...publicKeyJwk ? { publicKeyJwk } : {},
|
|
7643
|
+
grants
|
|
7644
|
+
};
|
|
7645
|
+
const { key } = await ctx.client.createKmsKey(org.id, input);
|
|
7646
|
+
console.error(`created ${key.purpose} key ${key.name} (${key.id}), ${grants.length} grant(s)`);
|
|
7647
|
+
});
|
|
7648
|
+
kms.command("ls").description("list keys you can see").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
7649
|
+
const ctx = buildContext();
|
|
7650
|
+
const org = await resolveOrg(ctx, options.org);
|
|
7651
|
+
const { keys } = await ctx.client.listKmsKeys(org.id);
|
|
7652
|
+
if (keys.length === 0) {
|
|
7653
|
+
console.error("(no keys)");
|
|
7654
|
+
return;
|
|
7655
|
+
}
|
|
7656
|
+
for (const k of keys) {
|
|
7657
|
+
const scope = k.applicationId ? `app:${k.applicationId}` : k.groupId ? `group:${k.groupId}` : "org";
|
|
7658
|
+
const state = k.disabledAt ? " [disabled]" : "";
|
|
7659
|
+
console.log(`${k.name}\t${k.purpose}\tv${k.currentVersion}\t${scope}\t${k.id}${state}`);
|
|
7660
|
+
}
|
|
7661
|
+
});
|
|
7662
|
+
kms.command("grant").description("grant a principal use of a key's current version").requiredOption("--key <name>", "key name or id").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--user <email>", "grant an org member").option("--token <tokenId>", "grant a service token").action(async (options) => {
|
|
7663
|
+
if (!options.user === !options.token) fail("pass exactly one of --user or --token");
|
|
7664
|
+
const ctx = buildContext();
|
|
7665
|
+
const org = await resolveOrg(ctx, options.org);
|
|
7666
|
+
const key = await kmsResolveKey(ctx, org.id, options.key);
|
|
7667
|
+
const recipient = await kmsResolveRecipient(ctx, org.id, options);
|
|
7668
|
+
const { material } = await kmsRecoverMaterial(ctx, org.id, key.id);
|
|
7669
|
+
await ctx.client.grantKmsKey(org.id, key.id, {
|
|
7670
|
+
principalType: recipient.principalType,
|
|
7671
|
+
principalId: recipient.principalId,
|
|
7672
|
+
wrappedKey: await wrapDek(material, recipient.publicKeyJwk)
|
|
7673
|
+
});
|
|
7674
|
+
console.error(`granted ${key.name} to ${recipient.principalId}`);
|
|
7675
|
+
});
|
|
7676
|
+
kms.command("revoke").description("revoke a principal from a key (all versions)").requiredOption("--key <name>", "key name or id").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--user <email>", "revoke this user's grant").option("--token <tokenId>", "revoke this service token's grant").option("-y, --yes", "skip the confirmation").action(async (options) => {
|
|
7677
|
+
if (!options.user === !options.token) fail("pass exactly one of --user or --token");
|
|
7678
|
+
const ctx = buildContext();
|
|
7679
|
+
const org = await resolveOrg(ctx, options.org);
|
|
7680
|
+
const key = await kmsResolveKey(ctx, org.id, options.key);
|
|
7681
|
+
const recipient = await kmsResolveRecipient(ctx, org.id, options);
|
|
7682
|
+
await confirmDestructive(options.yes, `Revoke ${recipient.principalId} from ${key.name}? Anything already decrypted stays decrypted.`);
|
|
7683
|
+
await ctx.client.revokeKmsKey(org.id, key.id, {
|
|
7684
|
+
principalType: recipient.principalType,
|
|
7685
|
+
principalId: recipient.principalId
|
|
7686
|
+
});
|
|
7687
|
+
console.error(`revoked ${recipient.principalId} from ${key.name}`);
|
|
7688
|
+
});
|
|
7689
|
+
kms.command("rotate").description("add a new key version and re-wrap it for every current grantee").requiredOption("--key <name>", "key name or id").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
7690
|
+
const ctx = buildContext();
|
|
7691
|
+
const org = await resolveOrg(ctx, options.org);
|
|
7692
|
+
const key = await kmsResolveKey(ctx, org.id, options.key);
|
|
7693
|
+
let toWrap;
|
|
7694
|
+
let publicKeyJwk;
|
|
7695
|
+
if (key.purpose === "encrypt") toWrap = generateEncryptKeyMaterial();
|
|
7696
|
+
else {
|
|
7697
|
+
const km = await generateSigningKeyMaterial();
|
|
7698
|
+
toWrap = km.privateKeyPkcs8;
|
|
7699
|
+
publicKeyJwk = km.publicKeyJwk;
|
|
7700
|
+
}
|
|
7701
|
+
const { grants: current } = await ctx.client.listKmsGrants(org.id, key.id);
|
|
7702
|
+
const [{ members }, { tokens }] = await Promise.all([ctx.client.listMembers(org.id), ctx.client.listTokens(org.id)]);
|
|
7240
7703
|
const grants = [];
|
|
7241
7704
|
for (const g of current) {
|
|
7242
7705
|
const pub = g.principalType === "user" ? members.find((m) => m.userId === g.principalId)?.publicKeyJwk : tokens.find((t) => t.id === g.principalId)?.publicKeyJwk;
|
|
@@ -7351,7 +7814,7 @@ function registerKmsCommands(program) {
|
|
|
7351
7814
|
//#endregion
|
|
7352
7815
|
//#region src/recovery.ts
|
|
7353
7816
|
/** Collect a repeatable option into a list. */
|
|
7354
|
-
function collect$
|
|
7817
|
+
function collect$7(value, acc = []) {
|
|
7355
7818
|
acc.push(value);
|
|
7356
7819
|
return acc;
|
|
7357
7820
|
}
|
|
@@ -7448,7 +7911,7 @@ function registerRecoveryCommands(program) {
|
|
|
7448
7911
|
for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
|
|
7449
7912
|
if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
|
|
7450
7913
|
});
|
|
7451
|
-
recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$
|
|
7914
|
+
recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$7, []).option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
7452
7915
|
const ctx = buildContext();
|
|
7453
7916
|
const org = await resolveOrg(ctx, options.org);
|
|
7454
7917
|
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
@@ -7466,7 +7929,7 @@ function registerRecoveryCommands(program) {
|
|
|
7466
7929
|
const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
|
|
7467
7930
|
console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
|
|
7468
7931
|
});
|
|
7469
|
-
recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$
|
|
7932
|
+
recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$7, []).option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
7470
7933
|
const ctx = buildContext();
|
|
7471
7934
|
const org = await resolveOrg(ctx, options.org);
|
|
7472
7935
|
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
@@ -7634,6 +8097,7 @@ function registerAppCommands(program) {
|
|
|
7634
8097
|
console.error(`deleted app ${ref.slug}`);
|
|
7635
8098
|
});
|
|
7636
8099
|
const env = program.command("env").description("manage environments");
|
|
8100
|
+
registerEnvDiff(env);
|
|
7637
8101
|
env.command("list").alias("ls").description("list an application's environments").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--json", "print the raw API response").action(async (options) => {
|
|
7638
8102
|
const ctx = buildContext();
|
|
7639
8103
|
const ref = await resolveApp(ctx, options);
|
|
@@ -8606,7 +9070,7 @@ function formatSecrets(values, format) {
|
|
|
8606
9070
|
* `buildContext()` in any of them silently breaks the promise the feature makes.
|
|
8607
9071
|
*/
|
|
8608
9072
|
/** Collect a repeatable option into a list. */
|
|
8609
|
-
function collect$
|
|
9073
|
+
function collect$6(value, acc = []) {
|
|
8610
9074
|
acc.push(value);
|
|
8611
9075
|
return acc;
|
|
8612
9076
|
}
|
|
@@ -8879,7 +9343,7 @@ function registerArchiveCommands(program) {
|
|
|
8879
9343
|
} catch {}
|
|
8880
9344
|
fail("none of the recovery shares in this archive unwrap with that key");
|
|
8881
9345
|
});
|
|
8882
|
-
archive.command("decrypt <file>").description("decrypt an archive's secrets with your own key (offline)").option("-o, --out <dir>", "write one file per environment into this directory").option("--stdout", "print plaintext to stdout instead of writing files").option("--env <label>", "only this environment (app/env, group@env, slug, or id)").option("--format <format>", "dotenv | json | shell", "dotenv").option("--token <skt_…>", "decrypt as a service token instead of a passphrase").option("--key-file <path>", "decrypt with a private key JWK file").option("--share <file>", "custodian share for an offline quorum (repeatable)", collect$
|
|
9346
|
+
archive.command("decrypt <file>").description("decrypt an archive's secrets with your own key (offline)").option("-o, --out <dir>", "write one file per environment into this directory").option("--stdout", "print plaintext to stdout instead of writing files").option("--env <label>", "only this environment (app/env, group@env, slug, or id)").option("--format <format>", "dotenv | json | shell", "dotenv").option("--token <skt_…>", "decrypt as a service token instead of a passphrase").option("--key-file <path>", "decrypt with a private key JWK file").option("--share <file>", "custodian share for an offline quorum (repeatable)", collect$6).option("-y, --yes", "skip the confirmation when printing plaintext to stdout").action(async (file, options) => {
|
|
8883
9347
|
if (!options.out && !options.stdout) fail("choose a destination: --out <dir> to write files, or --stdout to print plaintext");
|
|
8884
9348
|
if (![
|
|
8885
9349
|
"dotenv",
|
|
@@ -8930,525 +9394,1191 @@ function parseLimit(raw) {
|
|
|
8930
9394
|
if (!Number.isInteger(n) || n < 1) fail(`--limit must be a positive whole number, got "${raw}"`);
|
|
8931
9395
|
return Math.min(n, MAX_PAGE);
|
|
8932
9396
|
}
|
|
8933
|
-
/**
|
|
8934
|
-
* The org audit trail. Append-only and admin-only, so this is the record of
|
|
8935
|
-
* who did what — the CLI needs the same filters the dashboard has, plus
|
|
8936
|
-
* pagination, or "find the grant that shouldn't be there" means scrolling.
|
|
8937
|
-
*/
|
|
8938
|
-
function registerAuditCommands(program) {
|
|
8939
|
-
const audit = program.command("audit").description("read the org audit trail");
|
|
8940
|
-
audit.command("list", { isDefault: true }).alias("ls").description("show the org audit trail").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--limit <n>", `entries per page (max ${MAX_PAGE})`, "50").option("--action <action>", "only this action, e.g. env.key_granted").option("--resource-type <type>", "only this resource type, e.g. environment").option("--cursor <cursor>", "continue from a previous page's cursor").option("--all", "page through the whole trail, not just the first page").option("--metadata", "include each entry's metadata as JSON").option("--json", "print the raw API response").action(async (options) => {
|
|
8941
|
-
if (options.action && !AUDIT_ACTIONS.includes(options.action)) fail(`unknown action "${options.action}" — see \`seekrit audit actions\``);
|
|
8942
|
-
const ctx = buildContext();
|
|
8943
|
-
const ref = await resolveOrg(ctx, options.org);
|
|
8944
|
-
const limit = parseLimit(options.limit);
|
|
8945
|
-
const entries = [];
|
|
8946
|
-
let cursor = options.cursor;
|
|
8947
|
-
let nextCursor = null;
|
|
8948
|
-
do {
|
|
8949
|
-
const page = await ctx.client.listAudit(ref.id, {
|
|
8950
|
-
limit,
|
|
8951
|
-
cursor,
|
|
8952
|
-
action: options.action,
|
|
8953
|
-
resourceType: options.resourceType
|
|
8954
|
-
});
|
|
8955
|
-
entries.push(...page.entries);
|
|
8956
|
-
nextCursor = page.nextCursor;
|
|
8957
|
-
cursor = page.nextCursor ?? void 0;
|
|
8958
|
-
} while (options.all && cursor);
|
|
8959
|
-
emit(options, {
|
|
8960
|
-
entries,
|
|
8961
|
-
nextCursor
|
|
8962
|
-
}, () => {
|
|
8963
|
-
printTable(entries, [
|
|
8964
|
-
col("when", (e) => e.createdAt),
|
|
8965
|
-
col("action", (e) => e.action),
|
|
8966
|
-
col("actor", (e) => `${e.actorType}:${e.actorId}`),
|
|
8967
|
-
col("resource", (e) => `${e.resourceType}${e.resourceId ? `:${e.resourceId}` : ""}`),
|
|
8968
|
-
...options.metadata ? [col("metadata", (e) => e.metadata ? JSON.stringify(e.metadata) : "-")] : []
|
|
8969
|
-
], "no audit entries match");
|
|
8970
|
-
if (nextCursor && !options.all) console.error(`more entries — continue with --cursor ${nextCursor} (or pass --all)`);
|
|
8971
|
-
});
|
|
8972
|
-
});
|
|
8973
|
-
audit.command("actions").description("list every action the audit trail can record (for `audit --action`)").option("--json", "print the raw API response").action((options) => {
|
|
8974
|
-
emit(options, { actions: AUDIT_ACTIONS }, () => {
|
|
8975
|
-
for (const action of AUDIT_ACTIONS) console.log(action);
|
|
8976
|
-
});
|
|
8977
|
-
});
|
|
9397
|
+
/**
|
|
9398
|
+
* The org audit trail. Append-only and admin-only, so this is the record of
|
|
9399
|
+
* who did what — the CLI needs the same filters the dashboard has, plus
|
|
9400
|
+
* pagination, or "find the grant that shouldn't be there" means scrolling.
|
|
9401
|
+
*/
|
|
9402
|
+
function registerAuditCommands(program) {
|
|
9403
|
+
const audit = program.command("audit").description("read the org audit trail");
|
|
9404
|
+
audit.command("list", { isDefault: true }).alias("ls").description("show the org audit trail").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--limit <n>", `entries per page (max ${MAX_PAGE})`, "50").option("--action <action>", "only this action, e.g. env.key_granted").option("--resource-type <type>", "only this resource type, e.g. environment").option("--cursor <cursor>", "continue from a previous page's cursor").option("--all", "page through the whole trail, not just the first page").option("--metadata", "include each entry's metadata as JSON").option("--json", "print the raw API response").action(async (options) => {
|
|
9405
|
+
if (options.action && !AUDIT_ACTIONS.includes(options.action)) fail(`unknown action "${options.action}" — see \`seekrit audit actions\``);
|
|
9406
|
+
const ctx = buildContext();
|
|
9407
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9408
|
+
const limit = parseLimit(options.limit);
|
|
9409
|
+
const entries = [];
|
|
9410
|
+
let cursor = options.cursor;
|
|
9411
|
+
let nextCursor = null;
|
|
9412
|
+
do {
|
|
9413
|
+
const page = await ctx.client.listAudit(ref.id, {
|
|
9414
|
+
limit,
|
|
9415
|
+
cursor,
|
|
9416
|
+
action: options.action,
|
|
9417
|
+
resourceType: options.resourceType
|
|
9418
|
+
});
|
|
9419
|
+
entries.push(...page.entries);
|
|
9420
|
+
nextCursor = page.nextCursor;
|
|
9421
|
+
cursor = page.nextCursor ?? void 0;
|
|
9422
|
+
} while (options.all && cursor);
|
|
9423
|
+
emit(options, {
|
|
9424
|
+
entries,
|
|
9425
|
+
nextCursor
|
|
9426
|
+
}, () => {
|
|
9427
|
+
printTable(entries, [
|
|
9428
|
+
col("when", (e) => e.createdAt),
|
|
9429
|
+
col("action", (e) => e.action),
|
|
9430
|
+
col("actor", (e) => `${e.actorType}:${e.actorId}`),
|
|
9431
|
+
col("resource", (e) => `${e.resourceType}${e.resourceId ? `:${e.resourceId}` : ""}`),
|
|
9432
|
+
...options.metadata ? [col("metadata", (e) => e.metadata ? JSON.stringify(e.metadata) : "-")] : []
|
|
9433
|
+
], "no audit entries match");
|
|
9434
|
+
if (nextCursor && !options.all) console.error(`more entries — continue with --cursor ${nextCursor} (or pass --all)`);
|
|
9435
|
+
});
|
|
9436
|
+
});
|
|
9437
|
+
audit.command("actions").description("list every action the audit trail can record (for `audit --action`)").option("--json", "print the raw API response").action((options) => {
|
|
9438
|
+
emit(options, { actions: AUDIT_ACTIONS }, () => {
|
|
9439
|
+
for (const action of AUDIT_ACTIONS) console.log(action);
|
|
9440
|
+
});
|
|
9441
|
+
});
|
|
9442
|
+
}
|
|
9443
|
+
//#endregion
|
|
9444
|
+
//#region src/aws.ts
|
|
9445
|
+
/**
|
|
9446
|
+
* `seekrit aws` — temporary AWS credentials via STS AssumeRole (Vault-style
|
|
9447
|
+
* dynamic secrets, the tier-2 sibling of `seekrit pg`).
|
|
9448
|
+
*
|
|
9449
|
+
* Zero-knowledge for the leased credential: minting generates an ephemeral P-256
|
|
9450
|
+
* keypair on THIS machine and sends only the public key; STS mints the
|
|
9451
|
+
* credential and the broker returns it wrapped to that key, so the control plane
|
|
9452
|
+
* only ever relays ciphertext and only this machine can unwrap it. Registering a
|
|
9453
|
+
* target wraps the base IAM credential to the broker's public key locally, so
|
|
9454
|
+
* the control plane never sees it either — its only needed permission is
|
|
9455
|
+
* `sts:AssumeRole` on the target role.
|
|
9456
|
+
*/
|
|
9457
|
+
/** Parse a duration like `30m`, `1h`, or a bare seconds count. */
|
|
9458
|
+
function parseTtlSeconds$6(input) {
|
|
9459
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
9460
|
+
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
|
|
9461
|
+
return Number(m[1]) * ({
|
|
9462
|
+
s: 1,
|
|
9463
|
+
m: 60,
|
|
9464
|
+
h: 3600,
|
|
9465
|
+
d: 86400
|
|
9466
|
+
}[m[2] || "s"] ?? 1);
|
|
9467
|
+
}
|
|
9468
|
+
/**
|
|
9469
|
+
* The base IAM credential the broker assumes the role with. From flags or the
|
|
9470
|
+
* standard AWS env vars; JSON-serialized so the executor can parse it. Never the
|
|
9471
|
+
* plaintext leaves this machine unwrapped — it is wrapped to the broker key.
|
|
9472
|
+
*/
|
|
9473
|
+
function resolveBaseCredential(opts) {
|
|
9474
|
+
const accessKeyId = opts.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
|
|
9475
|
+
const secretAccessKey = opts.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
|
|
9476
|
+
const sessionToken = process.env.AWS_SESSION_TOKEN;
|
|
9477
|
+
if (!accessKeyId || !secretAccessKey) fail("provide the base IAM credential via --access-key-id/--secret-access-key or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (it needs only sts:AssumeRole on the role)");
|
|
9478
|
+
return JSON.stringify({
|
|
9479
|
+
accessKeyId,
|
|
9480
|
+
secretAccessKey,
|
|
9481
|
+
...sessionToken ? { sessionToken } : {}
|
|
9482
|
+
});
|
|
9483
|
+
}
|
|
9484
|
+
function registerAwsCommands(program) {
|
|
9485
|
+
const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
|
|
9486
|
+
const target = aws.command("target").description("manage AWS role targets");
|
|
9487
|
+
target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
|
|
9488
|
+
const ctx = buildContext();
|
|
9489
|
+
const org = await resolveOrg(ctx, options.org);
|
|
9490
|
+
const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
|
|
9491
|
+
const config = {
|
|
9492
|
+
provider: "aws",
|
|
9493
|
+
executor: "in_do",
|
|
9494
|
+
roleArn: options.roleArn,
|
|
9495
|
+
region: options.region,
|
|
9496
|
+
...options.externalId ? { externalId: options.externalId } : {},
|
|
9497
|
+
...sessionPolicy ? { sessionPolicy } : {},
|
|
9498
|
+
...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
|
|
9499
|
+
};
|
|
9500
|
+
const baseCredential = resolveBaseCredential(options);
|
|
9501
|
+
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
9502
|
+
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
|
|
9503
|
+
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
9504
|
+
name: options.name,
|
|
9505
|
+
config,
|
|
9506
|
+
wrappedAdminSecret
|
|
9507
|
+
});
|
|
9508
|
+
console.error(`registered AWS target ${created.name} (${created.id})`);
|
|
9509
|
+
console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
|
|
9510
|
+
console.log(awsTrustPolicyInstructions(config));
|
|
9511
|
+
});
|
|
9512
|
+
target.command("list").description("list AWS role targets").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
9513
|
+
const ctx = buildContext();
|
|
9514
|
+
const org = await resolveOrg(ctx, options.org);
|
|
9515
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
9516
|
+
for (const t of targets) {
|
|
9517
|
+
const cfg = t.config;
|
|
9518
|
+
if (cfg.provider !== "aws") continue;
|
|
9519
|
+
console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
|
|
9520
|
+
}
|
|
9521
|
+
});
|
|
9522
|
+
target.command("trust <targetId>").description("reprint the IAM trust-policy setup for an AWS target").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (targetId, options) => {
|
|
9523
|
+
const ctx = buildContext();
|
|
9524
|
+
const org = await resolveOrg(ctx, options.org);
|
|
9525
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
9526
|
+
const t = targets.find((x) => x.id === targetId || x.name === targetId);
|
|
9527
|
+
if (!t) fail(`no target "${targetId}" in ${org.slug}`);
|
|
9528
|
+
const cfg = t.config;
|
|
9529
|
+
if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
|
|
9530
|
+
console.log(awsTrustPolicyInstructions(cfg));
|
|
9531
|
+
});
|
|
9532
|
+
target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (targetId, options) => {
|
|
9533
|
+
const ctx = buildContext();
|
|
9534
|
+
const org = await resolveOrg(ctx, options.org);
|
|
9535
|
+
await confirmDestructive(options.yes, `Remove target ${targetId}? New leases against it stop working.`);
|
|
9536
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
9537
|
+
console.error(`deleted ${targetId}`);
|
|
9538
|
+
});
|
|
9539
|
+
aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
|
|
9540
|
+
const ctx = buildContext();
|
|
9541
|
+
const org = await resolveOrg(ctx, options.org);
|
|
9542
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
9543
|
+
const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
|
|
9544
|
+
if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
9545
|
+
const cfg = t.config;
|
|
9546
|
+
if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
|
|
9547
|
+
const ttlSeconds = parseTtlSeconds$6(options.ttl);
|
|
9548
|
+
if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
|
|
9549
|
+
const recipient = await generateAwsRecipientKeyPair();
|
|
9550
|
+
const { aws: leased } = await ctx.client.mintLease(org.id, {
|
|
9551
|
+
provider: "aws",
|
|
9552
|
+
targetId: t.id,
|
|
9553
|
+
recipientPublicKey: recipient.publicKeyJwk,
|
|
9554
|
+
ttlSeconds
|
|
9555
|
+
});
|
|
9556
|
+
const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
|
|
9557
|
+
console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
|
|
9558
|
+
if (options.json) console.log(JSON.stringify({
|
|
9559
|
+
...cred,
|
|
9560
|
+
roleArn: cfg.roleArn
|
|
9561
|
+
}, null, 2));
|
|
9562
|
+
else {
|
|
9563
|
+
console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
|
|
9564
|
+
console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
|
|
9565
|
+
console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
|
|
9566
|
+
console.log(`export AWS_REGION=${cred.region}`);
|
|
9567
|
+
}
|
|
9568
|
+
});
|
|
9569
|
+
aws.command("leases").description("list AWS leases (the ledger — never secret material)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
9570
|
+
const ctx = buildContext();
|
|
9571
|
+
const org = await resolveOrg(ctx, options.org);
|
|
9572
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
9573
|
+
for (const l of leases) {
|
|
9574
|
+
if (l.provider !== "aws") continue;
|
|
9575
|
+
console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
|
|
9576
|
+
}
|
|
9577
|
+
});
|
|
9578
|
+
aws.command("revoke <leaseId>").description("mark a lease revoked in the ledger (STS credentials stay valid until they expire)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (leaseId, options) => {
|
|
9579
|
+
const ctx = buildContext();
|
|
9580
|
+
const org = await resolveOrg(ctx, options.org);
|
|
9581
|
+
await confirmDestructive(options.yes, `Revoke lease ${leaseId}? Credentials already issued stay valid until they expire.`);
|
|
9582
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
9583
|
+
console.error(`revoked ${leaseId} (issued credentials remain valid until they expire)`);
|
|
9584
|
+
});
|
|
9585
|
+
}
|
|
9586
|
+
//#endregion
|
|
9587
|
+
//#region src/billing.ts
|
|
9588
|
+
/** `included: null` means unlimited; an unavailable meter is unknown, not zero. */
|
|
9589
|
+
function usageLine(usage) {
|
|
9590
|
+
if (!usage.available) return "unknown";
|
|
9591
|
+
return `${usage.quantity} of ${usage.included ?? "unlimited"}`;
|
|
9592
|
+
}
|
|
9593
|
+
/**
|
|
9594
|
+
* Plan, usage, and self-serve billing.
|
|
9595
|
+
*
|
|
9596
|
+
* Checkout and the billing portal are browser flows the biller hosts, so those
|
|
9597
|
+
* commands print a URL rather than pretending to complete a payment in the
|
|
9598
|
+
* terminal.
|
|
9599
|
+
*/
|
|
9600
|
+
function registerBillingCommands(program) {
|
|
9601
|
+
const billing = program.command("billing").description("plan, usage, and subscription");
|
|
9602
|
+
billing.command("show", { isDefault: true }).description("show the org's plan, what it includes, and current usage").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--json", "print the raw API response").action(async (options) => {
|
|
9603
|
+
const ctx = buildContext();
|
|
9604
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9605
|
+
const info = await ctx.client.getBilling(ref.id);
|
|
9606
|
+
emit(options, info, () => {
|
|
9607
|
+
printFields([
|
|
9608
|
+
["plan", `${info.plan.name} (${info.plan.id})`],
|
|
9609
|
+
["status", info.subscription?.status ?? "no subscription (default plan)"],
|
|
9610
|
+
["renews", info.subscription?.currentPeriodEnd ?? null],
|
|
9611
|
+
["cancels", info.subscription?.cancelAt ?? null],
|
|
9612
|
+
["trial ends", info.subscription?.trialEndsAt ?? null],
|
|
9613
|
+
["limits enforced", info.enforced ? "yes" : "no (informational only)"],
|
|
9614
|
+
["self-serve", [info.manage.checkout ? `checkout (${info.manage.checkoutFamilies.join(", ")})` : null, info.manage.portal ? "portal" : null].filter(Boolean).join(", ") || "unavailable"]
|
|
9615
|
+
]);
|
|
9616
|
+
section("usage");
|
|
9617
|
+
printTable(info.usage, [col("metric", (u) => u.label), col("used", (u) => usageLine(u))], "nothing metered yet");
|
|
9618
|
+
if (info.overrides.length > 0) {
|
|
9619
|
+
section("overrides");
|
|
9620
|
+
printTable(info.overrides, [
|
|
9621
|
+
col("key", (o) => o.key),
|
|
9622
|
+
col("value", (o) => String(o.value)),
|
|
9623
|
+
col("note", (o) => o.note),
|
|
9624
|
+
col("expires", (o) => o.expiresAt)
|
|
9625
|
+
]);
|
|
9626
|
+
}
|
|
9627
|
+
});
|
|
9628
|
+
});
|
|
9629
|
+
billing.command("entitlements").description("list every entitlement this org resolves to").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--json", "print the raw API response").action(async (options) => {
|
|
9630
|
+
const ctx = buildContext();
|
|
9631
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9632
|
+
const info = await ctx.client.getBilling(ref.id);
|
|
9633
|
+
emit(options, { entitlements: info.entitlements }, () => printTable(info.entitlements, [
|
|
9634
|
+
col("key", (e) => e.key),
|
|
9635
|
+
col("value", (e) => e.value === null ? "unlimited" : String(e.value)),
|
|
9636
|
+
col("source", (e) => e.source)
|
|
9637
|
+
]));
|
|
9638
|
+
});
|
|
9639
|
+
billing.command("checkout <family>").description(`start a self-serve upgrade (${VISIBLE_PLAN_FAMILY_IDS.join(" | ")}) — prints a URL`).option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (family, options) => {
|
|
9640
|
+
if (!PLAN_FAMILY_IDS.includes(family)) fail(`unknown plan "${family}" — one of: ${PLAN_FAMILY_IDS.join(", ")}`);
|
|
9641
|
+
const ctx = buildContext();
|
|
9642
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9643
|
+
const { url } = await ctx.client.startCheckout(ref.id, { family });
|
|
9644
|
+
console.error("open this to complete checkout:");
|
|
9645
|
+
console.log(url);
|
|
9646
|
+
});
|
|
9647
|
+
billing.command("portal").description("open the billing portal (prints a URL) to manage payment and invoices").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
9648
|
+
const ctx = buildContext();
|
|
9649
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9650
|
+
const { url } = await ctx.client.openBillingPortal(ref.id);
|
|
9651
|
+
console.error("open this to manage billing:");
|
|
9652
|
+
console.log(url);
|
|
9653
|
+
});
|
|
9654
|
+
billing.command("cancel").description("cancel the subscription and drop back to the Free plan").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation prompt").action(async (options) => {
|
|
9655
|
+
const ctx = buildContext();
|
|
9656
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9657
|
+
await confirmDestructive(options.yes, `Cancel ${ref.slug}'s subscription and move it to the Free plan?`);
|
|
9658
|
+
await ctx.client.cancelSubscription(ref.id);
|
|
9659
|
+
console.error(`${ref.slug} moved to the Free plan`);
|
|
9660
|
+
});
|
|
9661
|
+
}
|
|
9662
|
+
//#endregion
|
|
9663
|
+
//#region src/branches.ts
|
|
9664
|
+
/**
|
|
9665
|
+
* Resolve `--from` to the environment being branched.
|
|
9666
|
+
*
|
|
9667
|
+
* Not just `resolveAppEnv`: branch slugs live in the same namespace but are
|
|
9668
|
+
* excluded from the environment list, so naming one lands on "no environment
|
|
9669
|
+
* …". Branching a branch is a real thing people will try (depth is capped at
|
|
9670
|
+
* one), and it deserves an error that says so.
|
|
9671
|
+
*/
|
|
9672
|
+
async function resolveBranchParent(ctx, opts) {
|
|
9673
|
+
const app = await resolveApp(ctx, opts);
|
|
9674
|
+
const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
|
|
9675
|
+
const env = environments.find((e) => e.slug === opts.from || e.id === opts.from);
|
|
9676
|
+
if (env) return {
|
|
9677
|
+
orgId: app.orgId,
|
|
9678
|
+
appId: app.id,
|
|
9679
|
+
appSlug: app.slug,
|
|
9680
|
+
envId: env.id,
|
|
9681
|
+
envSlug: env.slug
|
|
9682
|
+
};
|
|
9683
|
+
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
9684
|
+
if (branches.some((b) => b.slug === opts.from || b.id === opts.from)) fail(`"${opts.from}" is itself a branch — branches are one level deep, so branch from the environment it overlays`);
|
|
9685
|
+
fail(`no environment "${opts.from}" in ${app.slug}`);
|
|
9686
|
+
}
|
|
9687
|
+
/**
|
|
9688
|
+
* Branch (ephemeral) configs: `seekrit branch create pr-142 --from dev`.
|
|
9689
|
+
*
|
|
9690
|
+
* A branch overlays its parent instead of copying it, so creating one encrypts
|
|
9691
|
+
* nothing — it mints a data key for the branch's own overrides and wraps that
|
|
9692
|
+
* key to whoever should read them. Everything the branch inherits stays where
|
|
9693
|
+
* it is, and stays live.
|
|
9694
|
+
*/
|
|
9695
|
+
function registerBranchCommands(program) {
|
|
9696
|
+
const branch = program.command("branch").description("ephemeral per-PR / preview configs layered on an environment");
|
|
9697
|
+
branch.command("create <slug>").description("fork an environment into an ephemeral branch (inherits its secrets)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--from <env>", "the environment to branch").option("--name <name>", "display name (defaults to the slug)").option("--ttl <duration>", "lifetime: 12h, 7d, 2w, … or `never`", "7d").option("--no-share", "don't give the parent's other readers access to this branch").action(async (slug, options) => {
|
|
9698
|
+
const ctx = buildContext();
|
|
9699
|
+
const parent = await resolveBranchParent(ctx, {
|
|
9700
|
+
org: options.org,
|
|
9701
|
+
app: options.app,
|
|
9702
|
+
from: options.from
|
|
9703
|
+
});
|
|
9704
|
+
const parsedTtl = parseBranchTtl(options.ttl);
|
|
9705
|
+
if (parsedTtl === null) fail(`invalid --ttl "${options.ttl}" (try 12h, 7d, 2w, or never)`);
|
|
9706
|
+
const ttlSeconds = Number.isFinite(parsedTtl) ? parsedTtl : null;
|
|
9707
|
+
const me = await kmsCallerIdentity(ctx);
|
|
9708
|
+
const dek = generateDek();
|
|
9709
|
+
const wrappedDek = await wrapDek(dek, me.publicKeyJwk);
|
|
9710
|
+
const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, parent.orgId, dek);
|
|
9711
|
+
const grants = [];
|
|
9712
|
+
if (options.share !== false) {
|
|
9713
|
+
const { grantees } = await ctx.client.listGrantees(parent.orgId, parent.envId);
|
|
9714
|
+
for (const grantee of grantees) {
|
|
9715
|
+
if (grantee.principalType === me.principalType && grantee.principalId === me.principalId) continue;
|
|
9716
|
+
grants.push({
|
|
9717
|
+
principalType: grantee.principalType,
|
|
9718
|
+
principalId: grantee.principalId,
|
|
9719
|
+
wrappedDek: await wrapDek(dek, grantee.publicKeyJwk)
|
|
9720
|
+
});
|
|
9721
|
+
}
|
|
9722
|
+
}
|
|
9723
|
+
const created = await ctx.client.createBranch(parent.orgId, parent.envId, {
|
|
9724
|
+
slug,
|
|
9725
|
+
name: options.name,
|
|
9726
|
+
ttlSeconds,
|
|
9727
|
+
wrappedDek,
|
|
9728
|
+
recoveryWrappedDek,
|
|
9729
|
+
grants
|
|
9730
|
+
});
|
|
9731
|
+
console.error(`created branch ${parent.appSlug}/${parent.envSlug}#${created.branch.slug} (${created.branch.id})`);
|
|
9732
|
+
console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
|
|
9733
|
+
if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
|
|
9734
|
+
});
|
|
9735
|
+
branch.command("list").alias("ls").description("list branches in an application (or of one environment)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--env <slug>", "only branches of this environment").option("--json", "print the raw API response").action(async (options) => {
|
|
9736
|
+
const ctx = buildContext();
|
|
9737
|
+
const branches = options.env ? await (async () => {
|
|
9738
|
+
const parent = await resolveAppEnv(ctx, options);
|
|
9739
|
+
return (await ctx.client.listBranches(parent.orgId, parent.envId)).branches;
|
|
9740
|
+
})() : await (async () => {
|
|
9741
|
+
const app = await resolveApp(ctx, options);
|
|
9742
|
+
return (await ctx.client.listAppBranches(app.orgId, app.id)).branches;
|
|
9743
|
+
})();
|
|
9744
|
+
emit(options, { branches }, () => printTable(branches, [
|
|
9745
|
+
col("slug", (b) => b.slug),
|
|
9746
|
+
col("expires", (b) => b.expiresAt ?? "never"),
|
|
9747
|
+
col("created", (b) => b.createdAt),
|
|
9748
|
+
col("id", (b) => b.id)
|
|
9749
|
+
], "no branches — create one with `seekrit branch create`"));
|
|
9750
|
+
});
|
|
9751
|
+
branch.command("delete <slug>").alias("rm").description("tear down a branch and everything it overrode").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (defaults to seekrit.json)").option("-y, --yes", "skip the confirmation").action(async (slug, options) => {
|
|
9752
|
+
const ctx = buildContext();
|
|
9753
|
+
const app = await resolveApp(ctx, options);
|
|
9754
|
+
const target = await resolveBranch(ctx, app, slug);
|
|
9755
|
+
await confirmDestructive(options.yes, `Tear down branch ${app.slug}#${target.slug} and every override in it?`);
|
|
9756
|
+
await ctx.client.deleteBranch(app.orgId, target.id);
|
|
9757
|
+
console.error(`deleted branch ${app.slug}#${target.slug}`);
|
|
9758
|
+
});
|
|
9759
|
+
}
|
|
9760
|
+
/** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
|
|
9761
|
+
const PLUGIN_ID = "seekrit";
|
|
9762
|
+
const PLUGIN_INTEGRATION_ID = "seekrit";
|
|
9763
|
+
/** The provider alias written into `secrets.providers`. */
|
|
9764
|
+
const PROVIDER_ALIAS = "seekrit";
|
|
9765
|
+
/**
|
|
9766
|
+
* Environment the resolver needs, and nothing else.
|
|
9767
|
+
*
|
|
9768
|
+
* OpenClaw hands an exec provider an empty environment apart from this
|
|
9769
|
+
* allowlist, which is a feature: it is the difference between "the resolver can
|
|
9770
|
+
* read the credential it needs" and "the resolver inherits the whole gateway
|
|
9771
|
+
* environment". `PATH` is here because Node's own startup needs it, `HOME` for
|
|
9772
|
+
* the CLI's login session, and the `SEEKRIT_*` set is how a machine credential
|
|
9773
|
+
* reaches a process nobody gets to pass flags to.
|
|
9774
|
+
*/
|
|
9775
|
+
const PASS_ENV = [
|
|
9776
|
+
"PATH",
|
|
9777
|
+
"HOME",
|
|
9778
|
+
"USERPROFILE",
|
|
9779
|
+
"APPDATA",
|
|
9780
|
+
"LOCALAPPDATA",
|
|
9781
|
+
"TEMP",
|
|
9782
|
+
"TMP",
|
|
9783
|
+
"SYSTEMROOT",
|
|
9784
|
+
"WINDIR",
|
|
9785
|
+
"XDG_CONFIG_HOME",
|
|
9786
|
+
"XDG_CACHE_HOME",
|
|
9787
|
+
"NODE_EXTRA_CA_CERTS",
|
|
9788
|
+
"SEEKRIT_TOKEN",
|
|
9789
|
+
"SEEKRIT_CLIENT_ID",
|
|
9790
|
+
"SEEKRIT_CLIENT_SECRET",
|
|
9791
|
+
"SEEKRIT_API_URL",
|
|
9792
|
+
"SEEKRIT_ORG",
|
|
9793
|
+
"SEEKRIT_APP",
|
|
9794
|
+
"SEEKRIT_ENV",
|
|
9795
|
+
"SEEKRIT_BRANCH"
|
|
9796
|
+
];
|
|
9797
|
+
/**
|
|
9798
|
+
* Generous, because the first resolve of a cold start does real work — an M2M
|
|
9799
|
+
* token mint, a resolve, and a key unwrap — and OpenClaw fails startup rather
|
|
9800
|
+
* than degrading when this expires. The 1Password integration picks 90s for the
|
|
9801
|
+
* same reason (its CLI may prompt for biometrics); 30s is enough here because
|
|
9802
|
+
* nothing in this path is interactive.
|
|
9803
|
+
*/
|
|
9804
|
+
const TIMEOUT_MS = 3e4;
|
|
9805
|
+
/** A seekrit secret name: what `seekrit secrets set` accepts. */
|
|
9806
|
+
const NAME_RE$1 = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9807
|
+
/** Slugs as the API spells them. */
|
|
9808
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
9809
|
+
/**
|
|
9810
|
+
* Parse an exec SecretRef id.
|
|
9811
|
+
*
|
|
9812
|
+
* Two shapes, mirroring 1Password's `op://vault/item/field` — a bare name for
|
|
9813
|
+
* the environment the credential already points at, and an explicit
|
|
9814
|
+
* `app/env/NAME` triple for a provider serving more than one environment:
|
|
9815
|
+
*
|
|
9816
|
+
* OPENAI_API_KEY
|
|
9817
|
+
* billing-api/production/STRIPE_SECRET_KEY
|
|
9818
|
+
*
|
|
9819
|
+
* A bare name is the common case and the one a service token wants, since the
|
|
9820
|
+
* token is already bound to an environment and there is nothing to disambiguate.
|
|
9821
|
+
*/
|
|
9822
|
+
function parseSecretId(id) {
|
|
9823
|
+
const trimmed = id.trim();
|
|
9824
|
+
if (trimmed.length === 0) return void 0;
|
|
9825
|
+
const parts = trimmed.split("/");
|
|
9826
|
+
if (parts.length === 1) {
|
|
9827
|
+
const [name = ""] = parts;
|
|
9828
|
+
return NAME_RE$1.test(name) ? { name } : void 0;
|
|
9829
|
+
}
|
|
9830
|
+
if (parts.length !== 3) return void 0;
|
|
9831
|
+
const [app = "", env = "", name = ""] = parts;
|
|
9832
|
+
if (!SLUG_RE.test(app) || !SLUG_RE.test(env) || !NAME_RE$1.test(name)) return void 0;
|
|
9833
|
+
return {
|
|
9834
|
+
app,
|
|
9835
|
+
env,
|
|
9836
|
+
name
|
|
9837
|
+
};
|
|
9838
|
+
}
|
|
9839
|
+
/** The (app, env) pair an id resolves against — `""` for the credential's own. */
|
|
9840
|
+
function scopeKey(id) {
|
|
9841
|
+
return id.app && id.env ? `${id.app}/${id.env}` : "";
|
|
8978
9842
|
}
|
|
8979
|
-
//#endregion
|
|
8980
|
-
//#region src/aws.ts
|
|
8981
9843
|
/**
|
|
8982
|
-
*
|
|
8983
|
-
* dynamic secrets, the tier-2 sibling of `seekrit pg`).
|
|
9844
|
+
* Parse a request, tolerantly in exactly one direction.
|
|
8984
9845
|
*
|
|
8985
|
-
*
|
|
8986
|
-
*
|
|
8987
|
-
*
|
|
8988
|
-
*
|
|
8989
|
-
*
|
|
8990
|
-
* the control plane never sees it either — its only needed permission is
|
|
8991
|
-
* `sts:AssumeRole` on the target role.
|
|
9846
|
+
* Non-string and empty ids are dropped rather than rejected — a request whose
|
|
9847
|
+
* shape we do not recognize should still resolve the ids we do, because the
|
|
9848
|
+
* alternative fails OpenClaw's whole startup over one malformed entry. A body
|
|
9849
|
+
* that is not an object with an `ids` array is a different matter: there is
|
|
9850
|
+
* nothing to answer, so it throws.
|
|
8992
9851
|
*/
|
|
8993
|
-
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9852
|
+
function parseExecRequest(input) {
|
|
9853
|
+
const parsed = JSON.parse(input);
|
|
9854
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("exec SecretRef request must be a JSON object");
|
|
9855
|
+
const ids = parsed.ids;
|
|
9856
|
+
if (!Array.isArray(ids)) throw new Error("exec SecretRef request must carry an `ids` array");
|
|
9857
|
+
return { ids: ids.filter((id) => typeof id === "string" && id.trim().length > 0) };
|
|
9858
|
+
}
|
|
9859
|
+
/** Assemble a response, omitting `errors` entirely when everything resolved. */
|
|
9860
|
+
function buildExecResponse(values, errors) {
|
|
9861
|
+
return {
|
|
9862
|
+
protocolVersion: 1,
|
|
9863
|
+
values,
|
|
9864
|
+
...Object.keys(errors).length > 0 ? { errors } : {}
|
|
9865
|
+
};
|
|
9003
9866
|
}
|
|
9004
9867
|
/**
|
|
9005
|
-
*
|
|
9006
|
-
*
|
|
9007
|
-
*
|
|
9868
|
+
* Resolve every requested id, reading each distinct environment exactly once.
|
|
9869
|
+
*
|
|
9870
|
+
* A batch is normally one environment, but a provider may serve several, and
|
|
9871
|
+
* resolving per id would mean an unwrap per id. Grouping keeps a 40-variable
|
|
9872
|
+
* gateway to one round trip.
|
|
9873
|
+
*
|
|
9874
|
+
* `resolveScope` is injected so the protocol can be tested without a network,
|
|
9875
|
+
* an API, or a key.
|
|
9008
9876
|
*/
|
|
9009
|
-
function
|
|
9010
|
-
const
|
|
9011
|
-
const
|
|
9012
|
-
const
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
});
|
|
9019
|
-
}
|
|
9020
|
-
function registerAwsCommands(program) {
|
|
9021
|
-
const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
|
|
9022
|
-
const target = aws.command("target").description("manage AWS role targets");
|
|
9023
|
-
target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
|
|
9024
|
-
const ctx = buildContext();
|
|
9025
|
-
const org = await resolveOrg(ctx, options.org);
|
|
9026
|
-
const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
|
|
9027
|
-
const config = {
|
|
9028
|
-
provider: "aws",
|
|
9029
|
-
executor: "in_do",
|
|
9030
|
-
roleArn: options.roleArn,
|
|
9031
|
-
region: options.region,
|
|
9032
|
-
...options.externalId ? { externalId: options.externalId } : {},
|
|
9033
|
-
...sessionPolicy ? { sessionPolicy } : {},
|
|
9034
|
-
...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
|
|
9035
|
-
};
|
|
9036
|
-
const baseCredential = resolveBaseCredential(options);
|
|
9037
|
-
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
9038
|
-
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
|
|
9039
|
-
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
9040
|
-
name: options.name,
|
|
9041
|
-
config,
|
|
9042
|
-
wrappedAdminSecret
|
|
9043
|
-
});
|
|
9044
|
-
console.error(`registered AWS target ${created.name} (${created.id})`);
|
|
9045
|
-
console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
|
|
9046
|
-
console.log(awsTrustPolicyInstructions(config));
|
|
9047
|
-
});
|
|
9048
|
-
target.command("list").description("list AWS role targets").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
9049
|
-
const ctx = buildContext();
|
|
9050
|
-
const org = await resolveOrg(ctx, options.org);
|
|
9051
|
-
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
9052
|
-
for (const t of targets) {
|
|
9053
|
-
const cfg = t.config;
|
|
9054
|
-
if (cfg.provider !== "aws") continue;
|
|
9055
|
-
console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
|
|
9877
|
+
async function resolveIds(ids, resolveScope) {
|
|
9878
|
+
const values = {};
|
|
9879
|
+
const errors = {};
|
|
9880
|
+
const byScope = /* @__PURE__ */ new Map();
|
|
9881
|
+
for (const id of ids) {
|
|
9882
|
+
const parsed = parseSecretId(id);
|
|
9883
|
+
if (!parsed) {
|
|
9884
|
+
errors[id] = { code: "INVALID_ID" };
|
|
9885
|
+
continue;
|
|
9056
9886
|
}
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
|
|
9066
|
-
console.log(awsTrustPolicyInstructions(cfg));
|
|
9067
|
-
});
|
|
9068
|
-
target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (targetId, options) => {
|
|
9069
|
-
const ctx = buildContext();
|
|
9070
|
-
const org = await resolveOrg(ctx, options.org);
|
|
9071
|
-
await confirmDestructive(options.yes, `Remove target ${targetId}? New leases against it stop working.`);
|
|
9072
|
-
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
9073
|
-
console.error(`deleted ${targetId}`);
|
|
9074
|
-
});
|
|
9075
|
-
aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
|
|
9076
|
-
const ctx = buildContext();
|
|
9077
|
-
const org = await resolveOrg(ctx, options.org);
|
|
9078
|
-
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
9079
|
-
const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
|
|
9080
|
-
if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
9081
|
-
const cfg = t.config;
|
|
9082
|
-
if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
|
|
9083
|
-
const ttlSeconds = parseTtlSeconds$6(options.ttl);
|
|
9084
|
-
if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
|
|
9085
|
-
const recipient = await generateAwsRecipientKeyPair();
|
|
9086
|
-
const { aws: leased } = await ctx.client.mintLease(org.id, {
|
|
9087
|
-
provider: "aws",
|
|
9088
|
-
targetId: t.id,
|
|
9089
|
-
recipientPublicKey: recipient.publicKeyJwk,
|
|
9090
|
-
ttlSeconds
|
|
9887
|
+
const key = scopeKey(parsed);
|
|
9888
|
+
const bucket = byScope.get(key) ?? {
|
|
9889
|
+
scope: parsed,
|
|
9890
|
+
ids: []
|
|
9891
|
+
};
|
|
9892
|
+
bucket.ids.push({
|
|
9893
|
+
id,
|
|
9894
|
+
name: parsed.name
|
|
9091
9895
|
});
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
console.log(`export AWS_REGION=${cred.region}`);
|
|
9896
|
+
byScope.set(key, bucket);
|
|
9897
|
+
}
|
|
9898
|
+
for (const { scope, ids: wanted } of byScope.values()) {
|
|
9899
|
+
let resolved;
|
|
9900
|
+
try {
|
|
9901
|
+
resolved = await resolveScope(scope);
|
|
9902
|
+
} catch (err) {
|
|
9903
|
+
const code = classify(err);
|
|
9904
|
+
for (const { id } of wanted) errors[id] = { code };
|
|
9905
|
+
continue;
|
|
9103
9906
|
}
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
const { leases } = await ctx.client.listLeases(org.id);
|
|
9109
|
-
for (const l of leases) {
|
|
9110
|
-
if (l.provider !== "aws") continue;
|
|
9111
|
-
console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
|
|
9907
|
+
for (const { id, name } of wanted) {
|
|
9908
|
+
const value = resolved[name];
|
|
9909
|
+
if (value === void 0) errors[id] = { code: "NOT_FOUND" };
|
|
9910
|
+
else values[id] = value;
|
|
9112
9911
|
}
|
|
9113
|
-
}
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9912
|
+
}
|
|
9913
|
+
return buildExecResponse(values, errors);
|
|
9914
|
+
}
|
|
9915
|
+
/**
|
|
9916
|
+
* Bucket a failure into a code an operator can act on.
|
|
9917
|
+
*
|
|
9918
|
+
* Deliberately coarse and deliberately message-free: it reads the error's own
|
|
9919
|
+
* text to pick a bucket and then throws that text away, because the text may
|
|
9920
|
+
* quote a token.
|
|
9921
|
+
*/
|
|
9922
|
+
function classify(err) {
|
|
9923
|
+
const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
|
9924
|
+
if (/unauthor|forbidden|token|credential|passphrase|decrypt|sign in|log in|login/.test(message)) return "UNAUTHORIZED";
|
|
9925
|
+
return "UNAVAILABLE";
|
|
9926
|
+
}
|
|
9927
|
+
/** OpenClaw's config directory: `$OPENCLAW_HOME`, else `~/.openclaw`. */
|
|
9928
|
+
function openclawHome(env = process.env) {
|
|
9929
|
+
const home = env.OPENCLAW_HOME?.trim();
|
|
9930
|
+
return home && home.length > 0 ? resolve(home) : join(homedir(), ".openclaw");
|
|
9121
9931
|
}
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9932
|
+
/**
|
|
9933
|
+
* The provider block for an installed `@seekrit/openclaw-plugin`.
|
|
9934
|
+
*
|
|
9935
|
+
* Preferred over the exec shape below for one reason that matters on upgrade:
|
|
9936
|
+
* OpenClaw reads the command out of the plugin's manifest at every startup, so
|
|
9937
|
+
* a plugin release that moves its resolver keeps working. A copied command is a
|
|
9938
|
+
* path pinned in someone's config forever.
|
|
9939
|
+
*/
|
|
9940
|
+
function pluginProvider() {
|
|
9941
|
+
return {
|
|
9942
|
+
source: "exec",
|
|
9943
|
+
pluginIntegration: {
|
|
9944
|
+
pluginId: PLUGIN_ID,
|
|
9945
|
+
integrationId: PLUGIN_INTEGRATION_ID
|
|
9946
|
+
}
|
|
9947
|
+
};
|
|
9128
9948
|
}
|
|
9129
9949
|
/**
|
|
9130
|
-
*
|
|
9950
|
+
* The provider block for a plain CLI install, with no plugin.
|
|
9131
9951
|
*
|
|
9132
|
-
*
|
|
9133
|
-
*
|
|
9134
|
-
*
|
|
9952
|
+
* `realpathSync` on both paths is not tidiness. OpenClaw's guard rejects a
|
|
9953
|
+
* symlinked command outright, and a Node installed by a version manager is
|
|
9954
|
+
* almost always reached through one — so resolving here is the difference
|
|
9955
|
+
* between a provider that works and a startup failure whose message points at
|
|
9956
|
+
* the config rather than at the shim.
|
|
9135
9957
|
*/
|
|
9136
|
-
function
|
|
9137
|
-
const
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
});
|
|
9165
|
-
billing.command("entitlements").description("list every entitlement this org resolves to").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--json", "print the raw API response").action(async (options) => {
|
|
9166
|
-
const ctx = buildContext();
|
|
9167
|
-
const ref = await resolveOrg(ctx, options.org);
|
|
9168
|
-
const info = await ctx.client.getBilling(ref.id);
|
|
9169
|
-
emit(options, { entitlements: info.entitlements }, () => printTable(info.entitlements, [
|
|
9170
|
-
col("key", (e) => e.key),
|
|
9171
|
-
col("value", (e) => e.value === null ? "unlimited" : String(e.value)),
|
|
9172
|
-
col("source", (e) => e.source)
|
|
9173
|
-
]));
|
|
9174
|
-
});
|
|
9175
|
-
billing.command("checkout <family>").description(`start a self-serve upgrade (${VISIBLE_PLAN_FAMILY_IDS.join(" | ")}) — prints a URL`).option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (family, options) => {
|
|
9176
|
-
if (!PLAN_FAMILY_IDS.includes(family)) fail(`unknown plan "${family}" — one of: ${PLAN_FAMILY_IDS.join(", ")}`);
|
|
9177
|
-
const ctx = buildContext();
|
|
9178
|
-
const ref = await resolveOrg(ctx, options.org);
|
|
9179
|
-
const { url } = await ctx.client.startCheckout(ref.id, { family });
|
|
9180
|
-
console.error("open this to complete checkout:");
|
|
9181
|
-
console.log(url);
|
|
9182
|
-
});
|
|
9183
|
-
billing.command("portal").description("open the billing portal (prints a URL) to manage payment and invoices").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
9184
|
-
const ctx = buildContext();
|
|
9185
|
-
const ref = await resolveOrg(ctx, options.org);
|
|
9186
|
-
const { url } = await ctx.client.openBillingPortal(ref.id);
|
|
9187
|
-
console.error("open this to manage billing:");
|
|
9188
|
-
console.log(url);
|
|
9189
|
-
});
|
|
9190
|
-
billing.command("cancel").description("cancel the subscription and drop back to the Free plan").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation prompt").action(async (options) => {
|
|
9191
|
-
const ctx = buildContext();
|
|
9192
|
-
const ref = await resolveOrg(ctx, options.org);
|
|
9193
|
-
await confirmDestructive(options.yes, `Cancel ${ref.slug}'s subscription and move it to the Free plan?`);
|
|
9194
|
-
await ctx.client.cancelSubscription(ref.id);
|
|
9195
|
-
console.error(`${ref.slug} moved to the Free plan`);
|
|
9196
|
-
});
|
|
9958
|
+
function execProvider(cliEntry, nodeBinary = process.execPath) {
|
|
9959
|
+
const command = realTo(nodeBinary);
|
|
9960
|
+
const entry = realTo(cliEntry);
|
|
9961
|
+
return {
|
|
9962
|
+
source: "exec",
|
|
9963
|
+
command,
|
|
9964
|
+
args: [
|
|
9965
|
+
entry,
|
|
9966
|
+
"openclaw",
|
|
9967
|
+
"resolve"
|
|
9968
|
+
],
|
|
9969
|
+
passEnv: [...PASS_ENV],
|
|
9970
|
+
jsonOnly: true,
|
|
9971
|
+
timeoutMs: TIMEOUT_MS,
|
|
9972
|
+
noOutputTimeoutMs: TIMEOUT_MS,
|
|
9973
|
+
trustedDirs: [.../* @__PURE__ */ new Set([dirname(command), dirname(entry)])]
|
|
9974
|
+
};
|
|
9975
|
+
}
|
|
9976
|
+
function realTo(path) {
|
|
9977
|
+
try {
|
|
9978
|
+
return realpathSync(path);
|
|
9979
|
+
} catch {
|
|
9980
|
+
return resolve(path);
|
|
9981
|
+
}
|
|
9982
|
+
}
|
|
9983
|
+
/** This CLI's own entry file — what `execProvider` hands to Node. */
|
|
9984
|
+
function cliEntryPath() {
|
|
9985
|
+
return fileURLToPath(new URL("index.js", import.meta.url));
|
|
9197
9986
|
}
|
|
9198
|
-
//#endregion
|
|
9199
|
-
//#region src/branches.ts
|
|
9200
9987
|
/**
|
|
9201
|
-
*
|
|
9988
|
+
* Add the seekrit provider to a parsed config without disturbing anything else.
|
|
9202
9989
|
*
|
|
9203
|
-
*
|
|
9204
|
-
*
|
|
9205
|
-
*
|
|
9206
|
-
*
|
|
9990
|
+
* An existing `seekrit` entry that differs is left alone unless forced, for the
|
|
9991
|
+
* same reason `seekrit paperclip init` leaves an existing MCP server alone:
|
|
9992
|
+
* someone who pinned a timeout or narrowed `passEnv` did it deliberately, and
|
|
9993
|
+
* silently reverting a security narrowing is the worst kind of helpful.
|
|
9207
9994
|
*/
|
|
9208
|
-
|
|
9209
|
-
const
|
|
9210
|
-
const
|
|
9211
|
-
const
|
|
9212
|
-
if (
|
|
9213
|
-
|
|
9214
|
-
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
|
|
9995
|
+
function mergeProvider(existing, provider, force) {
|
|
9996
|
+
const secrets = { ...existing.secrets ?? {} };
|
|
9997
|
+
const providers = { ...secrets.providers ?? {} };
|
|
9998
|
+
const current = providers[PROVIDER_ALIAS];
|
|
9999
|
+
if (current && !force && JSON.stringify(current) !== JSON.stringify(provider)) return {
|
|
10000
|
+
merged: existing,
|
|
10001
|
+
changed: false
|
|
10002
|
+
};
|
|
10003
|
+
if (current && JSON.stringify(current) === JSON.stringify(provider)) return {
|
|
10004
|
+
merged: existing,
|
|
10005
|
+
changed: false
|
|
10006
|
+
};
|
|
10007
|
+
providers[PROVIDER_ALIAS] = provider;
|
|
10008
|
+
return {
|
|
10009
|
+
merged: {
|
|
10010
|
+
...existing,
|
|
10011
|
+
secrets: {
|
|
10012
|
+
...secrets,
|
|
10013
|
+
providers
|
|
10014
|
+
}
|
|
10015
|
+
},
|
|
10016
|
+
changed: true
|
|
10017
|
+
};
|
|
10018
|
+
}
|
|
10019
|
+
/** A SecretRef pointing at this provider — what replaces a plaintext key. */
|
|
10020
|
+
function secretRef(id) {
|
|
10021
|
+
return {
|
|
10022
|
+
source: "exec",
|
|
10023
|
+
provider: PROVIDER_ALIAS,
|
|
10024
|
+
id
|
|
9218
10025
|
};
|
|
9219
|
-
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
9220
|
-
if (branches.some((b) => b.slug === opts.from || b.id === opts.from)) fail(`"${opts.from}" is itself a branch — branches are one level deep, so branch from the environment it overlays`);
|
|
9221
|
-
fail(`no environment "${opts.from}" in ${app.slug}`);
|
|
9222
10026
|
}
|
|
9223
10027
|
/**
|
|
9224
|
-
*
|
|
10028
|
+
* Read `openclaw.json`, or decline to.
|
|
9225
10029
|
*
|
|
9226
|
-
*
|
|
9227
|
-
*
|
|
9228
|
-
*
|
|
9229
|
-
*
|
|
10030
|
+
* The file is JSON5: comments and trailing commas are legal, and a parse/write
|
|
10031
|
+
* round trip through `JSON` would delete every comment in someone's gateway
|
|
10032
|
+
* config. So a file that is not also valid strict JSON is not edited at all —
|
|
10033
|
+
* the caller prints the block to paste instead. Losing a config's comments to
|
|
10034
|
+
* be helpful is worse than asking for one paste.
|
|
9230
10035
|
*/
|
|
9231
|
-
function
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9256
|
-
|
|
10036
|
+
function readOpenclawConfig(path) {
|
|
10037
|
+
if (!existsSync(path)) return { config: {} };
|
|
10038
|
+
let raw;
|
|
10039
|
+
try {
|
|
10040
|
+
raw = readFileSync(path, "utf8");
|
|
10041
|
+
} catch (err) {
|
|
10042
|
+
fail(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
10043
|
+
}
|
|
10044
|
+
if (raw.trim().length === 0) return { config: {} };
|
|
10045
|
+
try {
|
|
10046
|
+
const parsed = JSON.parse(raw);
|
|
10047
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { json5: true };
|
|
10048
|
+
return { config: parsed };
|
|
10049
|
+
} catch {
|
|
10050
|
+
return { json5: true };
|
|
10051
|
+
}
|
|
10052
|
+
}
|
|
10053
|
+
function registerOpenclawCommands(program) {
|
|
10054
|
+
const openclaw = program.command("openclaw").description("wire seekrit into OpenClaw as a SecretRef provider (`seekrit openclaw --help`)");
|
|
10055
|
+
openclaw.command("init").description("add the seekrit exec SecretRef provider to openclaw.json").option("--config <path>", "path to openclaw.json (default: $OPENCLAW_HOME/openclaw.json)").option("--plugin", "point at an installed @seekrit/openclaw-plugin (recommended)").option("--exec", "point directly at this CLI, with no plugin installed").option("--write", "write the file instead of printing the block to paste").option("--force", "replace a seekrit provider entry that already differs").option("--json", "machine-readable output").action((options) => {
|
|
10056
|
+
if (options.plugin && options.exec) fail("--plugin and --exec are mutually exclusive");
|
|
10057
|
+
const usePlugin = options.exec !== true;
|
|
10058
|
+
let provider;
|
|
10059
|
+
if (usePlugin) provider = pluginProvider();
|
|
10060
|
+
else {
|
|
10061
|
+
const entry = cliEntryPath();
|
|
10062
|
+
if (!existsSync(entry)) fail(`cannot find this CLI's entry file at ${entry} — --exec needs an installed seekrit (npm i -g @seekrit/cli), or use --plugin`);
|
|
10063
|
+
provider = execProvider(entry);
|
|
10064
|
+
}
|
|
10065
|
+
const path = options.config ? resolve(options.config) : join(openclawHome(), "openclaw.json");
|
|
10066
|
+
const read = readOpenclawConfig(path);
|
|
10067
|
+
const json5 = "json5" in read;
|
|
10068
|
+
const merged = json5 ? void 0 : mergeProvider(read.config, provider, options.force === true);
|
|
10069
|
+
const wrote = Boolean(options.write) && merged?.changed === true;
|
|
10070
|
+
if (wrote && merged) writeFileSync(path, `${JSON.stringify(merged.merged, null, 2)}\n`, { mode: 384 });
|
|
10071
|
+
emit(options, {
|
|
10072
|
+
path,
|
|
10073
|
+
provider,
|
|
10074
|
+
wrote,
|
|
10075
|
+
json5
|
|
10076
|
+
}, () => {
|
|
10077
|
+
section("openclaw secret provider");
|
|
10078
|
+
printFields([
|
|
10079
|
+
["config", path],
|
|
10080
|
+
["provider", PROVIDER_ALIAS],
|
|
10081
|
+
["shape", usePlugin ? "plugin integration" : "exec (this CLI)"],
|
|
10082
|
+
["written", wrote ? "yes" : "no"]
|
|
10083
|
+
]);
|
|
10084
|
+
console.log();
|
|
10085
|
+
if (json5) {
|
|
10086
|
+
console.log(`${path} is JSON5 (comments or trailing commas), which a rewrite here would delete.`);
|
|
10087
|
+
console.log("Merge this into it by hand:");
|
|
10088
|
+
} else if (!options.write) console.log("Merge this into your config, or re-run with --write:");
|
|
10089
|
+
else if (!wrote) console.log(`A different \`${PROVIDER_ALIAS}\` provider is already configured — left as it is. Re-run with --force to replace it.`);
|
|
10090
|
+
else console.log("Written. The block now in your config:");
|
|
10091
|
+
console.log();
|
|
10092
|
+
console.log(JSON.stringify({ secrets: { providers: { [PROVIDER_ALIAS]: provider } } }, null, 2));
|
|
10093
|
+
console.log();
|
|
10094
|
+
if (usePlugin) {
|
|
10095
|
+
console.log("Install the plugin, if you have not already:");
|
|
10096
|
+
console.log(" openclaw plugins install npm:@seekrit/openclaw-plugin");
|
|
10097
|
+
console.log(" openclaw plugins enable seekrit");
|
|
10098
|
+
console.log();
|
|
9257
10099
|
}
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
grants
|
|
10100
|
+
console.log("Then replace a plaintext credential with a ref, e.g.:");
|
|
10101
|
+
console.log(` ${JSON.stringify(secretRef("OPENAI_API_KEY"))}`);
|
|
10102
|
+
console.log();
|
|
10103
|
+
console.log("And check your work:");
|
|
10104
|
+
console.log(" openclaw secrets audit --check --allow-exec");
|
|
10105
|
+
console.log();
|
|
10106
|
+
console.log("Resolution is eager: OpenClaw reads every ref once at startup. After a rotation, run `openclaw secrets reload`.");
|
|
9266
10107
|
});
|
|
9267
|
-
console.error(`created branch ${parent.appSlug}/${parent.envSlug}#${created.branch.slug} (${created.branch.id})`);
|
|
9268
|
-
console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
|
|
9269
|
-
if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
|
|
9270
10108
|
});
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
const
|
|
9274
|
-
|
|
9275
|
-
|
|
9276
|
-
|
|
9277
|
-
const app = await resolveApp(ctx, options);
|
|
9278
|
-
return (await ctx.client.listAppBranches(app.orgId, app.id)).branches;
|
|
9279
|
-
})();
|
|
9280
|
-
emit(options, { branches }, () => printTable(branches, [
|
|
9281
|
-
col("slug", (b) => b.slug),
|
|
9282
|
-
col("expires", (b) => b.expiresAt ?? "never"),
|
|
9283
|
-
col("created", (b) => b.createdAt),
|
|
9284
|
-
col("id", (b) => b.id)
|
|
9285
|
-
], "no branches — create one with `seekrit branch create`"));
|
|
10109
|
+
openclaw.command("ref <name>").description("print the SecretRef object to paste in place of a plaintext credential").option("--app <slug>", "address a specific application (with --env)").option("--env <slug>", "address a specific environment (with --app)").option("--json", "machine-readable output").action((name, options) => {
|
|
10110
|
+
if (Boolean(options.app) !== Boolean(options.env)) fail("--app and --env go together (an id names both, or neither)");
|
|
10111
|
+
const id = options.app && options.env ? `${options.app}/${options.env}/${name}` : name;
|
|
10112
|
+
if (!parseSecretId(id)) fail(`not a valid SecretRef id: "${id}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
|
|
10113
|
+
const ref = secretRef(id);
|
|
10114
|
+
emit(options, ref, () => console.log(JSON.stringify(ref)));
|
|
9286
10115
|
});
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
10116
|
+
openclaw.command("resolve").description("resolve exec SecretRef ids from stdin (OpenClaw calls this; not for humans)").action(async () => {
|
|
10117
|
+
setFailThrows(true);
|
|
10118
|
+
let request;
|
|
10119
|
+
try {
|
|
10120
|
+
request = parseExecRequest(await readStdin());
|
|
10121
|
+
} catch (err) {
|
|
10122
|
+
process.stderr.write(`seekrit: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
10123
|
+
process.exitCode = 2;
|
|
10124
|
+
return;
|
|
10125
|
+
}
|
|
10126
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
10127
|
+
let ctx;
|
|
10128
|
+
const response = await resolveIds(request.ids, (scope) => {
|
|
10129
|
+
const key = scopeKey(scope);
|
|
10130
|
+
const cached = scopes.get(key);
|
|
10131
|
+
if (cached) return cached;
|
|
10132
|
+
ctx ??= buildContext();
|
|
10133
|
+
const pending = resolveEnvironment(ctx, scope);
|
|
10134
|
+
scopes.set(key, pending);
|
|
10135
|
+
return pending;
|
|
10136
|
+
});
|
|
10137
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
9294
10138
|
});
|
|
9295
10139
|
}
|
|
9296
|
-
/** Domain separators — must match `crates/seekrit-cache`. */
|
|
9297
|
-
const KEY_DOMAIN = "seekrit-lkg-cache-v1";
|
|
9298
|
-
const TOKEN_DOMAIN = "seekrit-token-fp-v1";
|
|
9299
|
-
/** 24h: long enough to ride out an outage, short enough to bound the tail. */
|
|
9300
|
-
const DEFAULT_MAX_AGE_MS = 1440 * 60 * 1e3;
|
|
9301
10140
|
/**
|
|
9302
|
-
*
|
|
9303
|
-
*
|
|
9304
|
-
* `
|
|
10141
|
+
* Resolve one environment's full variable set.
|
|
10142
|
+
*
|
|
10143
|
+
* `.env` overlays are deliberately switched off (`envFiles: []`). Everywhere
|
|
10144
|
+
* else in this CLI they are a convenience for a developer's shell; here the
|
|
10145
|
+
* working directory belongs to the OpenClaw gateway, and letting a file in it
|
|
10146
|
+
* override a gateway credential would make a stray `.env` a privilege
|
|
10147
|
+
* escalation. Reference expansion stays on — a `${OTHER_SECRET}` reference is
|
|
10148
|
+
* stored data, not a local override.
|
|
9305
10149
|
*/
|
|
9306
|
-
function
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
};
|
|
9318
|
-
}
|
|
9319
|
-
/** The hex SHA-256 identifying a token without storing it. */
|
|
9320
|
-
function tokenFingerprint(token) {
|
|
9321
|
-
return createHash("sha256").update(`${TOKEN_DOMAIN}\n${token}`).digest("hex");
|
|
10150
|
+
async function resolveEnvironment(ctx, scope) {
|
|
10151
|
+
let envId;
|
|
10152
|
+
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, {
|
|
10153
|
+
app: scope.app,
|
|
10154
|
+
env: scope.env
|
|
10155
|
+
})).envId;
|
|
10156
|
+
const { values } = await materializeEnv(ctx, {
|
|
10157
|
+
envId,
|
|
10158
|
+
envFiles: []
|
|
10159
|
+
});
|
|
10160
|
+
return values;
|
|
9322
10161
|
}
|
|
10162
|
+
//#endregion
|
|
10163
|
+
//#region src/doctor.ts
|
|
10164
|
+
/** Every `SEEKRIT_*` variable the CLI reads, for the set-but-blank check. */
|
|
10165
|
+
const READ_VARS = [
|
|
10166
|
+
"SEEKRIT_TOKEN",
|
|
10167
|
+
"SEEKRIT_API_URL",
|
|
10168
|
+
"SEEKRIT_CLIENT_ID",
|
|
10169
|
+
"SEEKRIT_CLIENT_SECRET",
|
|
10170
|
+
"SEEKRIT_DEV_USER",
|
|
10171
|
+
"SEEKRIT_ORG",
|
|
10172
|
+
"SEEKRIT_APP",
|
|
10173
|
+
"SEEKRIT_ENV"
|
|
10174
|
+
];
|
|
9323
10175
|
/**
|
|
9324
|
-
*
|
|
9325
|
-
*
|
|
10176
|
+
* A variable that is *set to nothing* is the trap this checks for.
|
|
10177
|
+
*
|
|
10178
|
+
* `present()` in context.ts treats blank as absent, which is the right
|
|
10179
|
+
* behaviour — an unset CI secret, a `${VAR}` that expanded to nothing, a bare
|
|
10180
|
+
* `export SEEKRIT_TOKEN=` all arrive as `""`. But it means someone who believes
|
|
10181
|
+
* they exported a token gets "no credentials found" and goes looking at their
|
|
10182
|
+
* login instead of at the empty variable in front of them.
|
|
9326
10183
|
*/
|
|
9327
|
-
function
|
|
9328
|
-
return
|
|
9329
|
-
}
|
|
9330
|
-
/** Render an age for a log line, rounded to its largest whole unit. */
|
|
9331
|
-
function humanize(ms) {
|
|
9332
|
-
const secs = Math.floor(ms / 1e3);
|
|
9333
|
-
if (secs < 60) return `${secs}s`;
|
|
9334
|
-
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
|
9335
|
-
if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
|
|
9336
|
-
return `${Math.floor(secs / 86400)}d`;
|
|
10184
|
+
function blankVars(env) {
|
|
10185
|
+
return READ_VARS.filter((key) => env[key] !== void 0 && env[key]?.trim() === "");
|
|
9337
10186
|
}
|
|
9338
10187
|
/**
|
|
9339
|
-
*
|
|
9340
|
-
*
|
|
9341
|
-
*
|
|
10188
|
+
* Every credential currently configured, highest precedence first.
|
|
10189
|
+
*
|
|
10190
|
+
* The list matters more than the winner. Six things can authenticate this CLI
|
|
10191
|
+
* and the order is not something anyone recites from memory, so the failure
|
|
10192
|
+
* mode is invisible rather than confusing: you run `seekrit login`, it succeeds,
|
|
10193
|
+
* and every command afterwards keeps using the `SEEKRIT_TOKEN` still exported
|
|
10194
|
+
* in that shell from last week. Nothing is wrong, nothing says anything, and
|
|
10195
|
+
* you are operating on the wrong org.
|
|
9342
10196
|
*/
|
|
9343
|
-
function
|
|
9344
|
-
|
|
9345
|
-
|
|
10197
|
+
function credentialSources(env, config) {
|
|
10198
|
+
const set = (v) => Boolean(v?.trim());
|
|
10199
|
+
const found = [];
|
|
10200
|
+
if (set(env.SEEKRIT_TOKEN)) found.push({
|
|
10201
|
+
origin: "SEEKRIT_TOKEN (environment)",
|
|
10202
|
+
kind: "bearer token"
|
|
10203
|
+
});
|
|
10204
|
+
if (set(config.token)) found.push({
|
|
10205
|
+
origin: "saved config: token",
|
|
10206
|
+
kind: "service token (`seekrit login --token`)"
|
|
10207
|
+
});
|
|
10208
|
+
if (set(config.sessionToken)) found.push({
|
|
10209
|
+
origin: "saved config: sessionToken",
|
|
10210
|
+
kind: "CLI session (`seekrit login`, revocable)"
|
|
10211
|
+
});
|
|
10212
|
+
if (set(env.SEEKRIT_DEV_USER)) found.push({
|
|
10213
|
+
origin: "SEEKRIT_DEV_USER (environment)",
|
|
10214
|
+
kind: "dev-mode identity"
|
|
10215
|
+
});
|
|
10216
|
+
if (set(config.devUser)) found.push({
|
|
10217
|
+
origin: "saved config: devUser",
|
|
10218
|
+
kind: "dev-mode identity"
|
|
10219
|
+
});
|
|
10220
|
+
if (set(env.SEEKRIT_CLIENT_ID) || set(config.clientId)) found.push({
|
|
10221
|
+
origin: set(env.SEEKRIT_CLIENT_ID) ? "SEEKRIT_CLIENT_ID (environment)" : "saved config: clientId",
|
|
10222
|
+
kind: "M2M client credentials (mint an admin token on use)"
|
|
10223
|
+
});
|
|
10224
|
+
return found;
|
|
9346
10225
|
}
|
|
9347
|
-
/**
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
|
|
9353
|
-
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9359
|
-
|
|
9360
|
-
|
|
9361
|
-
|
|
9362
|
-
|
|
9363
|
-
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
return {
|
|
9368
|
-
kind: "unusable",
|
|
9369
|
-
reason: `corrupt cache entry: ${message(err)}`
|
|
9370
|
-
};
|
|
9371
|
-
}
|
|
9372
|
-
if (envelope.version !== 1) return {
|
|
9373
|
-
kind: "unusable",
|
|
9374
|
-
reason: `cache entry is format v${envelope.version}, this build reads v1`
|
|
9375
|
-
};
|
|
9376
|
-
if (envelope.tokenFingerprint !== this.id.tokenFingerprint) return {
|
|
9377
|
-
kind: "unusable",
|
|
9378
|
-
reason: "cache entry belongs to a different token"
|
|
9379
|
-
};
|
|
9380
|
-
if (typeof envelope.body !== "string") return {
|
|
9381
|
-
kind: "unusable",
|
|
9382
|
-
reason: "cache entry has no body"
|
|
9383
|
-
};
|
|
9384
|
-
const ageMs = Math.max(0, Date.now() - envelope.fetchedAt * 1e3);
|
|
9385
|
-
if (ageMs >= this.maxAgeMs) return {
|
|
9386
|
-
kind: "expired",
|
|
9387
|
-
ageMs
|
|
9388
|
-
};
|
|
9389
|
-
return {
|
|
9390
|
-
kind: "hit",
|
|
9391
|
-
body: envelope.body,
|
|
9392
|
-
ageMs
|
|
9393
|
-
};
|
|
9394
|
-
}
|
|
9395
|
-
/**
|
|
9396
|
-
* Record a freshly-fetched response. Written to a temporary file and renamed,
|
|
9397
|
-
* so a concurrent reader sees either the old entry or the new one — never a
|
|
9398
|
-
* half-written file. Throws only on genuinely unexpected I/O; callers treat a
|
|
9399
|
-
* failed write as a warning, never as a failed resolve.
|
|
9400
|
-
*/
|
|
9401
|
-
write(body) {
|
|
9402
|
-
mkdirSync(this.dir, {
|
|
9403
|
-
recursive: true,
|
|
9404
|
-
mode: 448
|
|
10226
|
+
/** Findings about a resolved credential set. */
|
|
10227
|
+
function credentialChecks(env, config) {
|
|
10228
|
+
const group = "credentials";
|
|
10229
|
+
const checks = [];
|
|
10230
|
+
const blank = blankVars(env);
|
|
10231
|
+
if (blank.length > 0) checks.push({
|
|
10232
|
+
group,
|
|
10233
|
+
name: "blank variables",
|
|
10234
|
+
status: "warn",
|
|
10235
|
+
detail: `set but empty, so treated as unset: ${blank.join(", ")}`,
|
|
10236
|
+
fix: "unset them, or give them a value — the CLI treats blank as absent, so the symptom is a missing setting rather than an error"
|
|
10237
|
+
});
|
|
10238
|
+
const sources = credentialSources(env, config);
|
|
10239
|
+
if (sources.length === 0) {
|
|
10240
|
+
checks.push({
|
|
10241
|
+
group,
|
|
10242
|
+
name: "credential",
|
|
10243
|
+
status: "fail",
|
|
10244
|
+
detail: "none configured",
|
|
10245
|
+
fix: "run `seekrit login`"
|
|
9405
10246
|
});
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
9410
|
-
|
|
9411
|
-
|
|
9412
|
-
|
|
9413
|
-
|
|
9414
|
-
|
|
9415
|
-
|
|
9416
|
-
|
|
9417
|
-
|
|
9418
|
-
|
|
9419
|
-
|
|
9420
|
-
|
|
9421
|
-
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
|
|
10247
|
+
return checks;
|
|
10248
|
+
}
|
|
10249
|
+
const winner = sources[0];
|
|
10250
|
+
const shadowed = sources.slice(1);
|
|
10251
|
+
if (!winner) return checks;
|
|
10252
|
+
checks.push({
|
|
10253
|
+
group,
|
|
10254
|
+
name: "credential in use",
|
|
10255
|
+
status: "ok",
|
|
10256
|
+
detail: `${winner.kind} from ${winner.origin}`
|
|
10257
|
+
});
|
|
10258
|
+
if (shadowed.length > 0) checks.push({
|
|
10259
|
+
group,
|
|
10260
|
+
name: "shadowed credentials",
|
|
10261
|
+
status: "warn",
|
|
10262
|
+
detail: `${shadowed.length} other credential(s) configured and outranked: ${shadowed.map((s) => s.origin).join(", ")}`,
|
|
10263
|
+
fix: `commands authenticate as ${winner.origin} — unset it to fall through to the next one`
|
|
10264
|
+
});
|
|
10265
|
+
return checks;
|
|
10266
|
+
}
|
|
10267
|
+
/**
|
|
10268
|
+
* Does the CLI's own precedence agree with the model above?
|
|
10269
|
+
*
|
|
10270
|
+
* `credentialSources` is a second reading of `tryBuildContext`'s rules, and a
|
|
10271
|
+
* second reading can drift from the first. Rather than let it drift quietly and
|
|
10272
|
+
* report a confident wrong answer, compare the two and say so when they differ.
|
|
10273
|
+
*/
|
|
10274
|
+
function precedenceAgrees(env, config) {
|
|
10275
|
+
const predicted = credentialSources(env, config)[0];
|
|
10276
|
+
const actual = tryBuildContext();
|
|
10277
|
+
if (!predicted && !actual) return null;
|
|
10278
|
+
if (!predicted || !actual) return {
|
|
10279
|
+
group: "credentials",
|
|
10280
|
+
name: "precedence model",
|
|
10281
|
+
status: "fail",
|
|
10282
|
+
detail: `doctor and the CLI disagree about whether a credential is configured (doctor: ${predicted ? "yes" : "no"}, CLI: ${actual ? "yes" : "no"}) — this is a bug in doctor, not in your setup`
|
|
10283
|
+
};
|
|
10284
|
+
if (predicted.kind === "dev-mode identity" !== (actual.auth.type === "dev")) return {
|
|
10285
|
+
group: "credentials",
|
|
10286
|
+
name: "precedence model",
|
|
10287
|
+
status: "fail",
|
|
10288
|
+
detail: `doctor expected ${predicted.origin} to win, but the CLI authenticated as ${actual.auth.type} — this is a bug in doctor, not in your setup`
|
|
10289
|
+
};
|
|
10290
|
+
return null;
|
|
10291
|
+
}
|
|
10292
|
+
function skewCheck(serverDate, localNow) {
|
|
10293
|
+
const group = "api";
|
|
10294
|
+
if (!serverDate) return {
|
|
10295
|
+
group,
|
|
10296
|
+
name: "clock",
|
|
10297
|
+
status: "skip",
|
|
10298
|
+
detail: "server sent no Date header"
|
|
10299
|
+
};
|
|
10300
|
+
const server = Date.parse(serverDate);
|
|
10301
|
+
if (Number.isNaN(server)) return {
|
|
10302
|
+
group,
|
|
10303
|
+
name: "clock",
|
|
10304
|
+
status: "skip",
|
|
10305
|
+
detail: `unparseable Date: ${serverDate}`
|
|
10306
|
+
};
|
|
10307
|
+
const skew = server - localNow;
|
|
10308
|
+
const seconds = Math.round(Math.abs(skew) / 1e3);
|
|
10309
|
+
if (Math.abs(skew) < 6e4) return {
|
|
10310
|
+
group,
|
|
10311
|
+
name: "clock",
|
|
10312
|
+
status: "ok",
|
|
10313
|
+
detail: `within ${seconds}s of the API`
|
|
10314
|
+
};
|
|
10315
|
+
return {
|
|
10316
|
+
group,
|
|
10317
|
+
name: "clock",
|
|
10318
|
+
status: "warn",
|
|
10319
|
+
detail: `this machine is ${seconds}s ${skew > 0 ? "behind" : "ahead of"} the API`,
|
|
10320
|
+
fix: "sync this machine's clock — skew expires tokens and leases early, and the error names the credential instead"
|
|
10321
|
+
};
|
|
10322
|
+
}
|
|
10323
|
+
/**
|
|
10324
|
+
* The seekrit provider inside someone's `openclaw.json`.
|
|
10325
|
+
*
|
|
10326
|
+
* OpenClaw's exec-provider rules fail *silently*: a command that resolves
|
|
10327
|
+
* through a symlink is rejected outright, and an `args[0]` that no longer
|
|
10328
|
+
* exists resolves nothing. Neither produces a diagnostic naming seekrit, so a
|
|
10329
|
+
* gateway that stopped resolving credentials looks like a seekrit outage.
|
|
10330
|
+
*/
|
|
10331
|
+
function openclawChecks(config, env) {
|
|
10332
|
+
const group = "openclaw";
|
|
10333
|
+
const provider = config.secrets?.providers?.seekrit;
|
|
10334
|
+
if (provider === void 0) return [{
|
|
10335
|
+
group,
|
|
10336
|
+
name: "provider",
|
|
10337
|
+
status: "warn",
|
|
10338
|
+
detail: "openclaw.json has no `seekrit` secret provider",
|
|
10339
|
+
fix: "run `seekrit openclaw init`"
|
|
10340
|
+
}];
|
|
10341
|
+
if (provider === null || typeof provider !== "object") return [{
|
|
10342
|
+
group,
|
|
10343
|
+
name: "provider",
|
|
10344
|
+
status: "fail",
|
|
10345
|
+
detail: "the `seekrit` entry is not an object"
|
|
10346
|
+
}];
|
|
10347
|
+
const entry = provider;
|
|
10348
|
+
const checks = [];
|
|
10349
|
+
if (entry.pluginIntegration) {
|
|
10350
|
+
checks.push({
|
|
10351
|
+
group,
|
|
10352
|
+
name: "provider",
|
|
10353
|
+
status: "ok",
|
|
10354
|
+
detail: "resolved through the seekrit OpenClaw plugin"
|
|
10355
|
+
});
|
|
10356
|
+
return checks;
|
|
10357
|
+
}
|
|
10358
|
+
const command = typeof entry.command === "string" ? entry.command : null;
|
|
10359
|
+
if (!command) checks.push({
|
|
10360
|
+
group,
|
|
10361
|
+
name: "command",
|
|
10362
|
+
status: "fail",
|
|
10363
|
+
detail: "no `command` on the provider"
|
|
10364
|
+
});
|
|
10365
|
+
else if (!existsSync(command)) checks.push({
|
|
10366
|
+
group,
|
|
10367
|
+
name: "command",
|
|
10368
|
+
status: "fail",
|
|
10369
|
+
detail: `\`command\` does not exist: ${command}`,
|
|
10370
|
+
fix: "re-run `seekrit openclaw init --force` (a Node version manager upgrade moves this path)"
|
|
10371
|
+
});
|
|
10372
|
+
else {
|
|
10373
|
+
let real = null;
|
|
9433
10374
|
try {
|
|
9434
|
-
|
|
10375
|
+
real = realpathSync(command);
|
|
9435
10376
|
} catch {
|
|
9436
|
-
|
|
9437
|
-
}
|
|
9438
|
-
for (const name of names) {
|
|
9439
|
-
if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
|
|
9440
|
-
const path = join(this.dir, name);
|
|
9441
|
-
let expired = true;
|
|
9442
|
-
try {
|
|
9443
|
-
const envelope = JSON.parse(readFileSync(path, "utf8"));
|
|
9444
|
-
expired = Math.max(0, Date.now() - envelope.fetchedAt * 1e3) >= this.maxAgeMs;
|
|
9445
|
-
} catch {}
|
|
9446
|
-
if (expired) rmSync(path, { force: true });
|
|
10377
|
+
real = null;
|
|
9447
10378
|
}
|
|
10379
|
+
if (real && real !== command) checks.push({
|
|
10380
|
+
group,
|
|
10381
|
+
name: "command",
|
|
10382
|
+
status: "fail",
|
|
10383
|
+
detail: `\`command\` is a symlink to ${real} — OpenClaw rejects a symlinked command`,
|
|
10384
|
+
fix: "re-run `seekrit openclaw init --force`, which resolves the real path"
|
|
10385
|
+
});
|
|
10386
|
+
else checks.push({
|
|
10387
|
+
group,
|
|
10388
|
+
name: "command",
|
|
10389
|
+
status: "ok",
|
|
10390
|
+
detail: command
|
|
10391
|
+
});
|
|
10392
|
+
}
|
|
10393
|
+
const args = Array.isArray(entry.args) ? entry.args : [];
|
|
10394
|
+
const entryPath = typeof args[0] === "string" ? args[0] : null;
|
|
10395
|
+
if (!entryPath) checks.push({
|
|
10396
|
+
group,
|
|
10397
|
+
name: "resolver",
|
|
10398
|
+
status: "fail",
|
|
10399
|
+
detail: "no resolver path in `args`"
|
|
10400
|
+
});
|
|
10401
|
+
else if (!existsSync(entryPath)) checks.push({
|
|
10402
|
+
group,
|
|
10403
|
+
name: "resolver",
|
|
10404
|
+
status: "fail",
|
|
10405
|
+
detail: `the CLI it points at is gone: ${entryPath}`,
|
|
10406
|
+
fix: "re-run `seekrit openclaw init --force`"
|
|
10407
|
+
});
|
|
10408
|
+
else checks.push({
|
|
10409
|
+
group,
|
|
10410
|
+
name: "resolver",
|
|
10411
|
+
status: "ok",
|
|
10412
|
+
detail: entryPath
|
|
10413
|
+
});
|
|
10414
|
+
const passEnv = Array.isArray(entry.passEnv) ? entry.passEnv.filter((v) => typeof v === "string") : [];
|
|
10415
|
+
const missing = PASS_ENV.filter((v) => !passEnv.includes(v));
|
|
10416
|
+
const stranded = missing.filter((v) => Boolean(env[v]?.trim()));
|
|
10417
|
+
if (stranded.length > 0) checks.push({
|
|
10418
|
+
group,
|
|
10419
|
+
name: "passEnv",
|
|
10420
|
+
status: "fail",
|
|
10421
|
+
detail: `set here but not passed to the resolver: ${stranded.join(", ")}`,
|
|
10422
|
+
fix: "re-run `seekrit openclaw init --force`, or add them to `passEnv` by hand"
|
|
10423
|
+
});
|
|
10424
|
+
else if (missing.length > 0) checks.push({
|
|
10425
|
+
group,
|
|
10426
|
+
name: "passEnv",
|
|
10427
|
+
status: "warn",
|
|
10428
|
+
detail: `written by an older CLI — missing ${missing.join(", ")}`,
|
|
10429
|
+
fix: "re-run `seekrit openclaw init --force` before relying on those variables"
|
|
10430
|
+
});
|
|
10431
|
+
else checks.push({
|
|
10432
|
+
group,
|
|
10433
|
+
name: "passEnv",
|
|
10434
|
+
status: "ok",
|
|
10435
|
+
detail: `${passEnv.length} variables`
|
|
10436
|
+
});
|
|
10437
|
+
return checks;
|
|
10438
|
+
}
|
|
10439
|
+
/** Is something listening on a local `HTTPS_PROXY`? */
|
|
10440
|
+
async function localPortOpen(host, port) {
|
|
10441
|
+
return new Promise((resolve) => {
|
|
10442
|
+
const socket = connect({
|
|
10443
|
+
host,
|
|
10444
|
+
port
|
|
10445
|
+
});
|
|
10446
|
+
const done = (open) => {
|
|
10447
|
+
socket.destroy();
|
|
10448
|
+
resolve(open);
|
|
10449
|
+
};
|
|
10450
|
+
socket.setTimeout(1500);
|
|
10451
|
+
socket.once("connect", () => done(true));
|
|
10452
|
+
socket.once("timeout", () => done(false));
|
|
10453
|
+
socket.once("error", () => done(false));
|
|
10454
|
+
});
|
|
10455
|
+
}
|
|
10456
|
+
/**
|
|
10457
|
+
* An `HTTPS_PROXY` pointing at a local port with nothing behind it.
|
|
10458
|
+
*
|
|
10459
|
+
* This is the agent-proxy setup failure with no symptom of its own: every
|
|
10460
|
+
* outbound request from the workload fails at the transport, and the error it
|
|
10461
|
+
* raises names the upstream it never reached.
|
|
10462
|
+
*/
|
|
10463
|
+
async function proxyChecks(env) {
|
|
10464
|
+
const group = "proxy";
|
|
10465
|
+
const raw = env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
|
|
10466
|
+
if (!raw?.trim()) return [];
|
|
10467
|
+
let url;
|
|
10468
|
+
try {
|
|
10469
|
+
url = new URL(raw);
|
|
10470
|
+
} catch {
|
|
10471
|
+
return [{
|
|
10472
|
+
group,
|
|
10473
|
+
name: "HTTPS_PROXY",
|
|
10474
|
+
status: "fail",
|
|
10475
|
+
detail: `not a URL: ${raw}`
|
|
10476
|
+
}];
|
|
10477
|
+
}
|
|
10478
|
+
const host = url.hostname;
|
|
10479
|
+
if (![
|
|
10480
|
+
"127.0.0.1",
|
|
10481
|
+
"localhost",
|
|
10482
|
+
"::1",
|
|
10483
|
+
"0.0.0.0"
|
|
10484
|
+
].includes(host)) return [{
|
|
10485
|
+
group,
|
|
10486
|
+
name: "HTTPS_PROXY",
|
|
10487
|
+
status: "skip",
|
|
10488
|
+
detail: `${url.origin} (not local)`
|
|
10489
|
+
}];
|
|
10490
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
10491
|
+
return [await localPortOpen(host === "localhost" ? "127.0.0.1" : host, port) ? {
|
|
10492
|
+
group,
|
|
10493
|
+
name: "HTTPS_PROXY",
|
|
10494
|
+
status: "ok",
|
|
10495
|
+
detail: `${url.origin} is accepting connections`
|
|
10496
|
+
} : {
|
|
10497
|
+
group,
|
|
10498
|
+
name: "HTTPS_PROXY",
|
|
10499
|
+
status: "fail",
|
|
10500
|
+
detail: `${url.origin} is set but nothing is listening`,
|
|
10501
|
+
fix: "start it with `seekrit proxy run`, or unset HTTPS_PROXY — every outbound request fails at the transport until one of those"
|
|
10502
|
+
}];
|
|
10503
|
+
}
|
|
10504
|
+
/** Run every check. Exported for tests that drive the whole sweep. */
|
|
10505
|
+
async function runChecks() {
|
|
10506
|
+
const env = process.env;
|
|
10507
|
+
const config = readGlobalConfig();
|
|
10508
|
+
const checks = [...credentialChecks(env, config)];
|
|
10509
|
+
const drift = precedenceAgrees(env, config);
|
|
10510
|
+
if (drift) checks.push(drift);
|
|
10511
|
+
const project = findProjectConfig();
|
|
10512
|
+
checks.push(project ? {
|
|
10513
|
+
group: "project",
|
|
10514
|
+
name: "seekrit.json",
|
|
10515
|
+
status: "ok",
|
|
10516
|
+
detail: `org=${project.org ?? "unset"} app=${project.app ?? "unset"}`
|
|
10517
|
+
} : {
|
|
10518
|
+
group: "project",
|
|
10519
|
+
name: "seekrit.json",
|
|
10520
|
+
status: "skip",
|
|
10521
|
+
detail: "none found above the working directory",
|
|
10522
|
+
fix: "run `seekrit init` to stop passing --org/--app"
|
|
10523
|
+
});
|
|
10524
|
+
const apiUrl = env.SEEKRIT_API_URL?.trim() || config.apiUrl || "https://api.seekrit.dev";
|
|
10525
|
+
try {
|
|
10526
|
+
const response = await fetch(new URL("/health", apiUrl));
|
|
10527
|
+
checks.push({
|
|
10528
|
+
group: "api",
|
|
10529
|
+
name: "reachable",
|
|
10530
|
+
status: response.ok ? "ok" : "warn",
|
|
10531
|
+
detail: `${apiUrl} → ${response.status}`
|
|
10532
|
+
});
|
|
10533
|
+
checks.push(skewCheck(response.headers.get("date"), Date.now()));
|
|
10534
|
+
} catch (err) {
|
|
10535
|
+
checks.push({
|
|
10536
|
+
group: "api",
|
|
10537
|
+
name: "reachable",
|
|
10538
|
+
status: "fail",
|
|
10539
|
+
detail: describeError(err),
|
|
10540
|
+
fix: "check SEEKRIT_API_URL, your network, and any VPN"
|
|
10541
|
+
});
|
|
10542
|
+
}
|
|
10543
|
+
const openclawConfig = join(openclawHome(env), "openclaw.json");
|
|
10544
|
+
if (existsSync(openclawConfig)) {
|
|
10545
|
+
const read = readOpenclawConfig(openclawConfig);
|
|
10546
|
+
if ("json5" in read) checks.push({
|
|
10547
|
+
group: "openclaw",
|
|
10548
|
+
name: "openclaw.json",
|
|
10549
|
+
status: "warn",
|
|
10550
|
+
detail: `${openclawConfig} is JSON5 (comments or trailing commas), so it cannot be checked or edited automatically`
|
|
10551
|
+
});
|
|
10552
|
+
else checks.push(...openclawChecks(read.config, env));
|
|
9448
10553
|
}
|
|
10554
|
+
checks.push(...await proxyChecks(env));
|
|
10555
|
+
return checks;
|
|
10556
|
+
}
|
|
10557
|
+
const GLYPH = {
|
|
10558
|
+
ok: "ok ",
|
|
10559
|
+
warn: "warn",
|
|
10560
|
+
fail: "FAIL",
|
|
10561
|
+
skip: "-- "
|
|
9449
10562
|
};
|
|
9450
|
-
function
|
|
9451
|
-
|
|
10563
|
+
function registerDoctorCommand(program) {
|
|
10564
|
+
program.command("doctor").description("check the setup failures that would otherwise be silent").option("--json", "print the findings as JSON").action(async (options) => {
|
|
10565
|
+
const checks = await runChecks();
|
|
10566
|
+
emit(options, { checks }, () => {
|
|
10567
|
+
let group = "";
|
|
10568
|
+
for (const check of checks) {
|
|
10569
|
+
if (check.group !== group) {
|
|
10570
|
+
group = check.group;
|
|
10571
|
+
console.log(`\n${group}`);
|
|
10572
|
+
}
|
|
10573
|
+
console.log(` ${GLYPH[check.status]} ${check.name}: ${check.detail}`);
|
|
10574
|
+
if (check.fix) console.log(` → ${check.fix}`);
|
|
10575
|
+
}
|
|
10576
|
+
const failed = checks.filter((c) => c.status === "fail").length;
|
|
10577
|
+
const warned = checks.filter((c) => c.status === "warn").length;
|
|
10578
|
+
console.log(failed === 0 && warned === 0 ? "\nnothing to report." : `\n${failed} failure(s), ${warned} warning(s).`);
|
|
10579
|
+
});
|
|
10580
|
+
if (checks.some((c) => c.status === "fail")) process.exitCode = 1;
|
|
10581
|
+
});
|
|
9452
10582
|
}
|
|
9453
10583
|
//#endregion
|
|
9454
10584
|
//#region src/gcp.ts
|
|
@@ -9733,7 +10863,7 @@ function hermesHome(env = process.env) {
|
|
|
9733
10863
|
const home = env.HERMES_HOME?.trim();
|
|
9734
10864
|
return home && home.length > 0 ? resolve(home) : join(homedir(), ".hermes");
|
|
9735
10865
|
}
|
|
9736
|
-
const NAME_RE
|
|
10866
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9737
10867
|
/**
|
|
9738
10868
|
* Build an `skt://` reference.
|
|
9739
10869
|
*
|
|
@@ -9817,15 +10947,15 @@ function parseBinding(raw) {
|
|
|
9817
10947
|
if (eq <= 0) return void 0;
|
|
9818
10948
|
const variable = raw.slice(0, eq).trim();
|
|
9819
10949
|
const target = raw.slice(eq + 1).trim();
|
|
9820
|
-
if (!NAME_RE
|
|
10950
|
+
if (!NAME_RE.test(variable) || target.length === 0) return void 0;
|
|
9821
10951
|
const slash = target.indexOf("/");
|
|
9822
|
-
if (slash === -1) return NAME_RE
|
|
10952
|
+
if (slash === -1) return NAME_RE.test(target) ? {
|
|
9823
10953
|
variable,
|
|
9824
10954
|
name: target
|
|
9825
10955
|
} : void 0;
|
|
9826
10956
|
const tokenAlias = target.slice(0, slash).trim();
|
|
9827
10957
|
const name = target.slice(slash + 1).trim();
|
|
9828
|
-
if (tokenAlias.length === 0 || !NAME_RE
|
|
10958
|
+
if (tokenAlias.length === 0 || !NAME_RE.test(name)) return void 0;
|
|
9829
10959
|
return {
|
|
9830
10960
|
variable,
|
|
9831
10961
|
name,
|
|
@@ -9838,7 +10968,7 @@ function parseTokenAlias(raw) {
|
|
|
9838
10968
|
if (eq <= 0) return void 0;
|
|
9839
10969
|
const alias = raw.slice(0, eq).trim();
|
|
9840
10970
|
const variable = raw.slice(eq + 1).trim();
|
|
9841
|
-
if (alias.length === 0 || !NAME_RE
|
|
10971
|
+
if (alias.length === 0 || !NAME_RE.test(variable)) return void 0;
|
|
9842
10972
|
return {
|
|
9843
10973
|
alias,
|
|
9844
10974
|
variable
|
|
@@ -9913,7 +11043,7 @@ function registerHermesCommands(program) {
|
|
|
9913
11043
|
});
|
|
9914
11044
|
});
|
|
9915
11045
|
hermes.command("ref <name>").description("print the skt:// reference to bind a Hermes variable to").option("--token-alias <alias>", "resolve through a second token named in `tokens:`").option("--json", "machine-readable output").action((name, options) => {
|
|
9916
|
-
if (!NAME_RE
|
|
11046
|
+
if (!NAME_RE.test(name)) fail(`not a valid secret name: "${name}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
|
|
9917
11047
|
const ref = reference(name, options.tokenAlias);
|
|
9918
11048
|
emit(options, { reference: ref }, () => console.log(ref));
|
|
9919
11049
|
});
|
|
@@ -10133,7 +11263,7 @@ function parseTtlSeconds$4(input) {
|
|
|
10133
11263
|
}[m[2] || "s"] ?? 1);
|
|
10134
11264
|
}
|
|
10135
11265
|
/** Collect a repeatable option into an array. */
|
|
10136
|
-
function collect$
|
|
11266
|
+
function collect$5(value, previous) {
|
|
10137
11267
|
return [...previous, value];
|
|
10138
11268
|
}
|
|
10139
11269
|
/** Parse `readWrite@app` → { role, db } for a custom target. */
|
|
@@ -10158,7 +11288,7 @@ function resolveAdminUri(uri) {
|
|
|
10158
11288
|
function registerMongoCommands(program) {
|
|
10159
11289
|
const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
|
|
10160
11290
|
const target = mongo.command("target").description("manage MongoDB targets");
|
|
10161
|
-
target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$
|
|
11291
|
+
target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$5, []).option("--auth-source <db>", "authentication database (default admin)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--no-tls", "disable TLS to the cluster (TLS is on by default)").action(async (options) => {
|
|
10162
11292
|
const ctx = buildContext();
|
|
10163
11293
|
const org = await resolveOrg(ctx, options.org);
|
|
10164
11294
|
const adminUri = resolveAdminUri(options.uri);
|
|
@@ -10320,7 +11450,7 @@ function generateUserName$1(prefix = "tmp") {
|
|
|
10320
11450
|
function registerMysqlCommands(program) {
|
|
10321
11451
|
const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
|
|
10322
11452
|
const target = mysql.command("target").description("manage provisioning targets");
|
|
10323
|
-
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$
|
|
11453
|
+
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$4, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$4, []).action(async (options) => {
|
|
10324
11454
|
const ctx = buildContext();
|
|
10325
11455
|
const org = await resolveOrg(ctx, options.org);
|
|
10326
11456
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -10412,609 +11542,586 @@ function registerMysqlCommands(program) {
|
|
|
10412
11542
|
for (const l of leases) {
|
|
10413
11543
|
if (l.provider !== "mysql") continue;
|
|
10414
11544
|
console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
|
|
10415
|
-
}
|
|
10416
|
-
});
|
|
10417
|
-
mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (leaseId, options) => {
|
|
10418
|
-
const ctx = buildContext();
|
|
10419
|
-
const org = await resolveOrg(ctx, options.org);
|
|
10420
|
-
await confirmDestructive(options.yes, `Revoke lease ${leaseId} now? Its MySQL user is dropped immediately.`);
|
|
10421
|
-
await ctx.client.revokeLease(org.id, leaseId);
|
|
10422
|
-
console.error(`revoked ${leaseId}`);
|
|
10423
|
-
});
|
|
10424
|
-
}
|
|
10425
|
-
/** Collect a repeatable option into an array. */
|
|
10426
|
-
function collect$3(value, acc) {
|
|
10427
|
-
acc.push(value);
|
|
10428
|
-
return acc;
|
|
10429
|
-
}
|
|
10430
|
-
/**
|
|
10431
|
-
* Fetch + decrypt every secret in a single environment.
|
|
10432
|
-
*
|
|
10433
|
-
* `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
|
|
10434
|
-
* `interpolate`) unless `raw` is set. Only this environment's own secrets are in
|
|
10435
|
-
* scope here — a reference to a secret inherited from a composed group is left
|
|
10436
|
-
* literal, because the group layers aren't fetched. `materializeEnv` is the
|
|
10437
|
-
* fully-layered view.
|
|
10438
|
-
*/
|
|
10439
|
-
async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
|
|
10440
|
-
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
10441
|
-
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
10442
|
-
return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
|
|
10443
|
-
}
|
|
10444
|
-
/**
|
|
10445
|
-
* Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
|
|
10446
|
-
* the CLI's standard fatal exit — it is a config bug with no correct value to
|
|
10447
|
-
* emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
|
|
10448
|
-
*/
|
|
10449
|
-
function interpolateValues(values, enabled = true) {
|
|
10450
|
-
if (!enabled) return {
|
|
10451
|
-
values,
|
|
10452
|
-
interpolated: [],
|
|
10453
|
-
unresolvedRefs: []
|
|
10454
|
-
};
|
|
10455
|
-
try {
|
|
10456
|
-
const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
|
|
10457
|
-
return {
|
|
10458
|
-
values: expandedValues,
|
|
10459
|
-
interpolated: expanded,
|
|
10460
|
-
unresolvedRefs: unresolved
|
|
10461
|
-
};
|
|
10462
|
-
} catch (err) {
|
|
10463
|
-
return fail(err instanceof Error ? err.message : String(err));
|
|
10464
|
-
}
|
|
10465
|
-
}
|
|
10466
|
-
/**
|
|
10467
|
-
* Decrypt one historical version of a secret. Ciphertext is bound to
|
|
10468
|
-
* `(envId, name)` as AAD and neither changes across versions, so an old blob
|
|
10469
|
-
* opens with the environment's current data key — no special handling needed.
|
|
10470
|
-
*/
|
|
10471
|
-
async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
|
|
10472
|
-
const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
|
|
10473
|
-
const row = versions.find((v) => v.version === version);
|
|
10474
|
-
if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
|
|
10475
|
-
return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
|
|
10476
|
-
}
|
|
10477
|
-
async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
10478
|
-
const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
|
|
10479
|
-
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
10480
|
-
}
|
|
10481
|
-
/**
|
|
10482
|
-
* Encrypt and store many secrets into one environment. The DEK is fetched once
|
|
10483
|
-
* (so user auth prompts for the passphrase a single time, not per variable),
|
|
10484
|
-
* then each value is encrypted locally and written. Existing names are
|
|
10485
|
-
* overwritten; the result splits them into created vs. updated for a summary.
|
|
10486
|
-
* Callers validate the names first — a rejected name aborts before any write.
|
|
10487
|
-
*/
|
|
10488
|
-
async function importSecrets(ctx, orgId, envId, entries) {
|
|
10489
|
-
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
10490
|
-
const existing = new Set(secrets.map((s) => s.name));
|
|
10491
|
-
const result = {
|
|
10492
|
-
created: [],
|
|
10493
|
-
updated: []
|
|
10494
|
-
};
|
|
10495
|
-
for (const [name, value] of Object.entries(entries)) {
|
|
10496
|
-
const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
|
|
10497
|
-
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
10498
|
-
(existing.has(name) ? result.updated : result.created).push(name);
|
|
10499
|
-
}
|
|
10500
|
-
return result;
|
|
10501
|
-
}
|
|
10502
|
-
/**
|
|
10503
|
-
* Resolve the full, layered environment for a running app: composed group
|
|
10504
|
-
* secrets (lowest precedence) → the app env's own secrets → `.env` files.
|
|
10505
|
-
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
10506
|
-
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
10507
|
-
* that spawn a process layer it on top so the live shell always wins.
|
|
10508
|
-
*
|
|
10509
|
-
* `${OTHER_SECRET}` references are expanded last, against the merged set, so a
|
|
10510
|
-
* reference always resolves to whichever layer won the name.
|
|
10511
|
-
*/
|
|
10512
|
-
async function materializeEnv(ctx, opts) {
|
|
10513
|
-
const query = {};
|
|
10514
|
-
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
10515
|
-
if (opts.branch) query.branch = opts.branch;
|
|
10516
|
-
if (!isTokenAuth(ctx)) {
|
|
10517
|
-
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
10518
|
-
query.env = opts.envId;
|
|
10519
|
-
}
|
|
10520
|
-
const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
|
|
10521
|
-
const privateKey = await getPrivateKey(ctx);
|
|
10522
|
-
const values = {};
|
|
10523
|
-
const provenance = {};
|
|
10524
|
-
for (const layer of layers) {
|
|
10525
|
-
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
10526
|
-
let label;
|
|
10527
|
-
if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
|
|
10528
|
-
else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
|
|
10529
|
-
else label = `app:${scope.appSlug}/${layer.slug}`;
|
|
10530
|
-
for (const secret of layer.secrets) {
|
|
10531
|
-
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
10532
|
-
provenance[secret.name] = label;
|
|
10533
|
-
}
|
|
10534
|
-
}
|
|
10535
|
-
const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
|
|
10536
|
-
return {
|
|
10537
|
-
...interpolateValues(values, opts.interpolate !== false),
|
|
10538
|
-
provenance,
|
|
10539
|
-
scope,
|
|
10540
|
-
loadedEnvFiles
|
|
10541
|
-
};
|
|
10542
|
-
}
|
|
10543
|
-
/**
|
|
10544
|
-
* Resolve, going through the last-known-good cache when one is configured.
|
|
10545
|
-
*
|
|
10546
|
-
* Always live first: the cache exists for when the call cannot land, not to
|
|
10547
|
-
* save a round trip, so a recovered network is picked up on the very next
|
|
10548
|
-
* invocation. A *refused* resolve (401/403/…) drops the entry rather than
|
|
10549
|
-
* falling back to it — otherwise revoking a token would keep working offline
|
|
10550
|
-
* until the entry aged out.
|
|
10551
|
-
*/
|
|
10552
|
-
async function resolveWithCache(ctx, query, cache) {
|
|
10553
|
-
if (!cache) return ctx.client.resolve(query);
|
|
10554
|
-
try {
|
|
10555
|
-
const response = await ctx.client.resolve(query);
|
|
10556
|
-
try {
|
|
10557
|
-
cache.write(JSON.stringify(response));
|
|
10558
|
-
} catch (err) {
|
|
10559
|
-
warn(`could not update the cache: ${errorMessage(err)}`);
|
|
10560
|
-
}
|
|
10561
|
-
return response;
|
|
10562
|
-
} catch (err) {
|
|
10563
|
-
if (!mayFallBack(err)) {
|
|
10564
|
-
cache.invalidate();
|
|
10565
|
-
throw err;
|
|
10566
|
-
}
|
|
10567
|
-
const found = cache.read();
|
|
10568
|
-
if (found.kind === "hit") {
|
|
10569
|
-
warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
|
|
10570
|
-
return JSON.parse(found.body);
|
|
10571
|
-
}
|
|
10572
|
-
if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
|
|
10573
|
-
else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
|
|
10574
|
-
throw err;
|
|
10575
|
-
}
|
|
10576
|
-
}
|
|
10577
|
-
function warn(text) {
|
|
10578
|
-
process.stderr.write(`seekrit: ${text}\n`);
|
|
10579
|
-
}
|
|
10580
|
-
function errorMessage(err) {
|
|
10581
|
-
return err instanceof Error ? err.message : String(err);
|
|
10582
|
-
}
|
|
10583
|
-
/**
|
|
10584
|
-
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
10585
|
-
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
10586
|
-
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
10587
|
-
* managed secrets are unavailable but `.env` should still apply.
|
|
10588
|
-
*/
|
|
10589
|
-
function overlayEnvFiles(values, provenance, envFiles) {
|
|
10590
|
-
const loaded = [];
|
|
10591
|
-
for (const file of envFiles) {
|
|
10592
|
-
if (!existsSync(file)) continue;
|
|
10593
|
-
loaded.push(file);
|
|
10594
|
-
for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
|
|
10595
|
-
values[name] = value;
|
|
10596
|
-
provenance[name] = `dotenv:${file}`;
|
|
10597
|
-
}
|
|
10598
|
-
}
|
|
10599
|
-
return loaded;
|
|
10600
|
-
}
|
|
10601
|
-
/**
|
|
10602
|
-
* Print a name → source table to stderr (never the secret values). Names whose
|
|
10603
|
-
* value had references expanded are marked, and dangling references are called
|
|
10604
|
-
* out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
|
|
10605
|
-
* deliberately passed through as literal text.
|
|
10606
|
-
*/
|
|
10607
|
-
function printExplain(provenance, refs = {}) {
|
|
10608
|
-
const interpolated = new Set(refs.interpolated ?? []);
|
|
10609
|
-
const names = Object.keys(provenance).sort();
|
|
10610
|
-
const width = names.reduce((w, n) => Math.max(w, n.length), 0);
|
|
10611
|
-
for (const name of names) {
|
|
10612
|
-
const marker = interpolated.has(name) ? " (interpolated)" : "";
|
|
10613
|
-
process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
|
|
10614
|
-
}
|
|
10615
|
-
if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
|
|
11545
|
+
}
|
|
11546
|
+
});
|
|
11547
|
+
mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (leaseId, options) => {
|
|
11548
|
+
const ctx = buildContext();
|
|
11549
|
+
const org = await resolveOrg(ctx, options.org);
|
|
11550
|
+
await confirmDestructive(options.yes, `Revoke lease ${leaseId} now? Its MySQL user is dropped immediately.`);
|
|
11551
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
11552
|
+
console.error(`revoked ${leaseId}`);
|
|
11553
|
+
});
|
|
10616
11554
|
}
|
|
10617
|
-
/**
|
|
10618
|
-
|
|
10619
|
-
|
|
10620
|
-
|
|
10621
|
-
|
|
11555
|
+
/** Collect a repeatable option into an array. */
|
|
11556
|
+
function collect$4(value, acc) {
|
|
11557
|
+
acc.push(value);
|
|
11558
|
+
return acc;
|
|
11559
|
+
}
|
|
11560
|
+
//#endregion
|
|
11561
|
+
//#region src/proxy-binary.ts
|
|
10622
11562
|
/**
|
|
10623
|
-
*
|
|
11563
|
+
* Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
|
|
10624
11564
|
*
|
|
10625
|
-
*
|
|
10626
|
-
*
|
|
10627
|
-
*
|
|
10628
|
-
*
|
|
10629
|
-
* the
|
|
10630
|
-
*
|
|
11565
|
+
* The proxy is the strongest answer seekrit has for an untrusted workload — the
|
|
11566
|
+
* agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
|
|
11567
|
+
* thing here to *try*, because trying it meant `cargo` and a TOML file. This
|
|
11568
|
+
* module removes the first half: it resolves a prebuilt, checksum-verified
|
|
11569
|
+
* binary for the host platform and execs it, so `npx @seekrit/proxy` and
|
|
11570
|
+
* `seekrit proxy run` behave like the proxy was already installed.
|
|
11571
|
+
*
|
|
11572
|
+
* The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
|
|
11573
|
+
* for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
|
|
11574
|
+
* entrypoint over it, and the two must not drift.
|
|
11575
|
+
*
|
|
11576
|
+
* Three properties worth stating, since this downloads and executes code:
|
|
11577
|
+
*
|
|
11578
|
+
* - **The checksum is verified before anything is executed**, against a
|
|
11579
|
+
* `.sha256` fetched from the same release. That is integrity, not provenance —
|
|
11580
|
+
* it proves the bytes match what the release published, which is exactly the
|
|
11581
|
+
* guarantee `install.sh` gives and no more.
|
|
11582
|
+
* - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
|
|
11583
|
+
* short-circuits entirely, and a cached download for the same version+target is
|
|
11584
|
+
* reused, so this is a one-time cost per version.
|
|
11585
|
+
* - **Version is pinned, not floating.** A default of `latest` would make two
|
|
11586
|
+
* machines run different proxies from the same command; the pinned constant is
|
|
11587
|
+
* what this CLI was built against, overridable when you want otherwise.
|
|
10631
11588
|
*/
|
|
10632
|
-
const PASS_ENV = [
|
|
10633
|
-
"PATH",
|
|
10634
|
-
"HOME",
|
|
10635
|
-
"USERPROFILE",
|
|
10636
|
-
"APPDATA",
|
|
10637
|
-
"LOCALAPPDATA",
|
|
10638
|
-
"TEMP",
|
|
10639
|
-
"TMP",
|
|
10640
|
-
"SYSTEMROOT",
|
|
10641
|
-
"WINDIR",
|
|
10642
|
-
"XDG_CONFIG_HOME",
|
|
10643
|
-
"XDG_CACHE_HOME",
|
|
10644
|
-
"NODE_EXTRA_CA_CERTS",
|
|
10645
|
-
"SEEKRIT_TOKEN",
|
|
10646
|
-
"SEEKRIT_CLIENT_ID",
|
|
10647
|
-
"SEEKRIT_CLIENT_SECRET",
|
|
10648
|
-
"SEEKRIT_API_URL",
|
|
10649
|
-
"SEEKRIT_ORG",
|
|
10650
|
-
"SEEKRIT_APP",
|
|
10651
|
-
"SEEKRIT_ENV",
|
|
10652
|
-
"SEEKRIT_BRANCH"
|
|
10653
|
-
];
|
|
10654
11589
|
/**
|
|
10655
|
-
*
|
|
10656
|
-
*
|
|
10657
|
-
*
|
|
10658
|
-
*
|
|
10659
|
-
*
|
|
11590
|
+
* The proxy version this CLI was built against.
|
|
11591
|
+
*
|
|
11592
|
+
* Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
|
|
11593
|
+
* release-please-config.json), so the pin follows the crate without anyone
|
|
11594
|
+
* remembering to move it.
|
|
10660
11595
|
*/
|
|
10661
|
-
const
|
|
10662
|
-
|
|
10663
|
-
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10664
|
-
/** Slugs as the API spells them. */
|
|
10665
|
-
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
11596
|
+
const PROXY_VERSION = "0.12.0";
|
|
11597
|
+
const BIN = "seekrit-proxy";
|
|
10666
11598
|
/**
|
|
10667
|
-
*
|
|
10668
|
-
*
|
|
10669
|
-
* Two shapes, mirroring 1Password's `op://vault/item/field` — a bare name for
|
|
10670
|
-
* the environment the credential already points at, and an explicit
|
|
10671
|
-
* `app/env/NAME` triple for a provider serving more than one environment:
|
|
10672
|
-
*
|
|
10673
|
-
* OPENAI_API_KEY
|
|
10674
|
-
* billing-api/production/STRIPE_SECRET_KEY
|
|
11599
|
+
* Host → Rust target triple.
|
|
10675
11600
|
*
|
|
10676
|
-
*
|
|
10677
|
-
*
|
|
11601
|
+
* Linux always resolves to **musl**: that build is statically linked, so one
|
|
11602
|
+
* artifact covers glibc, musl, alpine, and distroless, and there is no libc
|
|
11603
|
+
* detection to get wrong on a machine where `ldd` says something unexpected.
|
|
10678
11604
|
*/
|
|
10679
|
-
function
|
|
10680
|
-
const
|
|
10681
|
-
if (
|
|
10682
|
-
|
|
10683
|
-
|
|
10684
|
-
|
|
10685
|
-
|
|
11605
|
+
function detectTarget(os = platform(), cpu = arch()) {
|
|
11606
|
+
const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
|
|
11607
|
+
if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
11608
|
+
switch (os) {
|
|
11609
|
+
case "linux": return {
|
|
11610
|
+
target: `${machine}-unknown-linux-musl`,
|
|
11611
|
+
exe: ""
|
|
11612
|
+
};
|
|
11613
|
+
case "darwin": return {
|
|
11614
|
+
target: `${machine}-apple-darwin`,
|
|
11615
|
+
exe: ""
|
|
11616
|
+
};
|
|
11617
|
+
case "win32":
|
|
11618
|
+
if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
|
|
11619
|
+
return {
|
|
11620
|
+
target: "x86_64-pc-windows-msvc",
|
|
11621
|
+
exe: ".exe"
|
|
11622
|
+
};
|
|
11623
|
+
default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
10686
11624
|
}
|
|
10687
|
-
if (parts.length !== 3) return void 0;
|
|
10688
|
-
const [app = "", env = "", name = ""] = parts;
|
|
10689
|
-
if (!SLUG_RE.test(app) || !SLUG_RE.test(env) || !NAME_RE.test(name)) return void 0;
|
|
10690
|
-
return {
|
|
10691
|
-
app,
|
|
10692
|
-
env,
|
|
10693
|
-
name
|
|
10694
|
-
};
|
|
10695
11625
|
}
|
|
10696
|
-
/**
|
|
10697
|
-
function
|
|
10698
|
-
|
|
11626
|
+
/** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
|
|
11627
|
+
function versionPrefix(version) {
|
|
11628
|
+
if (version === "latest") return "latest";
|
|
11629
|
+
return version.startsWith("v") ? version : `v${version}`;
|
|
10699
11630
|
}
|
|
10700
|
-
|
|
10701
|
-
|
|
10702
|
-
*
|
|
10703
|
-
* Non-string and empty ids are dropped rather than rejected — a request whose
|
|
10704
|
-
* shape we do not recognize should still resolve the ids we do, because the
|
|
10705
|
-
* alternative fails OpenClaw's whole startup over one malformed entry. A body
|
|
10706
|
-
* that is not an object with an `ids` array is a different matter: there is
|
|
10707
|
-
* nothing to answer, so it throws.
|
|
10708
|
-
*/
|
|
10709
|
-
function parseExecRequest(input) {
|
|
10710
|
-
const parsed = JSON.parse(input);
|
|
10711
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("exec SecretRef request must be a JSON object");
|
|
10712
|
-
const ids = parsed.ids;
|
|
10713
|
-
if (!Array.isArray(ids)) throw new Error("exec SecretRef request must carry an `ids` array");
|
|
10714
|
-
return { ids: ids.filter((id) => typeof id === "string" && id.trim().length > 0) };
|
|
11631
|
+
function resolveVersion(explicit) {
|
|
11632
|
+
return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.0";
|
|
10715
11633
|
}
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
10722
|
-
|
|
11634
|
+
function resolveBaseUrl(explicit) {
|
|
11635
|
+
return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
|
|
11636
|
+
}
|
|
11637
|
+
/** Where a resolved binary is kept, keyed so versions and targets never collide. */
|
|
11638
|
+
function proxyBinaryPath(version, target, exe) {
|
|
11639
|
+
return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
|
|
11640
|
+
}
|
|
11641
|
+
async function fetchBytes$1(url) {
|
|
11642
|
+
const res = await fetch(url);
|
|
11643
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
11644
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
10723
11645
|
}
|
|
10724
11646
|
/**
|
|
10725
|
-
*
|
|
10726
|
-
*
|
|
10727
|
-
* A batch is normally one environment, but a provider may serve several, and
|
|
10728
|
-
* resolving per id would mean an unwrap per id. Grouping keeps a 40-variable
|
|
10729
|
-
* gateway to one round trip.
|
|
11647
|
+
* Ensure a `seekrit-proxy` binary exists locally and return its path.
|
|
10730
11648
|
*
|
|
10731
|
-
*
|
|
10732
|
-
*
|
|
11649
|
+
* Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
|
|
11650
|
+
* version+target, then a fresh download. A binary already on `PATH` is
|
|
11651
|
+
* deliberately *not* used — silently running a different version than the one
|
|
11652
|
+
* this CLI pins is the kind of surprise that costs an afternoon.
|
|
10733
11653
|
*/
|
|
10734
|
-
async function
|
|
10735
|
-
const
|
|
10736
|
-
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
const parsed = parseSecretId(id);
|
|
10740
|
-
if (!parsed) {
|
|
10741
|
-
errors[id] = { code: "INVALID_ID" };
|
|
10742
|
-
continue;
|
|
10743
|
-
}
|
|
10744
|
-
const key = scopeKey(parsed);
|
|
10745
|
-
const bucket = byScope.get(key) ?? {
|
|
10746
|
-
scope: parsed,
|
|
10747
|
-
ids: []
|
|
10748
|
-
};
|
|
10749
|
-
bucket.ids.push({
|
|
10750
|
-
id,
|
|
10751
|
-
name: parsed.name
|
|
10752
|
-
});
|
|
10753
|
-
byScope.set(key, bucket);
|
|
11654
|
+
async function resolveProxyBinary(options = {}) {
|
|
11655
|
+
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
11656
|
+
if (override) {
|
|
11657
|
+
if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
|
|
11658
|
+
return override;
|
|
10754
11659
|
}
|
|
10755
|
-
|
|
10756
|
-
|
|
10757
|
-
|
|
10758
|
-
|
|
10759
|
-
|
|
10760
|
-
|
|
10761
|
-
|
|
10762
|
-
|
|
10763
|
-
|
|
10764
|
-
|
|
10765
|
-
|
|
10766
|
-
|
|
10767
|
-
|
|
10768
|
-
|
|
11660
|
+
const version = resolveVersion(options.version);
|
|
11661
|
+
const { target, exe } = detectTarget();
|
|
11662
|
+
const dest = proxyBinaryPath(version, target, exe);
|
|
11663
|
+
if (!options.force && version !== "latest" && existsSync(dest)) return dest;
|
|
11664
|
+
const baseUrl = resolveBaseUrl(options.baseUrl);
|
|
11665
|
+
const prefix = versionPrefix(version);
|
|
11666
|
+
const name = `${BIN}-${target}${exe}`;
|
|
11667
|
+
const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
|
|
11668
|
+
const sumUrl = `${binUrl}.sha256`;
|
|
11669
|
+
if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
|
|
11670
|
+
let bytes;
|
|
11671
|
+
let expected;
|
|
11672
|
+
try {
|
|
11673
|
+
[bytes, expected] = await Promise.all([fetchBytes$1(binUrl), fetchBytes$1(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
|
|
11674
|
+
} catch (err) {
|
|
11675
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11676
|
+
throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
|
|
11677
|
+
}
|
|
11678
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
11679
|
+
if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
|
|
11680
|
+
const dir = dirname(dest);
|
|
11681
|
+
mkdirSync(dir, { recursive: true });
|
|
11682
|
+
const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
|
|
11683
|
+
try {
|
|
11684
|
+
writeFileSync(staging, bytes, { mode: 493 });
|
|
11685
|
+
renameSync(staging, dest);
|
|
11686
|
+
} catch (err) {
|
|
11687
|
+
rmSync(staging, { force: true });
|
|
11688
|
+
throw err;
|
|
10769
11689
|
}
|
|
10770
|
-
|
|
11690
|
+
chmodSync(dest, 493);
|
|
11691
|
+
return dest;
|
|
10771
11692
|
}
|
|
10772
11693
|
/**
|
|
10773
|
-
*
|
|
11694
|
+
* Run the proxy, forwarding stdio, signals, and its exit status.
|
|
10774
11695
|
*
|
|
10775
|
-
*
|
|
10776
|
-
*
|
|
10777
|
-
*
|
|
11696
|
+
* The proxy is a long-lived foreground process, so this wrapper has to be
|
|
11697
|
+
* transparent: Node cannot exec-replace itself, and without relaying signals
|
|
11698
|
+
* Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
|
|
11699
|
+
* the proxy running, holding decrypted secrets, with the shell prompt back.
|
|
10778
11700
|
*/
|
|
10779
|
-
function
|
|
10780
|
-
const
|
|
10781
|
-
|
|
10782
|
-
|
|
11701
|
+
async function runProxyBinary(argv, options = {}) {
|
|
11702
|
+
const bin = await resolveProxyBinary(options);
|
|
11703
|
+
const child = spawn(bin, argv, {
|
|
11704
|
+
stdio: "inherit",
|
|
11705
|
+
env: {
|
|
11706
|
+
...process.env,
|
|
11707
|
+
...options.env
|
|
11708
|
+
}
|
|
11709
|
+
});
|
|
11710
|
+
const signals = [
|
|
11711
|
+
"SIGINT",
|
|
11712
|
+
"SIGTERM",
|
|
11713
|
+
"SIGHUP",
|
|
11714
|
+
"SIGQUIT"
|
|
11715
|
+
];
|
|
11716
|
+
const forward = (signal) => {
|
|
11717
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
11718
|
+
child.kill(signal);
|
|
11719
|
+
};
|
|
11720
|
+
for (const signal of signals) process.on(signal, forward);
|
|
11721
|
+
return new Promise((resolve, reject) => {
|
|
11722
|
+
child.on("error", (err) => {
|
|
11723
|
+
for (const s of signals) process.off(s, forward);
|
|
11724
|
+
reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
|
|
11725
|
+
});
|
|
11726
|
+
child.on("exit", (code, signal) => {
|
|
11727
|
+
for (const s of signals) process.off(s, forward);
|
|
11728
|
+
resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
|
|
11729
|
+
});
|
|
11730
|
+
});
|
|
10783
11731
|
}
|
|
10784
|
-
/**
|
|
10785
|
-
function
|
|
10786
|
-
|
|
10787
|
-
|
|
11732
|
+
/** Signal name → number, for the 128+n exit convention. */
|
|
11733
|
+
function signalNumber(signal) {
|
|
11734
|
+
return {
|
|
11735
|
+
SIGHUP: 1,
|
|
11736
|
+
SIGINT: 2,
|
|
11737
|
+
SIGQUIT: 3,
|
|
11738
|
+
SIGKILL: 9,
|
|
11739
|
+
SIGTERM: 15
|
|
11740
|
+
}[signal] ?? 0;
|
|
10788
11741
|
}
|
|
11742
|
+
//#endregion
|
|
11743
|
+
//#region src/nomad.ts
|
|
10789
11744
|
/**
|
|
10790
|
-
*
|
|
11745
|
+
* `seekrit nomad` — wire seekrit into [HashiCorp Nomad](https://developer.hashicorp.com/nomad)
|
|
11746
|
+
* as a **secret provider plugin**.
|
|
10791
11747
|
*
|
|
10792
|
-
*
|
|
10793
|
-
*
|
|
10794
|
-
*
|
|
10795
|
-
*
|
|
11748
|
+
* Nomad 1.11 grew a `secret` block in the job specification plus an external
|
|
11749
|
+
* plugin framework: drop an executable in `client.common_plugin_dir/secrets/`,
|
|
11750
|
+
* answer `fingerprint` and `fetch` with JSON on stdout, and jobs name
|
|
11751
|
+
* credentials instead of holding them. Two built-in providers ship (Nomad
|
|
11752
|
+
* Variables and Vault); everything else is a community binary.
|
|
11753
|
+
*
|
|
11754
|
+
* The plugin itself is Rust (`apps/nomad-plugin`), not this CLI, because it runs
|
|
11755
|
+
* as the Nomad agent's user on every client node — bare Linux hosts where
|
|
11756
|
+
* requiring a Node runtime would be a real adoption tax. This command covers the
|
|
11757
|
+
* three things around it that are not the protocol: putting the binary in the
|
|
11758
|
+
* right place, printing the configuration, and proving the install works.
|
|
11759
|
+
*
|
|
11760
|
+
* Three facts about Nomad shaped this file, and **all three fail silently**:
|
|
11761
|
+
*
|
|
11762
|
+
* **The `provider` value is the executable's filename.** Nomad looks for an
|
|
11763
|
+
* executable named exactly what the job's `provider` says. So the binary is
|
|
11764
|
+
* installed as `secrets/seekrit`, not as its artifact name — get it wrong and
|
|
11765
|
+
* the plugin is not registered, with no error and no log line pointing here.
|
|
11766
|
+
*
|
|
11767
|
+
* **Plugins are fingerprinted once, at client startup.** Installing a plugin
|
|
11768
|
+
* into a running agent does nothing until it restarts. So every path here says
|
|
11769
|
+
* so, rather than letting someone conclude the plugin is broken.
|
|
11770
|
+
*
|
|
11771
|
+
* **`common_plugin_dir` is opt-in.** An agent with no `common_plugin_dir` set
|
|
11772
|
+
* scans nothing. `init` prints the stanza because the most likely reason a
|
|
11773
|
+
* correct install does not work is that the agent was never told where to look.
|
|
10796
11774
|
*/
|
|
10797
|
-
function pluginProvider() {
|
|
10798
|
-
return {
|
|
10799
|
-
source: "exec",
|
|
10800
|
-
pluginIntegration: {
|
|
10801
|
-
pluginId: PLUGIN_ID,
|
|
10802
|
-
integrationId: PLUGIN_INTEGRATION_ID
|
|
10803
|
-
}
|
|
10804
|
-
};
|
|
10805
|
-
}
|
|
10806
11775
|
/**
|
|
10807
|
-
* The
|
|
11776
|
+
* The name a job's `secret` block puts in `provider`, **and** the filename the
|
|
11777
|
+
* binary must have on disk. Nomad ties the two together; they are one constant
|
|
11778
|
+
* here so they cannot drift.
|
|
11779
|
+
*/
|
|
11780
|
+
const PROVIDER_NAME = "seekrit";
|
|
11781
|
+
/** The artifact's own name — what the release publishes and install.sh fetches. */
|
|
11782
|
+
const PLUGIN_BIN = "seekrit-nomad-secrets";
|
|
11783
|
+
/**
|
|
11784
|
+
* The plugin version this CLI installs by default.
|
|
10808
11785
|
*
|
|
10809
|
-
*
|
|
10810
|
-
*
|
|
10811
|
-
*
|
|
10812
|
-
* between a provider that works and a startup failure whose message points at
|
|
10813
|
-
* the config rather than at the shim.
|
|
11786
|
+
* Bumped by release-please when `apps/nomad-plugin` releases (an `extra-files`
|
|
11787
|
+
* entry in release-please-config.json), so the pin follows the crate without
|
|
11788
|
+
* anyone remembering to move it — the same arrangement `PROXY_VERSION` has.
|
|
10814
11789
|
*/
|
|
10815
|
-
|
|
10816
|
-
|
|
10817
|
-
|
|
10818
|
-
|
|
10819
|
-
|
|
10820
|
-
|
|
10821
|
-
|
|
10822
|
-
|
|
10823
|
-
|
|
10824
|
-
|
|
10825
|
-
|
|
10826
|
-
|
|
10827
|
-
|
|
10828
|
-
|
|
10829
|
-
|
|
10830
|
-
|
|
10831
|
-
};
|
|
11790
|
+
const NOMAD_PLUGIN_VERSION = "0.2.0";
|
|
11791
|
+
/** Public artifact host. Mirrors GitHub Releases; public, no egress fees. */
|
|
11792
|
+
const DEFAULT_BASE_URL = "https://nomad.seekrit.dev";
|
|
11793
|
+
/** Where the plugin reads its node config, matching `config::DEFAULT_CONFIG_PATH`. */
|
|
11794
|
+
const DEFAULT_CONFIG_PATH = "/etc/seekrit/nomad.json";
|
|
11795
|
+
/**
|
|
11796
|
+
* Plugin directories to look in when `--plugin-dir` is not given.
|
|
11797
|
+
*
|
|
11798
|
+
* Deliberately a short list of conventional paths rather than a guess: an agent
|
|
11799
|
+
* can put `common_plugin_dir` anywhere, and installing into the wrong directory
|
|
11800
|
+
* produces a plugin that does not exist as far as Nomad is concerned.
|
|
11801
|
+
*/
|
|
11802
|
+
const PLUGIN_DIR_CANDIDATES = ["/opt/nomad/plugins", "/var/lib/nomad/plugins"];
|
|
11803
|
+
/** The first candidate that exists, or undefined — never a blind default. */
|
|
11804
|
+
function detectPluginDir(candidates = PLUGIN_DIR_CANDIDATES) {
|
|
11805
|
+
return candidates.find((dir) => existsSync(dir));
|
|
10832
11806
|
}
|
|
10833
|
-
|
|
10834
|
-
|
|
10835
|
-
|
|
10836
|
-
} catch {
|
|
10837
|
-
return resolve(path);
|
|
10838
|
-
}
|
|
11807
|
+
/** Where the executable has to end up for `provider = "seekrit"` to find it. */
|
|
11808
|
+
function pluginPath(pluginDir) {
|
|
11809
|
+
return join(pluginDir, "secrets", PROVIDER_NAME);
|
|
10839
11810
|
}
|
|
10840
|
-
/**
|
|
10841
|
-
function
|
|
10842
|
-
return
|
|
11811
|
+
/** The agent stanza that makes Nomad scan for plugins at all. */
|
|
11812
|
+
function agentStanza(pluginDir) {
|
|
11813
|
+
return [
|
|
11814
|
+
"client {",
|
|
11815
|
+
" enabled = true",
|
|
11816
|
+
` common_plugin_dir = ${JSON.stringify(pluginDir)}`,
|
|
11817
|
+
"}"
|
|
11818
|
+
].join("\n");
|
|
11819
|
+
}
|
|
11820
|
+
/** The node-side config file: which credential, and who may use it. */
|
|
11821
|
+
function pluginConfig(opts) {
|
|
11822
|
+
const profile = { token_file: opts.tokenFile };
|
|
11823
|
+
if (opts.namespaces?.length) profile.namespaces = opts.namespaces;
|
|
11824
|
+
if (opts.jobs?.length) profile.jobs = opts.jobs;
|
|
11825
|
+
return `${JSON.stringify({ profiles: { [opts.profile]: profile } }, null, 2)}\n`;
|
|
10843
11826
|
}
|
|
10844
11827
|
/**
|
|
10845
|
-
*
|
|
11828
|
+
* A job snippet.
|
|
10846
11829
|
*
|
|
10847
|
-
*
|
|
10848
|
-
*
|
|
10849
|
-
*
|
|
10850
|
-
*
|
|
11830
|
+
* `path` is the profile name, because the first segment of a path is always a
|
|
11831
|
+
* profile — never a secret name. seekrit names and slugs overlap (`api_key` is
|
|
11832
|
+
* a legal spelling of both), so a single-segment path that could mean either
|
|
11833
|
+
* would resolve differently depending on what a node happened to have
|
|
11834
|
+
* configured, which is the worst possible failure mode for an access decision.
|
|
10851
11835
|
*/
|
|
10852
|
-
function
|
|
10853
|
-
const
|
|
10854
|
-
const
|
|
10855
|
-
const
|
|
10856
|
-
|
|
10857
|
-
|
|
10858
|
-
|
|
10859
|
-
|
|
10860
|
-
|
|
10861
|
-
|
|
10862
|
-
|
|
10863
|
-
|
|
10864
|
-
|
|
10865
|
-
|
|
10866
|
-
|
|
10867
|
-
|
|
10868
|
-
|
|
10869
|
-
|
|
10870
|
-
|
|
10871
|
-
|
|
10872
|
-
}
|
|
10873
|
-
|
|
10874
|
-
};
|
|
11836
|
+
function jobSnippet(profile, names) {
|
|
11837
|
+
const vars = names.length > 0 ? names : ["DATABASE_URL", "STRIPE_SECRET_KEY"];
|
|
11838
|
+
const width = Math.max(...vars.map((n) => n.length));
|
|
11839
|
+
const INTERP = "${secret.seekrit.";
|
|
11840
|
+
const env = vars.map((n) => ` ${n.padEnd(width)} = "${INTERP}${n}}"`).join("\n");
|
|
11841
|
+
return [
|
|
11842
|
+
"job \"example\" {",
|
|
11843
|
+
" secret \"seekrit\" {",
|
|
11844
|
+
` provider = ${JSON.stringify(PROVIDER_NAME)}`,
|
|
11845
|
+
` path = ${JSON.stringify(profile)}`,
|
|
11846
|
+
" }",
|
|
11847
|
+
"",
|
|
11848
|
+
" group \"app\" {",
|
|
11849
|
+
" task \"server\" {",
|
|
11850
|
+
" driver = \"docker\"",
|
|
11851
|
+
" env {",
|
|
11852
|
+
env,
|
|
11853
|
+
" }",
|
|
11854
|
+
" }",
|
|
11855
|
+
" }",
|
|
11856
|
+
"}"
|
|
11857
|
+
].join("\n");
|
|
10875
11858
|
}
|
|
10876
|
-
|
|
10877
|
-
|
|
10878
|
-
|
|
10879
|
-
|
|
10880
|
-
provider: PROVIDER_ALIAS,
|
|
10881
|
-
id
|
|
10882
|
-
};
|
|
11859
|
+
async function fetchBytes(url) {
|
|
11860
|
+
const res = await fetch(url);
|
|
11861
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
11862
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
10883
11863
|
}
|
|
10884
11864
|
/**
|
|
10885
|
-
*
|
|
11865
|
+
* Download, verify, and place the plugin.
|
|
10886
11866
|
*
|
|
10887
|
-
*
|
|
10888
|
-
*
|
|
10889
|
-
*
|
|
10890
|
-
*
|
|
10891
|
-
*
|
|
11867
|
+
* Same integrity story as the proxy launcher: the SHA-256 is checked against a
|
|
11868
|
+
* `.sha256` published beside the binary *before* anything is written to the
|
|
11869
|
+
* plugin directory. That proves the bytes match what the release published —
|
|
11870
|
+
* integrity, not provenance.
|
|
11871
|
+
*
|
|
11872
|
+
* The staging file lives in the destination directory rather than `TMPDIR` so
|
|
11873
|
+
* the rename is atomic: a cross-device rename degrades to a copy, which would
|
|
11874
|
+
* let Nomad fingerprint a half-written executable.
|
|
10892
11875
|
*/
|
|
10893
|
-
function
|
|
10894
|
-
|
|
10895
|
-
|
|
11876
|
+
async function installPlugin(pluginDir, options = {}) {
|
|
11877
|
+
const version = options.version ?? "0.2.0";
|
|
11878
|
+
const baseUrl = (options.baseUrl ?? "https://nomad.seekrit.dev").replace(/\/+$/, "");
|
|
11879
|
+
const { target, exe } = detectTarget();
|
|
11880
|
+
if (exe !== "") fail("Nomad secret provider plugins are not supported on Windows clients");
|
|
11881
|
+
const prefix = versionPrefix(version);
|
|
11882
|
+
const name = `${PLUGIN_BIN}-${target}`;
|
|
11883
|
+
const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
|
|
11884
|
+
if (!options.quiet) process.stderr.write(`seekrit: fetching ${PLUGIN_BIN} ${prefix} (${target})…\n`);
|
|
11885
|
+
let bytes;
|
|
11886
|
+
let expected;
|
|
10896
11887
|
try {
|
|
10897
|
-
|
|
11888
|
+
[bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(`${binUrl}.sha256`).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
|
|
10898
11889
|
} catch (err) {
|
|
10899
|
-
|
|
11890
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11891
|
+
fail(`could not download ${PLUGIN_BIN} ${prefix} for ${target}: ${message}\n Build it from apps/nomad-plugin, or use https://nomad.seekrit.dev/install.sh`);
|
|
10900
11892
|
}
|
|
10901
|
-
|
|
11893
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
11894
|
+
if (!expected || actual !== expected.toLowerCase()) fail(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to install it.`);
|
|
11895
|
+
const dest = pluginPath(pluginDir);
|
|
11896
|
+
const dir = dirname(dest);
|
|
10902
11897
|
try {
|
|
10903
|
-
|
|
10904
|
-
|
|
10905
|
-
|
|
11898
|
+
mkdirSync(dir, { recursive: true });
|
|
11899
|
+
} catch (err) {
|
|
11900
|
+
fail(`could not create ${dir}: ${err instanceof Error ? err.message : String(err)}\n The plugin directory usually belongs to root — try again with sudo.`);
|
|
11901
|
+
}
|
|
11902
|
+
const staging = join(dir, `.${PROVIDER_NAME}-${process.pid}-${actual.slice(0, 12)}`);
|
|
11903
|
+
try {
|
|
11904
|
+
writeFileSync(staging, bytes, { mode: 493 });
|
|
11905
|
+
renameSync(staging, dest);
|
|
11906
|
+
} catch (err) {
|
|
11907
|
+
rmSync(staging, { force: true });
|
|
11908
|
+
fail(`could not install to ${dest}: ${err instanceof Error ? err.message : String(err)}\n The plugin directory usually belongs to root — try again with sudo.`);
|
|
11909
|
+
}
|
|
11910
|
+
chmodSync(dest, 493);
|
|
11911
|
+
return {
|
|
11912
|
+
path: dest,
|
|
11913
|
+
version: prefix,
|
|
11914
|
+
target,
|
|
11915
|
+
sha256: actual
|
|
11916
|
+
};
|
|
11917
|
+
}
|
|
11918
|
+
/**
|
|
11919
|
+
* Exercise an installed plugin exactly the way Nomad will.
|
|
11920
|
+
*
|
|
11921
|
+
* This exists because every way to get a Nomad plugin wrong fails *silently* —
|
|
11922
|
+
* a misnamed file, an unset `common_plugin_dir`, a plugin installed but never
|
|
11923
|
+
* fingerprinted because the agent was not restarted. None of those produce a
|
|
11924
|
+
* diagnostic, so the only reliable check is to run the thing yourself.
|
|
11925
|
+
*/
|
|
11926
|
+
function checkPlugin(pluginDir, opts = {}) {
|
|
11927
|
+
const steps = [];
|
|
11928
|
+
const bin = pluginPath(pluginDir);
|
|
11929
|
+
if (!existsSync(bin)) {
|
|
11930
|
+
steps.push({
|
|
11931
|
+
name: "installed",
|
|
11932
|
+
ok: false,
|
|
11933
|
+
detail: `${bin} is missing — run \`seekrit nomad install\``
|
|
11934
|
+
});
|
|
11935
|
+
return steps;
|
|
11936
|
+
}
|
|
11937
|
+
steps.push({
|
|
11938
|
+
name: "installed",
|
|
11939
|
+
ok: true,
|
|
11940
|
+
detail: bin
|
|
11941
|
+
});
|
|
11942
|
+
const fingerprint = spawnSync(bin, ["fingerprint"], {
|
|
11943
|
+
encoding: "utf8",
|
|
11944
|
+
timeout: 6e4
|
|
11945
|
+
});
|
|
11946
|
+
const fingerprintBody = safeJson(fingerprint.stdout);
|
|
11947
|
+
const fingerprintOk = fingerprint.status === 0 && fingerprintBody?.type === "secrets";
|
|
11948
|
+
steps.push({
|
|
11949
|
+
name: "fingerprint",
|
|
11950
|
+
ok: fingerprintOk,
|
|
11951
|
+
detail: fingerprintOk ? String(fingerprintBody?.version ?? "") : (fingerprint.stderr || fingerprint.stdout || "no output").trim()
|
|
11952
|
+
});
|
|
11953
|
+
if (!fingerprintOk) return steps;
|
|
11954
|
+
const fetch = spawnSync(bin, [...opts.config ? [
|
|
11955
|
+
"--config",
|
|
11956
|
+
opts.config,
|
|
11957
|
+
"fetch"
|
|
11958
|
+
] : ["fetch"], opts.path ?? ""], {
|
|
11959
|
+
encoding: "utf8",
|
|
11960
|
+
timeout: 6e4,
|
|
11961
|
+
env: {
|
|
11962
|
+
...process.env,
|
|
11963
|
+
...opts.namespace ? { NOMAD_NAMESPACE: opts.namespace } : {},
|
|
11964
|
+
...opts.job ? { NOMAD_JOB_ID: opts.job } : {}
|
|
11965
|
+
}
|
|
11966
|
+
});
|
|
11967
|
+
const body = safeJson(fetch.stdout);
|
|
11968
|
+
if (!body) {
|
|
11969
|
+
steps.push({
|
|
11970
|
+
name: "fetch",
|
|
11971
|
+
ok: false,
|
|
11972
|
+
detail: (fetch.stderr || "no JSON on stdout").trim()
|
|
11973
|
+
});
|
|
11974
|
+
return steps;
|
|
11975
|
+
}
|
|
11976
|
+
if (typeof body.error === "string") {
|
|
11977
|
+
steps.push({
|
|
11978
|
+
name: "fetch",
|
|
11979
|
+
ok: false,
|
|
11980
|
+
detail: body.error
|
|
11981
|
+
});
|
|
11982
|
+
return steps;
|
|
11983
|
+
}
|
|
11984
|
+
const count = Object.keys(body.result ?? {}).length;
|
|
11985
|
+
steps.push({
|
|
11986
|
+
name: "fetch",
|
|
11987
|
+
ok: true,
|
|
11988
|
+
detail: `${count} ${count === 1 ? "secret" : "secrets"}`
|
|
11989
|
+
});
|
|
11990
|
+
return steps;
|
|
11991
|
+
}
|
|
11992
|
+
function safeJson(text) {
|
|
11993
|
+
if (!text) return void 0;
|
|
11994
|
+
try {
|
|
11995
|
+
const parsed = JSON.parse(text.trim());
|
|
11996
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
10906
11997
|
} catch {
|
|
10907
|
-
return
|
|
11998
|
+
return;
|
|
10908
11999
|
}
|
|
10909
12000
|
}
|
|
10910
|
-
function
|
|
10911
|
-
|
|
10912
|
-
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
|
|
10920
|
-
|
|
12001
|
+
function requirePluginDir(explicit) {
|
|
12002
|
+
if (explicit) return resolve(explicit);
|
|
12003
|
+
const found = detectPluginDir();
|
|
12004
|
+
if (found) return found;
|
|
12005
|
+
fail("could not find the Nomad plugin directory — pass --plugin-dir <path> matching your agent's client.common_plugin_dir");
|
|
12006
|
+
}
|
|
12007
|
+
function collect$3(value, previous = []) {
|
|
12008
|
+
return [...previous, value];
|
|
12009
|
+
}
|
|
12010
|
+
function registerNomadCommands(program) {
|
|
12011
|
+
const nomad = program.command("nomad").description("wire seekrit into Nomad as a secret provider (`seekrit nomad --help`)");
|
|
12012
|
+
nomad.command("init").description("print the agent stanza, plugin config, and job snippet for this client").option("--plugin-dir <path>", "the agent's client.common_plugin_dir").option("--profile <name>", "profile name jobs will reference in `path`", "default").option("--token-file <path>", "where the profile's service token lives").option("--namespace <ns>", "restrict the profile to a Nomad namespace (repeatable)", collect$3).option("--job <id>", "restrict the profile to a Nomad job id (repeatable)", collect$3).option("--config <path>", "plugin config path", DEFAULT_CONFIG_PATH).option("--write", "write the plugin config file instead of printing it").option("--json", "machine-readable output").action((options) => {
|
|
12013
|
+
const pluginDir = options.pluginDir ? resolve(options.pluginDir) : detectPluginDir() ?? PLUGIN_DIR_CANDIDATES[0];
|
|
12014
|
+
const profile = options.profile ?? "default";
|
|
12015
|
+
const tokenFile = options.tokenFile ?? `/etc/seekrit/tokens/${profile}`;
|
|
12016
|
+
const configPath = resolve(options.config ?? "/etc/seekrit/nomad.json");
|
|
12017
|
+
const config = pluginConfig({
|
|
12018
|
+
profile,
|
|
12019
|
+
tokenFile,
|
|
12020
|
+
namespaces: options.namespace,
|
|
12021
|
+
jobs: options.job
|
|
12022
|
+
});
|
|
12023
|
+
let wrote = false;
|
|
12024
|
+
if (options.write) try {
|
|
12025
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
12026
|
+
writeFileSync(configPath, config, { mode: 384 });
|
|
12027
|
+
wrote = true;
|
|
12028
|
+
} catch (err) {
|
|
12029
|
+
fail(`could not write ${configPath}: ${err instanceof Error ? err.message : String(err)}\n This path usually belongs to root — try again with sudo.`);
|
|
10921
12030
|
}
|
|
10922
|
-
const path = options.config ? resolve(options.config) : join(openclawHome(), "openclaw.json");
|
|
10923
|
-
const read = readOpenclawConfig(path);
|
|
10924
|
-
const json5 = "json5" in read;
|
|
10925
|
-
const merged = json5 ? void 0 : mergeProvider(read.config, provider, options.force === true);
|
|
10926
|
-
const wrote = Boolean(options.write) && merged?.changed === true;
|
|
10927
|
-
if (wrote && merged) writeFileSync(path, `${JSON.stringify(merged.merged, null, 2)}\n`, { mode: 384 });
|
|
10928
12031
|
emit(options, {
|
|
10929
|
-
|
|
10930
|
-
|
|
10931
|
-
|
|
10932
|
-
|
|
12032
|
+
pluginDir,
|
|
12033
|
+
pluginPath: pluginPath(pluginDir),
|
|
12034
|
+
configPath,
|
|
12035
|
+
profile,
|
|
12036
|
+
wrote
|
|
10933
12037
|
}, () => {
|
|
10934
|
-
section("
|
|
12038
|
+
section("nomad secret provider");
|
|
10935
12039
|
printFields([
|
|
10936
|
-
["
|
|
10937
|
-
["
|
|
10938
|
-
["
|
|
10939
|
-
["
|
|
12040
|
+
["plugin dir", pluginDir],
|
|
12041
|
+
["plugin path", pluginPath(pluginDir)],
|
|
12042
|
+
["provider", PROVIDER_NAME],
|
|
12043
|
+
["config", configPath],
|
|
12044
|
+
["profile", profile],
|
|
12045
|
+
["config written", wrote ? "yes" : "no"]
|
|
10940
12046
|
]);
|
|
10941
12047
|
console.log();
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
|
|
10946
|
-
|
|
10947
|
-
|
|
12048
|
+
console.log("1. Install the plugin on every Nomad client:");
|
|
12049
|
+
console.log(" seekrit nomad install");
|
|
12050
|
+
console.log();
|
|
12051
|
+
console.log(" The file must be named `seekrit` — Nomad matches a job's `provider`");
|
|
12052
|
+
console.log(" against the executable's filename, and a mismatch registers nothing.");
|
|
12053
|
+
console.log();
|
|
12054
|
+
console.log("2. Point the agent at the plugin directory (/etc/nomad.d/client.hcl):");
|
|
12055
|
+
console.log();
|
|
12056
|
+
console.log(indent(agentStanza(pluginDir)));
|
|
10948
12057
|
console.log();
|
|
10949
|
-
console.log(
|
|
12058
|
+
console.log(`3. ${wrote ? `Written to ${configPath}:` : `Put this in ${configPath} (mode 0600):`}`);
|
|
10950
12059
|
console.log();
|
|
10951
|
-
|
|
10952
|
-
console.log("Install the plugin, if you have not already:");
|
|
10953
|
-
console.log(" openclaw plugins install npm:@seekrit/openclaw-plugin");
|
|
10954
|
-
console.log(" openclaw plugins enable seekrit");
|
|
10955
|
-
console.log();
|
|
10956
|
-
}
|
|
10957
|
-
console.log("Then replace a plaintext credential with a ref, e.g.:");
|
|
10958
|
-
console.log(` ${JSON.stringify(secretRef("OPENAI_API_KEY"))}`);
|
|
12060
|
+
console.log(indent(config.trimEnd()));
|
|
10959
12061
|
console.log();
|
|
10960
|
-
console.log(
|
|
10961
|
-
console.log(
|
|
12062
|
+
console.log(` Then write the token itself to ${tokenFile} (mode 0600):`);
|
|
12063
|
+
console.log(` seekrit token create --name nomad-${profile} --app <app> --env <env>`);
|
|
12064
|
+
console.log(` # then: echo "skt_…" | sudo tee ${tokenFile} && sudo chmod 600 ${tokenFile}`);
|
|
10962
12065
|
console.log();
|
|
10963
|
-
console.log("
|
|
12066
|
+
console.log("4. Restart the agent — plugins are fingerprinted at client startup:");
|
|
12067
|
+
console.log(" sudo systemctl restart nomad");
|
|
12068
|
+
console.log();
|
|
12069
|
+
console.log("5. Reference it from a job:");
|
|
12070
|
+
console.log();
|
|
12071
|
+
console.log(indent(jobSnippet(profile, [])));
|
|
12072
|
+
console.log();
|
|
12073
|
+
console.log("Check your work with: seekrit nomad check");
|
|
10964
12074
|
});
|
|
10965
12075
|
});
|
|
10966
|
-
|
|
10967
|
-
|
|
10968
|
-
|
|
10969
|
-
|
|
10970
|
-
|
|
10971
|
-
|
|
12076
|
+
nomad.command("install").description("download the secret provider plugin onto this Nomad client").option("--plugin-dir <path>", "the agent's client.common_plugin_dir").option("--version <version>", "plugin version to install", NOMAD_PLUGIN_VERSION).option("--base-url <url>", "artifact host", DEFAULT_BASE_URL).option("--json", "machine-readable output").action(async (options) => {
|
|
12077
|
+
const result = await installPlugin(requirePluginDir(options.pluginDir), {
|
|
12078
|
+
version: options.version,
|
|
12079
|
+
baseUrl: options.baseUrl,
|
|
12080
|
+
quiet: options.json === true
|
|
12081
|
+
});
|
|
12082
|
+
emit(options, result, () => {
|
|
12083
|
+
section("nomad plugin installed");
|
|
12084
|
+
printFields([
|
|
12085
|
+
["path", result.path],
|
|
12086
|
+
["version", result.version],
|
|
12087
|
+
["target", result.target],
|
|
12088
|
+
["sha256", result.sha256],
|
|
12089
|
+
["provider", PROVIDER_NAME]
|
|
12090
|
+
]);
|
|
12091
|
+
console.log();
|
|
12092
|
+
console.log("Nomad fingerprints plugins at client startup — restart the agent:");
|
|
12093
|
+
console.log(" sudo systemctl restart nomad");
|
|
12094
|
+
console.log();
|
|
12095
|
+
console.log("Then: seekrit nomad check");
|
|
12096
|
+
});
|
|
10972
12097
|
});
|
|
10973
|
-
|
|
10974
|
-
|
|
10975
|
-
|
|
10976
|
-
|
|
10977
|
-
|
|
10978
|
-
|
|
10979
|
-
|
|
10980
|
-
process.exitCode = 2;
|
|
10981
|
-
return;
|
|
10982
|
-
}
|
|
10983
|
-
const scopes = /* @__PURE__ */ new Map();
|
|
10984
|
-
let ctx;
|
|
10985
|
-
const response = await resolveIds(request.ids, (scope) => {
|
|
10986
|
-
const key = scopeKey(scope);
|
|
10987
|
-
const cached = scopes.get(key);
|
|
10988
|
-
if (cached) return cached;
|
|
10989
|
-
ctx ??= buildContext();
|
|
10990
|
-
const pending = resolveEnvironment(ctx, scope);
|
|
10991
|
-
scopes.set(key, pending);
|
|
10992
|
-
return pending;
|
|
12098
|
+
nomad.command("check").description("run the installed plugin the way Nomad does, and report what happened").option("--plugin-dir <path>", "the agent's client.common_plugin_dir").option("--path <path>", "the `path` a job would use (profile, or profile/NAME)", "").option("--namespace <ns>", "pretend to be a job in this Nomad namespace").option("--job <id>", "pretend to be this Nomad job id").option("--config <path>", "check against this plugin config instead of the installed one").option("--json", "machine-readable output").action((options) => {
|
|
12099
|
+
const pluginDir = requirePluginDir(options.pluginDir);
|
|
12100
|
+
const steps = checkPlugin(pluginDir, {
|
|
12101
|
+
path: options.path,
|
|
12102
|
+
namespace: options.namespace,
|
|
12103
|
+
job: options.job,
|
|
12104
|
+
config: options.config
|
|
10993
12105
|
});
|
|
10994
|
-
|
|
12106
|
+
const ok = steps.every((s) => s.ok);
|
|
12107
|
+
emit(options, {
|
|
12108
|
+
pluginDir,
|
|
12109
|
+
ok,
|
|
12110
|
+
steps
|
|
12111
|
+
}, () => {
|
|
12112
|
+
section("nomad plugin check");
|
|
12113
|
+
printFields(steps.map((s) => [s.name, `${s.ok ? "ok" : "FAILED"} — ${s.detail}`]));
|
|
12114
|
+
console.log();
|
|
12115
|
+
if (ok) {
|
|
12116
|
+
console.log("The plugin resolves. If a job still cannot, the agent has not been");
|
|
12117
|
+
console.log("restarted since the install, or client.common_plugin_dir is unset.");
|
|
12118
|
+
} else console.log("See `seekrit nomad init` for the configuration this expects.");
|
|
12119
|
+
});
|
|
12120
|
+
if (!ok) process.exitCode = 1;
|
|
10995
12121
|
});
|
|
10996
12122
|
}
|
|
10997
|
-
|
|
10998
|
-
|
|
10999
|
-
*
|
|
11000
|
-
* `.env` overlays are deliberately switched off (`envFiles: []`). Everywhere
|
|
11001
|
-
* else in this CLI they are a convenience for a developer's shell; here the
|
|
11002
|
-
* working directory belongs to the OpenClaw gateway, and letting a file in it
|
|
11003
|
-
* override a gateway credential would make a stray `.env` a privilege
|
|
11004
|
-
* escalation. Reference expansion stays on — a `${OTHER_SECRET}` reference is
|
|
11005
|
-
* stored data, not a local override.
|
|
11006
|
-
*/
|
|
11007
|
-
async function resolveEnvironment(ctx, scope) {
|
|
11008
|
-
let envId;
|
|
11009
|
-
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, {
|
|
11010
|
-
app: scope.app,
|
|
11011
|
-
env: scope.env
|
|
11012
|
-
})).envId;
|
|
11013
|
-
const { values } = await materializeEnv(ctx, {
|
|
11014
|
-
envId,
|
|
11015
|
-
envFiles: []
|
|
11016
|
-
});
|
|
11017
|
-
return values;
|
|
12123
|
+
function indent(text) {
|
|
12124
|
+
return text.split("\n").map((line) => line.length > 0 ? ` ${line}` : line).join("\n");
|
|
11018
12125
|
}
|
|
11019
12126
|
//#endregion
|
|
11020
12127
|
//#region src/orgs.ts
|
|
@@ -12142,188 +13249,6 @@ function collect$2(value, acc) {
|
|
|
12142
13249
|
return acc;
|
|
12143
13250
|
}
|
|
12144
13251
|
//#endregion
|
|
12145
|
-
//#region src/proxy-binary.ts
|
|
12146
|
-
/**
|
|
12147
|
-
* Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
|
|
12148
|
-
*
|
|
12149
|
-
* The proxy is the strongest answer seekrit has for an untrusted workload — the
|
|
12150
|
-
* agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
|
|
12151
|
-
* thing here to *try*, because trying it meant `cargo` and a TOML file. This
|
|
12152
|
-
* module removes the first half: it resolves a prebuilt, checksum-verified
|
|
12153
|
-
* binary for the host platform and execs it, so `npx @seekrit/proxy` and
|
|
12154
|
-
* `seekrit proxy run` behave like the proxy was already installed.
|
|
12155
|
-
*
|
|
12156
|
-
* The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
|
|
12157
|
-
* for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
|
|
12158
|
-
* entrypoint over it, and the two must not drift.
|
|
12159
|
-
*
|
|
12160
|
-
* Three properties worth stating, since this downloads and executes code:
|
|
12161
|
-
*
|
|
12162
|
-
* - **The checksum is verified before anything is executed**, against a
|
|
12163
|
-
* `.sha256` fetched from the same release. That is integrity, not provenance —
|
|
12164
|
-
* it proves the bytes match what the release published, which is exactly the
|
|
12165
|
-
* guarantee `install.sh` gives and no more.
|
|
12166
|
-
* - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
|
|
12167
|
-
* short-circuits entirely, and a cached download for the same version+target is
|
|
12168
|
-
* reused, so this is a one-time cost per version.
|
|
12169
|
-
* - **Version is pinned, not floating.** A default of `latest` would make two
|
|
12170
|
-
* machines run different proxies from the same command; the pinned constant is
|
|
12171
|
-
* what this CLI was built against, overridable when you want otherwise.
|
|
12172
|
-
*/
|
|
12173
|
-
/**
|
|
12174
|
-
* The proxy version this CLI was built against.
|
|
12175
|
-
*
|
|
12176
|
-
* Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
|
|
12177
|
-
* release-please-config.json), so the pin follows the crate without anyone
|
|
12178
|
-
* remembering to move it.
|
|
12179
|
-
*/
|
|
12180
|
-
const PROXY_VERSION = "0.12.0";
|
|
12181
|
-
const BIN = "seekrit-proxy";
|
|
12182
|
-
/**
|
|
12183
|
-
* Host → Rust target triple.
|
|
12184
|
-
*
|
|
12185
|
-
* Linux always resolves to **musl**: that build is statically linked, so one
|
|
12186
|
-
* artifact covers glibc, musl, alpine, and distroless, and there is no libc
|
|
12187
|
-
* detection to get wrong on a machine where `ldd` says something unexpected.
|
|
12188
|
-
*/
|
|
12189
|
-
function detectTarget(os = platform(), cpu = arch()) {
|
|
12190
|
-
const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
|
|
12191
|
-
if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
12192
|
-
switch (os) {
|
|
12193
|
-
case "linux": return {
|
|
12194
|
-
target: `${machine}-unknown-linux-musl`,
|
|
12195
|
-
exe: ""
|
|
12196
|
-
};
|
|
12197
|
-
case "darwin": return {
|
|
12198
|
-
target: `${machine}-apple-darwin`,
|
|
12199
|
-
exe: ""
|
|
12200
|
-
};
|
|
12201
|
-
case "win32":
|
|
12202
|
-
if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
|
|
12203
|
-
return {
|
|
12204
|
-
target: "x86_64-pc-windows-msvc",
|
|
12205
|
-
exe: ".exe"
|
|
12206
|
-
};
|
|
12207
|
-
default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
12208
|
-
}
|
|
12209
|
-
}
|
|
12210
|
-
/** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
|
|
12211
|
-
function versionPrefix(version) {
|
|
12212
|
-
if (version === "latest") return "latest";
|
|
12213
|
-
return version.startsWith("v") ? version : `v${version}`;
|
|
12214
|
-
}
|
|
12215
|
-
function resolveVersion(explicit) {
|
|
12216
|
-
return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.0";
|
|
12217
|
-
}
|
|
12218
|
-
function resolveBaseUrl(explicit) {
|
|
12219
|
-
return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
|
|
12220
|
-
}
|
|
12221
|
-
/** Where a resolved binary is kept, keyed so versions and targets never collide. */
|
|
12222
|
-
function proxyBinaryPath(version, target, exe) {
|
|
12223
|
-
return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
|
|
12224
|
-
}
|
|
12225
|
-
async function fetchBytes(url) {
|
|
12226
|
-
const res = await fetch(url);
|
|
12227
|
-
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
12228
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
12229
|
-
}
|
|
12230
|
-
/**
|
|
12231
|
-
* Ensure a `seekrit-proxy` binary exists locally and return its path.
|
|
12232
|
-
*
|
|
12233
|
-
* Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
|
|
12234
|
-
* version+target, then a fresh download. A binary already on `PATH` is
|
|
12235
|
-
* deliberately *not* used — silently running a different version than the one
|
|
12236
|
-
* this CLI pins is the kind of surprise that costs an afternoon.
|
|
12237
|
-
*/
|
|
12238
|
-
async function resolveProxyBinary(options = {}) {
|
|
12239
|
-
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
12240
|
-
if (override) {
|
|
12241
|
-
if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
|
|
12242
|
-
return override;
|
|
12243
|
-
}
|
|
12244
|
-
const version = resolveVersion(options.version);
|
|
12245
|
-
const { target, exe } = detectTarget();
|
|
12246
|
-
const dest = proxyBinaryPath(version, target, exe);
|
|
12247
|
-
if (!options.force && version !== "latest" && existsSync(dest)) return dest;
|
|
12248
|
-
const baseUrl = resolveBaseUrl(options.baseUrl);
|
|
12249
|
-
const prefix = versionPrefix(version);
|
|
12250
|
-
const name = `${BIN}-${target}${exe}`;
|
|
12251
|
-
const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
|
|
12252
|
-
const sumUrl = `${binUrl}.sha256`;
|
|
12253
|
-
if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
|
|
12254
|
-
let bytes;
|
|
12255
|
-
let expected;
|
|
12256
|
-
try {
|
|
12257
|
-
[bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
|
|
12258
|
-
} catch (err) {
|
|
12259
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
12260
|
-
throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
|
|
12261
|
-
}
|
|
12262
|
-
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
12263
|
-
if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
|
|
12264
|
-
const dir = dirname(dest);
|
|
12265
|
-
mkdirSync(dir, { recursive: true });
|
|
12266
|
-
const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
|
|
12267
|
-
try {
|
|
12268
|
-
writeFileSync(staging, bytes, { mode: 493 });
|
|
12269
|
-
renameSync(staging, dest);
|
|
12270
|
-
} catch (err) {
|
|
12271
|
-
rmSync(staging, { force: true });
|
|
12272
|
-
throw err;
|
|
12273
|
-
}
|
|
12274
|
-
chmodSync(dest, 493);
|
|
12275
|
-
return dest;
|
|
12276
|
-
}
|
|
12277
|
-
/**
|
|
12278
|
-
* Run the proxy, forwarding stdio, signals, and its exit status.
|
|
12279
|
-
*
|
|
12280
|
-
* The proxy is a long-lived foreground process, so this wrapper has to be
|
|
12281
|
-
* transparent: Node cannot exec-replace itself, and without relaying signals
|
|
12282
|
-
* Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
|
|
12283
|
-
* the proxy running, holding decrypted secrets, with the shell prompt back.
|
|
12284
|
-
*/
|
|
12285
|
-
async function runProxyBinary(argv, options = {}) {
|
|
12286
|
-
const bin = await resolveProxyBinary(options);
|
|
12287
|
-
const child = spawn(bin, argv, {
|
|
12288
|
-
stdio: "inherit",
|
|
12289
|
-
env: {
|
|
12290
|
-
...process.env,
|
|
12291
|
-
...options.env
|
|
12292
|
-
}
|
|
12293
|
-
});
|
|
12294
|
-
const signals = [
|
|
12295
|
-
"SIGINT",
|
|
12296
|
-
"SIGTERM",
|
|
12297
|
-
"SIGHUP",
|
|
12298
|
-
"SIGQUIT"
|
|
12299
|
-
];
|
|
12300
|
-
const forward = (signal) => {
|
|
12301
|
-
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
12302
|
-
child.kill(signal);
|
|
12303
|
-
};
|
|
12304
|
-
for (const signal of signals) process.on(signal, forward);
|
|
12305
|
-
return new Promise((resolve, reject) => {
|
|
12306
|
-
child.on("error", (err) => {
|
|
12307
|
-
for (const s of signals) process.off(s, forward);
|
|
12308
|
-
reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
|
|
12309
|
-
});
|
|
12310
|
-
child.on("exit", (code, signal) => {
|
|
12311
|
-
for (const s of signals) process.off(s, forward);
|
|
12312
|
-
resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
|
|
12313
|
-
});
|
|
12314
|
-
});
|
|
12315
|
-
}
|
|
12316
|
-
/** Signal name → number, for the 128+n exit convention. */
|
|
12317
|
-
function signalNumber(signal) {
|
|
12318
|
-
return {
|
|
12319
|
-
SIGHUP: 1,
|
|
12320
|
-
SIGINT: 2,
|
|
12321
|
-
SIGQUIT: 3,
|
|
12322
|
-
SIGKILL: 9,
|
|
12323
|
-
SIGTERM: 15
|
|
12324
|
-
}[signal] ?? 0;
|
|
12325
|
-
}
|
|
12326
|
-
//#endregion
|
|
12327
13252
|
//#region src/proxy.ts
|
|
12328
13253
|
/**
|
|
12329
13254
|
* `seekrit proxy` — get `seekrit-proxy` running without a Rust toolchain or a
|
|
@@ -14589,6 +15514,7 @@ registerProvisionerCommands(program);
|
|
|
14589
15514
|
registerProxyCommands(program);
|
|
14590
15515
|
registerAgentCommands(program);
|
|
14591
15516
|
registerPaperclipCommands(program);
|
|
15517
|
+
registerNomadCommands(program);
|
|
14592
15518
|
registerOpenclawCommands(program);
|
|
14593
15519
|
registerHermesCommands(program);
|
|
14594
15520
|
registerSshCommands(program);
|
|
@@ -14599,7 +15525,7 @@ registerKmsCommands(program);
|
|
|
14599
15525
|
registerRotationCommands(program);
|
|
14600
15526
|
registerRecoveryCommands(program);
|
|
14601
15527
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
14602
|
-
const { runMcpServer } = await import("./mcp-
|
|
15528
|
+
const { runMcpServer } = await import("./mcp-CjHg4P-V.js");
|
|
14603
15529
|
await runMcpServer();
|
|
14604
15530
|
});
|
|
14605
15531
|
registerAuditCommands(program);
|
|
@@ -14608,9 +15534,10 @@ registerArchiveCommands(program);
|
|
|
14608
15534
|
registerLogSinkCommands(program);
|
|
14609
15535
|
registerSyncCommands(program);
|
|
14610
15536
|
registerBillingCommands(program);
|
|
15537
|
+
registerDoctorCommand(program);
|
|
14611
15538
|
const argv = process.argv.map((arg) => arg === "-v" ? "--version" : arg);
|
|
14612
15539
|
program.parseAsync(argv).catch((err) => {
|
|
14613
15540
|
fail(describeError(err));
|
|
14614
15541
|
});
|
|
14615
15542
|
//#endregion
|
|
14616
|
-
export { verifyMessage as A, toBase64 as B, isServiceToken as C, importVerifyingKey as D, importSigningKey as E, kmsBlobKeyRef as F, kmsDecrypt as I, kmsEncrypt as L, generateMysqlCredential as M, generateDataKey as N, signMessage as O, generateEncryptKeyMaterial as P, wrapDek as R, createServiceToken as S, generateSigningKeyMaterial as T, parseBranchTtl as V, isTokenAuth as _,
|
|
15543
|
+
export { verifyMessage as A, toBase64 as B, isServiceToken as C, importVerifyingKey as D, importSigningKey as E, kmsBlobKeyRef as F, kmsDecrypt as I, kmsEncrypt as L, generateMysqlCredential as M, generateDataKey as N, signMessage as O, generateEncryptKeyMaterial as P, wrapDek as R, createServiceToken as S, generateSigningKeyMaterial as T, parseBranchTtl as V, isTokenAuth as _, kmsResolveRecipient as a, writeProjectConfig as b, fetchDecryptedVersion as c, resolveAppEnv as d, resolveBranch as f, getDek as g, resolveOrg as h, kmsResolveKey as i, generatePostgresCredential as j, signatureKeyRef as k, materializeEnv as l, resolveGroup as m, kmsCallerIdentity as n, encryptAndSetSecret as o, resolveEnvTarget as p, kmsRecoverMaterial as r, fetchDecryptedSecrets as s, ensureM2mAdminToken as t, resolveApp as u, tryBuildContext as v, parseServiceToken as w, version as x, setFailThrows as y, generateDek as z };
|