Files
Arcrun/cli/src/commands/creds.ts
T
Claude 1d19d46161 feat(credentials): T8 回填端點 + T9 治理端點/CLI (credential-store-migration)
- POST /credentials/migrate-to-workers-secrets:舊 KV credential 逐筆解密回填 D1+Workers
  Secrets,冪等可審,重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點
- GET /credentials 改讀 D1(與既有 /credentials/catalog 共用查詢);DELETE 改為新家優先、
  舊 KV fallback,避免孤兒資料
- acr creds list/replace/delete 三支 CLI 薄殼指令;順手修好過期的 acr creds push(舊
  client 端加密格式已被 T5 取代)
- 新增 cypher-executor/tests/credentials.test.ts + D1 test fixture

T6/T7(讀取/注入路徑、雙讀 fallback)需要重新編譯 registry/components/auth_static_key
的 TinyGo WASM,本環境無 tinygo 且 proxy 擋 github.com 下載,卡在工具鏈缺口,詳細分析
記錄在 credential-store-migration.md。

端到端驗證:部署到 leo21c 帳號真實跑過 GET/POST/DELETE 三分支 + migrate 端點(對真實
既存的兩筆 credential 跑,發現 cypher-executor 自己的 ENCRYPTION_KEY secret 疑似為空,
誠實記錄為待 leo/總管裁決的不可逆風險項,未擅自重設)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:00:07 +00:00

168 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* acr creds push/list/replace/delete
*
* credential-store-migration T52026-07-03Arcrun#2)已把 server 端寫入路徑從
* 「client AES-GCM 加密 + {name,encrypted,iv}」改為「明文值 + TLS 傳輸」(§2.4 選項甲:
* arcrun 不再自管 ENCRYPTION_KEY,密文改由 CF Workers Secrets 託管)。T9(§3 治理端點)
* 對應把 CLI 薄殼換成新的 list/replace/delete 三支指令:全部只做「讀 argv/yaml → 呼叫
* cypher-executor API → 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。
*
* 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)。
*/
import { readFileSync } from 'node:fs';
import yaml from 'js-yaml';
import chalk from 'chalk';
import ora from 'ora';
import { loadConfig, getCypherExecutorUrl, type ArcrunConfig } from '../lib/config.js';
function requireApiKey(config: ArcrunConfig): string {
if (!config.api_key) {
if (config.mode === 'self-hosted') {
console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。'));
console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):'));
console.log(chalk.cyan(' NAMESPACE=leo'));
} else {
console.error(chalk.red('缺少 api_key,請重新執行 acr init。'));
}
process.exit(1);
}
return config.api_key;
}
interface CredentialRow {
name: string;
service: string | null;
sensitivity: string;
created_at: number;
last_used_at: number | null;
}
/** acr creds list — 讀 D1 目錄顯示(不含值,D19:讀不回內容物)。 */
export async function cmdCredsList(): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
const baseUrl = getCypherExecutorUrl(config);
const res = await fetch(`${baseUrl}/credentials`, {
headers: { 'X-Arcrun-API-Key': apiKey },
});
const body = (await res.json().catch(() => null)) as
| { success?: boolean; credentials?: CredentialRow[]; error?: string }
| null;
if (!res.ok || !body?.success) {
console.error(chalk.red(`讀取失敗:${body?.error ?? `HTTP ${res.status}`}`));
process.exit(1);
}
const rows = body.credentials ?? [];
if (rows.length === 0) {
console.log(chalk.gray('(尚無 credential'));
return;
}
console.log(chalk.bold(`\n ${rows.length} 個 credential(只顯示目錄,值不可讀回)\n`));
for (const r of rows) {
const lastUsed = r.last_used_at ? new Date(r.last_used_at * 1000).toISOString() : '(從未使用)';
console.log(` ${chalk.cyan(r.name)} service=${r.service ?? '-'} sensitivity=${r.sensitivity} last_used=${lastUsed}`);
}
console.log();
}
/** acr creds replace <name> <value> — 整筆覆寫(只能 replace,不能 edit 局部,D19/§3)。 */
export async function cmdCredsReplace(
name: string,
value: string,
options: { service?: string; sensitivity?: string },
): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
const baseUrl = getCypherExecutorUrl(config);
const spinner = ora(` 覆寫 ${name}`).start();
try {
const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
body: JSON.stringify({ value, service: options.service, sensitivity: options.sensitivity }),
});
const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
if (!res.ok || !body?.success) {
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
spinner.succeed(chalk.green(` ✓ ${name} 已覆寫`));
} catch (e) {
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/** acr creds delete <name> — 刪除 credential(目錄 row + Workers Secret 本體)。 */
export async function cmdCredsDelete(name: string): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
const baseUrl = getCypherExecutorUrl(config);
const spinner = ora(` 刪除 ${name}`).start();
try {
const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: { 'X-Arcrun-API-Key': apiKey },
});
const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
if (!res.ok || !body?.success) {
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
spinner.succeed(chalk.green(` ✓ ${name} 已刪除`));
} catch (e) {
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/**
* acr creds push [credentials.yaml] —(保留 bulk 匯入的既有慣用法,內部改走新的明文 PUT
* 路徑,不再 client 端 AES-GCM 加密——舊實作對應的 server 格式已被 T5 取代,直接沿用會 400)。
*/
export async function cmdCredsPush(filePath: string): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
let creds: Record<string, string>;
try {
const raw = readFileSync(filePath, 'utf8');
creds = yaml.load(raw) as Record<string, string>;
} catch (e) {
console.error(chalk.red(`無法讀取 ${filePath}${e instanceof Error ? e.message : e}`));
process.exit(1);
}
const entries = Object.entries(creds).filter(([, v]) => typeof v === 'string' && v.length > 0);
if (entries.length === 0) {
console.log(chalk.yellow('credentials.yaml 中沒有有效的 credential(請取消注解並填入值)'));
return;
}
const baseUrl = getCypherExecutorUrl(config);
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\n`));
for (const [name, value] of entries) {
const spinner = ora(` ${name}`).start();
try {
const res = await fetch(`${baseUrl}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
body: JSON.stringify({ name, value: String(value) }),
});
if (!res.ok) {
const errBody = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`);
}
spinner.succeed(chalk.green(` ✓ ${name}`));
} catch (e) {
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
}
}
console.log(chalk.gray('\n Credential 已存進 Workers Secretscypher 唯寫,讀不回值)。執行 workflow 時會自動注入。\n'));
}