codex-octopus 1.0.0
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/LICENSE +15 -0
- package/README.md +242 -0
- package/assets/codex-octopus.png +0 -0
- package/assets/codex-octopus.svg +48 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +47 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +86 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +54 -0
- package/dist/lib.d.ts +30 -0
- package/dist/lib.js +148 -0
- package/dist/tools/factory.d.ts +2 -0
- package/dist/tools/factory.js +110 -0
- package/dist/tools/query.d.ts +3 -0
- package/dist/tools/query.js +167 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +1 -0
- package/package.json +32 -0
- package/src/config.ts +67 -0
- package/src/constants.ts +88 -0
- package/src/index.ts +69 -0
- package/src/lib.test.ts +301 -0
- package/src/lib.ts +187 -0
- package/src/tools/factory.ts +153 -0
- package/src/tools/query.ts +200 -0
- package/src/types.ts +34 -0
- package/tsconfig.json +15 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Codex Octopus — one brain, many arms.
|
|
5
|
+
*
|
|
6
|
+
* Wraps the OpenAI Codex SDK as MCP servers, letting you spawn multiple
|
|
7
|
+
* specialized Codex agents — each with its own model, sandbox, effort,
|
|
8
|
+
* and personality.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { envStr, envBool, sanitizeToolName } from "./lib.js";
|
|
15
|
+
import { buildBaseConfig } from "./config.js";
|
|
16
|
+
import { registerQueryTools } from "./tools/query.js";
|
|
17
|
+
import { registerFactoryTool } from "./tools/factory.js";
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { version: PKG_VERSION } = require("../package.json");
|
|
21
|
+
|
|
22
|
+
// ── Configuration ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const BASE_CONFIG = buildBaseConfig();
|
|
25
|
+
const API_KEY = envStr("CODEX_API_KEY");
|
|
26
|
+
|
|
27
|
+
const TOOL_NAME = sanitizeToolName(envStr("CODEX_TOOL_NAME") || "codex");
|
|
28
|
+
const REPLY_TOOL_NAME = `${TOOL_NAME}_reply`;
|
|
29
|
+
const SERVER_NAME = envStr("CODEX_SERVER_NAME") || "codex-octopus";
|
|
30
|
+
const FACTORY_ONLY = envBool("CODEX_FACTORY_ONLY", false);
|
|
31
|
+
|
|
32
|
+
const DEFAULT_DESCRIPTION = [
|
|
33
|
+
"Send a task to an autonomous Codex agent.",
|
|
34
|
+
"It reads/writes files, runs shell commands, searches codebases,",
|
|
35
|
+
"and handles complex software engineering tasks end-to-end.",
|
|
36
|
+
`Returns the result text plus a thread_id for follow-ups via ${REPLY_TOOL_NAME}.`,
|
|
37
|
+
].join(" ");
|
|
38
|
+
|
|
39
|
+
const TOOL_DESCRIPTION = envStr("CODEX_DESCRIPTION") || DEFAULT_DESCRIPTION;
|
|
40
|
+
|
|
41
|
+
// ── Server ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const server = new McpServer({ name: SERVER_NAME, version: PKG_VERSION });
|
|
44
|
+
|
|
45
|
+
if (!FACTORY_ONLY) {
|
|
46
|
+
registerQueryTools(server, BASE_CONFIG, TOOL_NAME, TOOL_DESCRIPTION, API_KEY);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (FACTORY_ONLY) {
|
|
50
|
+
registerFactoryTool(server);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Start ──────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
const transport = new StdioServerTransport();
|
|
57
|
+
await server.connect(transport);
|
|
58
|
+
const toolList = FACTORY_ONLY
|
|
59
|
+
? ["create_codex_mcp"]
|
|
60
|
+
: BASE_CONFIG.persistSession !== false
|
|
61
|
+
? [TOOL_NAME, REPLY_TOOL_NAME]
|
|
62
|
+
: [TOOL_NAME];
|
|
63
|
+
console.error(`${SERVER_NAME}: running on stdio (tools: ${toolList.join(", ")})`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch((error) => {
|
|
67
|
+
console.error(`${SERVER_NAME}: fatal:`, error);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
package/src/lib.test.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
envStr,
|
|
4
|
+
envList,
|
|
5
|
+
envNum,
|
|
6
|
+
envBool,
|
|
7
|
+
sanitizeToolName,
|
|
8
|
+
MAX_TOOL_NAME_LEN,
|
|
9
|
+
isDescendantPath,
|
|
10
|
+
validateSandboxMode,
|
|
11
|
+
narrowSandboxMode,
|
|
12
|
+
VALID_SANDBOX_MODES,
|
|
13
|
+
validateApprovalPolicy,
|
|
14
|
+
narrowApprovalPolicy,
|
|
15
|
+
VALID_APPROVAL_POLICIES,
|
|
16
|
+
validateEffort,
|
|
17
|
+
VALID_EFFORTS,
|
|
18
|
+
deriveServerName,
|
|
19
|
+
deriveToolName,
|
|
20
|
+
serializeArrayEnv,
|
|
21
|
+
formatErrorMessage,
|
|
22
|
+
} from "./lib.js";
|
|
23
|
+
|
|
24
|
+
// ── envStr ────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe("envStr", () => {
|
|
27
|
+
it("returns the value when set", () => {
|
|
28
|
+
expect(envStr("FOO", { FOO: "bar" })).toBe("bar");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns undefined for missing keys", () => {
|
|
32
|
+
expect(envStr("MISSING", {})).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns undefined for empty string", () => {
|
|
36
|
+
expect(envStr("EMPTY", { EMPTY: "" })).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ── envList ───────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe("envList", () => {
|
|
43
|
+
it("splits comma-separated values", () => {
|
|
44
|
+
expect(envList("X", { X: "a,b,c" })).toEqual(["a", "b", "c"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("trims whitespace", () => {
|
|
48
|
+
expect(envList("X", { X: " a , b " })).toEqual(["a", "b"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("parses JSON arrays", () => {
|
|
52
|
+
expect(envList("X", { X: '["a,b","c"]' })).toEqual(["a,b", "c"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns undefined for missing keys", () => {
|
|
56
|
+
expect(envList("MISSING", {})).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("falls back to comma-split on bad JSON", () => {
|
|
60
|
+
expect(envList("X", { X: "[bad" })).toEqual(["[bad"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("filters empty strings", () => {
|
|
64
|
+
expect(envList("X", { X: "a,,b," })).toEqual(["a", "b"]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── envNum ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("envNum", () => {
|
|
71
|
+
it("parses valid numbers", () => {
|
|
72
|
+
expect(envNum("X", { X: "42" })).toBe(42);
|
|
73
|
+
expect(envNum("X", { X: "3.14" })).toBeCloseTo(3.14);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns undefined for missing keys", () => {
|
|
77
|
+
expect(envNum("MISSING", {})).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns undefined for NaN", () => {
|
|
81
|
+
expect(envNum("X", { X: "abc" })).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── envBool ───────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("envBool", () => {
|
|
88
|
+
it("returns true for 'true' and '1'", () => {
|
|
89
|
+
expect(envBool("X", false, { X: "true" })).toBe(true);
|
|
90
|
+
expect(envBool("X", false, { X: "1" })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns false for other values", () => {
|
|
94
|
+
expect(envBool("X", true, { X: "false" })).toBe(false);
|
|
95
|
+
expect(envBool("X", true, { X: "0" })).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns fallback when missing", () => {
|
|
99
|
+
expect(envBool("MISSING", true, {})).toBe(true);
|
|
100
|
+
expect(envBool("MISSING", false, {})).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── sanitizeToolName ──────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe("sanitizeToolName", () => {
|
|
107
|
+
it("passes through valid names", () => {
|
|
108
|
+
expect(sanitizeToolName("my_tool")).toBe("my_tool");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("replaces invalid characters with underscore", () => {
|
|
112
|
+
expect(sanitizeToolName("my-tool.v2")).toBe("my_tool_v2");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("truncates to MAX_TOOL_NAME_LEN", () => {
|
|
116
|
+
const long = "a".repeat(100);
|
|
117
|
+
expect(sanitizeToolName(long).length).toBe(MAX_TOOL_NAME_LEN);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("falls back to 'codex' for empty result", () => {
|
|
121
|
+
expect(sanitizeToolName("")).toBe("codex");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── isDescendantPath ──────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
describe("isDescendantPath", () => {
|
|
128
|
+
it("accepts subdirectories", () => {
|
|
129
|
+
expect(isDescendantPath("subdir", "/srv/app")).toBe(true);
|
|
130
|
+
expect(isDescendantPath("a/b/c", "/srv/app")).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("accepts same directory", () => {
|
|
134
|
+
expect(isDescendantPath(".", "/srv/app")).toBe(true);
|
|
135
|
+
expect(isDescendantPath("/srv/app", "/srv/app")).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("rejects parent traversal", () => {
|
|
139
|
+
expect(isDescendantPath("..", "/srv/app")).toBe(false);
|
|
140
|
+
expect(isDescendantPath("../other", "/srv/app")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects sibling paths", () => {
|
|
144
|
+
expect(isDescendantPath("/srv/other", "/srv/app")).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects prefix attacks", () => {
|
|
148
|
+
expect(isDescendantPath("/srv/app-escape", "/srv/app")).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── validateSandboxMode ───────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe("validateSandboxMode", () => {
|
|
155
|
+
it("passes valid modes through", () => {
|
|
156
|
+
for (const mode of VALID_SANDBOX_MODES) {
|
|
157
|
+
expect(validateSandboxMode(mode)).toBe(mode);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("falls back to read-only for invalid modes", () => {
|
|
162
|
+
expect(validateSandboxMode("garbage")).toBe("read-only");
|
|
163
|
+
expect(validateSandboxMode("")).toBe("read-only");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── narrowSandboxMode ─────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
describe("narrowSandboxMode", () => {
|
|
170
|
+
it("allows tightening from danger-full-access", () => {
|
|
171
|
+
expect(narrowSandboxMode("danger-full-access", "workspace-write")).toBe("workspace-write");
|
|
172
|
+
expect(narrowSandboxMode("danger-full-access", "read-only")).toBe("read-only");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("allows tightening from workspace-write", () => {
|
|
176
|
+
expect(narrowSandboxMode("workspace-write", "read-only")).toBe("read-only");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rejects loosening", () => {
|
|
180
|
+
expect(narrowSandboxMode("read-only", "danger-full-access")).toBe("read-only");
|
|
181
|
+
expect(narrowSandboxMode("read-only", "workspace-write")).toBe("read-only");
|
|
182
|
+
expect(narrowSandboxMode("workspace-write", "danger-full-access")).toBe("workspace-write");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("same mode returns same mode", () => {
|
|
186
|
+
expect(narrowSandboxMode("read-only", "read-only")).toBe("read-only");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("invalid override returns base", () => {
|
|
190
|
+
expect(narrowSandboxMode("workspace-write", "garbage")).toBe("workspace-write");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── validateApprovalPolicy ────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
describe("validateApprovalPolicy", () => {
|
|
197
|
+
it("passes valid policies through", () => {
|
|
198
|
+
for (const policy of VALID_APPROVAL_POLICIES) {
|
|
199
|
+
expect(validateApprovalPolicy(policy)).toBe(policy);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("falls back to on-failure for invalid policies", () => {
|
|
204
|
+
expect(validateApprovalPolicy("garbage")).toBe("on-failure");
|
|
205
|
+
expect(validateApprovalPolicy("")).toBe("on-failure");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── narrowApprovalPolicy ──────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
describe("narrowApprovalPolicy", () => {
|
|
212
|
+
it("allows tightening from never", () => {
|
|
213
|
+
expect(narrowApprovalPolicy("never", "on-failure")).toBe("on-failure");
|
|
214
|
+
expect(narrowApprovalPolicy("never", "on-request")).toBe("on-request");
|
|
215
|
+
expect(narrowApprovalPolicy("never", "untrusted")).toBe("untrusted");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("rejects loosening", () => {
|
|
219
|
+
expect(narrowApprovalPolicy("untrusted", "never")).toBe("untrusted");
|
|
220
|
+
expect(narrowApprovalPolicy("on-request", "never")).toBe("on-request");
|
|
221
|
+
expect(narrowApprovalPolicy("on-failure", "never")).toBe("on-failure");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("same policy returns same", () => {
|
|
225
|
+
expect(narrowApprovalPolicy("never", "never")).toBe("never");
|
|
226
|
+
expect(narrowApprovalPolicy("untrusted", "untrusted")).toBe("untrusted");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("invalid override returns base", () => {
|
|
230
|
+
expect(narrowApprovalPolicy("on-failure", "garbage")).toBe("on-failure");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── validateEffort ────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe("validateEffort", () => {
|
|
237
|
+
it("passes valid efforts through", () => {
|
|
238
|
+
for (const effort of VALID_EFFORTS) {
|
|
239
|
+
expect(validateEffort(effort)).toBe(effort);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("falls back to medium for invalid", () => {
|
|
244
|
+
expect(validateEffort("garbage")).toBe("medium");
|
|
245
|
+
expect(validateEffort("")).toBe("medium");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── deriveServerName ──────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
describe("deriveServerName", () => {
|
|
252
|
+
it("slugifies descriptions", () => {
|
|
253
|
+
expect(deriveServerName("A strict code reviewer")).toBe("a-strict-code-reviewer");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("limits length to 30 chars", () => {
|
|
257
|
+
const long = "This is a very long description that should be truncated";
|
|
258
|
+
expect(deriveServerName(long).length).toBeLessThanOrEqual(30);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("falls back for empty input", () => {
|
|
262
|
+
expect(deriveServerName("!!!")).toMatch(/^agent-\d+$/);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── deriveToolName ────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe("deriveToolName", () => {
|
|
269
|
+
it("converts dashes to underscores", () => {
|
|
270
|
+
expect(deriveToolName("code-reviewer")).toBe("code_reviewer");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("falls back for empty result", () => {
|
|
274
|
+
expect(deriveToolName("---")).toBe("agent");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ── serializeArrayEnv ─────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
describe("serializeArrayEnv", () => {
|
|
281
|
+
it("joins with comma when no commas in values", () => {
|
|
282
|
+
expect(serializeArrayEnv(["a", "b"])).toBe("a,b");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("uses JSON when values contain commas", () => {
|
|
286
|
+
expect(serializeArrayEnv(["a,b", "c"])).toBe('["a,b","c"]');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ── formatErrorMessage ────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
describe("formatErrorMessage", () => {
|
|
293
|
+
it("extracts message from Error", () => {
|
|
294
|
+
expect(formatErrorMessage(new Error("boom"))).toBe("boom");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("stringifies non-Error values", () => {
|
|
298
|
+
expect(formatErrorMessage("oops")).toBe("oops");
|
|
299
|
+
expect(formatErrorMessage(42)).toBe("42");
|
|
300
|
+
});
|
|
301
|
+
});
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, testable logic extracted from index.ts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { normalize, resolve, sep } from "node:path";
|
|
6
|
+
|
|
7
|
+
// ── Env helpers ────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export function envStr(
|
|
10
|
+
key: string,
|
|
11
|
+
env: Record<string, string | undefined> = process.env
|
|
12
|
+
): string | undefined {
|
|
13
|
+
return env[key] || undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function envList(
|
|
17
|
+
key: string,
|
|
18
|
+
env: Record<string, string | undefined> = process.env
|
|
19
|
+
): string[] | undefined {
|
|
20
|
+
const val = env[key];
|
|
21
|
+
if (!val) return undefined;
|
|
22
|
+
if (val.startsWith("[")) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(val);
|
|
25
|
+
if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
|
|
26
|
+
} catch {
|
|
27
|
+
// fall through to comma-split
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return val
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function envNum(
|
|
37
|
+
key: string,
|
|
38
|
+
env: Record<string, string | undefined> = process.env
|
|
39
|
+
): number | undefined {
|
|
40
|
+
const val = env[key];
|
|
41
|
+
if (!val) return undefined;
|
|
42
|
+
const n = Number(val);
|
|
43
|
+
return Number.isNaN(n) ? undefined : n;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function envBool(
|
|
47
|
+
key: string,
|
|
48
|
+
fallback: boolean,
|
|
49
|
+
env: Record<string, string | undefined> = process.env
|
|
50
|
+
): boolean {
|
|
51
|
+
const val = env[key];
|
|
52
|
+
if (val === undefined) return fallback;
|
|
53
|
+
return val === "true" || val === "1";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Tool name sanitization ─────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const MAX_TOOL_NAME_LEN = 64 - "_reply".length;
|
|
59
|
+
|
|
60
|
+
export function sanitizeToolName(raw: string): string {
|
|
61
|
+
const sanitized = raw
|
|
62
|
+
.replace(/[^a-zA-Z0-9_]/g, "_")
|
|
63
|
+
.slice(0, MAX_TOOL_NAME_LEN);
|
|
64
|
+
return sanitized || "codex";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── cwd security check ────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export function isDescendantPath(
|
|
70
|
+
requested: string,
|
|
71
|
+
baseCwd: string
|
|
72
|
+
): boolean {
|
|
73
|
+
const normalBase = normalize(baseCwd);
|
|
74
|
+
const normalReq = normalize(resolve(normalBase, requested));
|
|
75
|
+
if (normalReq === normalBase) return true;
|
|
76
|
+
const baseWithSep = normalBase.endsWith(sep)
|
|
77
|
+
? normalBase
|
|
78
|
+
: normalBase + sep;
|
|
79
|
+
return normalReq.startsWith(baseWithSep);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Sandbox mode validation ───────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export const VALID_SANDBOX_MODES = new Set([
|
|
85
|
+
"read-only",
|
|
86
|
+
"workspace-write",
|
|
87
|
+
"danger-full-access",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
export function validateSandboxMode(mode: string): string {
|
|
91
|
+
return VALID_SANDBOX_MODES.has(mode) ? mode : "read-only";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Strictness order: most permissive → most restrictive
|
|
95
|
+
const SANDBOX_STRICTNESS: Record<string, number> = {
|
|
96
|
+
"danger-full-access": 0,
|
|
97
|
+
"workspace-write": 1,
|
|
98
|
+
"read-only": 2,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Narrow sandbox mode: returns the stricter of base and override.
|
|
103
|
+
* Callers can tighten but never loosen.
|
|
104
|
+
*/
|
|
105
|
+
export function narrowSandboxMode(base: string, override: string): string {
|
|
106
|
+
if (!VALID_SANDBOX_MODES.has(override)) return base;
|
|
107
|
+
const baseLevel = SANDBOX_STRICTNESS[base] ?? 2;
|
|
108
|
+
const overrideLevel = SANDBOX_STRICTNESS[override] ?? 2;
|
|
109
|
+
return overrideLevel >= baseLevel ? override : base;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Approval policy validation ────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export const VALID_APPROVAL_POLICIES = new Set([
|
|
115
|
+
"never",
|
|
116
|
+
"on-request",
|
|
117
|
+
"on-failure",
|
|
118
|
+
"untrusted",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
export function validateApprovalPolicy(policy: string): string {
|
|
122
|
+
return VALID_APPROVAL_POLICIES.has(policy) ? policy : "on-failure";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const APPROVAL_STRICTNESS: Record<string, number> = {
|
|
126
|
+
never: 0,
|
|
127
|
+
"on-failure": 1,
|
|
128
|
+
"on-request": 2,
|
|
129
|
+
untrusted: 3,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Narrow approval policy: returns the stricter of base and override.
|
|
134
|
+
* Callers can tighten but never loosen.
|
|
135
|
+
*/
|
|
136
|
+
export function narrowApprovalPolicy(base: string, override: string): string {
|
|
137
|
+
if (!VALID_APPROVAL_POLICIES.has(override)) return base;
|
|
138
|
+
const baseLevel = APPROVAL_STRICTNESS[base] ?? 1;
|
|
139
|
+
const overrideLevel = APPROVAL_STRICTNESS[override] ?? 1;
|
|
140
|
+
return overrideLevel >= baseLevel ? override : base;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Effort validation ─────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export const VALID_EFFORTS = new Set([
|
|
146
|
+
"minimal",
|
|
147
|
+
"low",
|
|
148
|
+
"medium",
|
|
149
|
+
"high",
|
|
150
|
+
"xhigh",
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
export function validateEffort(effort: string): string {
|
|
154
|
+
return VALID_EFFORTS.has(effort) ? effort : "medium";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Factory name derivation ────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function deriveServerName(description: string): string {
|
|
160
|
+
const slug = description
|
|
161
|
+
.toLowerCase()
|
|
162
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
163
|
+
.replace(/^-|-$/g, "")
|
|
164
|
+
.slice(0, 30);
|
|
165
|
+
return slug || `agent-${Date.now()}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function deriveToolName(name: string): string {
|
|
169
|
+
const slug = name
|
|
170
|
+
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
171
|
+
.replace(/^_|_$/g, "")
|
|
172
|
+
.slice(0, MAX_TOOL_NAME_LEN);
|
|
173
|
+
return slug || "agent";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Factory env serialization ──────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export function serializeArrayEnv(val: unknown[]): string {
|
|
179
|
+
const hasComma = val.some((v) => String(v).includes(","));
|
|
180
|
+
return hasComma ? JSON.stringify(val) : val.join(",");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Formatters ─────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export function formatErrorMessage(error: unknown): string {
|
|
186
|
+
return error instanceof Error ? error.message : String(error);
|
|
187
|
+
}
|