-
Notifications
You must be signed in to change notification settings - Fork 5
Add plugin cli-agent-bridge #16
Changes from 30 commits
02a9924
9cd9011
78515cf
77b9cb2
db2553e
fd9c432
a42a84d
d27f2e6
6ee0a0b
0c7a401
1612d21
f46431a
c7a4a15
8f599d2
13d4570
4a6fb48
72a0fb2
34b9690
47b4eca
36988ef
183aa04
d91e923
8e6c5aa
c6e6ec7
b17c929
dd7d4b4
a1ecd0a
6f158b2
bf51a02
95af068
68bbb14
2bbce49
7ac1c50
50664e4
5db74bf
6be15eb
05bfcff
51437d9
b2f7811
8f87ae4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| MIT License | ||
|
|
||
| Copyright (c) 2026 Hylouis233 | ||
|
|
||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
|
|
||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
|
|
||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| cli-agent-bridge is an original implementation informed by the following | ||
| open-source projects. Their licenses are retained where applicable. | ||
|
|
||
| - claude-subagent-mcp (https://github.com/ltxzs/claude-subagent-mcp) | ||
| MIT License. Copyright (c) ltxzs contributors. | ||
| Its headless spawn, timeout, capture-cap, and git-snapshot patterns | ||
| informed server.mjs. | ||
|
|
||
| - subagent-mcp (https://github.com/Heretyc/subagent-mcp) | ||
| Apache License 2.0. Copyright 2026 Lexi Blackburn. | ||
| Its delegated-CLI orchestration concepts informed the Skill guidance. | ||
|
|
||
| - wshobson/agents (https://github.com/wshobson/agents) | ||
| Reference for multi-harness agent plugin packaging patterns. |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "$comment": "Backend command templates for cli-agent-bridge. <task> and <session> are placeholders. Edit command or buildArgs to point at another binary or add flags. Never store credentials here; each CLI uses your own local authentication.", | ||
| "backends": { | ||
| "claude": { "label": "Claude Code", "command": "claude", "buildArgs": ["-p", "<task>", "--output-format", "text", "--permission-mode", "acceptEdits"], "resumeArgs": ["-p", "<task>", "--output-format", "text", "--permission-mode", "acceptEdits", "--resume", "<session>"], "experimental": false }, | ||
| "codex": { "label": "OpenAI Codex CLI", "command": "codex", "buildArgs": ["exec", "--", "<task>"], "resumeArgs": ["exec", "resume", "<session>", "--", "<task>"], "experimental": false }, | ||
| "kimi": { "label": "Kimi Code", "command": "kimi", "buildArgs": ["-p", "<task>"], "resumeArgs": ["-S", "<session>", "-p", "<task>"], "experimental": false }, | ||
| "zcode": { "label": "ZCode", "command": "zcode", "buildArgs": ["-p", "<task>"], "resumeArgs": null, "experimental": true, "notes": "Desktop ZCode builds have no verified headless mode; set command to your CLI if your distribution provides one." }, | ||
| "dsh": { "label": "DeepSeek Harness (dsh)", "command": "dsh", "buildArgs": ["--profile", "headless", "<task>"], "resumeArgs": null, "experimental": true, "notes": "Uses the documented headless profile; requires a headless profile under DSH_HOME/profiles." } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| import { createHash } from "node:crypto"; | ||
| import { constants } from "node:fs"; | ||
| import { access, mkdir, realpath, stat } from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| let executablePromise = null; | ||
| let hooksRootPromise = null; | ||
| const pathCommandPromises = new Map(); | ||
|
|
||
| function userScope() { | ||
| let identity; | ||
| try { | ||
| const user = os.userInfo(); | ||
| identity = Number.isInteger(user.uid) && user.uid >= 0 | ||
| ? process.platform + ":uid:" + String(user.uid) | ||
| : process.platform + ":" + user.username + ":" + user.homedir; | ||
| } catch { | ||
| identity = process.platform + ":" + (process.env.USERNAME ?? process.env.USER ?? os.homedir()); | ||
| } | ||
| return createHash("sha256").update(identity).digest("hex").slice(0, 20); | ||
| } | ||
|
|
||
| const DISABLED_HOOKS_ROOT = path.join( | ||
| os.tmpdir(), "minimax-cli-agent-bridge-git-" + userScope(), "disabled-hooks", | ||
| ); | ||
|
|
||
| async function resolveGitExecutable() { | ||
| const names = process.platform === "win32" ? ["git.exe", "git.com"] : ["git"]; | ||
| for (const rawDirectory of (process.env.PATH ?? "").split(path.delimiter)) { | ||
| const directory = rawDirectory.replace(/^"|"$/gu, ""); | ||
| // Never let a relative PATH component reinterpret an untrusted workspace | ||
| // as an executable search root after a Git command changes cwd. | ||
| if (!directory || !path.isAbsolute(directory)) continue; | ||
| for (const name of names) { | ||
| const candidate = path.join(directory, name); | ||
| try { | ||
| await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK); | ||
| if (!(await stat(candidate)).isFile()) continue; | ||
| return await realpath(candidate); | ||
| } catch { /* try the next trusted PATH entry */ } | ||
| } | ||
| } | ||
| throw new Error("cannot locate git in an absolute PATH directory"); | ||
| } | ||
|
|
||
| async function resolvePathCommandUncached(command) { | ||
| if (path.isAbsolute(command)) { | ||
| try { | ||
| await access(command, process.platform === "win32" ? constants.F_OK : constants.X_OK); | ||
| return (await stat(command)).isFile() ? await realpath(command) : null; | ||
| } catch { return null; } | ||
| } | ||
| if (/[\\/]/u.test(command)) return null; | ||
| const extensions = process.platform === "win32" | ||
| ? (path.extname(command) | ||
| ? [""] | ||
| : [...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean), ".ps1"]) | ||
| : [""]; | ||
| for (const rawDirectory of (process.env.PATH ?? "").split(path.delimiter)) { | ||
| const directory = rawDirectory.replace(/^"|"$/gu, ""); | ||
| if (!directory || !path.isAbsolute(directory)) continue; | ||
| for (const extension of extensions) { | ||
| const candidate = path.join(directory, command + extension); | ||
| try { | ||
| await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK); | ||
| if ((await stat(candidate)).isFile()) return await realpath(candidate); | ||
| } catch { /* continue searching */ } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| export function resolvePathCommand(command) { | ||
| if (typeof command !== "string" || !command) return Promise.resolve(null); | ||
| if (!pathCommandPromises.has(command)) { | ||
| const resolution = resolvePathCommandUncached(command); | ||
| pathCommandPromises.set(command, resolution); | ||
| // Share an in-flight lookup and retain positive results, but do not make a | ||
| // missing/not-yet-installed CLI permanent for the lifetime of the server. | ||
| // The identity guard prevents an older completion from deleting a newer | ||
| // retry that has already occupied the same cache slot. | ||
| void resolution.then((resolved) => { | ||
| if (resolved === null && pathCommandPromises.get(command) === resolution) { | ||
| pathCommandPromises.delete(command); | ||
| } | ||
| }, () => { | ||
| if (pathCommandPromises.get(command) === resolution) pathCommandPromises.delete(command); | ||
| }); | ||
| } | ||
| return pathCommandPromises.get(command); | ||
| } | ||
|
|
||
| export function trustedGitExecutable() { | ||
| executablePromise ??= resolveGitExecutable(); | ||
| return executablePromise; | ||
| } | ||
|
|
||
| async function disabledHooksRoot() { | ||
| hooksRootPromise ??= mkdir(DISABLED_HOOKS_ROOT, { recursive: true, mode: 0o700 }) | ||
| .then(() => DISABLED_HOOKS_ROOT) | ||
| .catch((error) => { | ||
| hooksRootPromise = null; | ||
| throw error; | ||
| }); | ||
| return await hooksRootPromise; | ||
| } | ||
|
|
||
| export async function safeGitInvocation(args) { | ||
| const safeArgs = [ | ||
| "-c", "core.hooksPath=" + await disabledHooksRoot(), | ||
| "-c", "core.fsmonitor=false", | ||
| "-c", "gc.autoDetach=false", | ||
| "-c", "maintenance.auto=false", | ||
| ...args, | ||
| ]; | ||
| if (args[0] === "diff") safeArgs.splice(9, 0, "--no-ext-diff", "--no-textconv"); | ||
| const env = { | ||
| ...process.env, | ||
| GIT_OPTIONAL_LOCKS: "0", | ||
| GIT_PAGER: "", | ||
| PAGER: "", | ||
| }; | ||
| for (const name of [ | ||
| "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG_COUNT", "GIT_DIR", "GIT_DIFF_OPTS", | ||
| "GIT_EXTERNAL_DIFF", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_WORK_TREE", | ||
| ]) delete env[name]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the MCP server inherits Useful? React with 👍 / 👎. |
||
| for (const name of Object.keys(env)) { | ||
| if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(name)) delete env[name]; | ||
| } | ||
| return { command: await trustedGitExecutable(), args: safeArgs, env }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an absolute PATH entry before the real Git executable is on a stalled network or FUSE filesystem, Useful? React with 👍 / 👎. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", | ||
| "mcpServers": { | ||
| "cli-agent-bridge": { | ||
| "type": "stdio", | ||
| "command": "node", | ||
| "args": ["./server.mjs"] | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", | ||
| "name": "cli-agent-bridge", | ||
| "version": "0.1.0", | ||
| "description": "Delegate coding tasks from MiniMax Code to locally installed coding CLIs (Claude Code, Codex, Kimi Code, ZCode, DSH) through a dependency-free stdio MCP server with git-diff review.", | ||
| "author": { "name": "Hylouis233", "url": "https://github.com/Hylouis233" }, | ||
| "license": "MIT", | ||
| "keywords": ["minimax-code", "plugin", "mcp", "multi-agent", "delegation", "orchestration"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When another local user pre-creates this predictable temp path (the suffix is derived from the platform and UID),
mkdir({ recursive: true })accepts the attacker-controlled directory without verifying its owner, permissions, or whether a component is a symlink. The attacker can place an executablereference-transactionhook there, and everysafeGitInvocation()then selects it throughcore.hooksPath; Git'sgithooksdocumentation says this hook runs during ref updates, so lock-storeupdate-refoperations execute attacker-controlled code with the bridge user's privileges. Create the directory with exclusive, user-owned semantics and verify every reused path before treating it as an empty hooks directory.Useful? React with 👍 / 👎.