refactor: dedupe cli config cron and install flows
This commit is contained in:
@@ -210,6 +210,31 @@ function assertNoCancel<T>(value: T | symbol, message: string): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateEnvNameCsv(value: string): string | undefined {
|
||||
const entries = parseCsv(value);
|
||||
for (const entry of entries) {
|
||||
if (!ENV_NAME_PATTERN.test(entry)) {
|
||||
return `Invalid env name: ${entry}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function promptEnvNameCsv(params: {
|
||||
message: string;
|
||||
initialValue: string;
|
||||
}): Promise<string[]> {
|
||||
const raw = assertNoCancel(
|
||||
await text({
|
||||
message: params.message,
|
||||
initialValue: params.initialValue,
|
||||
validate: (value) => validateEnvNameCsv(String(value ?? "")),
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
return parseCsv(String(raw ?? ""));
|
||||
}
|
||||
|
||||
async function promptOptionalPositiveInt(params: {
|
||||
message: string;
|
||||
initialValue?: number;
|
||||
@@ -275,23 +300,10 @@ async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRe
|
||||
async function promptEnvProvider(
|
||||
base?: Extract<SecretProviderConfig, { source: "env" }>,
|
||||
): Promise<Extract<SecretProviderConfig, { source: "env" }>> {
|
||||
const allowlistRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Env allowlist (comma-separated, blank for unrestricted)",
|
||||
initialValue: base?.allowlist?.join(",") ?? "",
|
||||
validate: (value) => {
|
||||
const entries = parseCsv(String(value ?? ""));
|
||||
for (const entry of entries) {
|
||||
if (!ENV_NAME_PATTERN.test(entry)) {
|
||||
return `Invalid env name: ${entry}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const allowlist = parseCsv(String(allowlistRaw ?? ""));
|
||||
const allowlist = await promptEnvNameCsv({
|
||||
message: "Env allowlist (comma-separated, blank for unrestricted)",
|
||||
initialValue: base?.allowlist?.join(",") ?? "",
|
||||
});
|
||||
return {
|
||||
source: "env",
|
||||
...(allowlist.length > 0 ? { allowlist } : {}),
|
||||
@@ -436,22 +448,10 @@ async function promptExecProvider(
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const passEnvRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Pass-through env vars (comma-separated, blank for none)",
|
||||
initialValue: base?.passEnv?.join(",") ?? "",
|
||||
validate: (value) => {
|
||||
const entries = parseCsv(String(value ?? ""));
|
||||
for (const entry of entries) {
|
||||
if (!ENV_NAME_PATTERN.test(entry)) {
|
||||
return `Invalid env name: ${entry}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const passEnv = await promptEnvNameCsv({
|
||||
message: "Pass-through env vars (comma-separated, blank for none)",
|
||||
initialValue: base?.passEnv?.join(",") ?? "",
|
||||
});
|
||||
|
||||
const trustedDirsRaw = assertNoCancel(
|
||||
await text({
|
||||
@@ -486,7 +486,6 @@ async function promptExecProvider(
|
||||
);
|
||||
|
||||
const args = await parseArgsInput(String(argsRaw ?? ""));
|
||||
const passEnv = parseCsv(String(passEnvRaw ?? ""));
|
||||
const trustedDirs = parseCsv(String(trustedDirsRaw ?? ""));
|
||||
|
||||
return {
|
||||
|
||||
@@ -27,6 +27,46 @@ describe("secret ref resolver", () => {
|
||||
return dir;
|
||||
};
|
||||
|
||||
type ExecProviderConfig = {
|
||||
source: "exec";
|
||||
command: string;
|
||||
passEnv?: string[];
|
||||
jsonOnly?: boolean;
|
||||
allowSymlinkCommand?: boolean;
|
||||
trustedDirs?: string[];
|
||||
args?: string[];
|
||||
};
|
||||
|
||||
function createExecProviderConfig(
|
||||
command: string,
|
||||
overrides: Partial<ExecProviderConfig> = {},
|
||||
): ExecProviderConfig {
|
||||
return {
|
||||
source: "exec",
|
||||
command,
|
||||
passEnv: ["PATH"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveExecSecret(
|
||||
command: string,
|
||||
overrides: Partial<ExecProviderConfig> = {},
|
||||
): Promise<string> {
|
||||
return resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: createExecProviderConfig(command, overrides),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-"));
|
||||
const sharedExecDir = path.join(fixtureRoot, "shared-exec");
|
||||
@@ -134,22 +174,7 @@ describe("secret ref resolver", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = await resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: execProtocolV1ScriptPath,
|
||||
passEnv: ["PATH"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const value = await resolveExecSecret(execProtocolV1ScriptPath);
|
||||
expect(value).toBe("value:openai/api-key");
|
||||
});
|
||||
|
||||
@@ -158,23 +183,7 @@ describe("secret ref resolver", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = await resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: execPlainScriptPath,
|
||||
passEnv: ["PATH"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const value = await resolveExecSecret(execPlainScriptPath, { jsonOnly: false });
|
||||
expect(value).toBe("plain-secret");
|
||||
});
|
||||
|
||||
@@ -210,25 +219,9 @@ describe("secret ref resolver", () => {
|
||||
const symlinkPath = path.join(root, "resolver-link.mjs");
|
||||
await fs.symlink(execPlainScriptPath, symlinkPath);
|
||||
|
||||
await expect(
|
||||
resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: symlinkPath,
|
||||
passEnv: ["PATH"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("must not be a symlink");
|
||||
await expect(resolveExecSecret(symlinkPath, { jsonOnly: false })).rejects.toThrow(
|
||||
"must not be a symlink",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows symlink command paths when allowSymlinkCommand is enabled", async () => {
|
||||
@@ -240,25 +233,11 @@ describe("secret ref resolver", () => {
|
||||
await fs.symlink(execPlainScriptPath, symlinkPath);
|
||||
const trustedRoot = await fs.realpath(fixtureRoot);
|
||||
|
||||
const value = await resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: symlinkPath,
|
||||
passEnv: ["PATH"],
|
||||
jsonOnly: false,
|
||||
allowSymlinkCommand: true,
|
||||
trustedDirs: [trustedRoot],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const value = await resolveExecSecret(symlinkPath, {
|
||||
jsonOnly: false,
|
||||
allowSymlinkCommand: true,
|
||||
trustedDirs: [trustedRoot],
|
||||
});
|
||||
expect(value).toBe("plain-secret");
|
||||
});
|
||||
|
||||
@@ -287,44 +266,15 @@ describe("secret ref resolver", () => {
|
||||
await fs.symlink(targetCommand, symlinkCommand);
|
||||
const trustedRoot = await fs.realpath(root);
|
||||
|
||||
await expect(
|
||||
resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: symlinkCommand,
|
||||
args: ["brew"],
|
||||
passEnv: ["PATH"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("must not be a symlink");
|
||||
|
||||
const value = await resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: symlinkCommand,
|
||||
args: ["brew"],
|
||||
allowSymlinkCommand: true,
|
||||
trustedDirs: [trustedRoot],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
await expect(resolveExecSecret(symlinkCommand, { args: ["brew"] })).rejects.toThrow(
|
||||
"must not be a symlink",
|
||||
);
|
||||
|
||||
const value = await resolveExecSecret(symlinkCommand, {
|
||||
args: ["brew"],
|
||||
allowSymlinkCommand: true,
|
||||
trustedDirs: [trustedRoot],
|
||||
});
|
||||
expect(value).toBe("brew:openai/api-key");
|
||||
});
|
||||
|
||||
@@ -337,25 +287,11 @@ describe("secret ref resolver", () => {
|
||||
await fs.symlink(execPlainScriptPath, symlinkPath);
|
||||
|
||||
await expect(
|
||||
resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: symlinkPath,
|
||||
passEnv: ["PATH"],
|
||||
jsonOnly: false,
|
||||
allowSymlinkCommand: true,
|
||||
trustedDirs: [root],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
resolveExecSecret(symlinkPath, {
|
||||
jsonOnly: false,
|
||||
allowSymlinkCommand: true,
|
||||
trustedDirs: [root],
|
||||
}),
|
||||
).rejects.toThrow("outside trustedDirs");
|
||||
});
|
||||
|
||||
@@ -363,73 +299,27 @@ describe("secret ref resolver", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
await expect(
|
||||
resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: execProtocolV2ScriptPath,
|
||||
passEnv: ["PATH"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("protocolVersion must be 1");
|
||||
await expect(resolveExecSecret(execProtocolV2ScriptPath)).rejects.toThrow(
|
||||
"protocolVersion must be 1",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects exec refs when response omits requested id", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
await expect(
|
||||
resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: execMissingIdScriptPath,
|
||||
passEnv: ["PATH"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('response missing id "openai/api-key"');
|
||||
await expect(resolveExecSecret(execMissingIdScriptPath)).rejects.toThrow(
|
||||
'response missing id "openai/api-key"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
await expect(
|
||||
resolveSecretRefString(
|
||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||
{
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
execmain: {
|
||||
source: "exec",
|
||||
command: execInvalidJsonScriptPath,
|
||||
passEnv: ["PATH"],
|
||||
jsonOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("returned invalid JSON");
|
||||
await expect(resolveExecSecret(execInvalidJsonScriptPath, { jsonOnly: true })).rejects.toThrow(
|
||||
"returned invalid JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports file singleValue mode with id=value", async () => {
|
||||
|
||||
Reference in New Issue
Block a user