Merge upstream/main: keep local workflows

This commit is contained in:
James Peter
2025-11-26 13:55:47 +10:00
28 changed files with 649 additions and 72 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
use codex_execpolicy::NegativeExamplePassedCheck;
use codex_execpolicy::get_default_policy;
use codex_execpolicy_legacy::NegativeExamplePassedCheck;
use codex_execpolicy_legacy::get_default_policy;
#[test]
fn verify_everything_in_bad_list_is_rejected() {
+11 -11
View File
@@ -1,15 +1,15 @@
extern crate codex_execpolicy;
extern crate codex_execpolicy_legacy;
use codex_execpolicy::ArgMatcher;
use codex_execpolicy::ArgType;
use codex_execpolicy::Error;
use codex_execpolicy::ExecCall;
use codex_execpolicy::MatchedArg;
use codex_execpolicy::MatchedExec;
use codex_execpolicy::Policy;
use codex_execpolicy::Result;
use codex_execpolicy::ValidExec;
use codex_execpolicy::get_default_policy;
use codex_execpolicy_legacy::ArgMatcher;
use codex_execpolicy_legacy::ArgType;
use codex_execpolicy_legacy::Error;
use codex_execpolicy_legacy::ExecCall;
use codex_execpolicy_legacy::MatchedArg;
use codex_execpolicy_legacy::MatchedExec;
use codex_execpolicy_legacy::Policy;
use codex_execpolicy_legacy::Result;
use codex_execpolicy_legacy::ValidExec;
use codex_execpolicy_legacy::get_default_policy;
#[expect(clippy::expect_used)]
fn setup() -> Policy {
+2 -2
View File
@@ -1,5 +1,5 @@
use codex_execpolicy::PositiveExampleFailedCheck;
use codex_execpolicy::get_default_policy;
use codex_execpolicy_legacy::PositiveExampleFailedCheck;
use codex_execpolicy_legacy::get_default_policy;
#[test]
fn verify_everything_in_good_list_is_allowed() {
+12 -12
View File
@@ -1,16 +1,16 @@
use codex_execpolicy::ArgMatcher;
use codex_execpolicy::ArgType;
use codex_execpolicy::Error;
use codex_execpolicy::ExecCall;
use codex_execpolicy::MatchedArg;
use codex_execpolicy::MatchedExec;
use codex_execpolicy::MatchedOpt;
use codex_execpolicy::Policy;
use codex_execpolicy::Result;
use codex_execpolicy::ValidExec;
use codex_execpolicy::get_default_policy;
use codex_execpolicy_legacy::ArgMatcher;
use codex_execpolicy_legacy::ArgType;
use codex_execpolicy_legacy::Error;
use codex_execpolicy_legacy::ExecCall;
use codex_execpolicy_legacy::MatchedArg;
use codex_execpolicy_legacy::MatchedExec;
use codex_execpolicy_legacy::MatchedOpt;
use codex_execpolicy_legacy::Policy;
use codex_execpolicy_legacy::Result;
use codex_execpolicy_legacy::ValidExec;
use codex_execpolicy_legacy::get_default_policy;
extern crate codex_execpolicy;
extern crate codex_execpolicy_legacy;
#[expect(clippy::expect_used)]
fn setup() -> Policy {
+9 -9
View File
@@ -1,13 +1,13 @@
use codex_execpolicy::ArgType;
use codex_execpolicy::Error;
use codex_execpolicy::ExecCall;
use codex_execpolicy::MatchedArg;
use codex_execpolicy::MatchedExec;
use codex_execpolicy::PolicyParser;
use codex_execpolicy::Result;
use codex_execpolicy::ValidExec;
use codex_execpolicy_legacy::ArgType;
use codex_execpolicy_legacy::Error;
use codex_execpolicy_legacy::ExecCall;
use codex_execpolicy_legacy::MatchedArg;
use codex_execpolicy_legacy::MatchedExec;
use codex_execpolicy_legacy::PolicyParser;
use codex_execpolicy_legacy::Result;
use codex_execpolicy_legacy::ValidExec;
extern crate codex_execpolicy;
extern crate codex_execpolicy_legacy;
#[test]
fn test_invalid_subcommand() -> Result<()> {
+11 -11
View File
@@ -1,15 +1,15 @@
extern crate codex_execpolicy;
extern crate codex_execpolicy_legacy;
use codex_execpolicy::ArgType;
use codex_execpolicy::Error;
use codex_execpolicy::ExecCall;
use codex_execpolicy::MatchedArg;
use codex_execpolicy::MatchedExec;
use codex_execpolicy::MatchedFlag;
use codex_execpolicy::Policy;
use codex_execpolicy::Result;
use codex_execpolicy::ValidExec;
use codex_execpolicy::get_default_policy;
use codex_execpolicy_legacy::ArgType;
use codex_execpolicy_legacy::Error;
use codex_execpolicy_legacy::ExecCall;
use codex_execpolicy_legacy::MatchedArg;
use codex_execpolicy_legacy::MatchedExec;
use codex_execpolicy_legacy::MatchedFlag;
use codex_execpolicy_legacy::Policy;
use codex_execpolicy_legacy::Result;
use codex_execpolicy_legacy::ValidExec;
use codex_execpolicy_legacy::get_default_policy;
#[expect(clippy::expect_used)]
fn setup() -> Policy {
@@ -1,5 +1,5 @@
use codex_execpolicy::Error;
use codex_execpolicy::parse_sed_command;
use codex_execpolicy_legacy::Error;
use codex_execpolicy_legacy::parse_sed_command;
#[test]
fn parses_simple_print_command() {
+9 -9
View File
@@ -1,15 +1,15 @@
extern crate codex_execpolicy;
extern crate codex_execpolicy_legacy;
use std::vec;
use codex_execpolicy::Error;
use codex_execpolicy::ExecCall;
use codex_execpolicy::MatchedExec;
use codex_execpolicy::MatchedFlag;
use codex_execpolicy::Policy;
use codex_execpolicy::PositionalArg;
use codex_execpolicy::ValidExec;
use codex_execpolicy::get_default_policy;
use codex_execpolicy_legacy::Error;
use codex_execpolicy_legacy::ExecCall;
use codex_execpolicy_legacy::MatchedExec;
use codex_execpolicy_legacy::MatchedFlag;
use codex_execpolicy_legacy::Policy;
use codex_execpolicy_legacy::PositionalArg;
use codex_execpolicy_legacy::ValidExec;
use codex_execpolicy_legacy::get_default_policy;
#[expect(clippy::expect_used)]
fn setup() -> Policy {
+12 -12
View File
@@ -1,16 +1,16 @@
extern crate codex_execpolicy;
extern crate codex_execpolicy_legacy;
use codex_execpolicy::ArgType;
use codex_execpolicy::Error;
use codex_execpolicy::ExecCall;
use codex_execpolicy::MatchedArg;
use codex_execpolicy::MatchedExec;
use codex_execpolicy::MatchedFlag;
use codex_execpolicy::MatchedOpt;
use codex_execpolicy::Policy;
use codex_execpolicy::Result;
use codex_execpolicy::ValidExec;
use codex_execpolicy::get_default_policy;
use codex_execpolicy_legacy::ArgType;
use codex_execpolicy_legacy::Error;
use codex_execpolicy_legacy::ExecCall;
use codex_execpolicy_legacy::MatchedArg;
use codex_execpolicy_legacy::MatchedExec;
use codex_execpolicy_legacy::MatchedFlag;
use codex_execpolicy_legacy::MatchedOpt;
use codex_execpolicy_legacy::Policy;
use codex_execpolicy_legacy::Result;
use codex_execpolicy_legacy::ValidExec;
use codex_execpolicy_legacy::get_default_policy;
#[expect(clippy::expect_used)]
fn setup() -> Policy {
+38
View File
@@ -0,0 +1,38 @@
# Execpolicy quickstart
Codex can enforce your own rules-based execution policy before it runs shell commands. Policies live in Starlark `.codexpolicy` files under `~/.codex/policy`.
## Create a policy
1. Create a policy directory: `mkdir -p ~/.codex/policy`.
2. Add one or more `.codexpolicy` files in that folder. Codex automatically loads every `.codexpolicy` file in there on startup.
3. Write `prefix_rule` entries to describe the commands you want to allow, prompt, or block:
```starlark
prefix_rule(
pattern = ["git", ["push", "fetch"]],
decision = "prompt", # allow | prompt | forbidden
match = [["git", "push", "origin", "main"]], # examples that must match
not_match = [["git", "status"]], # examples that must not match
)
```
- `pattern` is a list of shell tokens, evaluated from left to right; wrap tokens in a nested list to express alternatives (for example, match both `push` and `fetch`).
- `decision` sets the severity; Codex picks the strictest decision when multiple rules match (forbidden > prompt > allow).
- `match` and `not_match` act as optional unit tests. Codex validates them when it loads your policy, so you get feedback if an example has unexpected behavior.
In this example rule, if Codex wants to run commands with the prefix `git push` or `git fetch`, it will first ask for user approval.
## Preview decisions
Use the `codex execpolicy check` subcommand to preview decisions before you save a rule (see the [`codex-execpolicy` README](../codex-rs/execpolicy/README.md) for syntax details):
```shell
codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push origin main
```
Pass multiple `--policy` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](../codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax.
## Status
`execpolicy` commands are still in preview. The API may have breaking changes in the future.
+2 -2
View File
@@ -8,7 +8,7 @@ When commands run via `codex sandbox windows …` (or when the CLI/TUI calls int
## Known Security Limitations
Running `python windows-sandbox-rs/sandbox_smoketests.py` with full filesystem and network access currently results in **37/42** passing cases. The list below focuses on the four high-value failures numbered #32 and higher in the smoketests (earlier tests are less security-focused).
Running `python windows-sandbox-rs/sandbox_smoketests.py` with full filesystem and network access currently results in **37/41** passing cases. The list below focuses on the four high-value failures numbered #32 and higher in the smoketests (earlier tests are less security-focused).
| Test | Purpose |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -19,4 +19,4 @@ Running `python windows-sandbox-rs/sandbox_smoketests.py` with full filesystem a
## Want to Help?
If you are a security-minded Windows user, help us get these tests passing! Improved implementations that make these smoke tests pass meaningfully reduce Codex's escape surface. After iterating, rerun `python windows-sandbox-rs/sandbox_smoketests.py` to validate the fixes and help us drive the suite toward 42/42.
If you are a security-minded Windows user, help us get these tests passing! Improved implementations that make these smoke tests pass meaningfully reduce Codex's escape surface. After iterating, rerun `python windows-sandbox-rs/sandbox_smoketests.py` to validate the fixes and help us drive the suite toward 41/41.
+24
View File
@@ -59,6 +59,30 @@ importers:
specifier: ^8.45.0
version: 8.45.0([email protected])([email protected])
shell-tool-mcp:
devDependencies:
'@types/jest':
specifier: ^29.5.14
version: 29.5.14
'@types/node':
specifier: ^20.19.18
version: 20.19.18
jest:
specifier: ^29.7.0
version: 29.7.0(@types/[email protected])([email protected](@types/[email protected])([email protected]))
prettier:
specifier: ^3.6.2
version: 3.6.2
ts-jest:
specifier: ^29.3.4
version: 29.4.4(@babel/[email protected])(@jest/[email protected])(@jest/[email protected])([email protected](@babel/[email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])))([email protected])
tsup:
specifier: ^8.5.0
version: 8.5.0([email protected])([email protected])([email protected])
typescript:
specifier: ^5.9.2
version: 5.9.2
packages:
'@babel/[email protected]':
+1
View File
@@ -1,6 +1,7 @@
packages:
- docs
- sdk/typescript
- shell-tool-mcp
ignoredBuiltDependencies:
- esbuild
+2
View File
@@ -0,0 +1,2 @@
/bin/
/node_modules/
+31
View File
@@ -0,0 +1,31 @@
# @openai/codex-shell-tool-mcp
This package wraps the `codex-exec-mcp-server` binary and its helpers so that the shell MCP can be invoked via `npx @openai/codex-shell-tool-mcp`. It bundles:
- `codex-exec-mcp-server` and `codex-execve-wrapper` built for macOS (arm64, x64) and Linux (musl arm64, musl x64).
- A patched Bash that honors `BASH_EXEC_WRAPPER`, built for multiple glibc baselines (Ubuntu 24.04/22.04/20.04, Debian 12/11, CentOS-like 9) and macOS (15/14/13).
- A launcher (`bin/mcp-server.js`) that picks the correct binaries for the current `process.platform` / `process.arch`, specifying `--execve` and `--bash` for the MCP, as appropriate.
## Usage
```bash
npx @openai/codex-shell-tool-mcp --help
```
The launcher selects a Rust target triple based on the host and chooses the closest Bash variant by inspecting `/etc/os-release` on Linux or the Darwin major version on macOS.
## Patched Bash
We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `BASH_EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually:
```bash
git clone https://github.com/bminor/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply /path/to/patches/bash-exec-wrapper.patch
./configure --without-bash-malloc
make -j"$(nproc)"
```
## Release workflow
`.github/workflows/shell-tool-mcp.yml` builds the Rust binaries, compiles the patched Bash variants, assembles the `vendor/` tree, and creates `codex-shell-tool-mcp-npm-<version>.tgz` for inclusion in the Rust GitHub Release. When the version is a stable or alpha tag, the workflow also publishes the tarball to npm using OIDC. The workflow is invoked from `rust-release.yml` so the package ships alongside other Codex artifacts.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/tests"],
};
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@openai/codex-shell-tool-mcp",
"version": "0.0.0-dev",
"description": "Codex MCP server for the shell tool with patched Bash and exec wrappers.",
"license": "Apache-2.0",
"bin": {
"codex-shell-tool-mcp": "bin/mcp-server.js"
},
"engines": {
"node": ">=18"
},
"files": [
"bin",
"vendor",
"README.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git",
"directory": "shell-tool-mcp"
},
"scripts": {
"clean": "rm -rf bin",
"build": "tsup",
"build:watch": "tsup --watch",
"test": "jest",
"test:watch": "jest --watch",
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^20.19.18",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.3.4",
"tsup": "^8.5.0",
"typescript": "^5.9.2"
}
}
@@ -0,0 +1,24 @@
diff --git a/execute_cmd.c b/execute_cmd.c
index 070f5119..d20ad2b9 100644
--- a/execute_cmd.c
+++ b/execute_cmd.c
@@ -6129,6 +6129,19 @@ shell_execve (char *command, char **args, char **env)
char sample[HASH_BANG_BUFSIZ];
size_t larray;
+ char* exec_wrapper = getenv("BASH_EXEC_WRAPPER");
+ if (exec_wrapper && *exec_wrapper && !whitespace (*exec_wrapper))
+ {
+ char *orig_command = command;
+
+ larray = strvec_len (args);
+
+ memmove (args + 2, args, (++larray) * sizeof (char *));
+ args[0] = exec_wrapper;
+ args[1] = orig_command;
+ command = exec_wrapper;
+ }
+
SETOSTYPE (0); /* Some systems use for USG/POSIX semantics */
execve (command, args, env);
i = errno; /* error from execve() */
+115
View File
@@ -0,0 +1,115 @@
import path from "node:path";
import os from "node:os";
import { DARWIN_BASH_VARIANTS, LINUX_BASH_VARIANTS } from "./constants";
import { BashSelection, OsReleaseInfo } from "./types";
function supportedDetail(variants: ReadonlyArray<{ name: string }>): string {
return `Supported variants: ${variants.map((variant) => variant.name).join(", ")}`;
}
export function selectLinuxBash(
bashRoot: string,
info: OsReleaseInfo,
): BashSelection {
const versionId = info.versionId;
const candidates: Array<{
variant: (typeof LINUX_BASH_VARIANTS)[number];
matchesVersion: boolean;
}> = [];
for (const variant of LINUX_BASH_VARIANTS) {
const matchesId =
variant.ids.includes(info.id) ||
variant.ids.some((id) => info.idLike.includes(id));
if (!matchesId) {
continue;
}
const matchesVersion = Boolean(
versionId &&
variant.versions.some((prefix) => versionId.startsWith(prefix)),
);
candidates.push({ variant, matchesVersion });
}
const pickVariant = (list: typeof candidates) =>
list.find((item) => item.variant)?.variant;
const preferred = pickVariant(
candidates.filter((item) => item.matchesVersion),
);
if (preferred) {
return {
path: path.join(bashRoot, preferred.name, "bash"),
variant: preferred.name,
};
}
const fallbackMatch = pickVariant(candidates);
if (fallbackMatch) {
return {
path: path.join(bashRoot, fallbackMatch.name, "bash"),
variant: fallbackMatch.name,
};
}
const fallback = LINUX_BASH_VARIANTS[0];
if (fallback) {
return {
path: path.join(bashRoot, fallback.name, "bash"),
variant: fallback.name,
};
}
const detail = supportedDetail(LINUX_BASH_VARIANTS);
throw new Error(
`Unable to select a Bash variant for ${info.id || "unknown"} ${versionId || ""}. ${detail}`,
);
}
export function selectDarwinBash(
bashRoot: string,
darwinRelease: string,
): BashSelection {
const darwinMajor = Number.parseInt(darwinRelease.split(".")[0] || "0", 10);
const preferred = DARWIN_BASH_VARIANTS.find(
(variant) => darwinMajor >= variant.minDarwin,
);
if (preferred) {
return {
path: path.join(bashRoot, preferred.name, "bash"),
variant: preferred.name,
};
}
const fallback = DARWIN_BASH_VARIANTS[0];
if (fallback) {
return {
path: path.join(bashRoot, fallback.name, "bash"),
variant: fallback.name,
};
}
const detail = supportedDetail(DARWIN_BASH_VARIANTS);
throw new Error(
`Unable to select a macOS Bash build (darwin ${darwinMajor}). ${detail}`,
);
}
export function resolveBashPath(
targetRoot: string,
platform: NodeJS.Platform,
darwinRelease = os.release(),
osInfo: OsReleaseInfo | null = null,
): BashSelection {
const bashRoot = path.join(targetRoot, "bash");
if (platform === "linux") {
if (!osInfo) {
throw new Error("Linux OS info is required to select a Bash variant.");
}
return selectLinuxBash(bashRoot, osInfo);
}
if (platform === "darwin") {
return selectDarwinBash(bashRoot, darwinRelease);
}
throw new Error(`Unsupported platform for Bash selection: ${platform}`);
}
+20
View File
@@ -0,0 +1,20 @@
import { DarwinBashVariant, LinuxBashVariant } from "./types";
export const LINUX_BASH_VARIANTS: ReadonlyArray<LinuxBashVariant> = [
{ name: "ubuntu-24.04", ids: ["ubuntu"], versions: ["24.04"] },
{ name: "ubuntu-22.04", ids: ["ubuntu"], versions: ["22.04"] },
{ name: "ubuntu-20.04", ids: ["ubuntu"], versions: ["20.04"] },
{ name: "debian-12", ids: ["debian"], versions: ["12"] },
{ name: "debian-11", ids: ["debian"], versions: ["11"] },
{
name: "centos-9",
ids: ["centos", "rhel", "rocky", "almalinux"],
versions: ["9"],
},
];
export const DARWIN_BASH_VARIANTS: ReadonlyArray<DarwinBashVariant> = [
{ name: "macos-15", minDarwin: 24 },
{ name: "macos-14", minDarwin: 23 },
{ name: "macos-13", minDarwin: 22 },
];
+101
View File
@@ -0,0 +1,101 @@
// Launches the codex-exec-mcp-server binary bundled in this package.
import { spawn } from "node:child_process";
import { accessSync, constants } from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveBashPath } from "./bashSelection";
import { readOsRelease } from "./osRelease";
import { resolveTargetTriple } from "./platform";
const scriptPath = process.argv[1]
? path.resolve(process.argv[1])
: process.cwd();
const __dirname = path.dirname(scriptPath);
async function main(): Promise<void> {
const targetTriple = resolveTargetTriple(process.platform, process.arch);
const vendorRoot = path.join(__dirname, "..", "vendor");
const targetRoot = path.join(vendorRoot, targetTriple);
const execveWrapperPath = path.join(targetRoot, "codex-execve-wrapper");
const serverPath = path.join(targetRoot, "codex-exec-mcp-server");
const osInfo = process.platform === "linux" ? readOsRelease() : null;
const { path: bashPath } = resolveBashPath(
targetRoot,
process.platform,
os.release(),
osInfo,
);
[execveWrapperPath, serverPath, bashPath].forEach((checkPath) => {
try {
accessSync(checkPath, constants.F_OK);
} catch {
throw new Error(`Required binary missing: ${checkPath}`);
}
});
const args = [
"--execve",
execveWrapperPath,
"--bash",
bashPath,
...process.argv.slice(2),
];
const child = spawn(serverPath, args, {
stdio: "inherit",
});
const forwardSignal = (signal: NodeJS.Signals) => {
if (child.killed) {
return;
}
try {
child.kill(signal);
} catch {
/* ignore */
}
};
(["SIGINT", "SIGTERM", "SIGHUP"] as const).forEach((sig) => {
process.on(sig, () => forwardSignal(sig));
});
child.on("error", (err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
const childResult = await new Promise<
| { type: "signal"; signal: NodeJS.Signals }
| { type: "code"; exitCode: number }
>((resolve) => {
child.on("exit", (code, signal) => {
if (signal) {
resolve({ type: "signal", signal });
} else {
resolve({ type: "code", exitCode: code ?? 1 });
}
});
});
if (childResult.type === "signal") {
// This environment running under `node --test` may not allow rethrowing a signal.
// Wrap in a try to avoid masking the original termination reason.
try {
process.kill(process.pid, childResult.signal);
} catch {
process.exit(1);
}
} else {
process.exit(childResult.exitCode);
}
}
void main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { OsReleaseInfo } from "./types";
export function parseOsRelease(contents: string): OsReleaseInfo {
const lines = contents.split("\n").filter(Boolean);
const info: Record<string, string> = {};
for (const line of lines) {
const [rawKey, rawValue] = line.split("=", 2);
if (!rawKey || rawValue === undefined) {
continue;
}
const key = rawKey.toLowerCase();
const value = rawValue.replace(/^"/, "").replace(/"$/, "");
info[key] = value;
}
const idLike = (info.id_like || "")
.split(/\s+/)
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
return {
id: (info.id || "").toLowerCase(),
idLike,
versionId: info.version_id || "",
};
}
export function readOsRelease(pathname = "/etc/os-release"): OsReleaseInfo {
try {
const contents = readFileSync(pathname, "utf8");
return parseOsRelease(contents);
} catch {
return { id: "", idLike: [], versionId: "" };
}
}
+21
View File
@@ -0,0 +1,21 @@
export function resolveTargetTriple(
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
): string {
if (platform === "linux") {
if (arch === "x64") {
return "x86_64-unknown-linux-musl";
}
if (arch === "arm64") {
return "aarch64-unknown-linux-musl";
}
} else if (platform === "darwin") {
if (arch === "x64") {
return "x86_64-apple-darwin";
}
if (arch === "arm64") {
return "aarch64-apple-darwin";
}
}
throw new Error(`Unsupported platform: ${platform} (${arch})`);
}
+21
View File
@@ -0,0 +1,21 @@
export type LinuxBashVariant = {
name: string;
ids: Array<string>;
versions: Array<string>;
};
export type DarwinBashVariant = {
name: string;
minDarwin: number;
};
export type OsReleaseInfo = {
id: string;
idLike: Array<string>;
versionId: string;
};
export type BashSelection = {
path: string;
variant: string;
};
@@ -0,0 +1,41 @@
import { selectDarwinBash, selectLinuxBash } from "../src/bashSelection";
import { DARWIN_BASH_VARIANTS, LINUX_BASH_VARIANTS } from "../src/constants";
import { OsReleaseInfo } from "../src/types";
import path from "node:path";
describe("selectLinuxBash", () => {
const bashRoot = "/vendor/bash";
it("prefers exact version match when id is present", () => {
const info: OsReleaseInfo = {
id: "ubuntu",
idLike: ["debian"],
versionId: "24.04.1",
};
const selection = selectLinuxBash(bashRoot, info);
expect(selection.variant).toBe("ubuntu-24.04");
expect(selection.path).toBe(path.join(bashRoot, "ubuntu-24.04", "bash"));
});
it("falls back to first supported variant when no matches", () => {
const info: OsReleaseInfo = { id: "unknown", idLike: [], versionId: "1.0" };
const selection = selectLinuxBash(bashRoot, info);
expect(selection.variant).toBe(LINUX_BASH_VARIANTS[0].name);
});
});
describe("selectDarwinBash", () => {
const bashRoot = "/vendor/bash";
it("selects compatible darwin version", () => {
const darwinRelease = "24.0.0";
const selection = selectDarwinBash(bashRoot, darwinRelease);
expect(selection.variant).toBe("macos-15");
});
it("falls back to first darwin variant when release too old", () => {
const darwinRelease = "20.0.0";
const selection = selectDarwinBash(bashRoot, darwinRelease);
expect(selection.variant).toBe(DARWIN_BASH_VARIANTS[0].name);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { parseOsRelease } from "../src/osRelease";
describe("parseOsRelease", () => {
it("parses basic fields", () => {
const contents = `ID="ubuntu"
ID_LIKE="debian"
VERSION_ID=24.04
OTHER=ignored`;
const info = parseOsRelease(contents);
expect(info).toEqual({
id: "ubuntu",
idLike: ["debian"],
versionId: "24.04",
});
});
it("handles missing fields", () => {
const contents = "SOMETHING=else";
const info = parseOsRelease(contents);
expect(info).toEqual({ id: "", idLike: [], versionId: "" });
});
it("normalizes id_like entries", () => {
const contents = `ID="rhel"
ID_LIKE="CentOS Rocky"`;
const info = parseOsRelease(contents);
expect(info.idLike).toEqual(["centos", "rocky"]);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src", "tests"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
"mcp-server": "src/index.ts",
},
outDir: "bin",
format: ["cjs"],
target: "node18",
clean: true,
sourcemap: false,
banner: {
js: "#!/usr/bin/env node",
},
});