@seekrit/cli 1.0.0 → 1.1.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 +862 -239
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import { arch, homedir, hostname, platform, tmpdir, userInfo } from "node:os";
|
|
@@ -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 { fileURLToPath } from "node:url";
|
|
11
12
|
/** Default lifetime of a published bundle (7 days), in seconds. */
|
|
12
13
|
const POLICY_DEFAULT_TTL_SECONDS = 10080 * 60;
|
|
13
14
|
/** Bounds on a bundle's lifetime: an hour at the short end, 90 days at the long. */
|
|
@@ -4986,7 +4987,7 @@ async function createAgentTaskToken() {
|
|
|
4986
4987
|
}
|
|
4987
4988
|
//#endregion
|
|
4988
4989
|
//#region package.json
|
|
4989
|
-
var version = "1.
|
|
4990
|
+
var version = "1.1.0";
|
|
4990
4991
|
//#endregion
|
|
4991
4992
|
//#region ../../packages/api-client/src/index.ts
|
|
4992
4993
|
var SeekritApiError = class extends Error {
|
|
@@ -9584,6 +9585,224 @@ function registerGroupCommands(program) {
|
|
|
9584
9585
|
});
|
|
9585
9586
|
}
|
|
9586
9587
|
//#endregion
|
|
9588
|
+
//#region src/hermes.ts
|
|
9589
|
+
/**
|
|
9590
|
+
* `seekrit hermes` — wire seekrit into [Hermes Agent](https://hermes-agent.nousresearch.com)
|
|
9591
|
+
* as a **secret source**.
|
|
9592
|
+
*
|
|
9593
|
+
* Hermes reads credentials from `~/.hermes/.env` and the process environment, and
|
|
9594
|
+
* a *secret source* is the documented way to fill that environment from
|
|
9595
|
+
* somewhere else at startup. Implementing one is Python, and it lives in the
|
|
9596
|
+
* Python SDK (`seekrit.hermes`) rather than here — a `SecretSource` subclass and
|
|
9597
|
+
* the `register(ctx)` hook Hermes calls. This command exists for the two parts
|
|
9598
|
+
* that are not Python: the `config.yaml` block, and the plugin directory for a
|
|
9599
|
+
* Hermes install that cannot see the SDK's entry point.
|
|
9600
|
+
*
|
|
9601
|
+
* **`config.yaml` is printed, not written.** It is YAML that belongs to
|
|
9602
|
+
* somebody's agent, with comments and anchors this CLI carries no parser for;
|
|
9603
|
+
* a rewrite through a JSON round trip would delete them. The plugin directory
|
|
9604
|
+
* *is* written, because those two files are entirely ours.
|
|
9605
|
+
*
|
|
9606
|
+
* There are two sources, and which one to use is a real choice rather than a
|
|
9607
|
+
* default. `seekrit` is **bulk**: one environment, whole, nothing to enumerate.
|
|
9608
|
+
* `seekrit_refs` is **mapped**: explicit `VAR: skt://NAME` bindings, which is
|
|
9609
|
+
* what you need to rename a secret, read more than one environment, or win a
|
|
9610
|
+
* contested variable — Hermes lets a mapped claim beat a bulk one.
|
|
9611
|
+
*/
|
|
9612
|
+
/** The two source names `seekrit.hermes` registers. */
|
|
9613
|
+
const BULK_SOURCE = "seekrit";
|
|
9614
|
+
const MAPPED_SOURCE = "seekrit_refs";
|
|
9615
|
+
/** Hermes' home: `$HERMES_HOME`, else `~/.hermes`. */
|
|
9616
|
+
function hermesHome(env = process.env) {
|
|
9617
|
+
const home = env.HERMES_HOME?.trim();
|
|
9618
|
+
return home && home.length > 0 ? resolve(home) : join(homedir(), ".hermes");
|
|
9619
|
+
}
|
|
9620
|
+
const NAME_RE$1 = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9621
|
+
/**
|
|
9622
|
+
* Build an `skt://` reference.
|
|
9623
|
+
*
|
|
9624
|
+
* A service token is bound to one environment, so a reference names a *token*
|
|
9625
|
+
* (by the alias its `tokens:` map gives it) and a secret in that token's
|
|
9626
|
+
* environment — not an arbitrary app and env, which nothing on the read path
|
|
9627
|
+
* could resolve.
|
|
9628
|
+
*/
|
|
9629
|
+
function reference(name, tokenAlias) {
|
|
9630
|
+
return `skt://${tokenAlias ? `${tokenAlias}/` : ""}${name}`;
|
|
9631
|
+
}
|
|
9632
|
+
/**
|
|
9633
|
+
* The `secrets:` block for `~/.hermes/config.yaml`.
|
|
9634
|
+
*
|
|
9635
|
+
* Hand-rolled rather than serialized, because the shape is fixed and small and
|
|
9636
|
+
* the output is meant to be *read* — a generic emitter would quote keys and
|
|
9637
|
+
* flow-fold lists in ways that make a config someone has to paste look foreign.
|
|
9638
|
+
*/
|
|
9639
|
+
function configBlock(options = {}) {
|
|
9640
|
+
const source = options.mapped ? MAPPED_SOURCE : BULK_SOURCE;
|
|
9641
|
+
const lines = [
|
|
9642
|
+
"secrets:",
|
|
9643
|
+
` sources: [${source}]`,
|
|
9644
|
+
` ${source}:`,
|
|
9645
|
+
" enabled: true"
|
|
9646
|
+
];
|
|
9647
|
+
if (options.tokenEnv && options.tokenEnv !== "SEEKRIT_TOKEN") lines.push(` token_env: ${options.tokenEnv}`);
|
|
9648
|
+
if (options.mapped) {
|
|
9649
|
+
const tokens = Object.entries(options.tokens ?? {});
|
|
9650
|
+
if (tokens.length > 0) {
|
|
9651
|
+
lines.push(" tokens:");
|
|
9652
|
+
for (const [alias, variable] of tokens) lines.push(` ${alias}: ${variable}`);
|
|
9653
|
+
}
|
|
9654
|
+
lines.push(" env:");
|
|
9655
|
+
const bindings = options.bindings ?? [];
|
|
9656
|
+
if (bindings.length === 0) lines.push(` OPENAI_API_KEY: ${reference("OPENAI_API_KEY")}`);
|
|
9657
|
+
else for (const b of bindings) lines.push(` ${b.variable}: ${reference(b.name, b.tokenAlias)}`);
|
|
9658
|
+
}
|
|
9659
|
+
return `${lines.join("\n")}\n`;
|
|
9660
|
+
}
|
|
9661
|
+
/** `plugin.yaml` for the directory-install path. */
|
|
9662
|
+
function pluginYaml() {
|
|
9663
|
+
return [
|
|
9664
|
+
"name: seekrit",
|
|
9665
|
+
"description: >-",
|
|
9666
|
+
" Resolve Hermes provider credentials from seekrit — end-to-end encrypted",
|
|
9667
|
+
" secrets, decrypted in this process by your own service token.",
|
|
9668
|
+
""
|
|
9669
|
+
].join("\n");
|
|
9670
|
+
}
|
|
9671
|
+
/**
|
|
9672
|
+
* `__init__.py` for the directory-install path.
|
|
9673
|
+
*
|
|
9674
|
+
* Re-exporting `register` is the whole file: the implementation lives in the
|
|
9675
|
+
* published SDK, so a plugin scaffolded once keeps up with SDK releases instead
|
|
9676
|
+
* of pinning a copy of the resolver into somebody's home directory.
|
|
9677
|
+
*/
|
|
9678
|
+
function pluginInit() {
|
|
9679
|
+
return [
|
|
9680
|
+
"\"\"\"seekrit secret sources for Hermes Agent.",
|
|
9681
|
+
"",
|
|
9682
|
+
"Hermes calls `register(ctx)` from this module. The implementation lives in",
|
|
9683
|
+
"the `seekrit` package (`pip install seekrit`), so this file stays a re-export",
|
|
9684
|
+
"and never has to be regenerated.",
|
|
9685
|
+
"\"\"\"",
|
|
9686
|
+
"",
|
|
9687
|
+
"from seekrit.hermes import register # noqa: F401",
|
|
9688
|
+
""
|
|
9689
|
+
].join("\n");
|
|
9690
|
+
}
|
|
9691
|
+
/**
|
|
9692
|
+
* Parse a `VAR=NAME` or `VAR=alias/NAME` binding for `--bind`.
|
|
9693
|
+
*
|
|
9694
|
+
* The left side is the environment variable Hermes will set; the right side is
|
|
9695
|
+
* the seekrit secret it comes from. They differ often enough — that is most of
|
|
9696
|
+
* why the mapped source exists — that inferring one from the other would be
|
|
9697
|
+
* wrong more than it was convenient.
|
|
9698
|
+
*/
|
|
9699
|
+
function parseBinding(raw) {
|
|
9700
|
+
const eq = raw.indexOf("=");
|
|
9701
|
+
if (eq <= 0) return void 0;
|
|
9702
|
+
const variable = raw.slice(0, eq).trim();
|
|
9703
|
+
const target = raw.slice(eq + 1).trim();
|
|
9704
|
+
if (!NAME_RE$1.test(variable) || target.length === 0) return void 0;
|
|
9705
|
+
const slash = target.indexOf("/");
|
|
9706
|
+
if (slash === -1) return NAME_RE$1.test(target) ? {
|
|
9707
|
+
variable,
|
|
9708
|
+
name: target
|
|
9709
|
+
} : void 0;
|
|
9710
|
+
const tokenAlias = target.slice(0, slash).trim();
|
|
9711
|
+
const name = target.slice(slash + 1).trim();
|
|
9712
|
+
if (tokenAlias.length === 0 || !NAME_RE$1.test(name)) return void 0;
|
|
9713
|
+
return {
|
|
9714
|
+
variable,
|
|
9715
|
+
name,
|
|
9716
|
+
tokenAlias
|
|
9717
|
+
};
|
|
9718
|
+
}
|
|
9719
|
+
/** Parse a `--token alias=VAR` pair. */
|
|
9720
|
+
function parseTokenAlias(raw) {
|
|
9721
|
+
const eq = raw.indexOf("=");
|
|
9722
|
+
if (eq <= 0) return void 0;
|
|
9723
|
+
const alias = raw.slice(0, eq).trim();
|
|
9724
|
+
const variable = raw.slice(eq + 1).trim();
|
|
9725
|
+
if (alias.length === 0 || !NAME_RE$1.test(variable)) return void 0;
|
|
9726
|
+
return {
|
|
9727
|
+
alias,
|
|
9728
|
+
variable
|
|
9729
|
+
};
|
|
9730
|
+
}
|
|
9731
|
+
function registerHermesCommands(program) {
|
|
9732
|
+
const hermes = program.command("hermes").description("wire seekrit into a Hermes Agent as a secret source (`seekrit hermes --help`)");
|
|
9733
|
+
hermes.command("init").description("print the Hermes config that resolves credentials from seekrit").option("--mapped", `use the ${MAPPED_SOURCE} source (explicit VAR -> secret bindings)`).option("--token-env <var>", "variable holding the service token", "SEEKRIT_TOKEN").option("--bind <VAR=NAME...>", "a mapped binding, e.g. OPENAI_API_KEY=OPENAI_KEY").option("--token <alias=VAR...>", "name a second token, e.g. billing=SEEKRIT_TOKEN_BILLING").option("--plugin-dir", "also scaffold $HERMES_HOME/plugins/seekrit (for a pip-less install)").option("--home <path>", "Hermes home (default: $HERMES_HOME or ~/.hermes)").option("--json", "machine-readable output").action((options) => {
|
|
9734
|
+
const bindings = (options.bind ?? []).map((raw) => {
|
|
9735
|
+
const parsed = parseBinding(raw);
|
|
9736
|
+
if (!parsed) fail(`--bind must be VAR=NAME or VAR=alias/NAME (got "${raw}")`);
|
|
9737
|
+
return parsed;
|
|
9738
|
+
});
|
|
9739
|
+
const tokens = {};
|
|
9740
|
+
for (const raw of options.token ?? []) {
|
|
9741
|
+
const parsed = parseTokenAlias(raw);
|
|
9742
|
+
if (!parsed) fail(`--token must be alias=VARIABLE (got "${raw}")`);
|
|
9743
|
+
tokens[parsed.alias] = parsed.variable;
|
|
9744
|
+
}
|
|
9745
|
+
for (const binding of bindings) if (binding.tokenAlias && !tokens[binding.tokenAlias]) fail(`--bind ${binding.variable} names token alias "${binding.tokenAlias}", so pass --token ${binding.tokenAlias}=<VARIABLE> too`);
|
|
9746
|
+
if (!options.mapped && (bindings.length > 0 || Object.keys(tokens).length > 0)) fail("--bind and --token describe the mapped source: pass --mapped as well");
|
|
9747
|
+
const source = options.mapped ? MAPPED_SOURCE : BULK_SOURCE;
|
|
9748
|
+
const block = configBlock({
|
|
9749
|
+
mapped: options.mapped,
|
|
9750
|
+
tokenEnv: options.tokenEnv,
|
|
9751
|
+
bindings,
|
|
9752
|
+
tokens
|
|
9753
|
+
});
|
|
9754
|
+
const home = options.home ? resolve(options.home) : hermesHome();
|
|
9755
|
+
const pluginDir = join(home, "plugins", "seekrit");
|
|
9756
|
+
const written = [];
|
|
9757
|
+
if (options.pluginDir) {
|
|
9758
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
9759
|
+
const files = [["plugin.yaml", pluginYaml()], ["__init__.py", pluginInit()]];
|
|
9760
|
+
for (const [name, contents] of files) {
|
|
9761
|
+
const path = join(pluginDir, name);
|
|
9762
|
+
writeFileSync(path, contents);
|
|
9763
|
+
written.push(path);
|
|
9764
|
+
}
|
|
9765
|
+
}
|
|
9766
|
+
emit(options, {
|
|
9767
|
+
source,
|
|
9768
|
+
configPath: join(home, "config.yaml"),
|
|
9769
|
+
block,
|
|
9770
|
+
written
|
|
9771
|
+
}, () => {
|
|
9772
|
+
section("hermes secret source");
|
|
9773
|
+
printFields([
|
|
9774
|
+
["source", source],
|
|
9775
|
+
["shape", options.mapped ? "mapped (explicit bindings)" : "bulk (whole environment)"],
|
|
9776
|
+
["config", join(home, "config.yaml")],
|
|
9777
|
+
["plugin dir", options.pluginDir ? pluginDir : "not scaffolded"]
|
|
9778
|
+
]);
|
|
9779
|
+
console.log();
|
|
9780
|
+
console.log("1. Install the SDK into the environment Hermes runs in:");
|
|
9781
|
+
console.log(" pip install seekrit");
|
|
9782
|
+
console.log();
|
|
9783
|
+
console.log("2. Put the service token in ~/.hermes/.env (not config.yaml):");
|
|
9784
|
+
console.log(` ${options.tokenEnv ?? "SEEKRIT_TOKEN"}=skt_...`);
|
|
9785
|
+
console.log();
|
|
9786
|
+
console.log("3. Enable the plugin and merge this into ~/.hermes/config.yaml:");
|
|
9787
|
+
console.log(" hermes plugins enable seekrit");
|
|
9788
|
+
console.log();
|
|
9789
|
+
console.log(block.trimEnd());
|
|
9790
|
+
console.log();
|
|
9791
|
+
if (written.length > 0) {
|
|
9792
|
+
console.log("Scaffolded:");
|
|
9793
|
+
for (const path of written) console.log(` ${path}`);
|
|
9794
|
+
console.log();
|
|
9795
|
+
}
|
|
9796
|
+
console.log("The source overrides values already in .env or your shell, so a rotation wins — list a variable under `preserve_existing` when a local override is the point.");
|
|
9797
|
+
});
|
|
9798
|
+
});
|
|
9799
|
+
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) => {
|
|
9800
|
+
if (!NAME_RE$1.test(name)) fail(`not a valid secret name: "${name}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
|
|
9801
|
+
const ref = reference(name, options.tokenAlias);
|
|
9802
|
+
emit(options, { reference: ref }, () => console.log(ref));
|
|
9803
|
+
});
|
|
9804
|
+
}
|
|
9805
|
+
//#endregion
|
|
9587
9806
|
//#region src/honey.ts
|
|
9588
9807
|
/**
|
|
9589
9808
|
* Honey tokens — decoy credentials that unlock nothing and alert when used.
|
|
@@ -10036,61 +10255,650 @@ function registerMysqlCommands(program) {
|
|
|
10036
10255
|
const cfg = t.config;
|
|
10037
10256
|
console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
|
|
10038
10257
|
}
|
|
10039
|
-
});
|
|
10040
|
-
target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (targetId, options) => {
|
|
10041
|
-
const ctx = buildContext();
|
|
10042
|
-
const org = await resolveOrg(ctx, options.org);
|
|
10043
|
-
await confirmDestructive(options.yes, `Remove target ${targetId}? New leases against it stop working.`);
|
|
10044
|
-
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
10045
|
-
console.error(`removed ${targetId}`);
|
|
10046
|
-
});
|
|
10047
|
-
mysql.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--user <name>", "user name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
|
|
10048
|
-
const ctx = buildContext();
|
|
10049
|
-
const org = await resolveOrg(ctx, options.org);
|
|
10050
|
-
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
10051
|
-
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
10052
|
-
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
10053
|
-
if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
|
|
10054
|
-
const userName = options.user ?? generateUserName$1();
|
|
10055
|
-
const ttlSeconds = parseTtlSeconds$3(options.ttl);
|
|
10056
|
-
const { password, verifier } = await generateMysqlCredential();
|
|
10057
|
-
const { connection } = await ctx.client.mintLease(org.id, {
|
|
10058
|
-
provider: "mysql",
|
|
10059
|
-
targetId: target.id,
|
|
10060
|
-
roleName: userName,
|
|
10061
|
-
verifier,
|
|
10062
|
-
ttlSeconds
|
|
10258
|
+
});
|
|
10259
|
+
target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (targetId, options) => {
|
|
10260
|
+
const ctx = buildContext();
|
|
10261
|
+
const org = await resolveOrg(ctx, options.org);
|
|
10262
|
+
await confirmDestructive(options.yes, `Remove target ${targetId}? New leases against it stop working.`);
|
|
10263
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
10264
|
+
console.error(`removed ${targetId}`);
|
|
10265
|
+
});
|
|
10266
|
+
mysql.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--user <name>", "user name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
|
|
10267
|
+
const ctx = buildContext();
|
|
10268
|
+
const org = await resolveOrg(ctx, options.org);
|
|
10269
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
10270
|
+
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
10271
|
+
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
10272
|
+
if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
|
|
10273
|
+
const userName = options.user ?? generateUserName$1();
|
|
10274
|
+
const ttlSeconds = parseTtlSeconds$3(options.ttl);
|
|
10275
|
+
const { password, verifier } = await generateMysqlCredential();
|
|
10276
|
+
const { connection } = await ctx.client.mintLease(org.id, {
|
|
10277
|
+
provider: "mysql",
|
|
10278
|
+
targetId: target.id,
|
|
10279
|
+
roleName: userName,
|
|
10280
|
+
verifier,
|
|
10281
|
+
ttlSeconds
|
|
10282
|
+
});
|
|
10283
|
+
const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
10284
|
+
console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
|
|
10285
|
+
if (options.json) console.log(JSON.stringify({
|
|
10286
|
+
...connection,
|
|
10287
|
+
password,
|
|
10288
|
+
url
|
|
10289
|
+
}, null, 2));
|
|
10290
|
+
else console.log(url);
|
|
10291
|
+
});
|
|
10292
|
+
mysql.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
|
|
10293
|
+
const ctx = buildContext();
|
|
10294
|
+
const org = await resolveOrg(ctx, options.org);
|
|
10295
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
10296
|
+
for (const l of leases) {
|
|
10297
|
+
if (l.provider !== "mysql") continue;
|
|
10298
|
+
console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
|
|
10299
|
+
}
|
|
10300
|
+
});
|
|
10301
|
+
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) => {
|
|
10302
|
+
const ctx = buildContext();
|
|
10303
|
+
const org = await resolveOrg(ctx, options.org);
|
|
10304
|
+
await confirmDestructive(options.yes, `Revoke lease ${leaseId} now? Its MySQL user is dropped immediately.`);
|
|
10305
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
10306
|
+
console.error(`revoked ${leaseId}`);
|
|
10307
|
+
});
|
|
10308
|
+
}
|
|
10309
|
+
/** Collect a repeatable option into an array. */
|
|
10310
|
+
function collect$3(value, acc) {
|
|
10311
|
+
acc.push(value);
|
|
10312
|
+
return acc;
|
|
10313
|
+
}
|
|
10314
|
+
/**
|
|
10315
|
+
* Fetch + decrypt every secret in a single environment.
|
|
10316
|
+
*
|
|
10317
|
+
* `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
|
|
10318
|
+
* `interpolate`) unless `raw` is set. Only this environment's own secrets are in
|
|
10319
|
+
* scope here — a reference to a secret inherited from a composed group is left
|
|
10320
|
+
* literal, because the group layers aren't fetched. `materializeEnv` is the
|
|
10321
|
+
* fully-layered view.
|
|
10322
|
+
*/
|
|
10323
|
+
async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
|
|
10324
|
+
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
10325
|
+
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
10326
|
+
return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
|
|
10327
|
+
}
|
|
10328
|
+
/**
|
|
10329
|
+
* Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
|
|
10330
|
+
* the CLI's standard fatal exit — it is a config bug with no correct value to
|
|
10331
|
+
* emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
|
|
10332
|
+
*/
|
|
10333
|
+
function interpolateValues(values, enabled = true) {
|
|
10334
|
+
if (!enabled) return {
|
|
10335
|
+
values,
|
|
10336
|
+
interpolated: [],
|
|
10337
|
+
unresolvedRefs: []
|
|
10338
|
+
};
|
|
10339
|
+
try {
|
|
10340
|
+
const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
|
|
10341
|
+
return {
|
|
10342
|
+
values: expandedValues,
|
|
10343
|
+
interpolated: expanded,
|
|
10344
|
+
unresolvedRefs: unresolved
|
|
10345
|
+
};
|
|
10346
|
+
} catch (err) {
|
|
10347
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
10348
|
+
}
|
|
10349
|
+
}
|
|
10350
|
+
/**
|
|
10351
|
+
* Decrypt one historical version of a secret. Ciphertext is bound to
|
|
10352
|
+
* `(envId, name)` as AAD and neither changes across versions, so an old blob
|
|
10353
|
+
* opens with the environment's current data key — no special handling needed.
|
|
10354
|
+
*/
|
|
10355
|
+
async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
|
|
10356
|
+
const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
|
|
10357
|
+
const row = versions.find((v) => v.version === version);
|
|
10358
|
+
if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
|
|
10359
|
+
return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
|
|
10360
|
+
}
|
|
10361
|
+
async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
10362
|
+
const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
|
|
10363
|
+
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
10364
|
+
}
|
|
10365
|
+
/**
|
|
10366
|
+
* Encrypt and store many secrets into one environment. The DEK is fetched once
|
|
10367
|
+
* (so user auth prompts for the passphrase a single time, not per variable),
|
|
10368
|
+
* then each value is encrypted locally and written. Existing names are
|
|
10369
|
+
* overwritten; the result splits them into created vs. updated for a summary.
|
|
10370
|
+
* Callers validate the names first — a rejected name aborts before any write.
|
|
10371
|
+
*/
|
|
10372
|
+
async function importSecrets(ctx, orgId, envId, entries) {
|
|
10373
|
+
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
10374
|
+
const existing = new Set(secrets.map((s) => s.name));
|
|
10375
|
+
const result = {
|
|
10376
|
+
created: [],
|
|
10377
|
+
updated: []
|
|
10378
|
+
};
|
|
10379
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
10380
|
+
const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
|
|
10381
|
+
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
10382
|
+
(existing.has(name) ? result.updated : result.created).push(name);
|
|
10383
|
+
}
|
|
10384
|
+
return result;
|
|
10385
|
+
}
|
|
10386
|
+
/**
|
|
10387
|
+
* Resolve the full, layered environment for a running app: composed group
|
|
10388
|
+
* secrets (lowest precedence) → the app env's own secrets → `.env` files.
|
|
10389
|
+
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
10390
|
+
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
10391
|
+
* that spawn a process layer it on top so the live shell always wins.
|
|
10392
|
+
*
|
|
10393
|
+
* `${OTHER_SECRET}` references are expanded last, against the merged set, so a
|
|
10394
|
+
* reference always resolves to whichever layer won the name.
|
|
10395
|
+
*/
|
|
10396
|
+
async function materializeEnv(ctx, opts) {
|
|
10397
|
+
const query = {};
|
|
10398
|
+
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
10399
|
+
if (opts.branch) query.branch = opts.branch;
|
|
10400
|
+
if (!isTokenAuth(ctx)) {
|
|
10401
|
+
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
10402
|
+
query.env = opts.envId;
|
|
10403
|
+
}
|
|
10404
|
+
const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
|
|
10405
|
+
const privateKey = await getPrivateKey(ctx);
|
|
10406
|
+
const values = {};
|
|
10407
|
+
const provenance = {};
|
|
10408
|
+
for (const layer of layers) {
|
|
10409
|
+
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
10410
|
+
let label;
|
|
10411
|
+
if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
|
|
10412
|
+
else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
|
|
10413
|
+
else label = `app:${scope.appSlug}/${layer.slug}`;
|
|
10414
|
+
for (const secret of layer.secrets) {
|
|
10415
|
+
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
10416
|
+
provenance[secret.name] = label;
|
|
10417
|
+
}
|
|
10418
|
+
}
|
|
10419
|
+
const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
|
|
10420
|
+
return {
|
|
10421
|
+
...interpolateValues(values, opts.interpolate !== false),
|
|
10422
|
+
provenance,
|
|
10423
|
+
scope,
|
|
10424
|
+
loadedEnvFiles
|
|
10425
|
+
};
|
|
10426
|
+
}
|
|
10427
|
+
/**
|
|
10428
|
+
* Resolve, going through the last-known-good cache when one is configured.
|
|
10429
|
+
*
|
|
10430
|
+
* Always live first: the cache exists for when the call cannot land, not to
|
|
10431
|
+
* save a round trip, so a recovered network is picked up on the very next
|
|
10432
|
+
* invocation. A *refused* resolve (401/403/…) drops the entry rather than
|
|
10433
|
+
* falling back to it — otherwise revoking a token would keep working offline
|
|
10434
|
+
* until the entry aged out.
|
|
10435
|
+
*/
|
|
10436
|
+
async function resolveWithCache(ctx, query, cache) {
|
|
10437
|
+
if (!cache) return ctx.client.resolve(query);
|
|
10438
|
+
try {
|
|
10439
|
+
const response = await ctx.client.resolve(query);
|
|
10440
|
+
try {
|
|
10441
|
+
cache.write(JSON.stringify(response));
|
|
10442
|
+
} catch (err) {
|
|
10443
|
+
warn(`could not update the cache: ${errorMessage(err)}`);
|
|
10444
|
+
}
|
|
10445
|
+
return response;
|
|
10446
|
+
} catch (err) {
|
|
10447
|
+
if (!mayFallBack(err)) {
|
|
10448
|
+
cache.invalidate();
|
|
10449
|
+
throw err;
|
|
10450
|
+
}
|
|
10451
|
+
const found = cache.read();
|
|
10452
|
+
if (found.kind === "hit") {
|
|
10453
|
+
warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
|
|
10454
|
+
return JSON.parse(found.body);
|
|
10455
|
+
}
|
|
10456
|
+
if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
|
|
10457
|
+
else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
|
|
10458
|
+
throw err;
|
|
10459
|
+
}
|
|
10460
|
+
}
|
|
10461
|
+
function warn(text) {
|
|
10462
|
+
process.stderr.write(`seekrit: ${text}\n`);
|
|
10463
|
+
}
|
|
10464
|
+
function errorMessage(err) {
|
|
10465
|
+
return err instanceof Error ? err.message : String(err);
|
|
10466
|
+
}
|
|
10467
|
+
/**
|
|
10468
|
+
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
10469
|
+
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
10470
|
+
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
10471
|
+
* managed secrets are unavailable but `.env` should still apply.
|
|
10472
|
+
*/
|
|
10473
|
+
function overlayEnvFiles(values, provenance, envFiles) {
|
|
10474
|
+
const loaded = [];
|
|
10475
|
+
for (const file of envFiles) {
|
|
10476
|
+
if (!existsSync(file)) continue;
|
|
10477
|
+
loaded.push(file);
|
|
10478
|
+
for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
|
|
10479
|
+
values[name] = value;
|
|
10480
|
+
provenance[name] = `dotenv:${file}`;
|
|
10481
|
+
}
|
|
10482
|
+
}
|
|
10483
|
+
return loaded;
|
|
10484
|
+
}
|
|
10485
|
+
/**
|
|
10486
|
+
* Print a name → source table to stderr (never the secret values). Names whose
|
|
10487
|
+
* value had references expanded are marked, and dangling references are called
|
|
10488
|
+
* out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
|
|
10489
|
+
* deliberately passed through as literal text.
|
|
10490
|
+
*/
|
|
10491
|
+
function printExplain(provenance, refs = {}) {
|
|
10492
|
+
const interpolated = new Set(refs.interpolated ?? []);
|
|
10493
|
+
const names = Object.keys(provenance).sort();
|
|
10494
|
+
const width = names.reduce((w, n) => Math.max(w, n.length), 0);
|
|
10495
|
+
for (const name of names) {
|
|
10496
|
+
const marker = interpolated.has(name) ? " (interpolated)" : "";
|
|
10497
|
+
process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
|
|
10498
|
+
}
|
|
10499
|
+
if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
|
|
10500
|
+
}
|
|
10501
|
+
/** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
|
|
10502
|
+
const PLUGIN_ID = "seekrit";
|
|
10503
|
+
const PLUGIN_INTEGRATION_ID = "seekrit";
|
|
10504
|
+
/** The provider alias written into `secrets.providers`. */
|
|
10505
|
+
const PROVIDER_ALIAS = "seekrit";
|
|
10506
|
+
/**
|
|
10507
|
+
* Environment the resolver needs, and nothing else.
|
|
10508
|
+
*
|
|
10509
|
+
* OpenClaw hands an exec provider an empty environment apart from this
|
|
10510
|
+
* allowlist, which is a feature: it is the difference between "the resolver can
|
|
10511
|
+
* read the credential it needs" and "the resolver inherits the whole gateway
|
|
10512
|
+
* environment". `PATH` is here because Node's own startup needs it, `HOME` for
|
|
10513
|
+
* the CLI's login session, and the `SEEKRIT_*` set is how a machine credential
|
|
10514
|
+
* reaches a process nobody gets to pass flags to.
|
|
10515
|
+
*/
|
|
10516
|
+
const PASS_ENV = [
|
|
10517
|
+
"PATH",
|
|
10518
|
+
"HOME",
|
|
10519
|
+
"USERPROFILE",
|
|
10520
|
+
"APPDATA",
|
|
10521
|
+
"LOCALAPPDATA",
|
|
10522
|
+
"TEMP",
|
|
10523
|
+
"TMP",
|
|
10524
|
+
"SYSTEMROOT",
|
|
10525
|
+
"WINDIR",
|
|
10526
|
+
"XDG_CONFIG_HOME",
|
|
10527
|
+
"XDG_CACHE_HOME",
|
|
10528
|
+
"NODE_EXTRA_CA_CERTS",
|
|
10529
|
+
"SEEKRIT_TOKEN",
|
|
10530
|
+
"SEEKRIT_CLIENT_ID",
|
|
10531
|
+
"SEEKRIT_CLIENT_SECRET",
|
|
10532
|
+
"SEEKRIT_API_URL",
|
|
10533
|
+
"SEEKRIT_ORG",
|
|
10534
|
+
"SEEKRIT_APP",
|
|
10535
|
+
"SEEKRIT_ENV",
|
|
10536
|
+
"SEEKRIT_BRANCH"
|
|
10537
|
+
];
|
|
10538
|
+
/**
|
|
10539
|
+
* Generous, because the first resolve of a cold start does real work — an M2M
|
|
10540
|
+
* token mint, a resolve, and a key unwrap — and OpenClaw fails startup rather
|
|
10541
|
+
* than degrading when this expires. The 1Password integration picks 90s for the
|
|
10542
|
+
* same reason (its CLI may prompt for biometrics); 30s is enough here because
|
|
10543
|
+
* nothing in this path is interactive.
|
|
10544
|
+
*/
|
|
10545
|
+
const TIMEOUT_MS = 3e4;
|
|
10546
|
+
/** A seekrit secret name: what `seekrit secrets set` accepts. */
|
|
10547
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10548
|
+
/** Slugs as the API spells them. */
|
|
10549
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
10550
|
+
/**
|
|
10551
|
+
* Parse an exec SecretRef id.
|
|
10552
|
+
*
|
|
10553
|
+
* Two shapes, mirroring 1Password's `op://vault/item/field` — a bare name for
|
|
10554
|
+
* the environment the credential already points at, and an explicit
|
|
10555
|
+
* `app/env/NAME` triple for a provider serving more than one environment:
|
|
10556
|
+
*
|
|
10557
|
+
* OPENAI_API_KEY
|
|
10558
|
+
* billing-api/production/STRIPE_SECRET_KEY
|
|
10559
|
+
*
|
|
10560
|
+
* A bare name is the common case and the one a service token wants, since the
|
|
10561
|
+
* token is already bound to an environment and there is nothing to disambiguate.
|
|
10562
|
+
*/
|
|
10563
|
+
function parseSecretId(id) {
|
|
10564
|
+
const trimmed = id.trim();
|
|
10565
|
+
if (trimmed.length === 0) return void 0;
|
|
10566
|
+
const parts = trimmed.split("/");
|
|
10567
|
+
if (parts.length === 1) {
|
|
10568
|
+
const [name = ""] = parts;
|
|
10569
|
+
return NAME_RE.test(name) ? { name } : void 0;
|
|
10570
|
+
}
|
|
10571
|
+
if (parts.length !== 3) return void 0;
|
|
10572
|
+
const [app = "", env = "", name = ""] = parts;
|
|
10573
|
+
if (!SLUG_RE.test(app) || !SLUG_RE.test(env) || !NAME_RE.test(name)) return void 0;
|
|
10574
|
+
return {
|
|
10575
|
+
app,
|
|
10576
|
+
env,
|
|
10577
|
+
name
|
|
10578
|
+
};
|
|
10579
|
+
}
|
|
10580
|
+
/** The (app, env) pair an id resolves against — `""` for the credential's own. */
|
|
10581
|
+
function scopeKey(id) {
|
|
10582
|
+
return id.app && id.env ? `${id.app}/${id.env}` : "";
|
|
10583
|
+
}
|
|
10584
|
+
/**
|
|
10585
|
+
* Parse a request, tolerantly in exactly one direction.
|
|
10586
|
+
*
|
|
10587
|
+
* Non-string and empty ids are dropped rather than rejected — a request whose
|
|
10588
|
+
* shape we do not recognize should still resolve the ids we do, because the
|
|
10589
|
+
* alternative fails OpenClaw's whole startup over one malformed entry. A body
|
|
10590
|
+
* that is not an object with an `ids` array is a different matter: there is
|
|
10591
|
+
* nothing to answer, so it throws.
|
|
10592
|
+
*/
|
|
10593
|
+
function parseExecRequest(input) {
|
|
10594
|
+
const parsed = JSON.parse(input);
|
|
10595
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("exec SecretRef request must be a JSON object");
|
|
10596
|
+
const ids = parsed.ids;
|
|
10597
|
+
if (!Array.isArray(ids)) throw new Error("exec SecretRef request must carry an `ids` array");
|
|
10598
|
+
return { ids: ids.filter((id) => typeof id === "string" && id.trim().length > 0) };
|
|
10599
|
+
}
|
|
10600
|
+
/** Assemble a response, omitting `errors` entirely when everything resolved. */
|
|
10601
|
+
function buildExecResponse(values, errors) {
|
|
10602
|
+
return {
|
|
10603
|
+
protocolVersion: 1,
|
|
10604
|
+
values,
|
|
10605
|
+
...Object.keys(errors).length > 0 ? { errors } : {}
|
|
10606
|
+
};
|
|
10607
|
+
}
|
|
10608
|
+
/**
|
|
10609
|
+
* Resolve every requested id, reading each distinct environment exactly once.
|
|
10610
|
+
*
|
|
10611
|
+
* A batch is normally one environment, but a provider may serve several, and
|
|
10612
|
+
* resolving per id would mean an unwrap per id. Grouping keeps a 40-variable
|
|
10613
|
+
* gateway to one round trip.
|
|
10614
|
+
*
|
|
10615
|
+
* `resolveScope` is injected so the protocol can be tested without a network,
|
|
10616
|
+
* an API, or a key.
|
|
10617
|
+
*/
|
|
10618
|
+
async function resolveIds(ids, resolveScope) {
|
|
10619
|
+
const values = {};
|
|
10620
|
+
const errors = {};
|
|
10621
|
+
const byScope = /* @__PURE__ */ new Map();
|
|
10622
|
+
for (const id of ids) {
|
|
10623
|
+
const parsed = parseSecretId(id);
|
|
10624
|
+
if (!parsed) {
|
|
10625
|
+
errors[id] = { code: "INVALID_ID" };
|
|
10626
|
+
continue;
|
|
10627
|
+
}
|
|
10628
|
+
const key = scopeKey(parsed);
|
|
10629
|
+
const bucket = byScope.get(key) ?? {
|
|
10630
|
+
scope: parsed,
|
|
10631
|
+
ids: []
|
|
10632
|
+
};
|
|
10633
|
+
bucket.ids.push({
|
|
10634
|
+
id,
|
|
10635
|
+
name: parsed.name
|
|
10636
|
+
});
|
|
10637
|
+
byScope.set(key, bucket);
|
|
10638
|
+
}
|
|
10639
|
+
for (const { scope, ids: wanted } of byScope.values()) {
|
|
10640
|
+
let resolved;
|
|
10641
|
+
try {
|
|
10642
|
+
resolved = await resolveScope(scope);
|
|
10643
|
+
} catch (err) {
|
|
10644
|
+
const code = classify(err);
|
|
10645
|
+
for (const { id } of wanted) errors[id] = { code };
|
|
10646
|
+
continue;
|
|
10647
|
+
}
|
|
10648
|
+
for (const { id, name } of wanted) {
|
|
10649
|
+
const value = resolved[name];
|
|
10650
|
+
if (value === void 0) errors[id] = { code: "NOT_FOUND" };
|
|
10651
|
+
else values[id] = value;
|
|
10652
|
+
}
|
|
10653
|
+
}
|
|
10654
|
+
return buildExecResponse(values, errors);
|
|
10655
|
+
}
|
|
10656
|
+
/**
|
|
10657
|
+
* Bucket a failure into a code an operator can act on.
|
|
10658
|
+
*
|
|
10659
|
+
* Deliberately coarse and deliberately message-free: it reads the error's own
|
|
10660
|
+
* text to pick a bucket and then throws that text away, because the text may
|
|
10661
|
+
* quote a token.
|
|
10662
|
+
*/
|
|
10663
|
+
function classify(err) {
|
|
10664
|
+
const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
|
10665
|
+
if (/unauthor|forbidden|token|credential|passphrase|decrypt|sign in|log in|login/.test(message)) return "UNAUTHORIZED";
|
|
10666
|
+
return "UNAVAILABLE";
|
|
10667
|
+
}
|
|
10668
|
+
/** OpenClaw's config directory: `$OPENCLAW_HOME`, else `~/.openclaw`. */
|
|
10669
|
+
function openclawHome(env = process.env) {
|
|
10670
|
+
const home = env.OPENCLAW_HOME?.trim();
|
|
10671
|
+
return home && home.length > 0 ? resolve(home) : join(homedir(), ".openclaw");
|
|
10672
|
+
}
|
|
10673
|
+
/**
|
|
10674
|
+
* The provider block for an installed `@seekrit/openclaw-plugin`.
|
|
10675
|
+
*
|
|
10676
|
+
* Preferred over the exec shape below for one reason that matters on upgrade:
|
|
10677
|
+
* OpenClaw reads the command out of the plugin's manifest at every startup, so
|
|
10678
|
+
* a plugin release that moves its resolver keeps working. A copied command is a
|
|
10679
|
+
* path pinned in someone's config forever.
|
|
10680
|
+
*/
|
|
10681
|
+
function pluginProvider() {
|
|
10682
|
+
return {
|
|
10683
|
+
source: "exec",
|
|
10684
|
+
pluginIntegration: {
|
|
10685
|
+
pluginId: PLUGIN_ID,
|
|
10686
|
+
integrationId: PLUGIN_INTEGRATION_ID
|
|
10687
|
+
}
|
|
10688
|
+
};
|
|
10689
|
+
}
|
|
10690
|
+
/**
|
|
10691
|
+
* The provider block for a plain CLI install, with no plugin.
|
|
10692
|
+
*
|
|
10693
|
+
* `realpathSync` on both paths is not tidiness. OpenClaw's guard rejects a
|
|
10694
|
+
* symlinked command outright, and a Node installed by a version manager is
|
|
10695
|
+
* almost always reached through one — so resolving here is the difference
|
|
10696
|
+
* between a provider that works and a startup failure whose message points at
|
|
10697
|
+
* the config rather than at the shim.
|
|
10698
|
+
*/
|
|
10699
|
+
function execProvider(cliEntry, nodeBinary = process.execPath) {
|
|
10700
|
+
const command = realTo(nodeBinary);
|
|
10701
|
+
const entry = realTo(cliEntry);
|
|
10702
|
+
return {
|
|
10703
|
+
source: "exec",
|
|
10704
|
+
command,
|
|
10705
|
+
args: [
|
|
10706
|
+
entry,
|
|
10707
|
+
"openclaw",
|
|
10708
|
+
"resolve"
|
|
10709
|
+
],
|
|
10710
|
+
passEnv: [...PASS_ENV],
|
|
10711
|
+
jsonOnly: true,
|
|
10712
|
+
timeoutMs: TIMEOUT_MS,
|
|
10713
|
+
noOutputTimeoutMs: TIMEOUT_MS,
|
|
10714
|
+
trustedDirs: [.../* @__PURE__ */ new Set([dirname(command), dirname(entry)])]
|
|
10715
|
+
};
|
|
10716
|
+
}
|
|
10717
|
+
function realTo(path) {
|
|
10718
|
+
try {
|
|
10719
|
+
return realpathSync(path);
|
|
10720
|
+
} catch {
|
|
10721
|
+
return resolve(path);
|
|
10722
|
+
}
|
|
10723
|
+
}
|
|
10724
|
+
/** This CLI's own entry file — what `execProvider` hands to Node. */
|
|
10725
|
+
function cliEntryPath() {
|
|
10726
|
+
return fileURLToPath(new URL("index.js", import.meta.url));
|
|
10727
|
+
}
|
|
10728
|
+
/**
|
|
10729
|
+
* Add the seekrit provider to a parsed config without disturbing anything else.
|
|
10730
|
+
*
|
|
10731
|
+
* An existing `seekrit` entry that differs is left alone unless forced, for the
|
|
10732
|
+
* same reason `seekrit paperclip init` leaves an existing MCP server alone:
|
|
10733
|
+
* someone who pinned a timeout or narrowed `passEnv` did it deliberately, and
|
|
10734
|
+
* silently reverting a security narrowing is the worst kind of helpful.
|
|
10735
|
+
*/
|
|
10736
|
+
function mergeProvider(existing, provider, force) {
|
|
10737
|
+
const secrets = { ...existing.secrets ?? {} };
|
|
10738
|
+
const providers = { ...secrets.providers ?? {} };
|
|
10739
|
+
const current = providers[PROVIDER_ALIAS];
|
|
10740
|
+
if (current && !force && JSON.stringify(current) !== JSON.stringify(provider)) return {
|
|
10741
|
+
merged: existing,
|
|
10742
|
+
changed: false
|
|
10743
|
+
};
|
|
10744
|
+
if (current && JSON.stringify(current) === JSON.stringify(provider)) return {
|
|
10745
|
+
merged: existing,
|
|
10746
|
+
changed: false
|
|
10747
|
+
};
|
|
10748
|
+
providers[PROVIDER_ALIAS] = provider;
|
|
10749
|
+
return {
|
|
10750
|
+
merged: {
|
|
10751
|
+
...existing,
|
|
10752
|
+
secrets: {
|
|
10753
|
+
...secrets,
|
|
10754
|
+
providers
|
|
10755
|
+
}
|
|
10756
|
+
},
|
|
10757
|
+
changed: true
|
|
10758
|
+
};
|
|
10759
|
+
}
|
|
10760
|
+
/** A SecretRef pointing at this provider — what replaces a plaintext key. */
|
|
10761
|
+
function secretRef(id) {
|
|
10762
|
+
return {
|
|
10763
|
+
source: "exec",
|
|
10764
|
+
provider: PROVIDER_ALIAS,
|
|
10765
|
+
id
|
|
10766
|
+
};
|
|
10767
|
+
}
|
|
10768
|
+
/**
|
|
10769
|
+
* Read `openclaw.json`, or decline to.
|
|
10770
|
+
*
|
|
10771
|
+
* The file is JSON5: comments and trailing commas are legal, and a parse/write
|
|
10772
|
+
* round trip through `JSON` would delete every comment in someone's gateway
|
|
10773
|
+
* config. So a file that is not also valid strict JSON is not edited at all —
|
|
10774
|
+
* the caller prints the block to paste instead. Losing a config's comments to
|
|
10775
|
+
* be helpful is worse than asking for one paste.
|
|
10776
|
+
*/
|
|
10777
|
+
function readOpenclawConfig(path) {
|
|
10778
|
+
if (!existsSync(path)) return { config: {} };
|
|
10779
|
+
let raw;
|
|
10780
|
+
try {
|
|
10781
|
+
raw = readFileSync(path, "utf8");
|
|
10782
|
+
} catch (err) {
|
|
10783
|
+
fail(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
10784
|
+
}
|
|
10785
|
+
if (raw.trim().length === 0) return { config: {} };
|
|
10786
|
+
try {
|
|
10787
|
+
const parsed = JSON.parse(raw);
|
|
10788
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { json5: true };
|
|
10789
|
+
return { config: parsed };
|
|
10790
|
+
} catch {
|
|
10791
|
+
return { json5: true };
|
|
10792
|
+
}
|
|
10793
|
+
}
|
|
10794
|
+
function registerOpenclawCommands(program) {
|
|
10795
|
+
const openclaw = program.command("openclaw").description("wire seekrit into OpenClaw as a SecretRef provider (`seekrit openclaw --help`)");
|
|
10796
|
+
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) => {
|
|
10797
|
+
if (options.plugin && options.exec) fail("--plugin and --exec are mutually exclusive");
|
|
10798
|
+
const usePlugin = options.exec !== true;
|
|
10799
|
+
let provider;
|
|
10800
|
+
if (usePlugin) provider = pluginProvider();
|
|
10801
|
+
else {
|
|
10802
|
+
const entry = cliEntryPath();
|
|
10803
|
+
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`);
|
|
10804
|
+
provider = execProvider(entry);
|
|
10805
|
+
}
|
|
10806
|
+
const path = options.config ? resolve(options.config) : join(openclawHome(), "openclaw.json");
|
|
10807
|
+
const read = readOpenclawConfig(path);
|
|
10808
|
+
const json5 = "json5" in read;
|
|
10809
|
+
const merged = json5 ? void 0 : mergeProvider(read.config, provider, options.force === true);
|
|
10810
|
+
const wrote = Boolean(options.write) && merged?.changed === true;
|
|
10811
|
+
if (wrote && merged) writeFileSync(path, `${JSON.stringify(merged.merged, null, 2)}\n`, { mode: 384 });
|
|
10812
|
+
emit(options, {
|
|
10813
|
+
path,
|
|
10814
|
+
provider,
|
|
10815
|
+
wrote,
|
|
10816
|
+
json5
|
|
10817
|
+
}, () => {
|
|
10818
|
+
section("openclaw secret provider");
|
|
10819
|
+
printFields([
|
|
10820
|
+
["config", path],
|
|
10821
|
+
["provider", PROVIDER_ALIAS],
|
|
10822
|
+
["shape", usePlugin ? "plugin integration" : "exec (this CLI)"],
|
|
10823
|
+
["written", wrote ? "yes" : "no"]
|
|
10824
|
+
]);
|
|
10825
|
+
console.log();
|
|
10826
|
+
if (json5) {
|
|
10827
|
+
console.log(`${path} is JSON5 (comments or trailing commas), which a rewrite here would delete.`);
|
|
10828
|
+
console.log("Merge this into it by hand:");
|
|
10829
|
+
} else if (!options.write) console.log("Merge this into your config, or re-run with --write:");
|
|
10830
|
+
else if (!wrote) console.log(`A different \`${PROVIDER_ALIAS}\` provider is already configured — left as it is. Re-run with --force to replace it.`);
|
|
10831
|
+
else console.log("Written. The block now in your config:");
|
|
10832
|
+
console.log();
|
|
10833
|
+
console.log(JSON.stringify({ secrets: { providers: { [PROVIDER_ALIAS]: provider } } }, null, 2));
|
|
10834
|
+
console.log();
|
|
10835
|
+
if (usePlugin) {
|
|
10836
|
+
console.log("Install the plugin, if you have not already:");
|
|
10837
|
+
console.log(" openclaw plugins install npm:@seekrit/openclaw-plugin");
|
|
10838
|
+
console.log(" openclaw plugins enable seekrit");
|
|
10839
|
+
console.log();
|
|
10840
|
+
}
|
|
10841
|
+
console.log("Then replace a plaintext credential with a ref, e.g.:");
|
|
10842
|
+
console.log(` ${JSON.stringify(secretRef("OPENAI_API_KEY"))}`);
|
|
10843
|
+
console.log();
|
|
10844
|
+
console.log("And check your work:");
|
|
10845
|
+
console.log(" openclaw secrets audit --check --allow-exec");
|
|
10846
|
+
console.log();
|
|
10847
|
+
console.log("Resolution is eager: OpenClaw reads every ref once at startup. After a rotation, run `openclaw secrets reload`.");
|
|
10063
10848
|
});
|
|
10064
|
-
const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
10065
|
-
console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
|
|
10066
|
-
if (options.json) console.log(JSON.stringify({
|
|
10067
|
-
...connection,
|
|
10068
|
-
password,
|
|
10069
|
-
url
|
|
10070
|
-
}, null, 2));
|
|
10071
|
-
else console.log(url);
|
|
10072
10849
|
});
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
const
|
|
10076
|
-
|
|
10077
|
-
|
|
10078
|
-
|
|
10079
|
-
console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
|
|
10080
|
-
}
|
|
10850
|
+
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) => {
|
|
10851
|
+
if (Boolean(options.app) !== Boolean(options.env)) fail("--app and --env go together (an id names both, or neither)");
|
|
10852
|
+
const id = options.app && options.env ? `${options.app}/${options.env}/${name}` : name;
|
|
10853
|
+
if (!parseSecretId(id)) fail(`not a valid SecretRef id: "${id}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
|
|
10854
|
+
const ref = secretRef(id);
|
|
10855
|
+
emit(options, ref, () => console.log(JSON.stringify(ref)));
|
|
10081
10856
|
});
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
|
|
10087
|
-
|
|
10857
|
+
openclaw.command("resolve").description("resolve exec SecretRef ids from stdin (OpenClaw calls this; not for humans)").action(async () => {
|
|
10858
|
+
setFailThrows(true);
|
|
10859
|
+
let request;
|
|
10860
|
+
try {
|
|
10861
|
+
request = parseExecRequest(await readStdin());
|
|
10862
|
+
} catch (err) {
|
|
10863
|
+
process.stderr.write(`seekrit: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
10864
|
+
process.exitCode = 2;
|
|
10865
|
+
return;
|
|
10866
|
+
}
|
|
10867
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
10868
|
+
let ctx;
|
|
10869
|
+
const response = await resolveIds(request.ids, (scope) => {
|
|
10870
|
+
const key = scopeKey(scope);
|
|
10871
|
+
const cached = scopes.get(key);
|
|
10872
|
+
if (cached) return cached;
|
|
10873
|
+
ctx ??= buildContext();
|
|
10874
|
+
const pending = resolveEnvironment(ctx, scope);
|
|
10875
|
+
scopes.set(key, pending);
|
|
10876
|
+
return pending;
|
|
10877
|
+
});
|
|
10878
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
10088
10879
|
});
|
|
10089
10880
|
}
|
|
10090
|
-
/**
|
|
10091
|
-
|
|
10092
|
-
|
|
10093
|
-
|
|
10881
|
+
/**
|
|
10882
|
+
* Resolve one environment's full variable set.
|
|
10883
|
+
*
|
|
10884
|
+
* `.env` overlays are deliberately switched off (`envFiles: []`). Everywhere
|
|
10885
|
+
* else in this CLI they are a convenience for a developer's shell; here the
|
|
10886
|
+
* working directory belongs to the OpenClaw gateway, and letting a file in it
|
|
10887
|
+
* override a gateway credential would make a stray `.env` a privilege
|
|
10888
|
+
* escalation. Reference expansion stays on — a `${OTHER_SECRET}` reference is
|
|
10889
|
+
* stored data, not a local override.
|
|
10890
|
+
*/
|
|
10891
|
+
async function resolveEnvironment(ctx, scope) {
|
|
10892
|
+
let envId;
|
|
10893
|
+
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, {
|
|
10894
|
+
app: scope.app,
|
|
10895
|
+
env: scope.env
|
|
10896
|
+
})).envId;
|
|
10897
|
+
const { values } = await materializeEnv(ctx, {
|
|
10898
|
+
envId,
|
|
10899
|
+
envFiles: []
|
|
10900
|
+
});
|
|
10901
|
+
return values;
|
|
10094
10902
|
}
|
|
10095
10903
|
//#endregion
|
|
10096
10904
|
//#region src/orgs.ts
|
|
@@ -11968,193 +12776,6 @@ function registerRotationCommands(program) {
|
|
|
11968
12776
|
console.error(`disabled rotation of ${r.secretName}${rotatorRevoked ? " — rotator key access revoked for this environment" : ""}`);
|
|
11969
12777
|
});
|
|
11970
12778
|
}
|
|
11971
|
-
/**
|
|
11972
|
-
* Fetch + decrypt every secret in a single environment.
|
|
11973
|
-
*
|
|
11974
|
-
* `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
|
|
11975
|
-
* `interpolate`) unless `raw` is set. Only this environment's own secrets are in
|
|
11976
|
-
* scope here — a reference to a secret inherited from a composed group is left
|
|
11977
|
-
* literal, because the group layers aren't fetched. `materializeEnv` is the
|
|
11978
|
-
* fully-layered view.
|
|
11979
|
-
*/
|
|
11980
|
-
async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
|
|
11981
|
-
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
11982
|
-
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
11983
|
-
return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
|
|
11984
|
-
}
|
|
11985
|
-
/**
|
|
11986
|
-
* Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
|
|
11987
|
-
* the CLI's standard fatal exit — it is a config bug with no correct value to
|
|
11988
|
-
* emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
|
|
11989
|
-
*/
|
|
11990
|
-
function interpolateValues(values, enabled = true) {
|
|
11991
|
-
if (!enabled) return {
|
|
11992
|
-
values,
|
|
11993
|
-
interpolated: [],
|
|
11994
|
-
unresolvedRefs: []
|
|
11995
|
-
};
|
|
11996
|
-
try {
|
|
11997
|
-
const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
|
|
11998
|
-
return {
|
|
11999
|
-
values: expandedValues,
|
|
12000
|
-
interpolated: expanded,
|
|
12001
|
-
unresolvedRefs: unresolved
|
|
12002
|
-
};
|
|
12003
|
-
} catch (err) {
|
|
12004
|
-
return fail(err instanceof Error ? err.message : String(err));
|
|
12005
|
-
}
|
|
12006
|
-
}
|
|
12007
|
-
/**
|
|
12008
|
-
* Decrypt one historical version of a secret. Ciphertext is bound to
|
|
12009
|
-
* `(envId, name)` as AAD and neither changes across versions, so an old blob
|
|
12010
|
-
* opens with the environment's current data key — no special handling needed.
|
|
12011
|
-
*/
|
|
12012
|
-
async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
|
|
12013
|
-
const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
|
|
12014
|
-
const row = versions.find((v) => v.version === version);
|
|
12015
|
-
if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
|
|
12016
|
-
return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
|
|
12017
|
-
}
|
|
12018
|
-
async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
12019
|
-
const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
|
|
12020
|
-
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
12021
|
-
}
|
|
12022
|
-
/**
|
|
12023
|
-
* Encrypt and store many secrets into one environment. The DEK is fetched once
|
|
12024
|
-
* (so user auth prompts for the passphrase a single time, not per variable),
|
|
12025
|
-
* then each value is encrypted locally and written. Existing names are
|
|
12026
|
-
* overwritten; the result splits them into created vs. updated for a summary.
|
|
12027
|
-
* Callers validate the names first — a rejected name aborts before any write.
|
|
12028
|
-
*/
|
|
12029
|
-
async function importSecrets(ctx, orgId, envId, entries) {
|
|
12030
|
-
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
12031
|
-
const existing = new Set(secrets.map((s) => s.name));
|
|
12032
|
-
const result = {
|
|
12033
|
-
created: [],
|
|
12034
|
-
updated: []
|
|
12035
|
-
};
|
|
12036
|
-
for (const [name, value] of Object.entries(entries)) {
|
|
12037
|
-
const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
|
|
12038
|
-
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
12039
|
-
(existing.has(name) ? result.updated : result.created).push(name);
|
|
12040
|
-
}
|
|
12041
|
-
return result;
|
|
12042
|
-
}
|
|
12043
|
-
/**
|
|
12044
|
-
* Resolve the full, layered environment for a running app: composed group
|
|
12045
|
-
* secrets (lowest precedence) → the app env's own secrets → `.env` files.
|
|
12046
|
-
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
12047
|
-
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
12048
|
-
* that spawn a process layer it on top so the live shell always wins.
|
|
12049
|
-
*
|
|
12050
|
-
* `${OTHER_SECRET}` references are expanded last, against the merged set, so a
|
|
12051
|
-
* reference always resolves to whichever layer won the name.
|
|
12052
|
-
*/
|
|
12053
|
-
async function materializeEnv(ctx, opts) {
|
|
12054
|
-
const query = {};
|
|
12055
|
-
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
12056
|
-
if (opts.branch) query.branch = opts.branch;
|
|
12057
|
-
if (!isTokenAuth(ctx)) {
|
|
12058
|
-
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
12059
|
-
query.env = opts.envId;
|
|
12060
|
-
}
|
|
12061
|
-
const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
|
|
12062
|
-
const privateKey = await getPrivateKey(ctx);
|
|
12063
|
-
const values = {};
|
|
12064
|
-
const provenance = {};
|
|
12065
|
-
for (const layer of layers) {
|
|
12066
|
-
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
12067
|
-
let label;
|
|
12068
|
-
if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
|
|
12069
|
-
else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
|
|
12070
|
-
else label = `app:${scope.appSlug}/${layer.slug}`;
|
|
12071
|
-
for (const secret of layer.secrets) {
|
|
12072
|
-
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
12073
|
-
provenance[secret.name] = label;
|
|
12074
|
-
}
|
|
12075
|
-
}
|
|
12076
|
-
const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
|
|
12077
|
-
return {
|
|
12078
|
-
...interpolateValues(values, opts.interpolate !== false),
|
|
12079
|
-
provenance,
|
|
12080
|
-
scope,
|
|
12081
|
-
loadedEnvFiles
|
|
12082
|
-
};
|
|
12083
|
-
}
|
|
12084
|
-
/**
|
|
12085
|
-
* Resolve, going through the last-known-good cache when one is configured.
|
|
12086
|
-
*
|
|
12087
|
-
* Always live first: the cache exists for when the call cannot land, not to
|
|
12088
|
-
* save a round trip, so a recovered network is picked up on the very next
|
|
12089
|
-
* invocation. A *refused* resolve (401/403/…) drops the entry rather than
|
|
12090
|
-
* falling back to it — otherwise revoking a token would keep working offline
|
|
12091
|
-
* until the entry aged out.
|
|
12092
|
-
*/
|
|
12093
|
-
async function resolveWithCache(ctx, query, cache) {
|
|
12094
|
-
if (!cache) return ctx.client.resolve(query);
|
|
12095
|
-
try {
|
|
12096
|
-
const response = await ctx.client.resolve(query);
|
|
12097
|
-
try {
|
|
12098
|
-
cache.write(JSON.stringify(response));
|
|
12099
|
-
} catch (err) {
|
|
12100
|
-
warn(`could not update the cache: ${errorMessage(err)}`);
|
|
12101
|
-
}
|
|
12102
|
-
return response;
|
|
12103
|
-
} catch (err) {
|
|
12104
|
-
if (!mayFallBack(err)) {
|
|
12105
|
-
cache.invalidate();
|
|
12106
|
-
throw err;
|
|
12107
|
-
}
|
|
12108
|
-
const found = cache.read();
|
|
12109
|
-
if (found.kind === "hit") {
|
|
12110
|
-
warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
|
|
12111
|
-
return JSON.parse(found.body);
|
|
12112
|
-
}
|
|
12113
|
-
if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
|
|
12114
|
-
else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
|
|
12115
|
-
throw err;
|
|
12116
|
-
}
|
|
12117
|
-
}
|
|
12118
|
-
function warn(text) {
|
|
12119
|
-
process.stderr.write(`seekrit: ${text}\n`);
|
|
12120
|
-
}
|
|
12121
|
-
function errorMessage(err) {
|
|
12122
|
-
return err instanceof Error ? err.message : String(err);
|
|
12123
|
-
}
|
|
12124
|
-
/**
|
|
12125
|
-
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
12126
|
-
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
12127
|
-
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
12128
|
-
* managed secrets are unavailable but `.env` should still apply.
|
|
12129
|
-
*/
|
|
12130
|
-
function overlayEnvFiles(values, provenance, envFiles) {
|
|
12131
|
-
const loaded = [];
|
|
12132
|
-
for (const file of envFiles) {
|
|
12133
|
-
if (!existsSync(file)) continue;
|
|
12134
|
-
loaded.push(file);
|
|
12135
|
-
for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
|
|
12136
|
-
values[name] = value;
|
|
12137
|
-
provenance[name] = `dotenv:${file}`;
|
|
12138
|
-
}
|
|
12139
|
-
}
|
|
12140
|
-
return loaded;
|
|
12141
|
-
}
|
|
12142
|
-
/**
|
|
12143
|
-
* Print a name → source table to stderr (never the secret values). Names whose
|
|
12144
|
-
* value had references expanded are marked, and dangling references are called
|
|
12145
|
-
* out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
|
|
12146
|
-
* deliberately passed through as literal text.
|
|
12147
|
-
*/
|
|
12148
|
-
function printExplain(provenance, refs = {}) {
|
|
12149
|
-
const interpolated = new Set(refs.interpolated ?? []);
|
|
12150
|
-
const names = Object.keys(provenance).sort();
|
|
12151
|
-
const width = names.reduce((w, n) => Math.max(w, n.length), 0);
|
|
12152
|
-
for (const name of names) {
|
|
12153
|
-
const marker = interpolated.has(name) ? " (interpolated)" : "";
|
|
12154
|
-
process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
|
|
12155
|
-
}
|
|
12156
|
-
if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
|
|
12157
|
-
}
|
|
12158
12779
|
//#endregion
|
|
12159
12780
|
//#region src/ssh.ts
|
|
12160
12781
|
/**
|
|
@@ -13689,6 +14310,8 @@ registerProvisionerCommands(program);
|
|
|
13689
14310
|
registerProxyCommands(program);
|
|
13690
14311
|
registerAgentCommands(program);
|
|
13691
14312
|
registerPaperclipCommands(program);
|
|
14313
|
+
registerOpenclawCommands(program);
|
|
14314
|
+
registerHermesCommands(program);
|
|
13692
14315
|
registerSshCommands(program);
|
|
13693
14316
|
registerAwsCommands(program);
|
|
13694
14317
|
registerGcpCommands(program);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seekrit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"seekrit": "./dist/index.js"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
|
+
"./cli-entry": "./dist/index.js",
|
|
13
14
|
"./mcp": "./src/mcp.ts",
|
|
15
|
+
"./openclaw": "./src/openclaw.ts",
|
|
14
16
|
"./proxy-launcher": "./src/proxy-binary.ts"
|
|
15
17
|
},
|
|
16
18
|
"files": [
|
|
@@ -29,8 +31,8 @@
|
|
|
29
31
|
"tsdown": "^0.22.3",
|
|
30
32
|
"vitest": "^4.1.9",
|
|
31
33
|
"@seekrit/api-client": "0.0.1",
|
|
32
|
-
"@seekrit/
|
|
33
|
-
"@seekrit/
|
|
34
|
+
"@seekrit/crypto": "0.0.1",
|
|
35
|
+
"@seekrit/core": "0.0.1"
|
|
34
36
|
},
|
|
35
37
|
"scripts": {
|
|
36
38
|
"build": "tsdown",
|