cookbook-bridge 0.1.11 → 0.1.13
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/README.md +28 -6
- package/bridge.mjs +172 -38
- package/chef-persona.md +2 -0
- package/config.example.json +20 -1
- package/device.mjs +41 -4
- package/hands.mjs +260 -6
- package/harden.mjs +240 -1
- package/local.mjs +2 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Changelog
|
|
4
4
|
|
|
5
|
+
**0.1.13** (2026-09-02)
|
|
6
|
+
- Pre-flight: the moment a grant this Bridge hosts becomes active, it runs `env`, the doctor, `cli_versions` and the shape of its own config (tokens stripped) once, locally and read-only, and posts the result as a host-initiated `preflight` call, so the visiting agent starts with what the machine already knows. Remembered per grant in `bridge.state.json`; a failed post is retried once per process.
|
|
7
|
+
- One-click plans: a `plan` call carries `why` and an ordered list of steps. The Bridge recomputes the plan hash before running (a step added after the click is refused as `plan hash mismatch`), runs each step through the same authorization wall as a single call, with the plan's approval standing in for each write-class step's click, stops at the first failure, and reports every step with its duration. Plans cannot nest, cannot contain `preflight`, and run one at a time like every other call.
|
|
8
|
+
- Every result leaving the machine is capped at 64 KB, the same cap the server applies.
|
|
9
|
+
|
|
10
|
+
**0.1.12** (2026-09-02)
|
|
11
|
+
- Kimi Code is the fourth agent: `connect` finds `kimi`, mints a "Kimi" token and writes `~/.kimi-code/mcp.json` (owner-only, other servers kept). Runs use `kimi -p ... --output-format stream-json`; the board gets live text and the work log, resume by session id, and a duration-only receipt (kimi reports no token counts).
|
|
12
|
+
- A headless kimi run approves every tool and has no `--allowedTools` flag, so the Bridge turns the config's `--allowedTools` into a per-run agent file (`--agent-file`, a 0600 temp file) whose tools allowlist is exactly that list. `doctor` fails a Kimi agent that has no `--allowedTools`.
|
|
13
|
+
- Billing protection also hides `KIMI_API_KEY`, `MOONSHOT_API_KEY` and `KIMI_MODEL_API_KEY`.
|
|
14
|
+
- `doctor` gained Kimi rows: version, login (config.toml providers or OAuth credentials), the MCP file, and the two flags a headless run needs.
|
|
15
|
+
|
|
5
16
|
**0.1.11** (2026-09-02)
|
|
6
17
|
- The Bridge has a home: `~/.cookbook/config.json` (file 0600, folder 0700) for every command. `bridge.state.json`, `local.json` and `bridge.log` sit next to it. Before, the config lived next to `bridge.mjs`, so every `npx cookbook-bridge@latest` landed in a fresh cache folder and lost it. A config found next to `bridge.mjs` is copied to the home once (the old file stays) and the move is announced in one line. `--config <path>` and `COOKBOOK_CONFIG` still win.
|
|
7
18
|
- `connect` runs the Bridge right after the approval (pass `--no-run` to stop at "connected"). With no agent CLI found it prints the doctor instead.
|
|
@@ -25,7 +36,7 @@
|
|
|
25
36
|
- Set `COOKBOOK_NO_BROWSER=1` to stop `login`/`connect` from opening a browser (the URL is still printed).
|
|
26
37
|
|
|
27
38
|
|
|
28
|
-
Runs your **own AI agents** (Claude Code, Codex, Gemini) on **your own subscriptions**,
|
|
39
|
+
Runs your **own AI agents** (Claude Code, Codex, Gemini, Kimi Code) on **your own subscriptions**,
|
|
29
40
|
against your Cookbook workspaces — so tasks on the board get done by your agents
|
|
30
41
|
automatically, on your machine, with **no API credits**.
|
|
31
42
|
|
|
@@ -45,7 +56,7 @@ Trust model: your Cookbook's `/security` page.
|
|
|
45
56
|
|
|
46
57
|
```bash
|
|
47
58
|
npx cookbook-bridge@latest connect # ONE approval connects the Bridge AND every installed
|
|
48
|
-
# agent CLI (claude, codex, agy, openclaw), each with its
|
|
59
|
+
# agent CLI (claude, codex, agy, kimi, openclaw), each with its
|
|
49
60
|
# own attributed token, then RUNS the Bridge. Leave it open.
|
|
50
61
|
```
|
|
51
62
|
|
|
@@ -89,7 +100,7 @@ attribution label, "Claude · via you"), then configures each CLI via its own `m
|
|
|
89
100
|
**Account → Tokens** page.
|
|
90
101
|
|
|
91
102
|
Requires **Node 18+** (built-in `fetch`, no npm install) and at least one agent CLI
|
|
92
|
-
installed and logged in (`claude`, `agy` — the Antigravity CLI for Gemini — or the Codex app). Each agent must also be
|
|
103
|
+
installed and logged in (`claude`, `agy` — the Antigravity CLI for Gemini — `kimi`, or the Codex app). Each agent must also be
|
|
93
104
|
connected to Cookbook over MCP — that's how it completes tasks. Run `doctor`; it tells
|
|
94
105
|
you exactly which parts are ready and how to fix the rest.
|
|
95
106
|
|
|
@@ -143,6 +154,8 @@ local files are behind.
|
|
|
143
154
|
"command": ["claude", "-p", "{prompt}", "--allowedTools", "mcp__cookbook__*", "--output-format", "json"] },
|
|
144
155
|
{ "name": "Gemini", "match": ["gemini"], "enabled": true,
|
|
145
156
|
"command": ["agy", "-p", "{prompt}", "--sandbox", "--print-timeout", "3600s"] },
|
|
157
|
+
{ "name": "Kimi", "match": ["kimi"], "enabled": true,
|
|
158
|
+
"command": ["kimi", "-p", "{prompt}", "--allowedTools", "mcp__cookbook__*", "--output-format", "stream-json"] },
|
|
146
159
|
{ "name": "Codex", "match": ["codex", "chatgpt"], "enabled": false, "runner": "app-server",
|
|
147
160
|
"command": ["/Applications/Codex.app/Contents/Resources/codex"] }
|
|
148
161
|
]
|
|
@@ -158,6 +171,13 @@ local files are behind.
|
|
|
158
171
|
usual cause — `doctor` checks it.
|
|
159
172
|
- **Codex (ChatGPT)** runs through `codex app-server` (its headless `exec` can't call
|
|
160
173
|
MCP tools); see `_setup` in `config.example.json` for the 3-step enable.
|
|
174
|
+
- **Kimi Code** runs `kimi -p` with `--output-format stream-json` (live text, work log,
|
|
175
|
+
session resume; no token counts, so the receipt is duration only). Its headless mode
|
|
176
|
+
approves every tool and has no `--allowedTools` flag, so the Bridge turns that value
|
|
177
|
+
into a per-run agent file (`--agent-file`) whose `tools:` allowlist is exactly the
|
|
178
|
+
list. Keep it on the command; `doctor` fails a Kimi agent without it. `connect`
|
|
179
|
+
writes `~/.kimi-code/mcp.json` (kimi has no `mcp add`). Kimi does not stream its
|
|
180
|
+
thinking, so a long think looks like silence to `livenessTimeoutSeconds`.
|
|
161
181
|
- `"default"`: which agent takes tasks assigned to **any**.
|
|
162
182
|
|
|
163
183
|
## What rides into (and out of) every run
|
|
@@ -180,9 +200,11 @@ you opt an agent in, and only tasks explicitly posted as goals are ever eligible
|
|
|
180
200
|
|
|
181
201
|
## Safety rails (on by default)
|
|
182
202
|
|
|
183
|
-
- **Billing protection**: vendor API keys (`ANTHROPIC_API_KEY`
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
- **Billing protection**: vendor API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
|
204
|
+
`GEMINI_API_KEY`, `GOOGLE_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`,
|
|
205
|
+
`KIMI_MODEL_API_KEY`) are hidden from agent processes, so a task can never silently
|
|
206
|
+
bill your API account instead of your subscription. Opt out with
|
|
207
|
+
`"allowApiKeyBilling": true`.
|
|
186
208
|
- **Vulnerable-version gate**: gemini-cli below 0.39.1 (the CVSS-10.0 RCE fix) is refused; agy below 1.1.1 (headless MCP) is refused.
|
|
187
209
|
- **Who can use your agents**: `"acceptFrom": "anyone"` (default) or a list of member
|
|
188
210
|
names (e.g. `["dp", "pierre"]`); per-agent overrides supported. Plus the per-person
|
package/bridge.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import os from "node:os";
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
import { spawn } from "node:child_process";
|
|
36
|
-
import { callsFromStreamLine, foldCallEvent, wireCalls } from "./live.mjs";
|
|
36
|
+
import { callsFromStreamLine, foldCallEvent, wireCalls, shortTool, argFor } from "./live.mjs";
|
|
37
37
|
import { planFromStreamLine, notePlan, planLine } from "./plan.mjs";
|
|
38
38
|
|
|
39
39
|
// Node version guard: below 18 there is no global fetch and none of this runs. One
|
|
@@ -59,6 +59,7 @@ import { resolveAgentForTask } from "./chef.mjs";
|
|
|
59
59
|
import { locateConfig, cli, updateLine, configHome } from "./update.mjs";
|
|
60
60
|
let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, agentsQuery;
|
|
61
61
|
let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig;
|
|
62
|
+
let isKimiCommand, kimiFromLine, kimiResultEnvelope, kimiCommand, kimiLoginState, kimiMcpState, checkKimiVersion;
|
|
62
63
|
let extractUsage, displayText;
|
|
63
64
|
let volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities;
|
|
64
65
|
let buildPrompt, buildThreadFollowUpPrompt;
|
|
@@ -67,15 +68,16 @@ let hasCodexThread, reapCodexServer, killCodexServer;
|
|
|
67
68
|
let checkForUpdate, applyUpdate;
|
|
68
69
|
let createLocalServer, toolsForMode, modeForTools, vendorOf;
|
|
69
70
|
let connectAgentsProgrammatic, detectClis;
|
|
70
|
-
let serveCalls, describeCall, hostingMode, whichExec, argvForSpawn, redactText, resolveCmdShim, killTree;
|
|
71
|
+
let serveCalls, describeCall, hostingMode, whichExec, argvForSpawn, redactText, resolveCmdShim, killTree, runPreflight, grantsNeedingPreflight;
|
|
71
72
|
let fetchHands, claimHandsCall, reportHandsResult;
|
|
72
73
|
|
|
73
74
|
async function loadRuntime() {
|
|
74
75
|
({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
|
|
75
76
|
({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
|
|
76
77
|
({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult, agentsQuery } = await import("./cookbook.mjs"));
|
|
77
|
-
({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn, redact: redactText, resolveCmdShim, killTree } = await import("./hands.mjs"));
|
|
78
|
-
({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig
|
|
78
|
+
({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn, redact: redactText, resolveCmdShim, killTree, runPreflight, grantsNeedingPreflight } = await import("./hands.mjs"));
|
|
79
|
+
({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig,
|
|
80
|
+
isKimiCommand, kimiFromLine, kimiResultEnvelope, kimiCommand, kimiLoginState, kimiMcpState, checkKimiVersion } = await import("./harden.mjs"));
|
|
79
81
|
({ extractUsage, displayText } = await import("./usage.mjs"));
|
|
80
82
|
({ volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities } = await import("./volunteer.mjs"));
|
|
81
83
|
({ buildPrompt, buildThreadFollowUpPrompt } = await import("./prompt.mjs"));
|
|
@@ -123,6 +125,7 @@ function ensureAgentPath() {
|
|
|
123
125
|
if (home) {
|
|
124
126
|
dirs.push(path.join(home, ".claude/local")); // claude CLI local install
|
|
125
127
|
dirs.push(path.join(home, ".bun/bin"), path.join(home, ".local/bin"));
|
|
128
|
+
dirs.push(path.join(process.env.KIMI_CODE_HOME || path.join(home, ".kimi-code"), "bin")); // kimi's install-script binary
|
|
126
129
|
try {
|
|
127
130
|
const base = path.join(home, ".nvm/versions/node");
|
|
128
131
|
const vers = fs.readdirSync(base).sort();
|
|
@@ -405,20 +408,30 @@ export function shouldKill({ streaming, startedAt, lastActivityAt, now, liveness
|
|
|
405
408
|
export function withModel(command, model) {
|
|
406
409
|
if (!Array.isArray(command) || !model) return command;
|
|
407
410
|
const base = String(command[0] ?? "").split(/[\\/]/).pop().toLowerCase();
|
|
408
|
-
|
|
411
|
+
// Kimi: `-m <alias>` (an alias from ~/.kimi-code/config.toml). Same replace rule.
|
|
412
|
+
const kimi = base === "kimi" || base === "kimi.exe";
|
|
413
|
+
if (base !== "claude" && !kimi) return command;
|
|
414
|
+
const flags = kimi ? ["-m", "--model"] : ["--model"];
|
|
409
415
|
const out = [];
|
|
410
416
|
for (let i = 0; i < command.length; i++) {
|
|
411
|
-
if (command[i]
|
|
417
|
+
if (flags.includes(command[i])) { i++; continue; }
|
|
412
418
|
if (String(command[i]).startsWith("--model=")) continue;
|
|
413
419
|
out.push(command[i]);
|
|
414
420
|
}
|
|
415
|
-
out.push("--model", model);
|
|
421
|
+
out.push(kimi ? "-m" : "--model", model);
|
|
416
422
|
return out;
|
|
417
423
|
}
|
|
418
424
|
|
|
419
425
|
export function resumeCommand(command, sessionId) {
|
|
420
426
|
if (!Array.isArray(command) || !sessionId) return { command, resumed: false };
|
|
421
427
|
const base = String(command[0] ?? "").split(/[\\/]/).pop().toLowerCase();
|
|
428
|
+
if (base === "kimi" || base === "kimi.exe") {
|
|
429
|
+
// Kimi resumes with `-S <id>` (the id its session.resume_hint line carried).
|
|
430
|
+
// kimiCommand drops the --allowedTools jail on a resume: the session keeps
|
|
431
|
+
// the agent file it was created with, and kimi refuses --agent-file here.
|
|
432
|
+
if (command.some((a) => a === "-S" || a === "--session" || a === "-r" || a === "--resume")) return { command, resumed: true };
|
|
433
|
+
return { command: [command[0], "-S", sessionId, ...command.slice(1)], resumed: true };
|
|
434
|
+
}
|
|
422
435
|
if (base !== "claude") return { command, resumed: false };
|
|
423
436
|
if (command.includes("--resume")) return { command, resumed: true };
|
|
424
437
|
return { command: [command[0], "--resume", sessionId, ...command.slice(1)], resumed: true };
|
|
@@ -438,7 +451,14 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
438
451
|
// The bearer token leaves argv here: an inline --mcp-config JSON becomes a 0600
|
|
439
452
|
// temp file (harden.mjs materializeMcpConfig), removed when the run ends.
|
|
440
453
|
const mat = materializeMcpConfig ? materializeMcpConfig(command) : { command, cleanup: () => {} };
|
|
441
|
-
|
|
454
|
+
// KIMI JAIL: `--allowedTools X` becomes a per-run agent file (0600) whose tools
|
|
455
|
+
// allowlist is X, because kimi's headless mode approves every tool and has no
|
|
456
|
+
// such flag (harden.mjs kimiCommand). Dropped on a resume, where the session
|
|
457
|
+
// already carries it. Identity for every other CLI.
|
|
458
|
+
const jail = kimiCommand ? kimiCommand(mat.command) : { command: mat.command, cleanup: () => {} };
|
|
459
|
+
const kimi = isKimiCommand ? isKimiCommand(jail.command) : false;
|
|
460
|
+
const cleanupRun = () => { jail.cleanup(); mat.cleanup(); };
|
|
461
|
+
const [cmd, ...rawArgs] = jail.command;
|
|
442
462
|
// PROMPT DELIVERY. claude reads the prompt from stdin when `-p` has no
|
|
443
463
|
// positional prompt (synthesis.mjs relies on the same). That keeps workspace
|
|
444
464
|
// text out of argv: no argument-length ceiling, and on Windows no cmd.exe
|
|
@@ -459,7 +479,7 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
459
479
|
// or refuse with the fix (hands.mjs resolveCmdShim).
|
|
460
480
|
const script = resolveCmdShim ? resolveCmdShim(bare) : null;
|
|
461
481
|
if (!script) {
|
|
462
|
-
|
|
482
|
+
cleanupRun();
|
|
463
483
|
reject(new Error(`refusing to pass a prompt through the ${path.basename(bare)} shell shim on Windows (cmd.exe quoting is not safe for workspace text). Point this agent's command at the CLI's .js entry or its real executable instead.`));
|
|
464
484
|
return;
|
|
465
485
|
}
|
|
@@ -476,7 +496,7 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
476
496
|
...(agent.cwd ? { cwd: agent.cwd } : {}),
|
|
477
497
|
});
|
|
478
498
|
} catch (e) {
|
|
479
|
-
|
|
499
|
+
cleanupRun();
|
|
480
500
|
reject(new Error(`could not launch \`${cmd}\`: ${e.message}`));
|
|
481
501
|
return;
|
|
482
502
|
}
|
|
@@ -499,13 +519,19 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
499
519
|
let partialText = "";
|
|
500
520
|
// Live CALLS (show the work): tool_use/tool_result folded into a capped list.
|
|
501
521
|
let calls = [];
|
|
522
|
+
// Kimi: the last assistant text is the run's answer (no result line exists);
|
|
523
|
+
// it becomes a claude-shaped envelope on close (harden.mjs kimiResultEnvelope).
|
|
524
|
+
let kimiText = "";
|
|
525
|
+
let kimiTurns = 0;
|
|
502
526
|
const liveText = () => {
|
|
503
527
|
const full = partialText ? `${turnsText}${turnsText ? "\n\n" : ""}${partialText}` : turnsText;
|
|
504
528
|
return full.length > LIVE_TEXT_CAP ? "…" + full.slice(-LIVE_TEXT_CAP) : full;
|
|
505
529
|
};
|
|
506
530
|
const emit = () => {
|
|
507
531
|
const text = liveText();
|
|
508
|
-
|
|
532
|
+
// A tick with only calls (a run that reads before it speaks; every kimi run,
|
|
533
|
+
// whose stream carries no token counts) is still worth showing.
|
|
534
|
+
if (!onProgress || (acc.input_tokens === 0 && acc.output_tokens === 0 && !text && !calls.length)) return;
|
|
509
535
|
lastEmit = Date.now();
|
|
510
536
|
// session_ref rides every tick once known: the server-visible resume handle that
|
|
511
537
|
// lets a Composer-thread follow-up continue THIS conversation (0064), surviving
|
|
@@ -541,6 +567,24 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
541
567
|
// text throttle (still ≥300ms apart so a burst of reads is one tick).
|
|
542
568
|
let touched = false;
|
|
543
569
|
for (const ev of callsFromStreamLine(line)) { calls = foldCallEvent(calls, ev); touched = true; }
|
|
570
|
+
if (kimi) {
|
|
571
|
+
// Kimi's lines are keyed by `role` (claude's by `type`), so the claude
|
|
572
|
+
// parsers above ignore them and this is the only reader.
|
|
573
|
+
const kev = kimiFromLine(line);
|
|
574
|
+
if (kev) {
|
|
575
|
+
if (kev.sessionId && !sessionId) sessionId = kev.sessionId;
|
|
576
|
+
if (kev.text) {
|
|
577
|
+
turnsText += (turnsText ? "\n\n" : "") + kev.text;
|
|
578
|
+
kimiText = kev.text;
|
|
579
|
+
kimiTurns++;
|
|
580
|
+
}
|
|
581
|
+
for (const c of kev.calls) {
|
|
582
|
+
calls = foldCallEvent(calls, c.kind === "call" ? { kind: "call", id: c.id, name: shortTool(c.name), arg: argFor(c.input) } : c);
|
|
583
|
+
touched = true;
|
|
584
|
+
}
|
|
585
|
+
if (kev.retry) log(` ↳ ${agent.name}: kimi is retrying its API (${kev.retry.attempt}/${kev.retry.max}): ${kev.retry.error}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
544
588
|
// The CLI also says where the member's plan stands (claude: rate_limit_event).
|
|
545
589
|
// Remembered per vendor; the next heartbeat carries it (bridge/plan.mjs).
|
|
546
590
|
const plan = planFromStreamLine(line);
|
|
@@ -577,15 +621,18 @@ function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
|
|
|
577
621
|
|
|
578
622
|
child.on("error", (e) => {
|
|
579
623
|
clearInterval(watchdog);
|
|
580
|
-
|
|
624
|
+
cleanupRun();
|
|
581
625
|
reject(new Error(`could not launch \`${cmd}\` (is it installed + on PATH?): ${e.message}`));
|
|
582
626
|
});
|
|
583
627
|
child.on("close", (code) => {
|
|
584
628
|
clearInterval(watchdog);
|
|
585
|
-
|
|
629
|
+
cleanupRun();
|
|
586
630
|
// In streaming mode, hand back the final result line (json-mode-identical); fall
|
|
587
631
|
// back to the raw buffer if the run died before emitting one.
|
|
588
|
-
|
|
632
|
+
const kimiOut = kimi && streaming
|
|
633
|
+
? kimiResultEnvelope({ text: kimiText, sessionId, durationMs: Date.now() - startedAt, code, err, numTurns: kimiTurns })
|
|
634
|
+
: null;
|
|
635
|
+
resolve({ code, out: kimiOut ?? (streaming ? (resultLine || lineBuf || out) : out), err, sessionId });
|
|
589
636
|
});
|
|
590
637
|
});
|
|
591
638
|
}
|
|
@@ -694,6 +741,11 @@ function failureHint(result) {
|
|
|
694
741
|
const tail = tailSrc.split("\n").slice(-2).join(" ").slice(0, 240);
|
|
695
742
|
if (infra.includes("not logged in") || infra.includes("please log in"))
|
|
696
743
|
return "the agent CLI isn't logged in → run `claude auth login`";
|
|
744
|
+
// Kimi's own error strings (stderr: "error: failed to run prompt: provider.connection_error: …").
|
|
745
|
+
if (infra.includes("provider.connection_error") || infra.includes("connection error"))
|
|
746
|
+
return "the agent CLI can't reach its API (a network or DNS block on the vendor's hosts) → check the connection, then try `kimi -p hi` by hand";
|
|
747
|
+
if (infra.includes("no provider") || infra.includes("default_model") || infra.includes("no model configured"))
|
|
748
|
+
return "kimi has no provider configured → run `kimi login`";
|
|
697
749
|
if (infra.includes("no mcp") || infra.includes("requires authentication"))
|
|
698
750
|
return `the agent can't reach the Cookbook MCP → run \`${cli("doctor")}\``;
|
|
699
751
|
if (infra.includes("not allowed") || infra.includes("allowedtools"))
|
|
@@ -763,6 +815,7 @@ function loadRunState() {
|
|
|
763
815
|
for (const [id, n] of Object.entries(raw.attempts ?? {})) attempts.set(id, Number(n) || 0);
|
|
764
816
|
for (const id of raw.givenUp ?? []) givenUp.add(id);
|
|
765
817
|
for (const [id, ctx] of Object.entries(raw.retryCtx ?? {})) retryCtx.set(id, ctx);
|
|
818
|
+
for (const id of raw.preflighted ?? []) if (typeof id === "string") preflighted.add(id);
|
|
766
819
|
} catch { /* first run / unreadable — start clean */ }
|
|
767
820
|
}
|
|
768
821
|
function saveRunState() {
|
|
@@ -771,11 +824,15 @@ function saveRunState() {
|
|
|
771
824
|
const attEntries = [...attempts.entries()].slice(-500);
|
|
772
825
|
const given = [...givenUp].slice(-500);
|
|
773
826
|
const retries = [...retryCtx.entries()].slice(-200);
|
|
774
|
-
|
|
827
|
+
const flown = [...preflighted].slice(-500);
|
|
828
|
+
fs.writeFileSync(statePath(), JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries), preflighted: flown }));
|
|
775
829
|
} catch { /* best-effort — never let state persistence break a run */ }
|
|
776
830
|
}
|
|
777
831
|
|
|
778
832
|
const attempts = new Map(); // taskId -> count
|
|
833
|
+
// Grants this host has already pre-flighted (hands section). Persisted: a restart
|
|
834
|
+
// must not post a second pre-flight for a grant the visitor already read.
|
|
835
|
+
const preflighted = new Set();
|
|
779
836
|
// Retry context (Phase 1): what the LAST failed attempt knew — the claude session
|
|
780
837
|
// to resume and the failure to feed back — so a retry continues instead of redoing.
|
|
781
838
|
const retryCtx = new Map(); // taskId -> { sessionId, reason }
|
|
@@ -947,7 +1004,8 @@ function detectAgentsForStatus(cfg) {
|
|
|
947
1004
|
const rows = cfg.agents.map((a) => {
|
|
948
1005
|
const cmd = Array.isArray(a.command) ? a.command[0] : null;
|
|
949
1006
|
const binary = resolveBin(cmd);
|
|
950
|
-
|
|
1007
|
+
const vendor = isKimiCommand && isKimiCommand(a.command) ? "kimi" : (vendorOf ? vendorOf(a) : "other");
|
|
1008
|
+
return { name: a.name, vendor, binary, found: !!binary, enabled: true, runner: a.runner ?? "cli", configured: true };
|
|
951
1009
|
});
|
|
952
1010
|
try {
|
|
953
1011
|
for (const cli of detectClis ? detectClis() : []) {
|
|
@@ -1554,30 +1612,36 @@ function noteAwaiting(awaiting) {
|
|
|
1554
1612
|
if (announcedAwaiting.size > 500) announcedAwaiting.clear();
|
|
1555
1613
|
}
|
|
1556
1614
|
|
|
1615
|
+
/** The host context every hands path shares: serveCalls (visitor calls, plans) and
|
|
1616
|
+
* runPreflight (the host's own first call on a new grant). */
|
|
1617
|
+
function handsContext(cfg) {
|
|
1618
|
+
return {
|
|
1619
|
+
cfg,
|
|
1620
|
+
cfgPath: CONFIG_PATH,
|
|
1621
|
+
home: os.homedir(),
|
|
1622
|
+
// The host's OWN folder list. A granted folder is honoured only if it is here
|
|
1623
|
+
// (or inside one), so the server can never hand a visitor a directory.
|
|
1624
|
+
hostFolders: cfg.hosting?.folders ?? [],
|
|
1625
|
+
doctor: () => doctorReport(["--config", CONFIG_PATH]),
|
|
1626
|
+
claim: (id) => claimHandsCall(cfg, id),
|
|
1627
|
+
report: (id, r) => reportHandsResult(cfg, id, r),
|
|
1628
|
+
// Repair templates reach the same machinery the desktop app's buttons use, so
|
|
1629
|
+
// a fix an agent performs is exactly the fix the host could have clicked.
|
|
1630
|
+
restart: () => reexecSelf(),
|
|
1631
|
+
startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
|
|
1632
|
+
applyConfig: () => applyConfigFromDisk(cfg),
|
|
1633
|
+
log,
|
|
1634
|
+
stopped: () => stopped,
|
|
1635
|
+
visitorLabel: (c) => c.visitor ?? "a visiting agent",
|
|
1636
|
+
onCall: emitHands,
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1557
1640
|
async function serveHands(cfg, calls) {
|
|
1558
1641
|
if (hostingMode(cfg) === "off" || handsBusy || !calls || calls.length === 0) return;
|
|
1559
1642
|
handsBusy = true;
|
|
1560
1643
|
try {
|
|
1561
|
-
await serveCalls(calls,
|
|
1562
|
-
cfg,
|
|
1563
|
-
cfgPath: CONFIG_PATH,
|
|
1564
|
-
home: os.homedir(),
|
|
1565
|
-
// The host's OWN folder list. A granted folder is honoured only if it is here
|
|
1566
|
-
// (or inside one), so the server can never hand a visitor a directory.
|
|
1567
|
-
hostFolders: cfg.hosting?.folders ?? [],
|
|
1568
|
-
doctor: () => doctorReport(["--config", CONFIG_PATH]),
|
|
1569
|
-
claim: (id) => claimHandsCall(cfg, id),
|
|
1570
|
-
report: (id, r) => reportHandsResult(cfg, id, r),
|
|
1571
|
-
// Repair templates reach the same machinery the desktop app's buttons use, so
|
|
1572
|
-
// a fix an agent performs is exactly the fix the host could have clicked.
|
|
1573
|
-
restart: () => reexecSelf(),
|
|
1574
|
-
startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
|
|
1575
|
-
applyConfig: () => applyConfigFromDisk(cfg),
|
|
1576
|
-
log,
|
|
1577
|
-
stopped: () => stopped,
|
|
1578
|
-
visitorLabel: (c) => c.visitor ?? "a visiting agent",
|
|
1579
|
-
onCall: emitHands,
|
|
1580
|
-
});
|
|
1644
|
+
await serveCalls(calls, handsContext(cfg));
|
|
1581
1645
|
} catch (e) {
|
|
1582
1646
|
log(`! hands error: ${e.message}`);
|
|
1583
1647
|
} finally {
|
|
@@ -1585,6 +1649,51 @@ async function serveHands(cfg, calls) {
|
|
|
1585
1649
|
}
|
|
1586
1650
|
}
|
|
1587
1651
|
|
|
1652
|
+
/** Open a HOST-initiated call row on a grant (the pre-flight). Same bearer, same
|
|
1653
|
+
* route the claim uses; the server answers { call_id }. */
|
|
1654
|
+
async function createHostCall(cfg, grantId, verb) {
|
|
1655
|
+
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
|
|
1656
|
+
method: "POST",
|
|
1657
|
+
headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
|
|
1658
|
+
body: JSON.stringify({ grant_id: grantId, verb, host_initiated: true }),
|
|
1659
|
+
});
|
|
1660
|
+
if (!res.ok) throw new Error(`hands ${res.status}`);
|
|
1661
|
+
const j = await res.json().catch(() => ({}));
|
|
1662
|
+
return typeof j.call_id === "string" ? j.call_id : typeof j.call?.id === "string" ? j.call.id : null;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ── PRE-FLIGHT: the first thing a new grant gets is what the machine already knows.
|
|
1666
|
+
// When a grant this Bridge hosts becomes active, the host runs env + doctor +
|
|
1667
|
+
// cli_versions + its own config's shape (tokens stripped) once, locally, read-only,
|
|
1668
|
+
// and posts it as a host-initiated `preflight` call. The visitor reads it from
|
|
1669
|
+
// grant_get before asking anything the machine already answered. Persisted per
|
|
1670
|
+
// grant (bridge.state.json); at most two tries per grant per process.
|
|
1671
|
+
const preflightTries = new Map(); // grantId -> attempts this process
|
|
1672
|
+
async function preflightNewGrants(cfg) {
|
|
1673
|
+
if (hostingMode(cfg) === "off" || handsBusy || !runPreflight) return;
|
|
1674
|
+
const fresh = grantsNeedingPreflight(hands.activeGrants, { preflighted, tried: preflightTries });
|
|
1675
|
+
if (fresh.length === 0) return;
|
|
1676
|
+
handsBusy = true; // never alongside a visitor's call
|
|
1677
|
+
try {
|
|
1678
|
+
for (const grantId of fresh) {
|
|
1679
|
+
if (stopped) break;
|
|
1680
|
+
const n = (preflightTries.get(grantId) ?? 0) + 1;
|
|
1681
|
+
preflightTries.set(grantId, n);
|
|
1682
|
+
log(`◇ pre-flight for grant ${grantId.slice(0, 8)}: env, doctor, CLI versions, config shape`);
|
|
1683
|
+
try {
|
|
1684
|
+
const { callId } = await runPreflight(grantId, { ...handsContext(cfg), create: (gid) => createHostCall(cfg, gid, "preflight") });
|
|
1685
|
+
preflighted.add(grantId);
|
|
1686
|
+
saveRunState();
|
|
1687
|
+
log(` ↳ pre-flight posted (call ${String(callId).slice(0, 8)})`);
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
log(`! pre-flight for grant ${grantId.slice(0, 8)} failed: ${e.message}${n >= 2 ? " (not retrying until the Bridge restarts)" : " (will retry once)"}`);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
} finally {
|
|
1693
|
+
handsBusy = false;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1588
1697
|
/** Poll for granted calls (the net under the push channel, and the whole story on a
|
|
1589
1698
|
* server or network without SSE). No-ops entirely when not hosting. */
|
|
1590
1699
|
async function pollHands(cfg) {
|
|
@@ -1598,6 +1707,7 @@ async function pollHands(cfg) {
|
|
|
1598
1707
|
}
|
|
1599
1708
|
hands.activeGrants = r.grants ?? [];
|
|
1600
1709
|
noteAwaiting(r.awaiting ?? []);
|
|
1710
|
+
await preflightNewGrants(cfg);
|
|
1601
1711
|
await serveHands(cfg, r.calls);
|
|
1602
1712
|
} catch (e) {
|
|
1603
1713
|
if (!/40[13]/.test(e.message)) log(`! couldn't check for granted work: ${e.message}`);
|
|
@@ -1635,7 +1745,7 @@ async function pullOnce(cfg, { boot = false } = {}) {
|
|
|
1635
1745
|
if (j.hands && typeof j.hands === "object") {
|
|
1636
1746
|
hands.activeGrants = j.hands.grants ?? hands.activeGrants;
|
|
1637
1747
|
noteAwaiting(j.hands.awaiting ?? []);
|
|
1638
|
-
if (hostingMode(cfg) !== "off") void serveHands(cfg, j.hands.calls ?? []);
|
|
1748
|
+
if (hostingMode(cfg) !== "off") void preflightNewGrants(cfg).then(() => serveHands(cfg, j.hands.calls ?? []));
|
|
1639
1749
|
}
|
|
1640
1750
|
// 0092: synthesis (summary, caption, vision, answer) thinks HERE, on this
|
|
1641
1751
|
// member's subscription. Each job carries kind, model and image straight
|
|
@@ -1762,7 +1872,7 @@ async function socketLoop(cfg) {
|
|
|
1762
1872
|
const j = JSON.parse(ev.data);
|
|
1763
1873
|
hands.activeGrants = j.grants ?? hands.activeGrants;
|
|
1764
1874
|
noteAwaiting(j.awaiting ?? []);
|
|
1765
|
-
void serveHands(cfg, j.calls ?? []);
|
|
1875
|
+
void preflightNewGrants(cfg).then(() => serveHands(cfg, j.calls ?? []));
|
|
1766
1876
|
} catch { /* malformed frame — the poll covers it */ }
|
|
1767
1877
|
}
|
|
1768
1878
|
}
|
|
@@ -2550,6 +2660,30 @@ async function doctorReport(args) {
|
|
|
2550
2660
|
}
|
|
2551
2661
|
}
|
|
2552
2662
|
|
|
2663
|
+
// Kimi Code: version (no gate), login (config.toml providers / credentials),
|
|
2664
|
+
// the user-level mcp.json the Bridge writes, and the two flags a headless
|
|
2665
|
+
// run needs (the jail and the stream).
|
|
2666
|
+
if (isKimiCommand && isKimiCommand(cmd)) {
|
|
2667
|
+
const { version } = await checkKimiVersion([bin]);
|
|
2668
|
+
if (version) ok(`${agent.name}: kimi ${version}`);
|
|
2669
|
+
else warn(`${agent.name}: couldn't read kimi version`, "run `kimi --version` by hand");
|
|
2670
|
+
const login = kimiLoginState();
|
|
2671
|
+
if (login.loggedIn) ok(`${agent.name}: logged in (${login.providers.length ? `provider ${login.providers.join(", ")}` : `${login.credentials} OAuth credential(s)`})`);
|
|
2672
|
+
else bad(`${agent.name}: no Kimi login found (no provider with a key in ${login.configPath}, nothing under credentials/)`, "run `kimi login` (device code), or `/login` inside `kimi`");
|
|
2673
|
+
const mcp = kimiMcpState({ cookbookUrl: cfg.cookbookUrl });
|
|
2674
|
+
if (mcp.server && mcp.matches && mcp.hasAuth) ok(`${agent.name}: Cookbook MCP configured (${mcp.file})`);
|
|
2675
|
+
else if (mcp.server) warn(`${agent.name}: MCP server 'cookbook' in ${mcp.file} points at ${mcp.url || "(no url)"}${mcp.hasAuth ? "" : " with no Authorization header"}`, `expected ${cfg.cookbookUrl}/api/mcp with a bearer token; re-run \`${cli("connect")}\``);
|
|
2676
|
+
else bad(`${agent.name}: no 'cookbook' server in ${mcp.file}`, `run \`${cli("connect")}\` (kimi has no \`mcp add\`; the Bridge writes this file)`);
|
|
2677
|
+
const cmdArgs = agent.command || [];
|
|
2678
|
+
if (!cmdArgs.includes("--allowedTools")) {
|
|
2679
|
+
bad(`${agent.name}: no --allowedTools in the command; kimi's headless mode auto-approves EVERY tool (Bash included) and has no flag of its own`,
|
|
2680
|
+
`add "--allowedTools", "mcp__cookbook__*" to this agent's command (the Bridge turns it into a per-run agent file)`);
|
|
2681
|
+
} else if (cmdArgs.includes("-S") || cmdArgs.includes("--session") || cmdArgs.includes("-c")) {
|
|
2682
|
+
warn(`${agent.name}: the command resumes a session by hand, so --allowedTools is ignored on that run (kimi binds the agent at session creation)`);
|
|
2683
|
+
}
|
|
2684
|
+
if (!cmdArgs.includes("stream-json")) warn(`${agent.name}: no --output-format stream-json; the board gets no live progress and no work log from this agent`, `add "--output-format", "stream-json" to this agent's command`);
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2553
2687
|
if (isClaudeCommand && isClaudeCommand(agent.command)) {
|
|
2554
2688
|
if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
|
|
2555
2689
|
else warn(`${agent.name}: no per-agent token — runs use the claude CLI's OWN Cookbook login, which may be a different account and inherits stale claude.ai connectors`,
|
package/chef-persona.md
CHANGED
|
@@ -7,3 +7,5 @@ What you know: Cookbook is the shared brain a team's AI agents plug into. Every
|
|
|
7
7
|
How you work: the message you receive carries the rules for this conversation (a grant id, whether you may ask for machine access, and Cookbook Help notes that match the question). Follow those rules exactly; they override anything here. Use the Cookbook tools only. When the answer depends on the person's machine, ask for access the way the rules describe. Never send them to a terminal.
|
|
8
8
|
|
|
9
9
|
You are running on this person's own machine and subscription as their agent. You can see their workspaces. When you need to look at or change their setup, ask for a hands grant in the usual way; every action still waits for their click.
|
|
10
|
+
|
|
11
|
+
When a fix needs more than one change, do not ask for each one. Submit ONE hands_plan with a plain `why` and the steps in order, then wait for the person's single approval; the Bridge runs the steps one at a time and stops at the first failure. Before you ask about the machine, read the pre-flight the host posted on the grant (grant_get): what is installed, the doctor rows, CLI versions and the shape of the Bridge config are already there. Never ask for something the machine has already answered.
|
package/config.example.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"livenessTimeoutSeconds": 300,
|
|
9
9
|
"_concurrency": "How many task runs may be in flight at once. Runs launch in parallel up to this cap; the atomic pre-claim keeps every task single-runner.",
|
|
10
10
|
"maxConcurrentRuns": 3,
|
|
11
|
-
"_billing": "Agents run on the CLI subscriptions you already pay for. The Bridge hides ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY from agent processes so a task can never silently bill your API account instead. Set allowApiKeyBilling to true ONLY if you explicitly want API-key billing.",
|
|
11
|
+
"_billing": "Agents run on the CLI subscriptions you already pay for. The Bridge hides ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY / KIMI_API_KEY / MOONSHOT_API_KEY / KIMI_MODEL_API_KEY from agent processes so a task can never silently bill your API account instead. Set allowApiKeyBilling to true ONLY if you explicitly want API-key billing.",
|
|
12
12
|
"allowApiKeyBilling": false,
|
|
13
13
|
"_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities — decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
|
|
14
14
|
"volunteering": true,
|
|
@@ -57,6 +57,25 @@
|
|
|
57
57
|
"_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause; `npx cookbook-bridge@latest doctor` (tarball: `node bridge/bridge.mjs doctor`) checks it.",
|
|
58
58
|
"_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board — text works too, you just lose the usage report"
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
"name": "Kimi",
|
|
62
|
+
"match": [
|
|
63
|
+
"kimi",
|
|
64
|
+
"moonshot"
|
|
65
|
+
],
|
|
66
|
+
"enabled": true,
|
|
67
|
+
"command": [
|
|
68
|
+
"kimi",
|
|
69
|
+
"-p",
|
|
70
|
+
"{prompt}",
|
|
71
|
+
"--allowedTools",
|
|
72
|
+
"mcp__cookbook__*",
|
|
73
|
+
"--output-format",
|
|
74
|
+
"stream-json"
|
|
75
|
+
],
|
|
76
|
+
"_setup": "Kimi Code CLI (install: curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash, or npm i -g @moonshot-ai/kimi-code; needs 0.39+). Auth: `kimi login` (device code) or /login inside `kimi`; providers live in ~/.kimi-code/config.toml. MCP: `npx cookbook-bridge@latest connect` (tarball: `node bridge/bridge.mjs connect`) writes ~/.kimi-code/mcp.json owner-only (kimi has no `mcp add`). Headless `-p` refuses --yolo/--auto and ALWAYS runs with every tool auto-approved, so --allowedTools is required here: kimi has no such flag, the Bridge turns it into a per-run agent file (--agent-file) whose tools allowlist is exactly this list. Without it, a task's text could run Bash on this machine.",
|
|
77
|
+
"_usage": "kimi's stream-json carries no token counts; the board shows wall-clock duration for its runs. Thinking is not streamed either, so a long think looks like silence: keep livenessTimeoutSeconds generous on a Kimi-heavy board."
|
|
78
|
+
},
|
|
60
79
|
{
|
|
61
80
|
"name": "Codex",
|
|
62
81
|
"match": [
|
package/device.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `connect-agents` runs the same flow but asks for one attributed token per agent
|
|
14
14
|
* CLI found on this machine, then configures each CLI (Claude, Gemini/agy, Codex,
|
|
15
|
-
* OpenClaw). The pieces are exported separately so Bridge Local (local.mjs) can run
|
|
15
|
+
* OpenClaw, Kimi). The pieces are exported separately so Bridge Local (local.mjs) can run
|
|
16
16
|
* the identical flow behind a button: beginDeviceFlow → waitForDeviceToken →
|
|
17
17
|
* saveLoginConfig → configureClis.
|
|
18
18
|
*
|
|
@@ -31,6 +31,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { listWorkspaces } from "./cookbook.mjs";
|
|
33
33
|
import { which, argvForSpawn } from "./hands.mjs";
|
|
34
|
+
import { kimiHome } from "./harden.mjs";
|
|
34
35
|
import { locateConfig, writeConfigFile, cli, updateLine, checkForUpdate } from "./update.mjs";
|
|
35
36
|
|
|
36
37
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -157,6 +158,7 @@ function exampleAgentVendor(a) {
|
|
|
157
158
|
if (base === "claude") return "claude";
|
|
158
159
|
if (base === "agy" || base === "gemini") return "gemini";
|
|
159
160
|
if (base === "codex") return "codex";
|
|
161
|
+
if (base === "kimi" || base === "kimi.exe") return "kimi";
|
|
160
162
|
return "other";
|
|
161
163
|
}
|
|
162
164
|
|
|
@@ -184,7 +186,7 @@ export function seedConfigFromExample(example, found, { log = () => {} } = {}) {
|
|
|
184
186
|
if (a.runner === "app-server" && hit?.path) a.command = [hit.path];
|
|
185
187
|
}
|
|
186
188
|
cfg.agents = kept;
|
|
187
|
-
const preferred = ["claude", "codex", "gemini", "openclaw"];
|
|
189
|
+
const preferred = ["claude", "codex", "gemini", "kimi", "openclaw"];
|
|
188
190
|
const enabled = kept.filter((a) => a.enabled !== false);
|
|
189
191
|
const dflt = preferred.map((v) => enabled.find((a) => exampleAgentVendor(a) === v)).find(Boolean) ?? enabled[0] ?? kept[0] ?? null;
|
|
190
192
|
if (dflt) cfg.default = dflt.name; else delete cfg.default;
|
|
@@ -230,6 +232,36 @@ export function findCodexBinary() {
|
|
|
230
232
|
return which("codex");
|
|
231
233
|
}
|
|
232
234
|
|
|
235
|
+
/** Kimi's install script puts the binary at $KIMI_CODE_HOME/bin/kimi (~/.kimi-code/bin),
|
|
236
|
+
* which a GUI-launched Bridge's PATH does not have; an npm install lands on PATH. */
|
|
237
|
+
export function findKimiBinary({ home = os.homedir(), env = process.env } = {}) {
|
|
238
|
+
const onPath = which("kimi");
|
|
239
|
+
if (onPath) return onPath;
|
|
240
|
+
const c = path.join(kimiHome({ home, env }), "bin", process.platform === "win32" ? "kimi.exe" : "kimi");
|
|
241
|
+
try { fs.accessSync(c, fs.constants.X_OK); return c; } catch { return null; }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Kimi Code has no `mcp add`; merge-write its user-level mcp.json (docs: mcpServers.<name>
|
|
246
|
+
* with `url` + `headers` for HTTP). Other servers are kept; the cookbook entry keeps
|
|
247
|
+
* harmless tuning (timeouts, tool lists) and gets exactly one way in: this bearer.
|
|
248
|
+
* The file carries a token, so it is owner-only like config.json.
|
|
249
|
+
*/
|
|
250
|
+
export function kimiConfigure(url, token, { home = os.homedir(), env = process.env } = {}) {
|
|
251
|
+
const cfgPath = path.join(kimiHome({ home, env }), "mcp.json");
|
|
252
|
+
let current = {};
|
|
253
|
+
try { current = JSON.parse(fs.readFileSync(cfgPath, "utf8")) ?? {}; } catch { /* fresh file */ }
|
|
254
|
+
if (typeof current !== "object" || Array.isArray(current)) current = {};
|
|
255
|
+
const servers = current.mcpServers && typeof current.mcpServers === "object" && !Array.isArray(current.mcpServers) ? current.mcpServers : {};
|
|
256
|
+
const prev = servers.cookbook && typeof servers.cookbook === "object" ? servers.cookbook : {};
|
|
257
|
+
const { headers: _h, bearerTokenEnvVar: _b, transport: _t, url: _u, serverUrl: _s, command: _c, args: _a, env: _e, cwd: _w, enabled: _en, ...rest } = prev;
|
|
258
|
+
current.mcpServers = { ...servers, cookbook: { ...rest, url, headers: { Authorization: `Bearer ${token}` } } };
|
|
259
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
260
|
+
fs.writeFileSync(cfgPath, JSON.stringify(current, null, 2) + "\n", { mode: 0o600 });
|
|
261
|
+
try { fs.chmodSync(cfgPath, 0o600); } catch { /* best-effort on platforms without chmod */ }
|
|
262
|
+
return cfgPath;
|
|
263
|
+
}
|
|
264
|
+
|
|
233
265
|
/** agy (Antigravity) has no `mcp add`; merge-write its documented config file. */
|
|
234
266
|
function agyConfigure(url, token) {
|
|
235
267
|
const cfgPath = path.join(process.env.HOME || os.homedir(), ".gemini", "config", "mcp_config.json");
|
|
@@ -312,6 +344,8 @@ export function detectClis() {
|
|
|
312
344
|
if (agy) out.push({ agent: "Gemini", vendor: "gemini", path: agy, kind: "file" });
|
|
313
345
|
const codex = findCodexBinary();
|
|
314
346
|
if (codex) out.push({ agent: "Codex", vendor: "codex", path: codex, kind: "codex" });
|
|
347
|
+
const kimi = findKimiBinary();
|
|
348
|
+
if (kimi) out.push({ agent: "Kimi", vendor: "kimi", path: kimi, kind: "kimi" });
|
|
315
349
|
const openclaw = which("openclaw");
|
|
316
350
|
if (openclaw) out.push({ agent: "OpenClaw", vendor: "openclaw", path: openclaw, kind: "openclaw" });
|
|
317
351
|
return out;
|
|
@@ -355,6 +389,9 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
|
|
|
355
389
|
} else if (cli.kind === "openclaw") {
|
|
356
390
|
const wrote = openclawConfigure(mcpUrl, token);
|
|
357
391
|
results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote}, streamable-http)` });
|
|
392
|
+
} else if (cli.kind === "kimi") {
|
|
393
|
+
const wrote = kimiConfigure(mcpUrl, token);
|
|
394
|
+
results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote})` });
|
|
358
395
|
}
|
|
359
396
|
} catch (e) {
|
|
360
397
|
results.push({ agent: cli.agent, ok: false, detail: String(e?.message || e).slice(0, 200) });
|
|
@@ -542,14 +579,14 @@ function probeAgent(cmd) {
|
|
|
542
579
|
/**
|
|
543
580
|
* connect-agents — one command, one human approval, every installed agent CLI
|
|
544
581
|
* connected to Cookbook with CORRECT ATTRIBUTION. Detects Claude, Gemini (agy),
|
|
545
|
-
* Codex (ChatGPT app) and OpenClaw; mints one named token per agent in the same
|
|
582
|
+
* Codex (ChatGPT app), Kimi and OpenClaw; mints one named token per agent in the same
|
|
546
583
|
* approval as the Bridge token; configures each via its official path.
|
|
547
584
|
*/
|
|
548
585
|
export async function connectAgents(argv, { willRun = false } = {}) {
|
|
549
586
|
const cfgPath = configPath(argv);
|
|
550
587
|
const found = detectClis();
|
|
551
588
|
if (found.length === 0) {
|
|
552
|
-
console.log("\nNo agent CLIs found (looked for: claude, agy, codex/ChatGPT.app, openclaw).");
|
|
589
|
+
console.log("\nNo agent CLIs found (looked for: claude, agy, codex/ChatGPT.app, kimi, openclaw).");
|
|
553
590
|
console.log(`Install one, then re-run: ${cli("connect")}\n`);
|
|
554
591
|
return { ok: false, reason: "no-agents", cfgPath };
|
|
555
592
|
}
|
package/hands.mjs
CHANGED
|
@@ -28,8 +28,10 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import os from "node:os";
|
|
31
|
+
import { kimiLoginState } from "./harden.mjs";
|
|
31
32
|
import path from "node:path";
|
|
32
33
|
import { spawn, spawnSync } from "node:child_process";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
33
35
|
|
|
34
36
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
37
|
// REDACTION
|
|
@@ -138,6 +140,7 @@ export const SETUP_FILES = Object.freeze([
|
|
|
138
140
|
".codex-bridge/config.toml", // the Bridge's own Codex home
|
|
139
141
|
".gemini/config/mcp_config.json", // agy's MCP config (agy has no `mcp add`)
|
|
140
142
|
".gemini/antigravity-cli/settings.json",
|
|
143
|
+
".kimi-code/mcp.json", // kimi's MCP servers (kimi has no `mcp add`)
|
|
141
144
|
".openclaw/openclaw.json",
|
|
142
145
|
".cookbook/config.json", // Bridge config (projected: tokens stripped)
|
|
143
146
|
".cookbook/bridge.state.json", // attempt counters (projected)
|
|
@@ -173,6 +176,8 @@ const NEVER_READ = Object.freeze([
|
|
|
173
176
|
/(^|\/)\.codex\/auth\.json$/i,
|
|
174
177
|
/(^|\/)\.codex-bridge\/auth\.json$/i,
|
|
175
178
|
/(^|\/)\.gemini\/oauth_creds\.json$/i,
|
|
179
|
+
/(^|\/)\.kimi-code\/credentials(\/|$)/i, // kimi OAuth credentials (dir + files)
|
|
180
|
+
/(^|\/)\.kimi-code\/config\.toml$/i, // kimi keeps provider API keys HERE, not in env
|
|
176
181
|
/(^|\/)\.openclaw\/(auth|credentials)[^/]*$/i,
|
|
177
182
|
/(^|\/)\.ssh(\/|$)/i,
|
|
178
183
|
/(^|\/)\.gnupg(\/|$)/i,
|
|
@@ -310,6 +315,17 @@ const PROJECTIONS = Object.freeze({
|
|
|
310
315
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
311
316
|
const MAX_OUTPUT_CHARS = 40_000;
|
|
312
317
|
const MAX_DIR_ENTRIES = 400;
|
|
318
|
+
/** The most a single call result may weigh when it leaves this machine. The server
|
|
319
|
+
* applies the same cap (src/lib/workspaces/grants.ts MAX_OUTPUT_BYTES); doing it
|
|
320
|
+
* here too means a plan of twenty steps or a pre-flight cannot outgrow a receipt. */
|
|
321
|
+
export const MAX_UPLOAD_BYTES = 64 * 1024;
|
|
322
|
+
/** Trim an output to MAX_UPLOAD_BYTES the same way the server does. Pure. */
|
|
323
|
+
export function capOutput(output) {
|
|
324
|
+
if (output === null || output === undefined) return output;
|
|
325
|
+
const json = JSON.stringify(output);
|
|
326
|
+
if (typeof json !== "string" || json.length <= MAX_UPLOAD_BYTES) return output;
|
|
327
|
+
return { truncated: true, bytes: json.length, preview: json.slice(0, MAX_UPLOAD_BYTES) };
|
|
328
|
+
}
|
|
313
329
|
|
|
314
330
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
315
331
|
// THE LOCAL CEILING — what this machine will EVER do, regardless of what it is told
|
|
@@ -325,7 +341,10 @@ const MAX_DIR_ENTRIES = 400;
|
|
|
325
341
|
* hand a visitor a directory — only the host's OWN config can (cfg.hosting.folders).
|
|
326
342
|
*/
|
|
327
343
|
export const LOCAL_CEILING = Object.freeze({
|
|
328
|
-
|
|
344
|
+
// `plan` is a container, not a capability: its steps are each checked on their own
|
|
345
|
+
// (executePlan), so listing it here widens nothing. `preflight` is deliberately
|
|
346
|
+
// absent: the host runs it on its own initiative and never accepts it as a call.
|
|
347
|
+
verbs: Object.freeze(["doctor", "env", "read_file", "list_dir", "run", "write_file", "restore_backup", "open_url", "plan"]),
|
|
329
348
|
run_allow: Object.freeze([
|
|
330
349
|
// read
|
|
331
350
|
"node_version", "claude_mcp_list", "cli_versions", "tail_log",
|
|
@@ -397,6 +416,11 @@ export const VERB_RISK = Object.freeze({
|
|
|
397
416
|
write_file: "write",
|
|
398
417
|
restore_backup: "write",
|
|
399
418
|
open_url: "login",
|
|
419
|
+
// A plan always waits for the host's single click, whatever its steps: "write"
|
|
420
|
+
// is the label that makes the generic wall say so. Pre-flight is read-only by
|
|
421
|
+
// construction (env, doctor, CLI versions, the config's shape).
|
|
422
|
+
plan: "write",
|
|
423
|
+
preflight: "read",
|
|
400
424
|
});
|
|
401
425
|
|
|
402
426
|
export const RUN_TEMPLATE_RISK = Object.freeze({
|
|
@@ -812,6 +836,7 @@ const VERBS = {
|
|
|
812
836
|
claude: marker(".claude/.credentials.json"),
|
|
813
837
|
codex: marker(".codex/auth.json"),
|
|
814
838
|
gemini: marker(".gemini/oauth_creds.json"),
|
|
839
|
+
kimi: kimiLoginState({ home }).loggedIn,
|
|
815
840
|
openclaw: marker(".openclaw"),
|
|
816
841
|
},
|
|
817
842
|
setup_files_present: SETUP_FILES.filter((rel) => marker(rel)),
|
|
@@ -949,9 +974,27 @@ const VERBS = {
|
|
|
949
974
|
stderr: String(res.stderr).slice(0, 4000),
|
|
950
975
|
};
|
|
951
976
|
},
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* PRE-FLIGHT: everything a visiting agent would otherwise spend its first four
|
|
980
|
+
* calls asking. Read-only by construction. The host runs it on its own initiative
|
|
981
|
+
* the moment a grant becomes active (runPreflight) and posts it as a
|
|
982
|
+
* host-initiated call; a queued `preflight` from the server is refused by the wall.
|
|
983
|
+
*/
|
|
984
|
+
async preflight(_args, ctx) {
|
|
985
|
+
return collectPreflight(ctx);
|
|
986
|
+
},
|
|
987
|
+
|
|
988
|
+
/** A plan is executed by executePlan, never as a bare verb: executeCall routes
|
|
989
|
+
* it there before this is reached. Kept in the table so the wall knows the name. */
|
|
990
|
+
async plan() {
|
|
991
|
+
return { error: "A plan runs through executePlan, one step at a time." };
|
|
992
|
+
},
|
|
952
993
|
};
|
|
953
994
|
|
|
954
995
|
export const VERB_NAMES = Object.freeze(Object.keys(VERBS));
|
|
996
|
+
/** Verbs that contain or replace other calls. Never allowed inside a plan. */
|
|
997
|
+
export const META_VERBS = Object.freeze(["plan", "preflight"]);
|
|
955
998
|
|
|
956
999
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
957
1000
|
// EXECUTION
|
|
@@ -962,11 +1005,21 @@ export const VERB_NAMES = Object.freeze(Object.keys(VERBS));
|
|
|
962
1005
|
* whole authorization decision is unit-testable without a machine to break.
|
|
963
1006
|
* Returns { ok: true, risk } or { ok: false, error }.
|
|
964
1007
|
*/
|
|
965
|
-
export function authorizeCall(call, scope) {
|
|
1008
|
+
export function authorizeCall(call, scope, opts = {}) {
|
|
966
1009
|
if (!call || typeof call.verb !== "string") return { ok: false, error: "Malformed call." };
|
|
967
1010
|
if (!scope || typeof scope !== "object") return { ok: false, error: "No grant scope." };
|
|
968
1011
|
const verb = call.verb;
|
|
969
1012
|
if (!VERBS[verb]) return { ok: false, error: `This Bridge has no verb '${verb}'.` };
|
|
1013
|
+
// The host runs its own pre-flight; it is never something a visitor queues.
|
|
1014
|
+
if (verb === "preflight") return { ok: false, error: "Pre-flight runs on the host's own initiative, never as a queued call." };
|
|
1015
|
+
// A PLAN is a container. Its permission is exactly the permission of its steps,
|
|
1016
|
+
// each of which is checked on its own inside executePlan, so the plan row itself
|
|
1017
|
+
// needs only the one thing a container can carry: the host's click.
|
|
1018
|
+
if (verb === "plan") {
|
|
1019
|
+
if (opts.approvedByPlan) return { ok: false, error: "A plan can't contain another plan." };
|
|
1020
|
+
if (call.status !== "approved") return { ok: false, error: "A plan needs the host's approval before it can run here." };
|
|
1021
|
+
return { ok: true, risk: VERB_RISK.plan };
|
|
1022
|
+
}
|
|
970
1023
|
if (!Array.isArray(scope.verbs) || !scope.verbs.includes(verb)) {
|
|
971
1024
|
return { ok: false, error: `The grant doesn't allow '${verb}'.` };
|
|
972
1025
|
}
|
|
@@ -986,8 +1039,10 @@ export function authorizeCall(call, scope) {
|
|
|
986
1039
|
}
|
|
987
1040
|
// THE CONSENT WALL: when the grant says a class needs a human, this machine runs
|
|
988
1041
|
// it only if the host has already flipped the row to `approved`. A UI bug, or a
|
|
989
|
-
// server that sends it as `queued`, is refused here.
|
|
990
|
-
|
|
1042
|
+
// server that sends it as `queued`, is refused here. Inside a plan the host's one
|
|
1043
|
+
// click on the plan row is the click for every step (`approvedByPlan`), and
|
|
1044
|
+
// executePlan sets that flag only when the plan row itself said `approved`.
|
|
1045
|
+
if (policy === "ask" && call.status !== "approved" && opts.approvedByPlan !== true) {
|
|
991
1046
|
return { ok: false, error: `That needs the host's approval before it can run here.` };
|
|
992
1047
|
}
|
|
993
1048
|
return { ok: true, risk };
|
|
@@ -998,12 +1053,13 @@ export function authorizeCall(call, scope) {
|
|
|
998
1053
|
* ALWAYS resolves — a thrown verb becomes a failed call, never a crashed Bridge.
|
|
999
1054
|
*/
|
|
1000
1055
|
export async function executeCall(call, ctx) {
|
|
1056
|
+
if (call?.verb === "plan") return executePlan(call, ctx);
|
|
1001
1057
|
const home = ctx.home ?? os.homedir();
|
|
1002
1058
|
// NEVER the server's scope as sent — always its intersection with what this
|
|
1003
1059
|
// machine will do at all. This is the line that makes "a lying server cannot make
|
|
1004
1060
|
// your laptop run something you didn't grant" a true statement instead of a hope.
|
|
1005
1061
|
const scope = effectiveScope(call.scope ?? ctx.scope, { hostFolders: ctx.hostFolders ?? [], home });
|
|
1006
|
-
const auth = authorizeCall(call, scope);
|
|
1062
|
+
const auth = authorizeCall(call, scope, { approvedByPlan: ctx.approvedByPlan === true });
|
|
1007
1063
|
if (!auth.ok) return { status: "denied", output: null, error: auth.error };
|
|
1008
1064
|
|
|
1009
1065
|
const args = call.args && typeof call.args === "object" ? call.args : {};
|
|
@@ -1015,12 +1071,204 @@ export async function executeCall(call, ctx) {
|
|
|
1015
1071
|
// the visitor should see it as such and try something else.
|
|
1016
1072
|
return { status: "failed", output: null, error: clean.error };
|
|
1017
1073
|
}
|
|
1018
|
-
return { status: "done", output: clean, error: null };
|
|
1074
|
+
return { status: "done", output: capOutput(clean), error: null };
|
|
1019
1075
|
} catch (e) {
|
|
1020
1076
|
return { status: "failed", output: null, error: redact(String(e?.message ?? e), { home }).slice(0, 500) };
|
|
1021
1077
|
}
|
|
1022
1078
|
}
|
|
1023
1079
|
|
|
1080
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1081
|
+
// PLANS — one click, several steps, the same wall for each
|
|
1082
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1083
|
+
|
|
1084
|
+
/** The most steps one click may cover. A fix needs three or four; twenty is a script. */
|
|
1085
|
+
export const PLAN_MAX_STEPS = 20;
|
|
1086
|
+
|
|
1087
|
+
/** The hash the server stamps on a plan row: sha256 of the steps EXACTLY as sent.
|
|
1088
|
+
* Recomputed here before a plan runs, so nothing can be appended after the click. Pure. */
|
|
1089
|
+
export function planHash(steps) {
|
|
1090
|
+
return createHash("sha256").update(JSON.stringify(steps)).digest("hex");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Is this plan the plan the host clicked? Structure, size, no nested containers, and
|
|
1095
|
+
* the hash. Returns { ok: true, steps, why } or { ok: false, error }. Pure.
|
|
1096
|
+
*/
|
|
1097
|
+
export function validatePlan(call) {
|
|
1098
|
+
const args = call?.args && typeof call.args === "object" ? call.args : {};
|
|
1099
|
+
const steps = args.steps;
|
|
1100
|
+
if (!Array.isArray(steps) || steps.length === 0) return { ok: false, error: "A plan needs at least one step." };
|
|
1101
|
+
if (steps.length > PLAN_MAX_STEPS) return { ok: false, error: `A plan may have at most ${PLAN_MAX_STEPS} steps; this one has ${steps.length}.` };
|
|
1102
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1103
|
+
const step = steps[i];
|
|
1104
|
+
if (!step || typeof step !== "object" || typeof step.verb !== "string") return { ok: false, error: `Step ${i + 1} is malformed (needs a verb).` };
|
|
1105
|
+
if (META_VERBS.includes(step.verb)) return { ok: false, error: `Step ${i + 1} is '${step.verb}', which can't be inside a plan.` };
|
|
1106
|
+
if (step.args !== undefined && (step.args === null || typeof step.args !== "object" || Array.isArray(step.args))) {
|
|
1107
|
+
return { ok: false, error: `Step ${i + 1} has malformed args.` };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// THE HASH: the server cannot append a step after the host clicked, because the
|
|
1111
|
+
// click was on this exact list. A missing hash is a mismatch too.
|
|
1112
|
+
if (typeof call.plan_hash !== "string" || planHash(steps) !== call.plan_hash) return { ok: false, error: "plan hash mismatch" };
|
|
1113
|
+
return { ok: true, steps, why: typeof args.why === "string" ? args.why : "" };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The word for a step in the log: the template for a run, the verb otherwise. */
|
|
1117
|
+
function stepLabel(step) {
|
|
1118
|
+
if (step?.verb === "run") return String(step.args?.template ?? "run");
|
|
1119
|
+
return String(step?.verb ?? "?");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Run an approved plan step by step, through the SAME authorizeCall + executeCall
|
|
1124
|
+
* path a single call takes. The one difference is `approvedByPlan`: the host's click
|
|
1125
|
+
* on the plan row is the click for every write-class step inside it, and it is set
|
|
1126
|
+
* only when the plan row itself is `approved`. Every step is still measured against
|
|
1127
|
+
* effectiveScope and LOCAL_CEILING on its own, so a step the ceiling forbids fails
|
|
1128
|
+
* the plan at that index. Stops at the first failure. ALWAYS resolves.
|
|
1129
|
+
* `ctx.executeStep` (tests) replaces executeCall for the steps.
|
|
1130
|
+
*/
|
|
1131
|
+
export async function executePlan(call, ctx) {
|
|
1132
|
+
const home = ctx.home ?? os.homedir();
|
|
1133
|
+
const scope = effectiveScope(call.scope ?? ctx.scope, { hostFolders: ctx.hostFolders ?? [], home });
|
|
1134
|
+
const auth = authorizeCall(call, scope, { approvedByPlan: ctx.approvedByPlan === true });
|
|
1135
|
+
if (!auth.ok) return { status: "denied", output: null, error: auth.error };
|
|
1136
|
+
const v = validatePlan(call);
|
|
1137
|
+
if (!v.ok) return { status: "denied", output: null, error: v.error };
|
|
1138
|
+
|
|
1139
|
+
const runStep = typeof ctx.executeStep === "function" ? ctx.executeStep : executeCall;
|
|
1140
|
+
const total = v.steps.length;
|
|
1141
|
+
const steps = [];
|
|
1142
|
+
let stoppedAt;
|
|
1143
|
+
for (let i = 0; i < total; i++) {
|
|
1144
|
+
if (ctx.stopped?.()) { stoppedAt = i; steps.push({ verb: v.steps[i].verb, ok: false, error: "The Bridge is stopping.", duration_ms: 0 }); break; }
|
|
1145
|
+
const step = v.steps[i];
|
|
1146
|
+
// A step never inherits the row's status: the plan's click reaches it ONLY as
|
|
1147
|
+
// approvedByPlan, which is what the wall is written to look at.
|
|
1148
|
+
const stepCall = {
|
|
1149
|
+
id: `${call.id ?? "plan"}#${i + 1}`,
|
|
1150
|
+
grant_id: call.grant_id,
|
|
1151
|
+
workspace_id: call.workspace_id,
|
|
1152
|
+
visitor: call.visitor,
|
|
1153
|
+
verb: step.verb,
|
|
1154
|
+
args: step.args && typeof step.args === "object" ? step.args : {},
|
|
1155
|
+
status: "queued",
|
|
1156
|
+
scope: call.scope ?? ctx.scope,
|
|
1157
|
+
};
|
|
1158
|
+
const t0 = Date.now();
|
|
1159
|
+
let r;
|
|
1160
|
+
try {
|
|
1161
|
+
r = await runStep(stepCall, { ...ctx, approvedByPlan: call.status === "approved" });
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
r = { status: "failed", output: null, error: String(e?.message ?? e).slice(0, 500) };
|
|
1164
|
+
}
|
|
1165
|
+
const duration_ms = Date.now() - t0;
|
|
1166
|
+
const ok = r?.status === "done";
|
|
1167
|
+
const row = { verb: step.verb, ...(step.verb === "run" ? { template: stepLabel(step) } : {}), ok, duration_ms };
|
|
1168
|
+
if (ok) row.output = r.output ?? null;
|
|
1169
|
+
else row.error = String(r?.error ?? `step ${r?.status ?? "failed"}`);
|
|
1170
|
+
steps.push(row);
|
|
1171
|
+
ctx.log?.(`◇ plan step ${i + 1}/${total}: ${stepLabel(step)} ... ${ok ? "ok" : `failed: ${row.error}`} (${(duration_ms / 1000).toFixed(1)}s)`);
|
|
1172
|
+
if (!ok) { stoppedAt = i; break; }
|
|
1173
|
+
}
|
|
1174
|
+
const output = capOutput(redactDeep({ steps, ...(stoppedAt !== undefined ? { stopped_at: stoppedAt } : {}) }, { home }));
|
|
1175
|
+
if (stoppedAt !== undefined) {
|
|
1176
|
+
const failed = steps[stoppedAt];
|
|
1177
|
+
return { status: "failed", output, error: redact(`Stopped at step ${stoppedAt + 1} of ${total} (${stepLabel(v.steps[stoppedAt])}): ${failed?.error ?? "failed"}`, { home }).slice(0, 500) };
|
|
1178
|
+
}
|
|
1179
|
+
return { status: "done", output, error: null };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1183
|
+
// PRE-FLIGHT — what the host tells a visitor before it asks
|
|
1184
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The four read-only pieces of a pre-flight. Each is a function of the host ctx so a
|
|
1188
|
+
* test can swap one (cli_versions spawns every vendor CLI, which is slow and machine
|
|
1189
|
+
* dependent). Every piece fails on its own: a doctor that throws still leaves env.
|
|
1190
|
+
*/
|
|
1191
|
+
export const PREFLIGHT_PARTS = Object.freeze({
|
|
1192
|
+
env: (ctx) => VERBS.env({}, ctx),
|
|
1193
|
+
doctor: async (ctx) => {
|
|
1194
|
+
if (typeof ctx.doctor !== "function") return { error: "This Bridge can't run its doctor." };
|
|
1195
|
+
const report = await ctx.doctor();
|
|
1196
|
+
const rows = Array.isArray(report?.rows) ? report.rows : [];
|
|
1197
|
+
return {
|
|
1198
|
+
rows: rows.map((r) => ({ label: String(r?.label ?? ""), ok: r?.level === "ok", detail: typeof r?.fix === "string" ? r.fix : null })),
|
|
1199
|
+
fails: Number(report?.fails ?? rows.filter((r) => r?.level === "bad").length),
|
|
1200
|
+
warns: Number(report?.warns ?? rows.filter((r) => r?.level === "warn").length),
|
|
1201
|
+
};
|
|
1202
|
+
},
|
|
1203
|
+
cli_versions: async (ctx) => {
|
|
1204
|
+
const spec = RUN_TEMPLATES.cli_versions({}, ctx);
|
|
1205
|
+
return spec.local ? spec.local() : { error: spec.error ?? "cli_versions is unavailable" };
|
|
1206
|
+
},
|
|
1207
|
+
bridge_config: (ctx) => {
|
|
1208
|
+
if (!ctx.cfgPath) return { error: "This Bridge has no config path." };
|
|
1209
|
+
let text;
|
|
1210
|
+
try { text = fs.readFileSync(ctx.cfgPath, "utf8"); } catch (e) { return { error: `can't read the config: ${e.code || e.message}` }; }
|
|
1211
|
+
return projectedConfig(text);
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Gather the pre-flight: env, doctor rows, CLI versions and this Bridge's own config
|
|
1217
|
+
* with every token stripped, then redacted and capped exactly like any other result.
|
|
1218
|
+
* Read-only. ALWAYS resolves.
|
|
1219
|
+
*/
|
|
1220
|
+
export async function collectPreflight(ctx, parts = PREFLIGHT_PARTS) {
|
|
1221
|
+
const home = ctx.home ?? os.homedir();
|
|
1222
|
+
const out = {};
|
|
1223
|
+
for (const key of ["env", "doctor", "cli_versions", "bridge_config"]) {
|
|
1224
|
+
try {
|
|
1225
|
+
out[key] = await parts[key]({ ...ctx, home });
|
|
1226
|
+
} catch (e) {
|
|
1227
|
+
out[key] = { error: String(e?.message ?? e).slice(0, 300) };
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
out.at = new Date().toISOString();
|
|
1231
|
+
return capOutput(redactDeep(out, { home }));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Which active grants still owe a pre-flight from this host. A grant counts once it
|
|
1236
|
+
* is `active`, has verbs (a conversation-only grant has no machine), and is neither
|
|
1237
|
+
* already pre-flighted (persisted) nor out of attempts for this process. Pure.
|
|
1238
|
+
*/
|
|
1239
|
+
export function grantsNeedingPreflight(grants, { preflighted, tried, maxAttempts = 2 } = {}) {
|
|
1240
|
+
const done = preflighted ?? new Set();
|
|
1241
|
+
const attempts = tried ?? new Map();
|
|
1242
|
+
const out = [];
|
|
1243
|
+
for (const g of Array.isArray(grants) ? grants : []) {
|
|
1244
|
+
const id = g?.id;
|
|
1245
|
+
if (typeof id !== "string" || !id) continue;
|
|
1246
|
+
if (g.status && g.status !== "active") continue;
|
|
1247
|
+
if (g.conversation_only === true) continue;
|
|
1248
|
+
if (Array.isArray(g.scope?.verbs) && g.scope.verbs.length === 0) continue;
|
|
1249
|
+
if (done.has(id)) continue;
|
|
1250
|
+
if ((attempts.get(id) ?? 0) >= maxAttempts) continue;
|
|
1251
|
+
out.push(id);
|
|
1252
|
+
}
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Run the pre-flight for one grant and post it as a host-initiated call:
|
|
1258
|
+
* `ctx.create(grantId)` asks the server for a call row (POST /api/bridge/hands with
|
|
1259
|
+
* { grant_id, verb: "preflight", host_initiated: true }) and returns its id;
|
|
1260
|
+
* `ctx.report(callId, result)` posts the output through the same route every other
|
|
1261
|
+
* call uses. Throws when the server refuses, so the caller can count the attempt.
|
|
1262
|
+
*/
|
|
1263
|
+
export async function runPreflight(grantId, ctx) {
|
|
1264
|
+
const output = await collectPreflight(ctx, ctx.preflightParts ?? PREFLIGHT_PARTS);
|
|
1265
|
+
const callId = await ctx.create(grantId);
|
|
1266
|
+
if (typeof callId !== "string" || !callId) throw new Error("the server didn't open a pre-flight call");
|
|
1267
|
+
const posted = await ctx.report(callId, { status: "done", output, error: null });
|
|
1268
|
+
if (posted === false) throw new Error("the server refused the pre-flight result");
|
|
1269
|
+
return { callId, output };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1024
1272
|
/**
|
|
1025
1273
|
* The host loop: claim each pending call (CAS server-side), run it, post the result.
|
|
1026
1274
|
* Serialized per grant — one visiting agent, one pair of hands, one thing at a time,
|
|
@@ -1052,6 +1300,12 @@ export function describeCall(call) {
|
|
|
1052
1300
|
case "run": return `run ${a.template}`;
|
|
1053
1301
|
case "doctor": return "run the setup doctor";
|
|
1054
1302
|
case "env": return "look at what's installed";
|
|
1303
|
+
case "preflight": return "pre-flight: what's installed, the doctor, CLI versions, the config's shape";
|
|
1304
|
+
case "plan": {
|
|
1305
|
+
const steps = Array.isArray(a.steps) ? a.steps : [];
|
|
1306
|
+
const why = typeof a.why === "string" && a.why.trim() ? a.why.trim().slice(0, 120) : "a plan";
|
|
1307
|
+
return `plan: ${why} (${steps.length} step${steps.length === 1 ? "" : "s"}: ${steps.map(stepLabel).join(", ").slice(0, 200)})`;
|
|
1308
|
+
}
|
|
1055
1309
|
// The three verbs a host must approve are the three that used to render as a
|
|
1056
1310
|
// bare verb name (ultrareview #123, bug_007). Say WHAT, not just which.
|
|
1057
1311
|
case "write_file": return `write ${a.path}${typeof a.content === "string" ? ` (${a.content.length} chars)` : ""}`;
|
package/harden.mjs
CHANGED
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* 3. AGY VERSION GATE — Antigravity CLI (gemini's successor) below 1.1.1 cannot call
|
|
20
20
|
* MCP tools in headless -p mode: the run LOOKS fine but complete_task never lands,
|
|
21
21
|
* burning every attempt. Confirmed-old versions are refused with a clear message.
|
|
22
|
+
*
|
|
23
|
+
* 4. KIMI CODE CONTRACT (0.1.12): the fourth vendor's CLI shape lives at the bottom
|
|
24
|
+
* of this file: stream parsing, the per-run tool jail (kimi has no --allowedTools),
|
|
25
|
+
* login and MCP state. Kept here rather than in a new module so the shipped file
|
|
26
|
+
* lists (BRIDGE_RUNTIME_FILES, next.config tracing, package.json files) stay as
|
|
27
|
+
* they are.
|
|
22
28
|
*/
|
|
23
29
|
import fs from "node:fs";
|
|
24
30
|
import os from "node:os";
|
|
@@ -26,7 +32,15 @@ import path from "node:path";
|
|
|
26
32
|
import { spawn } from "node:child_process";
|
|
27
33
|
|
|
28
34
|
/** Env vars that flip the official CLIs from subscription auth to API-key billing. */
|
|
29
|
-
export const API_BILLING_KEYS = [
|
|
35
|
+
export const API_BILLING_KEYS = [
|
|
36
|
+
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
|
|
37
|
+
// Kimi Code reads provider keys from config.toml, not the shell, EXCEPT the
|
|
38
|
+
// env-defined model (KIMI_MODEL_NAME + KIMI_MODEL_API_KEY), which would put a
|
|
39
|
+
// Bridge run on an API key. KIMI_API_KEY / MOONSHOT_API_KEY are the names the
|
|
40
|
+
// docs and the Moonshot SDKs use; hidden too, so a future CLI that reads them
|
|
41
|
+
// cannot flip billing either.
|
|
42
|
+
"KIMI_API_KEY", "MOONSHOT_API_KEY", "KIMI_MODEL_API_KEY",
|
|
43
|
+
];
|
|
30
44
|
|
|
31
45
|
/**
|
|
32
46
|
* The environment agent processes get. By default: the parent env MINUS vendor billing
|
|
@@ -225,3 +239,228 @@ export function withCookbookMcp(command, { token, cookbookUrl } = {}) {
|
|
|
225
239
|
});
|
|
226
240
|
return { command: [command[0], "--strict-mcp-config", "--mcp-config", cfg, ...command.slice(1)], injected: true, reason: null };
|
|
227
241
|
}
|
|
242
|
+
|
|
243
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
244
|
+
// KIMI CODE (the fourth vendor, 0.1.12): the CLI contract, pinned empirically
|
|
245
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
246
|
+
//
|
|
247
|
+
// Read off `kimi --help` (0.39.1), the prompt-mode emitter in the binary, and the
|
|
248
|
+
// docs at moonshotai.github.io/kimi-code (2026-09-02):
|
|
249
|
+
//
|
|
250
|
+
// - Headless: `kimi -p "<prompt>" --output-format stream-json`. `-p` takes the
|
|
251
|
+
// prompt as an argument (there is no stdin mode). `-p` REFUSES --yolo, --auto
|
|
252
|
+
// and --plan: prompt mode always runs in "auto" permission (every tool call is
|
|
253
|
+
// approved; static deny rules still apply). There is no --allowedTools flag.
|
|
254
|
+
// The jail is an agent file: `--agent-file <md>` whose frontmatter `tools:` is
|
|
255
|
+
// an allowlist (`mcp__cookbook__*` globs work). kimiCommand() below writes one
|
|
256
|
+
// per run from the command's `--allowedTools` value, so config.json keeps the
|
|
257
|
+
// same shape as the Claude entry and localizeCommand keeps working.
|
|
258
|
+
// - stream-json: one JSON object per stdout line, keyed by `role`:
|
|
259
|
+
// {"role":"meta","type":"system.version","version":"0.39.1"}
|
|
260
|
+
// {"role":"assistant","content":"..."} a finished turn's text
|
|
261
|
+
// {"role":"assistant","tool_calls":[{"type":"function","id":"...",
|
|
262
|
+
// "function":{"name":"Read","arguments":"{\"path\":\"...\"}"}}]}
|
|
263
|
+
// {"role":"tool","tool_call_id":"...","content":"..."} the tool's output
|
|
264
|
+
// {"role":"meta","type":"turn.step.retrying","failed_attempt":1,"next_attempt":2,
|
|
265
|
+
// "max_attempts":10,"delay_ms":557.5,"error_name":"APIConnectionError",
|
|
266
|
+
// "error_message":"Connection error."}
|
|
267
|
+
// {"role":"meta","type":"session.resume_hint","session_id":"session_<uuid>",
|
|
268
|
+
// "command":"kimi -r session_<uuid>","content":"To resume this session: ..."}
|
|
269
|
+
// Thinking is never written to stdout; tool progress and notices go to stderr.
|
|
270
|
+
// No usage line exists, so the receipt is duration-only.
|
|
271
|
+
// - Resume: `-S <id>` (hidden alias `-r`), legal with `-p`, illegal with
|
|
272
|
+
// `--agent-file` (a resumed session keeps the agent it was created with).
|
|
273
|
+
// - Model: `-m <alias>`; aliases come from ~/.kimi-code/config.toml.
|
|
274
|
+
// - MCP: ~/.kimi-code/mcp.json ($KIMI_CODE_HOME/mcp.json): mcpServers.<name> with
|
|
275
|
+
// `url` (+ `headers`) for HTTP. A project-level .kimi-code/mcp.json exists but
|
|
276
|
+
// sits behind the workspace-trust prompt, so the Bridge writes the user file.
|
|
277
|
+
// Tools are named mcp__<server>__<tool>.
|
|
278
|
+
// - Login: [providers.<name>] in config.toml (api_key, or an oauth table written
|
|
279
|
+
// by `kimi login`); OAuth credentials under ~/.kimi-code/credentials/.
|
|
280
|
+
|
|
281
|
+
/** Kimi's data root: $KIMI_CODE_HOME, else ~/.kimi-code. */
|
|
282
|
+
export function kimiHome({ home = process.env.HOME || os.homedir(), env = process.env } = {}) {
|
|
283
|
+
return env.KIMI_CODE_HOME || path.join(home, ".kimi-code");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** True when this command (argv array or bare string) runs the kimi CLI. */
|
|
287
|
+
export function isKimiCommand(command) {
|
|
288
|
+
const first = Array.isArray(command) ? command[0] : command;
|
|
289
|
+
const base = String(first ?? "").split(/[\\/]/).pop().toLowerCase();
|
|
290
|
+
return base === "kimi" || base === "kimi.exe";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Probe `kimi --version` (e.g. "0.39.1"). No gate: nothing is refused, doctor prints it. */
|
|
294
|
+
export async function checkKimiVersion(argv, timeoutMs = 10_000) {
|
|
295
|
+
return { version: await probeVersion(argv, timeoutMs) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Kimi built-in tool names the live work log should read as verbs (live.mjs
|
|
299
|
+
* already knows Read/Write/Edit/Bash/Grep/Glob/WebSearch by their lowercase). */
|
|
300
|
+
const KIMI_TOOL_VERBS = { fetchurl: "fetch", readmediafile: "read", todolist: "todo", agent: "agent", agentswarm: "agent", skill: "skill" };
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One stream-json line → what the Bridge shows. Null for non-kimi lines (a claude
|
|
304
|
+
* line has `type`, never a top-level `role`). Pure.
|
|
305
|
+
* { role, text, calls: [{kind:'call', id, name, input} | {kind:'result', id, err}],
|
|
306
|
+
* sessionId, retry: {attempt, max, error} | null }
|
|
307
|
+
*/
|
|
308
|
+
export function kimiFromLine(line) {
|
|
309
|
+
let j;
|
|
310
|
+
try { j = JSON.parse(line); } catch { return null; }
|
|
311
|
+
if (!j || typeof j !== "object" || typeof j.role !== "string") return null;
|
|
312
|
+
const ev = { role: j.role, text: null, calls: [], sessionId: null, retry: null };
|
|
313
|
+
if (j.role === "assistant") {
|
|
314
|
+
if (typeof j.content === "string" && j.content.trim()) ev.text = j.content;
|
|
315
|
+
for (const tc of Array.isArray(j.tool_calls) ? j.tool_calls : []) {
|
|
316
|
+
const raw = String(tc?.function?.name ?? tc?.name ?? "").trim();
|
|
317
|
+
if (!raw) continue;
|
|
318
|
+
let input = tc?.function?.arguments ?? tc?.arguments;
|
|
319
|
+
if (typeof input === "string") { try { input = JSON.parse(input); } catch { /* partial or plain text: keep the string */ } }
|
|
320
|
+
const name = raw.startsWith("mcp__") ? raw : (KIMI_TOOL_VERBS[raw.toLowerCase()] ?? raw);
|
|
321
|
+
ev.calls.push({ kind: "call", id: String(tc?.id ?? ""), name, input });
|
|
322
|
+
}
|
|
323
|
+
} else if (j.role === "tool") {
|
|
324
|
+
ev.calls.push({ kind: "result", id: String(j.tool_call_id ?? ""), err: kimiToolFailed(j.content) });
|
|
325
|
+
} else if (j.role === "meta") {
|
|
326
|
+
if (j.type === "session.resume_hint" && typeof j.session_id === "string" && j.session_id) ev.sessionId = j.session_id;
|
|
327
|
+
if (j.type === "turn.step.retrying") {
|
|
328
|
+
ev.retry = {
|
|
329
|
+
attempt: Number(j.failed_attempt) || 0,
|
|
330
|
+
max: Number(j.max_attempts) || 0,
|
|
331
|
+
error: `${j.error_name ?? "error"}${j.error_message ? `: ${j.error_message}` : ""}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return ev;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Best-effort: a tool result that reads as a failure (kimi has no is_error flag). */
|
|
339
|
+
function kimiToolFailed(content) {
|
|
340
|
+
const text = typeof content === "string" ? content : (content == null ? "" : JSON.stringify(content));
|
|
341
|
+
return /^\s*(error\b|tool error|permission denied|denied\b|command failed|failed to)/i.test(text);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The claude-shaped result envelope for a finished kimi run, so displayText,
|
|
346
|
+
* resultError, failureHint and extractUsage need no kimi branch. Pure.
|
|
347
|
+
*/
|
|
348
|
+
export function kimiResultEnvelope({ text = "", sessionId = null, durationMs = 0, code = 0, err = "", numTurns = 0 } = {}) {
|
|
349
|
+
const failed = typeof code === "number" && code !== 0;
|
|
350
|
+
const tail = String(err ?? "").trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
|
|
351
|
+
return JSON.stringify({
|
|
352
|
+
type: "result",
|
|
353
|
+
subtype: failed ? "error_during_execution" : "success",
|
|
354
|
+
is_error: failed,
|
|
355
|
+
result: String(text ?? ""),
|
|
356
|
+
duration_ms: Math.max(0, Math.round(Number(durationMs) || 0)),
|
|
357
|
+
num_turns: numTurns,
|
|
358
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
359
|
+
...(failed ? { errors: [tail || `kimi exited ${code}`] } : {}),
|
|
360
|
+
runner: "kimi",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Claude tool names that appear in Bridge configs → the kimi name of the same tool. */
|
|
365
|
+
export const KIMI_TOOL_ALIASES = Object.freeze({ WebFetch: "FetchURL", TodoWrite: "TodoList", Task: "Agent" });
|
|
366
|
+
|
|
367
|
+
/** "Bash,Read,mcp__cookbook__*" → ["Bash", "Read", "mcp__cookbook__*"], kimi names, deduped. */
|
|
368
|
+
export function kimiTools(spec) {
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const raw of String(spec ?? "").split(",")) {
|
|
371
|
+
const t = raw.trim();
|
|
372
|
+
// Tool names are plain tokens; anything else is not a tool and never reaches YAML.
|
|
373
|
+
if (!t || !/^[A-Za-z0-9_*.:-]+$/.test(t)) continue;
|
|
374
|
+
const name = KIMI_TOOL_ALIASES[t] ?? t;
|
|
375
|
+
if (!out.includes(name)) out.push(name);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The agent file that jails one run: same system prompt, only these tools.
|
|
381
|
+
* An empty list means NO tools (kimi: `tools: []`), which is what a summary wants. */
|
|
382
|
+
export function kimiAgentFile(spec) {
|
|
383
|
+
const tools = kimiTools(spec);
|
|
384
|
+
const list = tools.length ? "\n" + tools.map((t) => ` - ${t}`).join("\n") : " []";
|
|
385
|
+
return [
|
|
386
|
+
"---",
|
|
387
|
+
"name: cookbook-bridge",
|
|
388
|
+
"description: A Cookbook Bridge run. Only the tools this task was given.",
|
|
389
|
+
`tools:${list}`,
|
|
390
|
+
"---",
|
|
391
|
+
"${base_prompt}",
|
|
392
|
+
"",
|
|
393
|
+
].join("\n");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Turn a Bridge-shaped kimi command into what the CLI accepts: `--allowedTools X`
|
|
398
|
+
* (which kimi does not have) becomes `--agent-file <0600 temp file>` carrying X as
|
|
399
|
+
* the tools allowlist. On a resume (-S/-r/-c) the flag is simply dropped: kimi
|
|
400
|
+
* refuses --agent-file there and the session already carries the jail it was
|
|
401
|
+
* created with. Identity for non-kimi commands and commands without the flag.
|
|
402
|
+
* Returns { command, file, cleanup, tools }. IO; tested.
|
|
403
|
+
*/
|
|
404
|
+
export function kimiCommand(command, { dir = os.tmpdir() } = {}) {
|
|
405
|
+
const noop = { command, file: null, cleanup: () => {}, tools: null };
|
|
406
|
+
if (!Array.isArray(command) || !isKimiCommand(command)) return noop;
|
|
407
|
+
const i = command.indexOf("--allowedTools");
|
|
408
|
+
if (i < 0) return noop;
|
|
409
|
+
const spec = i + 1 < command.length ? String(command[i + 1]) : "";
|
|
410
|
+
const rest = command.filter((_, k) => k !== i && k !== i + 1);
|
|
411
|
+
const resuming = rest.some((a) => ["-S", "--session", "-r", "--resume", "-c", "-C", "--continue"].includes(String(a)));
|
|
412
|
+
if (resuming || rest.includes("--agent-file") || rest.includes("--agent")) return { ...noop, command: rest };
|
|
413
|
+
const privDir = fs.mkdtempSync(path.join(dir, "cookbook-bridge-kimi-"));
|
|
414
|
+
try { fs.chmodSync(privDir, 0o700); } catch { /* best effort on platforms without modes */ }
|
|
415
|
+
const file = path.join(privDir, "agent.md");
|
|
416
|
+
fs.writeFileSync(file, kimiAgentFile(spec), { mode: 0o600 });
|
|
417
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
418
|
+
liveMcpDirs.add(privDir);
|
|
419
|
+
installMcpExitHook();
|
|
420
|
+
const cleanup = () => {
|
|
421
|
+
liveMcpDirs.delete(privDir);
|
|
422
|
+
try { fs.rmSync(privDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
423
|
+
};
|
|
424
|
+
return { command: [rest[0], "--agent-file", file, ...rest.slice(1)], file, cleanup, tools: kimiTools(spec) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Is kimi logged in? Reads what `kimi login` / `/login` write: a [providers.<name>]
|
|
429
|
+
* section in config.toml with an api_key (or a [providers.<name>.env] key, or an
|
|
430
|
+
* oauth sub-table), or an OAuth credential file under credentials/. Never throws.
|
|
431
|
+
*/
|
|
432
|
+
export function kimiLoginState({ home = process.env.HOME || os.homedir(), env = process.env } = {}) {
|
|
433
|
+
const root = kimiHome({ home, env });
|
|
434
|
+
const configPath = path.join(root, "config.toml");
|
|
435
|
+
let toml = "";
|
|
436
|
+
try { toml = fs.readFileSync(configPath, "utf8"); } catch { /* not installed or never run */ }
|
|
437
|
+
const providers = new Set();
|
|
438
|
+
let section = null; // "providers.<name>" or "providers.<name>.env" / ".oauth"
|
|
439
|
+
for (const raw of toml.split(/\r?\n/)) {
|
|
440
|
+
const line = raw.trim();
|
|
441
|
+
const head = line.match(/^\[+\s*([^\]]+?)\s*\]+$/);
|
|
442
|
+
if (head) {
|
|
443
|
+
const name = head[1].replace(/"/g, "");
|
|
444
|
+
section = name.startsWith("providers.") ? name : null;
|
|
445
|
+
if (section && /^providers\.[^.]+\.oauth$/.test(section)) providers.add(section.split(".")[1]);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (!section) continue;
|
|
449
|
+
if (/^(api_key|KIMI_API_KEY|MOONSHOT_API_KEY|ANTHROPIC_API_KEY|OPENAI_API_KEY|GOOGLE_API_KEY)\s*=\s*"[^"]+"/.test(line)) providers.add(section.split(".")[1]);
|
|
450
|
+
}
|
|
451
|
+
let credentials = 0;
|
|
452
|
+
try { credentials = fs.readdirSync(path.join(root, "credentials")).filter((f) => f.endsWith(".json")).length; } catch { /* none */ }
|
|
453
|
+
return { root, configPath, loggedIn: providers.size > 0 || credentials > 0, providers: [...providers], credentials };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The Cookbook entry in kimi's user-level mcp.json, judged against this Bridge's URL. */
|
|
457
|
+
export function kimiMcpState({ home = process.env.HOME || os.homedir(), env = process.env, cookbookUrl = null } = {}) {
|
|
458
|
+
const file = path.join(kimiHome({ home, env }), "mcp.json");
|
|
459
|
+
let json = null;
|
|
460
|
+
try { json = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* absent or unparseable */ }
|
|
461
|
+
const srv = json && typeof json === "object" && json.mcpServers && typeof json.mcpServers === "object" ? json.mcpServers.cookbook ?? null : null;
|
|
462
|
+
const url = srv && typeof srv === "object" ? (srv.url ?? srv.serverUrl ?? null) : null;
|
|
463
|
+
const want = cookbookUrl ? `${String(cookbookUrl).replace(/\/$/, "")}/api/mcp` : null;
|
|
464
|
+
const hasAuth = !!(srv && typeof srv === "object" && ((srv.headers && srv.headers.Authorization) || srv.bearerTokenEnvVar));
|
|
465
|
+
return { file, exists: json !== null, server: srv, url, matches: !!url && (!want || url === want), hasAuth };
|
|
466
|
+
}
|
package/local.mjs
CHANGED
|
@@ -52,7 +52,7 @@ export function modeForTools(allowedTools) {
|
|
|
52
52
|
* vendors' own state). Relative to home. */
|
|
53
53
|
const DENIED_UNDER_HOME = [
|
|
54
54
|
"Library", ".ssh", ".gnupg", ".aws", ".config", ".claude", ".codex", ".codex-bridge",
|
|
55
|
-
".gemini", ".openclaw", ".cursor", ".npm", ".nvm", ".Trash",
|
|
55
|
+
".gemini", ".kimi-code", ".openclaw", ".cursor", ".npm", ".nvm", ".Trash",
|
|
56
56
|
];
|
|
57
57
|
|
|
58
58
|
/**
|
|
@@ -100,6 +100,7 @@ export function vendorOf(agent) {
|
|
|
100
100
|
if (base === "claude") return "claude";
|
|
101
101
|
if (base === "codex" || /ChatGPT\.app|Codex\.app/.test(cmd) || agent.runner === "app-server") return "codex";
|
|
102
102
|
if (base === "agy" || base === "gemini") return "gemini";
|
|
103
|
+
if (base === "kimi") return "kimi";
|
|
103
104
|
if (base === "openclaw") return "openclaw";
|
|
104
105
|
return "other";
|
|
105
106
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cookbook-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Run your own Claude, Codex and Gemini subscriptions against your Cookbook workspaces. One approval connects every agent CLI on your machine, with a receipt for every run.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"node": ">=18"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
|
-
"test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs"
|
|
43
|
+
"test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs test/kimi.test.mjs test/hands.test.mjs"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"cookbook",
|