talentos 1.1.5 → 1.2.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/dist/agent/adapters.js +73 -85
- package/dist/agent/codex-policy.js +72 -0
- package/dist/agent/codex-probe.js +424 -0
- package/dist/agent/mcp-entry.js +5 -2
- package/dist/agent/native/approvals.js +188 -0
- package/dist/agent/native/config.js +118 -0
- package/dist/agent/native/disqualify.js +46 -0
- package/dist/agent/native/env.js +29 -0
- package/dist/agent/native/gate.js +102 -0
- package/dist/agent/native/hook-entry.js +170 -0
- package/dist/agent/native/launch.js +91 -0
- package/dist/agent/native/paths.js +137 -0
- package/dist/agent/native/policy.js +653 -0
- package/dist/agent/native/probe.js +474 -0
- package/dist/agent/native/shell.js +174 -0
- package/dist/agent/native/workspace.js +87 -0
- package/dist/agent/serve.js +77 -8
- package/dist/args.js +1 -0
- package/dist/commands/agent-native.js +64 -0
- package/dist/commands/agent-serve.js +14 -11
- package/dist/commands/agent.js +5 -1
- package/dist/mcp-proxy.js +47 -6
- package/dist/upload-file.js +172 -0
- package/package.json +1 -1
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtemp, readFile, realpath, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { writePrivateFile } from "../agent-config.js";
|
|
9
|
+
import { homeDir } from "../install.js";
|
|
10
|
+
import { ProviderBinaryError, providerPathEnv } from "../provider-binary.js";
|
|
11
|
+
import { codexRoomArgs, ROOM_TOOLS } from "./codex-policy.js";
|
|
12
|
+
const PROBE_MS = 30_000;
|
|
13
|
+
const MAX_BYTES = 4_000_000;
|
|
14
|
+
const PROBE_REVISION = 2;
|
|
15
|
+
const RESOURCE_REFUSAL = /Method not found|unknown MCP server/u;
|
|
16
|
+
const INPUT_TYPES = new Set([
|
|
17
|
+
"message",
|
|
18
|
+
"reasoning",
|
|
19
|
+
"compaction",
|
|
20
|
+
"function_call",
|
|
21
|
+
"function_call_output",
|
|
22
|
+
"tool_search_call",
|
|
23
|
+
"additional_tools",
|
|
24
|
+
"tool_search_output",
|
|
25
|
+
]);
|
|
26
|
+
// These route only to the sole tools-only MCP server, which has no resource
|
|
27
|
+
// handlers. The loopback probe exercises their refusal on fresh and resume.
|
|
28
|
+
const RESOURCE_HELPERS = [
|
|
29
|
+
"list_mcp_resources", // Sole tools-only MCP server: no resources to list.
|
|
30
|
+
"list_mcp_resource_templates", // Sole tools-only MCP server: no templates.
|
|
31
|
+
"read_mcp_resource", // MCP RPC only; the proxy has no resources/read handler.
|
|
32
|
+
];
|
|
33
|
+
export class CodexBoundaryError extends ProviderBinaryError {
|
|
34
|
+
constructor(version) {
|
|
35
|
+
super(`Codex ${version} cannot safely run room jobs. Update Codex, then run talentos agent repair. If this persists, report this version to TalentOS support.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function record(value) {
|
|
39
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
40
|
+
throw new Error("Invalid tool envelope");
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/** Names retain their namespace: another MCP server cannot impersonate a room tool. */
|
|
45
|
+
export function codexAdvertisedTools(value) {
|
|
46
|
+
const request = record(value);
|
|
47
|
+
if (request.functions !== undefined) {
|
|
48
|
+
throw new Error("Unknown top-level schema container");
|
|
49
|
+
}
|
|
50
|
+
const names = [];
|
|
51
|
+
function inspect(value, namespace = "") {
|
|
52
|
+
if (!Array.isArray(value)) {
|
|
53
|
+
throw new Error("Invalid tools");
|
|
54
|
+
}
|
|
55
|
+
for (const item of value) {
|
|
56
|
+
const tool = record(item);
|
|
57
|
+
if (tool.type === "namespace") {
|
|
58
|
+
if (namespace ||
|
|
59
|
+
tool.functions !== undefined ||
|
|
60
|
+
tool.function !== undefined ||
|
|
61
|
+
tool.namespace !== undefined ||
|
|
62
|
+
!["functions", "mcp__talentos"].includes(String(tool.name))) {
|
|
63
|
+
throw new Error("Unknown namespace");
|
|
64
|
+
}
|
|
65
|
+
inspect(tool.tools, String(tool.name));
|
|
66
|
+
}
|
|
67
|
+
else if (tool.type === "function" && typeof tool.name === "string") {
|
|
68
|
+
if (tool.tools !== undefined ||
|
|
69
|
+
tool.functions !== undefined ||
|
|
70
|
+
tool.function !== undefined ||
|
|
71
|
+
(namespace &&
|
|
72
|
+
tool.namespace !== undefined &&
|
|
73
|
+
tool.namespace !== namespace)) {
|
|
74
|
+
throw new Error("Conflicting tool envelope");
|
|
75
|
+
}
|
|
76
|
+
const ns = namespace ||
|
|
77
|
+
(typeof tool.namespace === "string" ? tool.namespace : "");
|
|
78
|
+
const name = tool.name;
|
|
79
|
+
const allowed = ns === "mcp__talentos"
|
|
80
|
+
? ROOM_TOOLS.includes(name)
|
|
81
|
+
: ns === "functions" && RESOURCE_HELPERS.includes(name);
|
|
82
|
+
if (!(allowed && tool.parameters) ||
|
|
83
|
+
typeof tool.parameters !== "object" ||
|
|
84
|
+
Array.isArray(tool.parameters)) {
|
|
85
|
+
throw new Error("Unexpected callable tool");
|
|
86
|
+
}
|
|
87
|
+
names.push(`${ns}.${name}`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
throw new Error("Unknown tool schema");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (request.tools !== undefined) {
|
|
95
|
+
inspect(request.tools);
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(request.input)) {
|
|
98
|
+
throw new Error("Missing input");
|
|
99
|
+
}
|
|
100
|
+
for (const item of request.input) {
|
|
101
|
+
const input = record(item);
|
|
102
|
+
if (typeof input.type !== "string" || !INPUT_TYPES.has(input.type)) {
|
|
103
|
+
throw new Error("Unknown input envelope");
|
|
104
|
+
}
|
|
105
|
+
if (input.type === "additional_tools" ||
|
|
106
|
+
input.type === "tool_search_output") {
|
|
107
|
+
inspect(input.tools);
|
|
108
|
+
}
|
|
109
|
+
else if (input.tools !== undefined || input.functions !== undefined) {
|
|
110
|
+
throw new Error("Unknown schema envelope");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return names;
|
|
114
|
+
}
|
|
115
|
+
export function probeResponse(response, item) {
|
|
116
|
+
const result = {
|
|
117
|
+
id: "probe",
|
|
118
|
+
object: "response",
|
|
119
|
+
status: "completed",
|
|
120
|
+
output: [item],
|
|
121
|
+
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
|
122
|
+
};
|
|
123
|
+
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
124
|
+
for (const event of [
|
|
125
|
+
{
|
|
126
|
+
type: "response.created",
|
|
127
|
+
response: { ...result, status: "in_progress", output: [] },
|
|
128
|
+
},
|
|
129
|
+
{ type: "response.output_item.added", output_index: 0, item },
|
|
130
|
+
{ type: "response.output_item.done", output_index: 0, item },
|
|
131
|
+
{ type: "response.completed", response: result },
|
|
132
|
+
]) {
|
|
133
|
+
response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
134
|
+
}
|
|
135
|
+
response.end();
|
|
136
|
+
}
|
|
137
|
+
async function execute(binary, args, cwd, env, signal) {
|
|
138
|
+
signal.throwIfAborted();
|
|
139
|
+
const child = spawn(binary, args, {
|
|
140
|
+
cwd,
|
|
141
|
+
env,
|
|
142
|
+
detached: process.platform !== "win32",
|
|
143
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
144
|
+
});
|
|
145
|
+
const kill = () => {
|
|
146
|
+
try {
|
|
147
|
+
if (process.platform === "win32") {
|
|
148
|
+
child.kill("SIGKILL");
|
|
149
|
+
}
|
|
150
|
+
else if (child.pid) {
|
|
151
|
+
process.kill(-child.pid, "SIGKILL");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* The child may already have exited. */
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
159
|
+
try {
|
|
160
|
+
return await new Promise((resolve, reject) => {
|
|
161
|
+
let output = "";
|
|
162
|
+
child.stdout.on("data", (chunk) => {
|
|
163
|
+
output += chunk.toString();
|
|
164
|
+
if (Buffer.byteLength(output) > MAX_BYTES) {
|
|
165
|
+
kill();
|
|
166
|
+
reject(new Error("Probe output limit"));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
child.once("error", reject);
|
|
170
|
+
child.once("close", (code) => code === 0 && !signal.aborted
|
|
171
|
+
? resolve(output)
|
|
172
|
+
: reject(new Error("Probe failed")));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
signal.removeEventListener("abort", kill);
|
|
177
|
+
kill();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function digest(value) {
|
|
181
|
+
return createHash("sha256").update(value).digest("hex");
|
|
182
|
+
}
|
|
183
|
+
async function identity(binary) {
|
|
184
|
+
const target = await realpath(binary);
|
|
185
|
+
const info = await stat(target);
|
|
186
|
+
return JSON.stringify([
|
|
187
|
+
binary,
|
|
188
|
+
target,
|
|
189
|
+
info.dev,
|
|
190
|
+
info.ino,
|
|
191
|
+
info.size,
|
|
192
|
+
info.mtimeMs,
|
|
193
|
+
info.ctimeMs,
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
/** A synthetic turn uses no owner login or live API. Inconclusive probes never permit service. */
|
|
197
|
+
export async function probeCodexBoundary(binary, version, catalog, stop) {
|
|
198
|
+
try {
|
|
199
|
+
await proveCodexBoundary(binary, version, catalog, stop);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new CodexBoundaryError(version);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function proveCodexBoundary(binary, version, catalog, stop) {
|
|
206
|
+
const before = await identity(binary);
|
|
207
|
+
const key = digest(JSON.stringify([
|
|
208
|
+
PROBE_REVISION,
|
|
209
|
+
RESOURCE_HELPERS,
|
|
210
|
+
before,
|
|
211
|
+
version,
|
|
212
|
+
catalog,
|
|
213
|
+
codexRoomArgs({
|
|
214
|
+
catalogPath: "catalog",
|
|
215
|
+
mcpCommand: "mcp",
|
|
216
|
+
mcpArgs: [],
|
|
217
|
+
prompt: "probe",
|
|
218
|
+
}),
|
|
219
|
+
]));
|
|
220
|
+
const cache = join(homeDir(), ".talentos", "codex-boundary", `${key}.json`);
|
|
221
|
+
try {
|
|
222
|
+
if ((await readFile(cache, "utf8")) === JSON.stringify({ key, safe: true })) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* Missing or corrupt cache requires a new proof. */
|
|
228
|
+
}
|
|
229
|
+
const directory = await mkdtemp(join(tmpdir(), "talentos-codex-boundary-"));
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
const timer = setTimeout(() => controller.abort(), PROBE_MS);
|
|
232
|
+
const signal = stop
|
|
233
|
+
? AbortSignal.any([stop, controller.signal])
|
|
234
|
+
: controller.signal;
|
|
235
|
+
const server = createServer();
|
|
236
|
+
let failure;
|
|
237
|
+
let turn = 0;
|
|
238
|
+
let requests = 0;
|
|
239
|
+
const observations = [new Set(), new Set()];
|
|
240
|
+
const nonce = randomUUID();
|
|
241
|
+
const marker = `room-probe-${nonce}`;
|
|
242
|
+
const calls = [[], []];
|
|
243
|
+
const finished = [false, false];
|
|
244
|
+
const turnSteps = [];
|
|
245
|
+
let canaryUrl = "";
|
|
246
|
+
const canary = join(directory, "private-canary");
|
|
247
|
+
try {
|
|
248
|
+
await writeFile(canary, nonce, { mode: 0o600 });
|
|
249
|
+
const catalogPath = join(directory, "catalog.json");
|
|
250
|
+
await writeFile(catalogPath, catalog, { mode: 0o600 });
|
|
251
|
+
const mcp = join(directory, "mcp.mjs");
|
|
252
|
+
// Match startMcpProxy's sole capability and handlers. No resources, owner
|
|
253
|
+
// credentials, filesystem readers or generic network tool can be reached.
|
|
254
|
+
await writeFile(mcp, `import {Server} from ${JSON.stringify(import.meta.resolve("@modelcontextprotocol/sdk/server/index.js"))};
|
|
255
|
+
import {StdioServerTransport} from ${JSON.stringify(import.meta.resolve("@modelcontextprotocol/sdk/server/stdio.js"))};
|
|
256
|
+
import {ListToolsRequestSchema,CallToolRequestSchema} from ${JSON.stringify(import.meta.resolve("@modelcontextprotocol/sdk/types.js"))};
|
|
257
|
+
const server=new Server({name:'talentos',version:'probe'},{capabilities:{tools:{}}});
|
|
258
|
+
server.setRequestHandler(ListToolsRequestSchema,async()=>({tools:${JSON.stringify([...ROOM_TOOLS, "search_memory", "run_routine"].map((name) => ({ name, description: name, inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } })))}}));
|
|
259
|
+
server.setRequestHandler(CallToolRequestSchema,async r=>({content:[{type:'text',text:r.params.name==='read_room'?${JSON.stringify(marker)}:'private-tool-refused'}]}));
|
|
260
|
+
await server.connect(new StdioServerTransport());`, { mode: 0o600 });
|
|
261
|
+
server.on("request", async (request, response) => {
|
|
262
|
+
try {
|
|
263
|
+
if (request.method !== "POST" ||
|
|
264
|
+
request.url !== "/v1/responses" ||
|
|
265
|
+
++requests > 20) {
|
|
266
|
+
throw new Error("Unexpected probe request");
|
|
267
|
+
}
|
|
268
|
+
let raw = "";
|
|
269
|
+
for await (const chunk of request) {
|
|
270
|
+
raw += chunk.toString();
|
|
271
|
+
if (Buffer.byteLength(raw) > MAX_BYTES) {
|
|
272
|
+
throw new Error("Request limit");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const body = record(JSON.parse(raw));
|
|
276
|
+
for (const name of codexAdvertisedTools(body)) {
|
|
277
|
+
observations[turn]?.add(name);
|
|
278
|
+
}
|
|
279
|
+
const helpers = RESOURCE_HELPERS.filter((name) => observations[turn]?.has(`functions.${name}`));
|
|
280
|
+
const steps = turnSteps[turn] ?? [
|
|
281
|
+
"read_room",
|
|
282
|
+
...helpers,
|
|
283
|
+
...(helpers.includes("read_mcp_resource")
|
|
284
|
+
? ["read_mcp_resource", "read_mcp_resource"]
|
|
285
|
+
: []),
|
|
286
|
+
];
|
|
287
|
+
turnSteps[turn] = steps;
|
|
288
|
+
if (helpers.some((name) => !steps.includes(name))) {
|
|
289
|
+
throw new Error("Helper surface changed during proof");
|
|
290
|
+
}
|
|
291
|
+
const input = body.input;
|
|
292
|
+
const completed = calls[turn] ?? [];
|
|
293
|
+
if (completed.length) {
|
|
294
|
+
const output = input.find((item) => item.type === "function_call_output" &&
|
|
295
|
+
item.call_id === `${turn}-${completed.length - 1}`);
|
|
296
|
+
if (!output) {
|
|
297
|
+
throw new Error("Missing tool result");
|
|
298
|
+
}
|
|
299
|
+
const text = JSON.stringify(output.output);
|
|
300
|
+
const previous = completed.at(-1);
|
|
301
|
+
if (previous === "read_room") {
|
|
302
|
+
if (!text.includes(marker)) {
|
|
303
|
+
throw new Error("Room tool did not execute");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else if (previous === "list_mcp_resources" ||
|
|
307
|
+
previous === "list_mcp_resource_templates") {
|
|
308
|
+
const field = previous === "list_mcp_resources"
|
|
309
|
+
? "resources"
|
|
310
|
+
: "resourceTemplates";
|
|
311
|
+
if (output.output !== JSON.stringify({ [field]: [] })) {
|
|
312
|
+
throw new Error("Resource listing is not empty");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (typeof output.output !== "string" ||
|
|
316
|
+
!output.output.startsWith("resources/read failed:") ||
|
|
317
|
+
!RESOURCE_REFUSAL.test(output.output) ||
|
|
318
|
+
text.includes(nonce)) {
|
|
319
|
+
throw new Error("Resource helper did not refuse");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const name = steps[completed.length];
|
|
323
|
+
if (!name) {
|
|
324
|
+
finished[turn] = true;
|
|
325
|
+
probeResponse(response, {
|
|
326
|
+
type: "message",
|
|
327
|
+
id: "answer",
|
|
328
|
+
role: "assistant",
|
|
329
|
+
status: "completed",
|
|
330
|
+
content: [
|
|
331
|
+
{
|
|
332
|
+
type: "output_text",
|
|
333
|
+
text: "Boundary verified.",
|
|
334
|
+
annotations: [],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
completed.push(name);
|
|
341
|
+
const reads = completed.filter((tool) => tool === "read_mcp_resource").length;
|
|
342
|
+
probeResponse(response, {
|
|
343
|
+
type: "function_call",
|
|
344
|
+
id: `${turn}-${completed.length - 1}`,
|
|
345
|
+
call_id: `${turn}-${completed.length - 1}`,
|
|
346
|
+
name,
|
|
347
|
+
namespace: name === "read_room" ? "mcp__talentos" : "functions",
|
|
348
|
+
arguments: JSON.stringify(name === "read_mcp_resource"
|
|
349
|
+
? {
|
|
350
|
+
server: reads === 3 ? "unknown_owner_server" : "talentos",
|
|
351
|
+
uri: reads > 1 ? canaryUrl : pathToFileURL(canary).href,
|
|
352
|
+
}
|
|
353
|
+
: {}),
|
|
354
|
+
status: "completed",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
failure = error;
|
|
359
|
+
response.writeHead(400).end();
|
|
360
|
+
controller.abort();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
await new Promise((resolve, reject) => {
|
|
364
|
+
server.once("error", reject);
|
|
365
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
366
|
+
});
|
|
367
|
+
const address = server.address();
|
|
368
|
+
if (!address || typeof address === "string") {
|
|
369
|
+
throw new Error("Missing probe port");
|
|
370
|
+
}
|
|
371
|
+
canaryUrl = `http://127.0.0.1:${address.port}/private-canary`;
|
|
372
|
+
const env = {
|
|
373
|
+
HOME: directory,
|
|
374
|
+
CODEX_HOME: directory,
|
|
375
|
+
XDG_CONFIG_HOME: directory,
|
|
376
|
+
XDG_CACHE_HOME: directory,
|
|
377
|
+
PATH: providerPathEnv(binary, directory),
|
|
378
|
+
TALENTOS_PROBE_KEY: "loopback-only",
|
|
379
|
+
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
|
380
|
+
};
|
|
381
|
+
let session;
|
|
382
|
+
for (turn = 0; turn < 2; turn++) {
|
|
383
|
+
const args = codexRoomArgs({
|
|
384
|
+
catalogPath,
|
|
385
|
+
mcpCommand: process.execPath,
|
|
386
|
+
mcpArgs: [mcp],
|
|
387
|
+
prompt: "Verify the room boundary.",
|
|
388
|
+
sessionId: session,
|
|
389
|
+
});
|
|
390
|
+
args.unshift("-c", 'model_provider="talentos_probe"', "-c", 'model_providers.talentos_probe.name="Loopback"', "-c", `model_providers.talentos_probe.base_url="http://127.0.0.1:${address.port}/v1"`, "-c", 'model_providers.talentos_probe.wire_api="responses"', "-c", 'model_providers.talentos_probe.env_key="TALENTOS_PROBE_KEY"');
|
|
391
|
+
const output = await execute(binary, args, directory, env, signal);
|
|
392
|
+
if (failure ||
|
|
393
|
+
!finished[turn] ||
|
|
394
|
+
calls[turn]?.length !== turnSteps[turn]?.length ||
|
|
395
|
+
!observations[turn]?.has("mcp__talentos.read_room")) {
|
|
396
|
+
throw new Error("Incomplete boundary proof");
|
|
397
|
+
}
|
|
398
|
+
const events = output
|
|
399
|
+
.trim()
|
|
400
|
+
.split("\n")
|
|
401
|
+
.map((line) => record(JSON.parse(line)));
|
|
402
|
+
const thread = events.find((event) => event.type === "thread.started")?.thread_id;
|
|
403
|
+
if (typeof thread !== "string" ||
|
|
404
|
+
(session && session !== thread) ||
|
|
405
|
+
!events.some((event) => event.type === "turn.completed")) {
|
|
406
|
+
throw new Error("Missing resumed session");
|
|
407
|
+
}
|
|
408
|
+
session = thread;
|
|
409
|
+
}
|
|
410
|
+
if ((await identity(binary)) !== before) {
|
|
411
|
+
throw new Error("Codex changed during probe");
|
|
412
|
+
}
|
|
413
|
+
signal.throwIfAborted();
|
|
414
|
+
// Failure to persist an optimization does not invalidate a completed proof.
|
|
415
|
+
await writePrivateFile(cache, JSON.stringify({ key, safe: true })).catch(() => undefined);
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
controller.abort();
|
|
420
|
+
server.closeAllConnections();
|
|
421
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
422
|
+
await rm(directory, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
}
|
package/dist/agent/mcp-entry.js
CHANGED
|
@@ -4,8 +4,11 @@ import { AgentClient } from "../client.js";
|
|
|
4
4
|
import { startMcpProxy } from "../mcp-proxy.js";
|
|
5
5
|
// A local stdio entry avoids putting the bearer key in Codex's argv or child
|
|
6
6
|
// environment. Only the MCP proxy reads the linked-agent credential file.
|
|
7
|
-
const
|
|
7
|
+
const { options } = parseArgv(process.argv.slice(2));
|
|
8
|
+
const config = (await localAgents()).find((agent) => agent.agentId === options.agent);
|
|
8
9
|
if (!config) {
|
|
9
10
|
throw new Error("Linked agent configuration is unavailable.");
|
|
10
11
|
}
|
|
11
|
-
await startMcpProxy(new AgentClient({ apiUrl: config.apiUrl, apiKey: config.apiKey })
|
|
12
|
+
await startMcpProxy(new AgentClient({ apiUrl: config.apiUrl, apiKey: config.apiKey }),
|
|
13
|
+
// The launcher names the job's workspace; a job with none uploads nothing.
|
|
14
|
+
options["upload-root"]);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
// The supervisor's half of an ask (#10987 PR 2). The hook (a separate process,
|
|
6
|
+
// inside the provider) connects to this process over a unix socket, sends the
|
|
7
|
+
// ask, and WAITS ON THAT CONNECTION. Nothing is ever read back from a file: an
|
|
8
|
+
// answer exists only as bytes this process writes on the asker's own connection,
|
|
9
|
+
// after the server said so. There is no decision file for the model to forge.
|
|
10
|
+
//
|
|
11
|
+
// The socket lives in a fresh 0700 directory under the system temp folder, never
|
|
12
|
+
// inside any workspace or the job folder. Code that runs as the same user (an
|
|
13
|
+
// approved script) could still impersonate this process; that needs running
|
|
14
|
+
// code, which is itself an owner-approved tier 1 action.
|
|
15
|
+
const ACTION_KEY = /^[0-9a-f]{64}$/u;
|
|
16
|
+
const STATUS_EVERY_MS = 1000;
|
|
17
|
+
const MAX_LINE = 4096;
|
|
18
|
+
const asAnswer = (status) => status === "approved" || status === "denied" || status === "expired"
|
|
19
|
+
? status
|
|
20
|
+
: undefined;
|
|
21
|
+
function parseAsk(line) {
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(line);
|
|
24
|
+
if (typeof value.actionKey === "string" &&
|
|
25
|
+
ACTION_KEY.test(value.actionKey) &&
|
|
26
|
+
typeof value.kind === "string" &&
|
|
27
|
+
value.kind.length <= 80 &&
|
|
28
|
+
typeof value.summary === "string" &&
|
|
29
|
+
value.summary.length <= 200 &&
|
|
30
|
+
(value.tier === 1 || value.tier === 2)) {
|
|
31
|
+
return {
|
|
32
|
+
actionKey: value.actionKey,
|
|
33
|
+
kind: value.kind,
|
|
34
|
+
summary: value.summary,
|
|
35
|
+
tier: value.tier,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Not an ask.
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
export class ApprovalBridge {
|
|
45
|
+
gateway;
|
|
46
|
+
onWaiting;
|
|
47
|
+
statusEveryMs;
|
|
48
|
+
waiters = new Map();
|
|
49
|
+
waiting = false;
|
|
50
|
+
polling = false;
|
|
51
|
+
lastPollAt = 0;
|
|
52
|
+
constructor(input) {
|
|
53
|
+
this.gateway = input.gateway;
|
|
54
|
+
this.onWaiting = input.onWaiting ?? (() => undefined);
|
|
55
|
+
this.statusEveryMs = input.statusEveryMs ?? STATUS_EVERY_MS;
|
|
56
|
+
}
|
|
57
|
+
/** Listens for the hook's asks; `stop` refuses anything still held. */
|
|
58
|
+
async start() {
|
|
59
|
+
const directory = mkdtempSync(join(tmpdir(), "tos-"));
|
|
60
|
+
const socketPath = join(directory, "a.sock");
|
|
61
|
+
const server = createServer((socket) => this.accept(socket));
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
server.once("error", reject);
|
|
64
|
+
server.listen(socketPath, resolve);
|
|
65
|
+
});
|
|
66
|
+
const timer = setInterval(() => {
|
|
67
|
+
this.poll().catch(() => undefined);
|
|
68
|
+
}, Math.max(100, Math.min(this.statusEveryMs, 250)));
|
|
69
|
+
return {
|
|
70
|
+
socketPath,
|
|
71
|
+
stop: () => {
|
|
72
|
+
clearInterval(timer);
|
|
73
|
+
for (const sockets of this.waiters.values()) {
|
|
74
|
+
for (const socket of sockets) {
|
|
75
|
+
socket.destroy();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.waiters.clear();
|
|
79
|
+
this.signal();
|
|
80
|
+
server.close();
|
|
81
|
+
rmSync(directory, { force: true, recursive: true });
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
accept(socket) {
|
|
86
|
+
let buffer = "";
|
|
87
|
+
socket.setEncoding("utf8");
|
|
88
|
+
socket.on("error", () => socket.destroy());
|
|
89
|
+
socket.on("data", (chunk) => {
|
|
90
|
+
buffer += chunk;
|
|
91
|
+
if (buffer.length > MAX_LINE) {
|
|
92
|
+
socket.destroy();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const end = buffer.indexOf("\n");
|
|
96
|
+
if (end < 0) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const ask = parseAsk(buffer.slice(0, end));
|
|
100
|
+
buffer = "";
|
|
101
|
+
if (!ask) {
|
|
102
|
+
socket.destroy();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.forward(ask, socket).catch(() => socket.destroy());
|
|
106
|
+
});
|
|
107
|
+
socket.on("close", () => {
|
|
108
|
+
for (const [key, sockets] of this.waiters) {
|
|
109
|
+
sockets.delete(socket);
|
|
110
|
+
if (sockets.size === 0) {
|
|
111
|
+
this.waiters.delete(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
this.signal();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async forward(ask, socket) {
|
|
118
|
+
try {
|
|
119
|
+
const { status } = await this.gateway.request(ask);
|
|
120
|
+
const answer = asAnswer(status);
|
|
121
|
+
if (answer) {
|
|
122
|
+
this.reply(socket, answer);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// A lost lease ends the ask; an unreachable server also refuses, because a
|
|
128
|
+
// held call must never outlive the answer it waits for.
|
|
129
|
+
this.reply(socket, "expired");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const sockets = this.waiters.get(ask.actionKey) ?? new Set();
|
|
133
|
+
sockets.add(socket);
|
|
134
|
+
this.waiters.set(ask.actionKey, sockets);
|
|
135
|
+
this.signal();
|
|
136
|
+
}
|
|
137
|
+
async poll() {
|
|
138
|
+
if (this.polling ||
|
|
139
|
+
this.waiters.size === 0 ||
|
|
140
|
+
Date.now() - this.lastPollAt < this.statusEveryMs) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.polling = true;
|
|
144
|
+
this.lastPollAt = Date.now();
|
|
145
|
+
try {
|
|
146
|
+
const keys = [...this.waiters.keys()].slice(0, 10);
|
|
147
|
+
const answers = new Map((await this.gateway.status(keys)).map((row) => [
|
|
148
|
+
row.actionKey,
|
|
149
|
+
row.status,
|
|
150
|
+
]));
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
const status = answers.get(key);
|
|
153
|
+
const answer = status === undefined ? "expired" : asAnswer(status);
|
|
154
|
+
if (answer) {
|
|
155
|
+
for (const socket of this.waiters.get(key) ?? []) {
|
|
156
|
+
this.reply(socket, answer);
|
|
157
|
+
}
|
|
158
|
+
this.waiters.delete(key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
const code = error.status;
|
|
164
|
+
if (code === 409 || code === 404) {
|
|
165
|
+
for (const sockets of this.waiters.values()) {
|
|
166
|
+
for (const socket of sockets) {
|
|
167
|
+
this.reply(socket, "expired");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.waiters.clear();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
this.polling = false;
|
|
175
|
+
this.signal();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
reply(socket, status) {
|
|
179
|
+
socket.end(`${JSON.stringify({ status })}\n`);
|
|
180
|
+
}
|
|
181
|
+
signal() {
|
|
182
|
+
const waiting = this.waiters.size > 0;
|
|
183
|
+
if (waiting !== this.waiting) {
|
|
184
|
+
this.waiting = waiting;
|
|
185
|
+
this.onWaiting(waiting);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|