Files
Arcrun/cli/src/commands/creds.ts
T
uncle6me-web 44b915554b fix(self-hosted): 身份改明碼 namespace(.env)+ path-based webhook trigger
壓測 §7.2:seed 通了但 creds push/push/runtime 全卡「缺少 api_key」——
self-hosted init 從不發 api_key,但三條路徑都建在多租戶 {api_key}:cred 模型上。

richblack 拍板:self-hosted 不需祕密 api_key,只需 namespace(分區標籤):
- config:ENV_MAP 加 NAMESPACE/ENCRYPTION_KEY + .env 自動載入(無 dotenv 依賴)
- namespace 明碼用戶自填(.env NAMESPACE=leo),沿用 api_key 路徑 → 零分叉
- encryption_key 用戶 .env 自填(工具不生成不 hash),須與 worker secret 一致
- creds/push/init:缺值改引導設 .env,不再叫去 register
- runtime:cypher 加 POST /webhooks/named/:ns/:name/trigger(namespace 走 path,
  公開表單免 header);與 header 路徑共用 triggerNamed,不分叉
- push:self-hosted 顯示 path-based 公開 webhook URL

誠實限制:namespace 明碼非密碼;防外部呼叫靠 webhook 保護(mindset §6)。
CLI 1.3.0 → 1.3.1。SDD: self-hosted-init.md §7.7。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:30:16 +08:00

124 lines
5.1 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 [credentials.yaml]
*
* 讀取 credentials.yaml,以 ENCRYPTION_KEY 加密後 POST 至 cypher.arcrun.dev/credentials。
* Server 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV(多租戶隔離)。
*
* 不再需要用戶提供 CF API Token 或 KV Namespace ID。
*/
import { readFileSync } from 'node:fs';
import yaml from 'js-yaml';
import chalk from 'chalk';
import ora from 'ora';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
async function encryptValue(value: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: 'AES-GCM' },
false,
['encrypt'],
);
const ivBytes = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(value);
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, cryptoKey, encoded);
return {
encrypted: Buffer.from(new Uint8Array(cipherBuffer)).toString('base64'),
iv: Buffer.from(ivBytes).toString('base64'),
};
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
export async function cmdCredsPush(filePath: string): Promise<void> {
const config = loadConfig();
if (config.mode === 'local') {
console.error(chalk.red('Local 模式不支援 acr creds push。'));
console.log(chalk.gray('請先執行 acr init 設定 Standard 模式,取得 API Key。'));
process.exit(1);
}
if (!config.api_key) {
// self-hosted 用「資料分區標籤」(明碼,用戶在 .env 設 NAMESPACE)當 KV 前綴,非平台發的 api_key。
if (config.mode === 'self-hosted') {
console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。'));
console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):'));
console.log(chalk.cyan(' NAMESPACE=leo'));
console.log(chalk.gray('(要防外部呼叫請對 webhook 加保護;見 README「讓 AI 連到對的 arcrun」段)'));
} else {
console.error(chalk.red('缺少 api_key,請重新執行 acr init。'));
}
process.exit(1);
}
// 讀取 credentials.yaml
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;
}
// 加密金鑰:優先從 config 讀(含 .env 的 ENCRYPTION_KEY / ARCRUN_ENCRYPTION_KEY,見 config.ts loadDotEnvOnce),
// 其次環境變數。self-hosted:你自己保管這把(工具不生成、不外傳),須與 worker 的 ENCRYPTION_KEY secret 一致。
const encryptionKey = config.encryption_key ?? process.env.ARCRUN_ENCRYPTION_KEY ?? '';
if (!encryptionKey || encryptionKey.length < 64) {
if (config.mode === 'self-hosted') {
console.error(chalk.red('缺少 encryption_key(或長度不足,需 ≥64 hex chars = 256-bit)。'));
console.log(chalk.gray('在專案 .env 設(你自己保管,忘了就解不開已上傳的 credential):'));
console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # 產生:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'));
console.log(chalk.gray('同一把也要設進 workerwrangler secret put ENCRYPTION_KEY(見 acr init 提示)'));
} else {
console.error(chalk.red('缺少 encryption_key。請重新執行 acr init 取得設定。'));
}
process.exit(1);
}
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 { encrypted, iv } = await encryptValue(String(value), encryptionKey);
const res = await fetch(`${baseUrl}/credentials`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Arcrun-API-Key': config.api_key!,
},
body: JSON.stringify({ name, encrypted, iv }),
});
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 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n'));
}