From 91bee1bd661a3ce32654481b0bd71bb82728c7af Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 27 May 2026 01:46:47 +0000 Subject: [PATCH 1/7] feat(scripts): add live env config writer --- README.md | 6 + package.json | 1 + scripts/ickb-live-config-from-env.mjs | 402 +++++++++++++++++ scripts/ickb-live-config-from-env.test.mjs | 490 +++++++++++++++++++++ 4 files changed, 899 insertions(+) create mode 100644 scripts/ickb-live-config-from-env.mjs create mode 100644 scripts/ickb-live-config-from-env.test.mjs diff --git a/README.md b/README.md index 1081f402..5aa63cd3 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ pnpm --filter @ickb/supervisor build pnpm live:supervisor ``` +To rebuild the standard ignored testnet configs from environment variables without printing private keys, set `ICKB_TESTNET_BOT_PRIVATE_KEY` and `ICKB_TESTNET_TESTER_PRIVATE_KEY`, optionally set `ICKB_TESTNET_RPC_URL`, `ICKB_TESTNET_SLEEP_INTERVAL_SECONDS`, `ICKB_TESTNET_MAX_ITERATIONS`, and `ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS`, then run: + +```bash +pnpm live:config-from-env +``` + By default the supervisor uses ignored `config/bot-testnet.json` and `config/tester-testnet.json`, writes artifacts under ignored `logs/live-supervisor//` paths, and runs deterministic bounded bot/tester commands only. It does not patch, verify, rebuild, relaunch, or invoke an LLM; external loops and operators consume `summary.json` between runs. For repeated bounded invocations, keep loop-owned options before `--` and supervisor options after it: diff --git a/package.json b/package.json index 66983336..a1a93ed1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "forks:ccc:smoke": "node scripts/forks-ccc-smoke.mjs", "forks:ccc:watch": "node scripts/forks-ccc.mjs --watch", "live:generate-config": "node scripts/ickb-generate-config.mjs", + "live:config-from-env": "node scripts/ickb-live-config-from-env.mjs", "live:preflight": "node scripts/ickb-live-preflight.mjs", "live:supervisor": "node apps/supervisor/dist/index.js", "live:supervisor:loop": "node scripts/ickb-supervisor-loop.mjs", diff --git a/scripts/ickb-live-config-from-env.mjs b/scripts/ickb-live-config-from-env.mjs new file mode 100644 index 00000000..333ab26b --- /dev/null +++ b/scripts/ickb-live-config-from-env.mjs @@ -0,0 +1,402 @@ +#!/usr/bin/env node +import { constants } from "node:fs"; +import { link, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; + +const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); +const OUTPUTS = [ + { role: "bot", envName: "ICKB_TESTNET_BOT_PRIVATE_KEY", out: "config/bot-testnet.json" }, + { role: "tester", envName: "ICKB_TESTNET_TESTER_PRIVATE_KEY", out: "config/tester-testnet.json" }, +]; + +export function parseArgs(argv) { + const args = { force: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + if (arg === "-h" || arg === "--help") { + args.help = true; + continue; + } + if (arg === "--force") { + args.force = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return args; +} + +export function usage() { + return [ + "Usage: node scripts/ickb-live-config-from-env.mjs [--force]", + "Required env: ICKB_TESTNET_BOT_PRIVATE_KEY, ICKB_TESTNET_TESTER_PRIVATE_KEY", + "Optional env: ICKB_TESTNET_RPC_URL, ICKB_TESTNET_SLEEP_INTERVAL_SECONDS, ICKB_TESTNET_MAX_ITERATIONS, ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS", + "Writes ignored testnet configs under config/ without printing secrets. Omitted RPC URL lets CCC use its default endpoint.", + ].join("\n"); +} + +export async function runLiveConfigFromEnv({ argv, env = process.env, root = rootDir, dependencies = {} }) { + const args = parseArgs(argv); + if (args.help) { + return { help: usage() }; + } + + const sleepIntervalSeconds = parseOptionalPositiveInteger(env.ICKB_TESTNET_SLEEP_INTERVAL_SECONDS, "ICKB_TESTNET_SLEEP_INTERVAL_SECONDS") ?? 1; + const maxIterations = parseOptionalPositiveInteger(env.ICKB_TESTNET_MAX_ITERATIONS, "ICKB_TESTNET_MAX_ITERATIONS") ?? 1; + const maxRetryableAttempts = parseOptionalPositiveInteger(env.ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS, "ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS") ?? 10; + const rpcUrl = parseOptionalRpcUrl(env.ICKB_TESTNET_RPC_URL, "ICKB_TESTNET_RPC_URL"); + const outputs = OUTPUTS.map((output) => ({ + ...output, + privateKey: parsePrivateKey(env[output.envName], output.envName), + target: outputPath(root, output.out), + })); + for (const output of outputs) { + assertIgnoredPath(root, output.target.relativePath, dependencies.checkIgnored); + } + if (!args.force) { + await assertNoExistingTargets(outputs.map((output) => output.target.absolutePath), dependencies); + } + + for (const output of outputs) { + await makeSafeParentDir(output.target.absolutePath, root, dependencies); + } + + const staged = []; + let caught; + try { + for (const [index, output] of outputs.entries()) { + const config = buildRuntimeConfig({ + privateKey: output.privateKey, + rpcUrl, + sleepIntervalSeconds, + maxIterations, + maxRetryableAttempts, + }); + const tempPath = tempConfigPath(output.target.absolutePath, "tmp", index); + await writeConfigFile(tempPath, `${JSON.stringify(config)}\n`, false, dependencies); + staged.push({ ...output, tempPath }); + } + await commitStagedConfigs(staged, args.force, dependencies); + } catch (error) { + caught = error; + } + try { + await cleanupPaths(staged.map((output) => output.tempPath), dependencies); + } catch (error) { + if (caught === undefined) { + throw error; + } + } + if (caught !== undefined) { + throw caught; + } + + return { + written: outputs.map((output) => ({ + role: output.role, + outputPath: output.target.relativePath, + chain: "testnet", + rpcConfigured: rpcUrl !== undefined, + sleepIntervalSeconds, + maxIterations, + maxRetryableAttempts, + privateKey: "", + })), + }; +} + +export function buildRuntimeConfig({ privateKey, rpcUrl, sleepIntervalSeconds, maxIterations, maxRetryableAttempts }) { + return { + chain: "testnet", + privateKey, + ...(rpcUrl === undefined ? {} : { rpcUrl }), + sleepIntervalSeconds, + maxIterations, + maxRetryableAttempts, + }; +} + +async function commitStagedConfigs(staged, force, dependencies) { + for (const output of staged) { + await assertNoSymlinkTarget(output.target.absolutePath, dependencies); + } + if (force) { + await replaceStagedConfigs(staged, dependencies); + return; + } + await createStagedConfigs(staged, dependencies); +} + +async function createStagedConfigs(staged, dependencies) { + const linked = []; + try { + for (const output of staged) { + await (dependencies.link ?? link)(output.tempPath, output.target.absolutePath); + linked.push(output.target.absolutePath); + } + } catch (error) { + await cleanupPaths(linked, dependencies); + throw error; + } +} + +async function replaceStagedConfigs(staged, dependencies) { + const backups = []; + const installedWithoutBackup = []; + try { + for (const [index, output] of staged.entries()) { + const backupPath = tempConfigPath(output.target.absolutePath, "backup", index); + if (await pathExists(output.target.absolutePath, dependencies)) { + await (dependencies.rename ?? rename)(output.target.absolutePath, backupPath); + backups.push({ targetPath: output.target.absolutePath, backupPath }); + } + } + const backedUpTargets = new Set(backups.map((backup) => backup.targetPath)); + for (const output of staged) { + await (dependencies.rename ?? rename)(output.tempPath, output.target.absolutePath); + if (!backedUpTargets.has(output.target.absolutePath)) { + installedWithoutBackup.push(output.target.absolutePath); + } + } + } catch (error) { + for (const backup of backups.reverse()) { + if (await pathExists(backup.backupPath, dependencies)) { + await (dependencies.rename ?? rename)(backup.backupPath, backup.targetPath); + } + } + await cleanupPaths(installedWithoutBackup, dependencies); + throw error; + } + await cleanupPaths(backups.map((backup) => backup.backupPath), dependencies); +} + +function tempConfigPath(path, kind, index) { + return `${path}.${kind}-${process.pid}-${Date.now()}-${index}`; +} + +async function pathExists(path, dependencies) { + try { + await (dependencies.lstat ?? lstat)(path); + return true; + } catch (error) { + if (isNotFoundError(error)) { + return false; + } + throw error; + } +} + +async function cleanupPaths(paths, dependencies) { + for (const path of paths) { + try { + await (dependencies.unlink ?? unlink)(path); + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + } +} + +function parsePrivateKey(value, envName) { + if (typeof value !== "string") { + throw new Error(`Missing env ${envName}`); + } + if (!/^0x[0-9a-f]{64}$/u.test(value)) { + throw new Error(`Invalid env ${envName}`); + } + const key = BigInt(value); + if (key <= 0n || key >= SECP256K1_ORDER) { + throw new Error(`Invalid env ${envName}`); + } + return value; +} + +function parseOptionalPositiveInteger(value, envName) { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`Invalid env ${envName}`); + } + if (!/^[1-9][0-9]*$/u.test(value)) { + throw new Error(`Invalid env ${envName}`); + } + const parsed = BigInt(value); + if (parsed > MAX_SAFE_INTEGER) { + throw new Error(`Invalid env ${envName}: expected a safe integer`); + } + return Number(parsed); +} + +function parseOptionalRpcUrl(value, envName) { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`Invalid env ${envName}`); + } + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (/\s/u.test(value[index] ?? "") || code < 0x20 || code === 0x7f) { + throw new Error(`Invalid env ${envName}`); + } + } + let url; + try { + url = new URL(value); + } catch { + throw new Error(`Invalid env ${envName}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Invalid env ${envName}`); + } + return value; +} + +async function assertNoExistingTargets(paths, dependencies) { + for (const path of paths) { + try { + await (dependencies.lstat ?? lstat)(path); + throw new Error("Config already exists; rerun with --force to overwrite"); + } catch (error) { + if (isNotFoundError(error)) { + continue; + } + throw error; + } + } +} + +function isNotFoundError(error) { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +function outputPath(root, out) { + const absolutePath = isAbsolute(out) ? out : resolve(root, out); + const relativePath = relative(root, absolutePath); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + throw new Error("Output path must stay inside the repo"); + } + return { absolutePath, relativePath }; +} + +async function writeConfigFile(path, text, force, dependencies) { + if (dependencies.writeFile !== undefined) { + await dependencies.writeFile(path, text, { flag: force ? "w" : "wx", mode: 0o600 }); + return; + } + await assertNoSymlinkTarget(path, dependencies); + await assertRealParent(path, dependencies); + const flags = constants.O_WRONLY | + constants.O_CREAT | + constants.O_NOFOLLOW | + (force ? constants.O_TRUNC : constants.O_EXCL); + const handle = await (dependencies.open ?? open)(path, flags, 0o600); + try { + await handle.writeFile(text, "utf8"); + await handle.chmod(0o600); + } finally { + await handle.close(); + } +} + +async function makeSafeParentDir(path, root, dependencies) { + const parent = dirname(path); + await assertRealAncestor(root, dependencies); + const missing = []; + let current = parent; + for (;;) { + try { + await assertRealAncestor(current, dependencies); + break; + } catch (error) { + if (isNotFoundError(error)) { + missing.push(current); + const next = dirname(current); + if (next === current) { + throw error; + } + current = next; + continue; + } + throw error; + } + } + for (const dir of missing.reverse()) { + await (dependencies.mkdir ?? mkdir)(dir, { mode: 0o700 }); + await assertRealAncestor(dir, dependencies); + } +} + +async function assertRealAncestor(path, dependencies) { + const stat = await (dependencies.lstat ?? lstat)(path); + if (stat.isSymbolicLink()) { + throw new Error("Refusing to write config through symlinked parent directory"); + } +} + +async function assertNoSymlinkTarget(path, dependencies) { + try { + const stat = await (dependencies.lstat ?? lstat)(path); + if (stat.isSymbolicLink()) { + throw new Error("Refusing to write through symlink config path"); + } + } catch (error) { + if (isNotFoundError(error)) { + return; + } + throw error; + } +} + +async function assertRealParent(path, dependencies) { + const parent = dirname(path); + await assertRealAncestor(parent, dependencies); + const resolvedParent = await (dependencies.realpath ?? realpath)(parent); + if (resolvedParent !== parent) { + throw new Error("Refusing to write config through symlinked parent directory"); + } +} + +function assertIgnoredPath(root, relativePath, checkIgnored = defaultCheckIgnored) { + if (!checkIgnored(root, relativePath)) { + throw new Error(`Refusing to write non-ignored config path: ${relativePath}`); + } +} + +function defaultCheckIgnored(root, relativePath) { + const result = spawnSync("git", ["-C", root, "check-ignore", "--", relativePath], { + encoding: "utf8", + }); + return result.status === 0; +} + +export async function main(argv, io = {}) { + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + try { + const result = await runLiveConfigFromEnv({ argv }); + if (result.help !== undefined) { + stdout.write(`${result.help}\n`); + return 0; + } + stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + stderr.write(`Live config rebuild failed: ${message}\n${usage()}\n`); + return 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exit(await main(process.argv.slice(2))); +} diff --git a/scripts/ickb-live-config-from-env.test.mjs b/scripts/ickb-live-config-from-env.test.mjs new file mode 100644 index 00000000..b8f3e38c --- /dev/null +++ b/scripts/ickb-live-config-from-env.test.mjs @@ -0,0 +1,490 @@ +import assert from "node:assert/strict"; +import { constants } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + buildRuntimeConfig, + parseArgs, + runLiveConfigFromEnv, + usage, +} from "./ickb-live-config-from-env.mjs"; + +const botPrivateKey = `0x${"11".repeat(32)}`; +const testerPrivateKey = `0x${"22".repeat(32)}`; + +test("live env config helper parses CLI arguments", () => { + assert.deepEqual(parseArgs([]), { force: false }); + assert.deepEqual(parseArgs(["--force"]), { force: true }); + assert.deepEqual(parseArgs(["--", "--help"]), { force: false, help: true }); + assert.throws(() => parseArgs(["--chain", "testnet"]), /Unknown argument/u); + assert.match(usage(), /ICKB_TESTNET_BOT_PRIVATE_KEY/u); + assert.match(usage(), /ICKB_TESTNET_RPC_URL/u); + assert.match(usage(), /ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS/u); +}); + +test("live env config helper builds configs with optional RPC URL", () => { + assert.deepEqual(buildRuntimeConfig({ + privateKey: botPrivateKey, + rpcUrl: undefined, + sleepIntervalSeconds: 1, + maxIterations: 1, + maxRetryableAttempts: 10, + }), { + chain: "testnet", + privateKey: botPrivateKey, + sleepIntervalSeconds: 1, + maxIterations: 1, + maxRetryableAttempts: 10, + }); + assert.deepEqual(buildRuntimeConfig({ + privateKey: botPrivateKey, + rpcUrl: "https://testnet.example/", + sleepIntervalSeconds: 2, + maxIterations: 3, + maxRetryableAttempts: 4, + }), { + chain: "testnet", + privateKey: botPrivateKey, + rpcUrl: "https://testnet.example/", + sleepIntervalSeconds: 2, + maxIterations: 3, + maxRetryableAttempts: 4, + }); +}); + +test("live env config helper writes both ignored testnet configs from env", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + try { + const result = await runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { + checkIgnored: (_root, relativePath) => relativePath.startsWith("config/"), + }, + }); + + assert.deepEqual(result, { + written: [ + { + role: "bot", + outputPath: "config/bot-testnet.json", + chain: "testnet", + rpcConfigured: false, + sleepIntervalSeconds: 1, + maxIterations: 1, + maxRetryableAttempts: 10, + privateKey: "", + }, + { + role: "tester", + outputPath: "config/tester-testnet.json", + chain: "testnet", + rpcConfigured: false, + sleepIntervalSeconds: 1, + maxIterations: 1, + maxRetryableAttempts: 10, + privateKey: "", + }, + ], + }); + assert.doesNotMatch(JSON.stringify(result), /0x11|0x22/u); + + const botConfigPath = join(dir, "config", "bot-testnet.json"); + const testerConfigPath = join(dir, "config", "tester-testnet.json"); + assert.deepEqual(JSON.parse(await readFile(botConfigPath, "utf8")), { + chain: "testnet", + privateKey: botPrivateKey, + sleepIntervalSeconds: 1, + maxIterations: 1, + maxRetryableAttempts: 10, + }); + assert.deepEqual(JSON.parse(await readFile(testerConfigPath, "utf8")), { + chain: "testnet", + privateKey: testerPrivateKey, + sleepIntervalSeconds: 1, + maxIterations: 1, + maxRetryableAttempts: 10, + }); + assert.equal((await stat(botConfigPath)).mode & 0o777, 0o600); + assert.equal((await stat(testerConfigPath)).mode & 0o777, 0o600); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("live env config helper reports configured RPC URLs without exposing them", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + const written = []; + const dirs = new Set([dir]); + try { + const result = await runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + ICKB_TESTNET_RPC_URL: "https://user:pass@testnet.example/path?token=secret", + ICKB_TESTNET_SLEEP_INTERVAL_SECONDS: "10", + ICKB_TESTNET_MAX_ITERATIONS: "2", + ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS: "3", + }, + dependencies: { + checkIgnored: () => true, + mkdir: async (path) => { + dirs.add(path); + }, + lstat: async (path) => { + if (dirs.has(path)) { + return { isSymbolicLink: () => false }; + } + const error = new Error("missing"); + error.code = "ENOENT"; + throw error; + }, + realpath: async (path) => path, + writeFile: async (path, text, options) => { + written.push({ path, text, options }); + }, + link: async () => undefined, + unlink: async () => undefined, + }, + }); + + assert.equal(written.length, 2); + assert.deepEqual(written.map((entry) => entry.options), [ + { flag: "wx", mode: 0o600 }, + { flag: "wx", mode: 0o600 }, + ]); + assert.deepEqual(written.map((entry) => JSON.parse(entry.text)), [ + { + chain: "testnet", + privateKey: botPrivateKey, + rpcUrl: "https://user:pass@testnet.example/path?token=secret", + sleepIntervalSeconds: 10, + maxIterations: 2, + maxRetryableAttempts: 3, + }, + { + chain: "testnet", + privateKey: testerPrivateKey, + rpcUrl: "https://user:pass@testnet.example/path?token=secret", + sleepIntervalSeconds: 10, + maxIterations: 2, + maxRetryableAttempts: 3, + }, + ]); + assert.deepEqual(result.written.map((entry) => entry.rpcConfigured), [true, true]); + assert.doesNotMatch(JSON.stringify(result), /0x11|0x22|user:pass|token=secret/u); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("live env config helper validates env before writing", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + const writes = []; + try { + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey }, + dependencies: { + checkIgnored: () => true, + writeFile: async () => { + writes.push("write"); + }, + link: async () => undefined, + unlink: async () => undefined, + }, + }), + /Missing env ICKB_TESTNET_TESTER_PRIVATE_KEY/u, + ); + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + ICKB_TESTNET_RPC_URL: "", + }, + dependencies: { + checkIgnored: () => true, + writeFile: async () => { + writes.push("write"); + }, + link: async () => undefined, + unlink: async () => undefined, + }, + }), + /Invalid env ICKB_TESTNET_RPC_URL/u, + ); + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS: "0", + }, + dependencies: { + checkIgnored: () => true, + writeFile: async () => { + writes.push("write"); + }, + link: async () => undefined, + unlink: async () => undefined, + }, + }), + /Invalid env ICKB_TESTNET_MAX_RETRYABLE_ATTEMPTS/u, + ); + assert.deepEqual(writes, []); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("live env config helper refuses non-ignored outputs and existing configs", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + try { + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { checkIgnored: () => false }, + }), + /Refusing to write non-ignored config path/u, + ); + + await runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { checkIgnored: () => true }, + }); + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { checkIgnored: () => true }, + }), + /Config already exists/u, + ); + await runLiveConfigFromEnv({ + argv: ["--force"], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: `0x${"33".repeat(32)}`, + ICKB_TESTNET_TESTER_PRIVATE_KEY: `0x${"44".repeat(32)}`, + }, + dependencies: { checkIgnored: () => true }, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("live env config helper opens output with no-follow and exclusive defaults", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + const opened = []; + const dirs = new Set([dir]); + try { + await runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { + checkIgnored: () => true, + mkdir: async (path) => { + dirs.add(path); + }, + lstat: async (path) => { + if (dirs.has(path)) { + return { isSymbolicLink: () => false }; + } + const error = new Error("missing"); + error.code = "ENOENT"; + throw error; + }, + realpath: async (path) => path, + open: async (path, flags, mode) => { + opened.push({ path, flags, mode }); + return { + writeFile: async () => undefined, + chmod: async () => undefined, + close: async () => undefined, + }; + }, + link: async () => undefined, + unlink: async () => undefined, + }, + }); + + assert.equal(opened.length, 2); + for (const entry of opened) { + assert.equal(entry.flags & constants.O_NOFOLLOW, constants.O_NOFOLLOW); + assert.equal(entry.flags & constants.O_EXCL, constants.O_EXCL); + assert.equal(entry.mode, 0o600); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("live env config helper removes partial create outputs when pair commit fails", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + const dirs = new Set([dir]); + const linked = []; + const unlinked = []; + try { + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { + checkIgnored: () => true, + mkdir: async (path) => { + dirs.add(path); + }, + lstat: async (path) => { + if (dirs.has(path)) { + return { isSymbolicLink: () => false }; + } + const error = new Error("missing"); + error.code = "ENOENT"; + throw error; + }, + realpath: async (path) => path, + writeFile: async () => undefined, + link: async (_from, to) => { + if (linked.length === 1) { + throw new Error("second link failed"); + } + linked.push(to); + }, + unlink: async (path) => { + unlinked.push(path); + }, + }, + }), + /second link failed/u, + ); + + assert.equal(linked.length, 1); + assert.match(linked[0], /config\/bot-testnet\.json$/u); + assert(unlinked.includes(linked[0])); + assert.equal(new Set(unlinked.filter((path) => /\.tmp-/u.test(path))).size, 2); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("live env config helper restores previous configs when forced pair replace fails", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + const botPath = join(dir, "config", "bot-testnet.json"); + const testerPath = join(dir, "config", "tester-testnet.json"); + const dirs = new Set([dir, join(dir, "config")]); + const files = new Set([botPath, testerPath]); + const renames = []; + try { + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: ["--force"], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { + checkIgnored: () => true, + mkdir: async (path) => { + dirs.add(path); + }, + lstat: async (path) => { + if (dirs.has(path) || files.has(path)) { + return { isSymbolicLink: () => false }; + } + const error = new Error("missing"); + error.code = "ENOENT"; + throw error; + }, + realpath: async (path) => path, + writeFile: async (path) => { + files.add(path); + }, + rename: async (from, to) => { + if (/\.tmp-/u.test(from) && to === testerPath) { + throw new Error("second rename failed"); + } + renames.push({ from, to }); + files.delete(from); + files.add(to); + }, + unlink: async (path) => { + files.delete(path); + }, + }, + }), + /second rename failed/u, + ); + + assert(files.has(botPath)); + assert(files.has(testerPath)); + assert.equal([...files].filter((path) => /\.tmp-|\.backup-/u.test(path)).length, 0); + assert(renames.some((entry) => entry.from === botPath && /bot-testnet\.json\.backup-/u.test(entry.to))); + assert(renames.some((entry) => entry.from === testerPath && /tester-testnet\.json\.backup-/u.test(entry.to))); + assert(renames.some((entry) => /bot-testnet\.json\.backup-/u.test(entry.from) && entry.to === botPath)); + assert(renames.some((entry) => /tester-testnet\.json\.backup-/u.test(entry.from) && entry.to === testerPath)); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("live env config helper refuses symlinked output paths", async () => { + const dir = await mkdtemp(join(tmpdir(), "ickb-live-config-env-")); + try { + await mkdir(join(dir, "target")); + await symlink(join(dir, "target"), join(dir, "config"), "dir"); + + await assert.rejects( + () => runLiveConfigFromEnv({ + argv: [], + root: dir, + env: { + ICKB_TESTNET_BOT_PRIVATE_KEY: botPrivateKey, + ICKB_TESTNET_TESTER_PRIVATE_KEY: testerPrivateKey, + }, + dependencies: { checkIgnored: () => true }, + }), + /symlinked parent directory/u, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From 30e72091fb23cab0e7c9793e007a1864404324be Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Wed, 27 May 2026 01:47:26 +0000 Subject: [PATCH 2/7] feat(scripts): extend generated runtime config --- apps/bot/README.md | 4 +- apps/tester/README.md | 4 +- scripts/ickb-generate-config.mjs | 112 +++++++++++++++++--------- scripts/ickb-generate-config.test.mjs | 65 ++++++++++++++- 4 files changed, 143 insertions(+), 42 deletions(-) diff --git a/apps/bot/README.md b/apps/bot/README.md index 7e0bae18..a1481cb4 100644 --- a/apps/bot/README.md +++ b/apps/bot/README.md @@ -13,10 +13,10 @@ The bot minimizes excess iCKB holdings so more liquidity stays available in CKB The bot reads one strict JSON config file named by `BOT_CONFIG_FILE`: ```json -{"chain":"testnet","privateKey":"0x...","rpcUrl":"http://127.0.0.1:8114/","sleepIntervalSeconds":60} +{"chain":"testnet","privateKey":"0x...","sleepIntervalSeconds":60,"maxRetryableAttempts":10} ``` -The JSON config accepts exactly `chain`, `privateKey`, `rpcUrl`, `sleepIntervalSeconds`, and optional `maxIterations`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git. +The JSON config accepts exactly `chain`, `privateKey`, optional `rpcUrl`, `sleepIntervalSeconds`, optional `maxIterations`, and optional `maxRetryableAttempts`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. Omitting `rpcUrl` lets CCC use its default endpoint. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git. Current network support: diff --git a/apps/tester/README.md b/apps/tester/README.md index 5aedc322..7f3e5337 100644 --- a/apps/tester/README.md +++ b/apps/tester/README.md @@ -7,10 +7,10 @@ The tester is now CCC-native. It waits while its own fresh matchable orders are The tester reads one strict JSON config file named by `TESTER_CONFIG_FILE`: ```json -{"chain":"testnet","privateKey":"0x...","rpcUrl":"http://127.0.0.1:8114/","sleepIntervalSeconds":10,"maxIterations":1} +{"chain":"testnet","privateKey":"0x...","sleepIntervalSeconds":10,"maxIterations":1,"maxRetryableAttempts":10} ``` -The JSON config accepts exactly `chain`, `privateKey`, `rpcUrl`, `sleepIntervalSeconds`, and optional `maxIterations`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git. +The JSON config accepts exactly `chain`, `privateKey`, optional `rpcUrl`, `sleepIntervalSeconds`, optional `maxIterations`, and optional `maxRetryableAttempts`. Unknown keys, wrong types, non-HTTP(S) RPC URLs, whitespace/control characters in `rpcUrl`, and non-canonical private keys are rejected. Omitting `rpcUrl` lets CCC use its default endpoint. The private key must be exactly lowercase `0x` plus 64 lowercase hex characters, with no newline, spaces, tabs, or comments. Local config files under `config/` are ignored by git. Current network support: diff --git a/scripts/ickb-generate-config.mjs b/scripts/ickb-generate-config.mjs index 33fdcbfd..68c636dc 100644 --- a/scripts/ickb-generate-config.mjs +++ b/scripts/ickb-generate-config.mjs @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { constants } from "node:fs"; -import { lstat, mkdir, open, realpath, writeFile } from "node:fs/promises"; +import { link, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -21,6 +21,7 @@ export function parseArgs(argv) { role: "bot", sleepIntervalSeconds: 1, maxIterations: 1, + maxRetryableAttempts: 10, force: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -56,6 +57,14 @@ export function parseArgs(argv) { args.maxIterations = parsePositiveInteger(valueAfter(argv, ++index, arg), arg); continue; } + if (arg === "--max-retryable-attempts") { + args.maxRetryableAttempts = parsePositiveInteger(valueAfter(argv, ++index, arg), arg); + continue; + } + if (arg === "--no-max-retryable-attempts") { + args.maxRetryableAttempts = undefined; + continue; + } if (arg === "--no-max-iterations") { args.maxIterations = undefined; continue; @@ -74,8 +83,8 @@ export function parseArgs(argv) { export function usage() { return [ - "Usage: node scripts/ickb-generate-config.mjs [--chain testnet|mainnet] [--role