talentos 1.1.6 → 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 +47 -5
- package/dist/agent/codex-policy.js +5 -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 +74 -8
- package/dist/args.js +1 -0
- package/dist/commands/agent-native.js +64 -0
- 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
package/dist/agent/adapters.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { rm } from "node:fs/promises";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { delimiter, dirname, join } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { promisify } from "node:util";
|
|
@@ -11,6 +11,7 @@ import { gatewayLauncherPath } from "../gateway-install.js";
|
|
|
11
11
|
import { providerPathEnv, resolveProviderBinary } from "../provider-binary.js";
|
|
12
12
|
import { codexRoomArgs, ROOM_TOOLS } from "./codex-policy.js";
|
|
13
13
|
import { CodexBoundaryError, probeCodexBoundary } from "./codex-probe.js";
|
|
14
|
+
import { prepareOwnerLaunch } from "./native/launch.js";
|
|
14
15
|
const CHILD_ENV = [
|
|
15
16
|
"HOME",
|
|
16
17
|
"PATH",
|
|
@@ -190,7 +191,26 @@ export async function runProvider(input) {
|
|
|
190
191
|
const catalogPath = join(dirname(workDir), ".talentos-codex-models.json");
|
|
191
192
|
const binary = resolveProviderBinary(config.provider, config.providerPath);
|
|
192
193
|
let command, args;
|
|
193
|
-
|
|
194
|
+
let ownerLaunch;
|
|
195
|
+
let stopWatching;
|
|
196
|
+
if (input.owner && config.provider === "claude_code") {
|
|
197
|
+
const watching = await input.owner.watch?.();
|
|
198
|
+
stopWatching = watching?.stop;
|
|
199
|
+
ownerLaunch = await prepareOwnerLaunch({
|
|
200
|
+
agentId: config.agentId,
|
|
201
|
+
askSocket: watching?.socketPath,
|
|
202
|
+
directory: dirname(workDir),
|
|
203
|
+
home: input.owner.home,
|
|
204
|
+
instructions,
|
|
205
|
+
jobId: job.jobId,
|
|
206
|
+
prompt,
|
|
207
|
+
sessionId,
|
|
208
|
+
workspace: input.owner.workspace,
|
|
209
|
+
});
|
|
210
|
+
command = binary;
|
|
211
|
+
args = ownerLaunch.args;
|
|
212
|
+
}
|
|
213
|
+
else if (config.provider === "claude_code") {
|
|
194
214
|
await writePrivateFile(mcpPath, JSON.stringify({
|
|
195
215
|
mcpServers: {
|
|
196
216
|
talentos: {
|
|
@@ -249,11 +269,26 @@ export async function runProvider(input) {
|
|
|
249
269
|
});
|
|
250
270
|
}
|
|
251
271
|
const child = spawn(command, args, {
|
|
252
|
-
cwd: workDir,
|
|
253
|
-
env:
|
|
272
|
+
cwd: input.owner?.workspace.cwd ?? workDir,
|
|
273
|
+
env: input.owner
|
|
274
|
+
? {
|
|
275
|
+
...input.owner.env,
|
|
276
|
+
PATH: [dirname(binary), input.owner.env.PATH]
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join(delimiter),
|
|
279
|
+
}
|
|
280
|
+
: childEnvironment(process.env, binary),
|
|
254
281
|
stdio: ["ignore", "pipe", "ignore"],
|
|
255
282
|
detached: process.platform !== "win32",
|
|
256
283
|
});
|
|
284
|
+
const marked = new Set();
|
|
285
|
+
const mark = (name) => {
|
|
286
|
+
if (!marked.has(name)) {
|
|
287
|
+
marked.add(name);
|
|
288
|
+
input.mark?.(name);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
child.once("spawn", () => mark("spawn"));
|
|
257
292
|
let killTimer;
|
|
258
293
|
const stop = () => {
|
|
259
294
|
const pid = child.pid;
|
|
@@ -302,6 +337,10 @@ export async function runProvider(input) {
|
|
|
302
337
|
continue;
|
|
303
338
|
}
|
|
304
339
|
const event = readProviderEvent(config.provider, value);
|
|
340
|
+
mark("firstEvent");
|
|
341
|
+
if (event.status) {
|
|
342
|
+
mark("firstTool");
|
|
343
|
+
}
|
|
305
344
|
if (event.failed) {
|
|
306
345
|
failed = true;
|
|
307
346
|
}
|
|
@@ -309,7 +348,8 @@ export async function runProvider(input) {
|
|
|
309
348
|
await input.remember(event.sessionId);
|
|
310
349
|
}
|
|
311
350
|
if (event.status) {
|
|
312
|
-
|
|
351
|
+
// Room-visible status never names the owner's private tools.
|
|
352
|
+
input.status(ownerLaunch ? "Working on your computer…" : event.status);
|
|
313
353
|
}
|
|
314
354
|
if (event.text !== undefined) {
|
|
315
355
|
text = event.text.slice(0, 12_000);
|
|
@@ -334,5 +374,7 @@ export async function runProvider(input) {
|
|
|
334
374
|
}
|
|
335
375
|
await rm(mcpPath, { force: true });
|
|
336
376
|
await rm(catalogPath, { force: true });
|
|
377
|
+
stopWatching?.();
|
|
378
|
+
await ownerLaunch?.cleanup();
|
|
337
379
|
}
|
|
338
380
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdirSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { isMissing, writePrivateFile } from "../../agent-config.js";
|
|
7
|
+
import { isInside, reachesProtected } from "./paths.js";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
export const NATIVE_FILE = "native.json";
|
|
10
|
+
export async function readNativeConfig(directory) {
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(await readFile(join(directory, NATIVE_FILE), "utf8"));
|
|
13
|
+
if (typeof value.root === "string" &&
|
|
14
|
+
(value.kind === "repo" || value.kind === "managed") &&
|
|
15
|
+
typeof value.optInAt === "number" &&
|
|
16
|
+
typeof value.dev === "number" &&
|
|
17
|
+
typeof value.ino === "number") {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (isMissing(error) || error instanceof SyntaxError) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function disableNative(directory) {
|
|
30
|
+
await rm(join(directory, NATIVE_FILE), { force: true });
|
|
31
|
+
}
|
|
32
|
+
/** [a-z0-9-] only: a display name never forms a path. */
|
|
33
|
+
export function folderSlug(name, agentId) {
|
|
34
|
+
const slug = name
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z\d]+/gu, "-")
|
|
37
|
+
.replace(/^-+|-+$/gu, "")
|
|
38
|
+
.slice(0, 24);
|
|
39
|
+
return `${slug || "agent"}-${agentId
|
|
40
|
+
.replace(/[^a-z\d]/giu, "")
|
|
41
|
+
.slice(0, 8)
|
|
42
|
+
.toLowerCase()}`;
|
|
43
|
+
}
|
|
44
|
+
async function repoRoot(cwd) {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await exec("git", ["rev-parse", "--show-toplevel"], {
|
|
47
|
+
cwd,
|
|
48
|
+
timeout: 5000,
|
|
49
|
+
});
|
|
50
|
+
return stdout.trim() || undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Why a root may not serve as the agent's workspace, or undefined. Checked at
|
|
58
|
+
* enable time and again before every owner job: the folder must still be the
|
|
59
|
+
* same directory (device + inode), owned by this user, and neither the home
|
|
60
|
+
* folder nor anything that contains credentials.
|
|
61
|
+
*/
|
|
62
|
+
export function rootProblem(config, home) {
|
|
63
|
+
try {
|
|
64
|
+
const real = realpathSync.native(config.root);
|
|
65
|
+
const stats = statSync(real);
|
|
66
|
+
const homeReal = realpathSync.native(home);
|
|
67
|
+
if (!stats.isDirectory()) {
|
|
68
|
+
return "The workspace is not a folder.";
|
|
69
|
+
}
|
|
70
|
+
if (stats.dev !== config.dev || stats.ino !== config.ino) {
|
|
71
|
+
return "The workspace folder changed since it was set up.";
|
|
72
|
+
}
|
|
73
|
+
if (process.getuid && stats.uid !== process.getuid()) {
|
|
74
|
+
return "The workspace folder is not yours.";
|
|
75
|
+
}
|
|
76
|
+
if (real === "/" ||
|
|
77
|
+
real === homeReal ||
|
|
78
|
+
isInside(homeReal, real) ||
|
|
79
|
+
reachesProtected(real, home)) {
|
|
80
|
+
return "The workspace folder is too broad.";
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return "The workspace folder is missing.";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Records the owner's yes. The workspace is automatic: the repo the command
|
|
90
|
+
* runs in, otherwise ~/TalentOS/<name>-<id>/ (created here, empty).
|
|
91
|
+
*/
|
|
92
|
+
export async function enableNative(input) {
|
|
93
|
+
let root = await repoRoot(input.cwd);
|
|
94
|
+
let kind = "repo";
|
|
95
|
+
if (root) {
|
|
96
|
+
root = realpathSync.native(root);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
kind = "managed";
|
|
100
|
+
root = join(input.home, "TalentOS", folderSlug(input.name, input.agentId));
|
|
101
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
102
|
+
root = realpathSync.native(root);
|
|
103
|
+
}
|
|
104
|
+
const stats = statSync(root);
|
|
105
|
+
const config = {
|
|
106
|
+
dev: stats.dev,
|
|
107
|
+
ino: stats.ino,
|
|
108
|
+
kind,
|
|
109
|
+
optInAt: input.now ?? Date.now(),
|
|
110
|
+
root,
|
|
111
|
+
};
|
|
112
|
+
const problem = rootProblem(config, input.home);
|
|
113
|
+
if (problem) {
|
|
114
|
+
throw new Error(`${problem} Run this from a project folder, or from a normal folder to use ~/TalentOS.`);
|
|
115
|
+
}
|
|
116
|
+
await writePrivateFile(join(input.directory, NATIVE_FILE), `${JSON.stringify(config)}\n`);
|
|
117
|
+
return config;
|
|
118
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
// R4 (R12): the hook is the only thing between a steered model and the owner's
|
|
4
|
+
// tools, so every owner job checks the owner's own settings for anything that
|
|
5
|
+
// would switch it off. Cheap file reads, run per job (the behavioural probe is
|
|
6
|
+
// cached; this is not).
|
|
7
|
+
const MANAGED = [
|
|
8
|
+
"/Library/Application Support/ClaudeCode/managed-settings.json",
|
|
9
|
+
"/etc/claude-code/managed-settings.json",
|
|
10
|
+
];
|
|
11
|
+
function readSettings(path) {
|
|
12
|
+
try {
|
|
13
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
14
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
15
|
+
? value
|
|
16
|
+
: "bad";
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return error.code === "ENOENT"
|
|
20
|
+
? undefined
|
|
21
|
+
: "bad";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Why the owner's setup cannot run an owner job safely, or undefined. */
|
|
25
|
+
export function claudeDisqualifier(input) {
|
|
26
|
+
const files = [
|
|
27
|
+
...MANAGED,
|
|
28
|
+
join(input.configDir, "settings.json"),
|
|
29
|
+
join(input.configDir, "settings.local.json"),
|
|
30
|
+
...input.projectRoots.flatMap((root) => [
|
|
31
|
+
join(root, ".claude", "settings.json"),
|
|
32
|
+
join(root, ".claude", "settings.local.json"),
|
|
33
|
+
]),
|
|
34
|
+
];
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const settings = readSettings(file);
|
|
37
|
+
if (settings === "bad") {
|
|
38
|
+
return "A Claude Code settings file could not be read, so the room safety hook cannot be confirmed.";
|
|
39
|
+
}
|
|
40
|
+
if (settings?.disableAllHooks === true ||
|
|
41
|
+
settings?.allowManagedHooksOnly === true) {
|
|
42
|
+
return "Your Claude Code settings turn hooks off, which the room safety check needs.";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const MARK = "@@talentos-env@@";
|
|
3
|
+
// TalentOS and its deployment credentials never reach the model's environment,
|
|
4
|
+
// and a parent Claude Code session's markers must not make the child behave
|
|
5
|
+
// as if it were nested.
|
|
6
|
+
const DROP = /^(TALENTOS_|CONVEX_|CLAUDECODE$|CLAUDE_CODE_ENTRYPOINT$|CLAUDE_CODE_SSE_PORT$)/u;
|
|
7
|
+
export function scrubEnv(env) {
|
|
8
|
+
return Object.fromEntries(Object.entries(env).filter(([key, value]) => value !== undefined && !DROP.test(key)));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The owner's login-shell environment (PATH for nvm/uvx/npx, tokens their own
|
|
12
|
+
* MCP servers expect), captured once. A launchd service starts with almost
|
|
13
|
+
* nothing, which would break the very setup the owner wants to bring. Falls
|
|
14
|
+
* back to the service environment when the shell cannot be read.
|
|
15
|
+
*/
|
|
16
|
+
export async function loginShellEnv(base = process.env, shell = base.SHELL || "/bin/zsh") {
|
|
17
|
+
const script = `printf %s ${MARK}; "${process.execPath}" -e 'process.stdout.write(JSON.stringify(process.env))'; printf %s ${MARK}`;
|
|
18
|
+
const stdout = await new Promise((resolve) => {
|
|
19
|
+
execFile(shell, ["-ilc", script], { env: base, maxBuffer: 2_000_000, timeout: 8000 }, (error, out) => resolve(error ? "" : String(out)));
|
|
20
|
+
});
|
|
21
|
+
const parts = stdout.split(MARK);
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(parts[1] ?? "");
|
|
24
|
+
return scrubEnv({ ...base, ...parsed });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return scrubEnv(base);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { providerPathEnv, resolveProviderBinary, } from "../../provider-binary.js";
|
|
6
|
+
import { NATIVE_FILE, readNativeConfig } from "./config.js";
|
|
7
|
+
import { claudeDisqualifier } from "./disqualify.js";
|
|
8
|
+
import { loginShellEnv } from "./env.js";
|
|
9
|
+
import { ensureOwnerNativeProbe } from "./probe.js";
|
|
10
|
+
import { prepareWorkspace } from "./workspace.js";
|
|
11
|
+
const WHITESPACE = /\s+/u;
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
const ENV_TTL_MS = 10 * 60_000;
|
|
14
|
+
const RETRY_MS = 10 * 60_000;
|
|
15
|
+
/**
|
|
16
|
+
* Everything the owner-native profile needs on this computer, decided here and
|
|
17
|
+
* nowhere else: the owner's local yes, a passing cached probe, settings that
|
|
18
|
+
* leave the hook on, a workspace that still checks out. Any miss means the job
|
|
19
|
+
* runs room-only exactly as before. The probe runs once per provider change, in
|
|
20
|
+
* the background, never per job.
|
|
21
|
+
*/
|
|
22
|
+
export class NativeGate {
|
|
23
|
+
probe = "none";
|
|
24
|
+
retryAt = 0;
|
|
25
|
+
env;
|
|
26
|
+
agent;
|
|
27
|
+
directory;
|
|
28
|
+
home;
|
|
29
|
+
constructor(input) {
|
|
30
|
+
this.agent = input.agent;
|
|
31
|
+
this.directory = input.directory;
|
|
32
|
+
this.home = input.home;
|
|
33
|
+
}
|
|
34
|
+
/** The owner's yes exists on this computer (a file, so `native off` is instant). */
|
|
35
|
+
optedIn() {
|
|
36
|
+
return existsSync(join(this.directory, NATIVE_FILE));
|
|
37
|
+
}
|
|
38
|
+
/** Whether to tell the plane this computer is ready (heartbeat). */
|
|
39
|
+
reportable() {
|
|
40
|
+
return this.probe === "ok" && this.optedIn();
|
|
41
|
+
}
|
|
42
|
+
/** Starts the cached probe when the owner has opted in. Safe to call every beat. */
|
|
43
|
+
refresh() {
|
|
44
|
+
if (this.agent.provider !== "claude_code" ||
|
|
45
|
+
process.platform === "win32" ||
|
|
46
|
+
!this.optedIn() ||
|
|
47
|
+
this.probe === "pending" ||
|
|
48
|
+
this.probe === "ok" ||
|
|
49
|
+
(this.probe === "failed" && Date.now() < this.retryAt)) {
|
|
50
|
+
if (!this.optedIn()) {
|
|
51
|
+
this.probe = "none";
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.probe = "pending";
|
|
56
|
+
this.runProbe().then(() => {
|
|
57
|
+
this.probe = "ok";
|
|
58
|
+
}, () => {
|
|
59
|
+
this.probe = "failed";
|
|
60
|
+
// A failing provider is not re-probed on every heartbeat.
|
|
61
|
+
this.retryAt = Date.now() + RETRY_MS;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async runProbe() {
|
|
65
|
+
const binary = resolveProviderBinary("claude_code", this.agent.providerPath);
|
|
66
|
+
const { stdout } = await exec(binary, ["--version"], {
|
|
67
|
+
env: { ...process.env, PATH: providerPathEnv(binary, this.home) },
|
|
68
|
+
timeout: 5000,
|
|
69
|
+
});
|
|
70
|
+
await ensureOwnerNativeProbe({
|
|
71
|
+
binary,
|
|
72
|
+
cachePath: join(this.directory, "native-probe.json"),
|
|
73
|
+
version: stdout.trim().split(WHITESPACE)[0] ?? "unknown",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** The login-shell env, captured once and refreshed after ten minutes. */
|
|
77
|
+
loginEnv() {
|
|
78
|
+
if (!this.env || Date.now() - this.env.at > ENV_TTL_MS) {
|
|
79
|
+
this.env = { at: Date.now(), value: loginShellEnv() };
|
|
80
|
+
}
|
|
81
|
+
return this.env.value;
|
|
82
|
+
}
|
|
83
|
+
/** Per owner job (R4): returns the launch inputs, or undefined to run room-only. */
|
|
84
|
+
async prepare(threadId) {
|
|
85
|
+
this.refresh();
|
|
86
|
+
const config = await readNativeConfig(this.directory);
|
|
87
|
+
if (!config || this.probe !== "ok") {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const env = await this.loginEnv();
|
|
91
|
+
const configDir = env.CLAUDE_CONFIG_DIR || join(this.home, ".claude");
|
|
92
|
+
if (claudeDisqualifier({ configDir, projectRoots: [config.root] })) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const workspace = await prepareWorkspace({
|
|
96
|
+
config,
|
|
97
|
+
home: this.home,
|
|
98
|
+
threadId,
|
|
99
|
+
});
|
|
100
|
+
return { env, home: this.home, workspace };
|
|
101
|
+
}
|
|
102
|
+
}
|