mtech-ai-local-runner 0.1.46 → 0.1.48
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/README.md +107 -2
- package/dist/src/adapters/claude-code/adapter.js +57 -36
- package/dist/src/adapters/claude-code/runtime-options.js +114 -33
- package/dist/src/asks/commands.js +100 -0
- package/dist/src/asks/doctor.js +106 -0
- package/dist/src/asks/hook-client.js +236 -0
- package/dist/src/asks/hub-protocol.js +97 -0
- package/dist/src/asks/hub.js +202 -0
- package/dist/src/asks/inbox.js +347 -0
- package/dist/src/asks/integrations.js +433 -0
- package/dist/src/asks/messenger.js +135 -0
- package/dist/src/asks/notifier.js +73 -0
- package/dist/src/asks/routing.js +239 -0
- package/dist/src/asks/runtime.js +198 -0
- package/dist/src/asks/shim.js +78 -0
- package/dist/src/asks/store.js +210 -0
- package/dist/src/asks/templates.js +44 -0
- package/dist/src/asks/types.js +64 -0
- package/dist/src/cli.js +90 -31
- package/dist/src/config.js +22 -2
- package/dist/src/daemon/runtime.js +36 -4
- package/dist/src/gateway-client.js +18 -3
- package/dist/src/registration/local-config.js +7 -1
- package/dist/src/safety/claude-binary-identity.js +81 -0
- package/dist/src/safety/local-edit-content.js +14 -0
- package/dist/src/safety/local-tool-policy.js +3 -0
- package/dist/src/safety/local-tool-server.js +2 -2
- package/dist/src/safety/local-tool-worker.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
+
## Unreleased: AI-first ask delivery and messenger mode (disabled)
|
|
2
|
+
|
|
3
|
+
The runner can put an AI Gateway ask (a Work Decision, a named review or a work request), and the answer to it,
|
|
4
|
+
into the employee's own running Claude Code or Codex session before any Slack message is sent (Work Item e1c08b33,
|
|
5
|
+
owner Work Decision 266f9475, option A). Deliveries arrive as ids only on the existing session-open wait. The daemon
|
|
6
|
+
keeps them in `asks.json` (mode 0600) and reports `received`, `shown`, `seen` or `dropped` with
|
|
7
|
+
`local_agent_ask_delivery_report`; the runner sends the Gateway no session id, cwd, prompt or text. Hooks reach the
|
|
8
|
+
daemon through `hub.sock` (0600, in the 0700 state root) and the stable shim `bin/gateway-hook`, which runs a small
|
|
9
|
+
hook client that prints nothing unless the daemon answers within 300 ms.
|
|
10
|
+
|
|
11
|
+
Only a session that a person is attending is served. A Claude Code session counts when `CLAUDE_CODE_SESSION_ATTENDED`
|
|
12
|
+
is `1` or, where Claude Code does not export that variable, when `CLAUDE_CODE_ENTRYPOINT` names a terminal, the Claude
|
|
13
|
+
desktop app or the VS Code extension. A headless `claude -p` or Agent SDK run gets nothing and is never counted as
|
|
14
|
+
having seen an ask. Codex exports no such marker, so every Codex hook session counts. The runner's own jobs never
|
|
15
|
+
count: a hook whose `HOME` is under `<stateRoot>/claude-jobs/` (where every Claude job runs) is refused, and Codex jobs
|
|
16
|
+
run with `features.hooks=false`, so they run no hook at all.
|
|
17
|
+
|
|
18
|
+
An ask is shown at a session start, at the next prompt (where it also counts as seen), at a turn end, or by waking an
|
|
19
|
+
idle Claude session through an `asyncRewake` Stop watcher. An ask shown at a session start, at a turn end or by a wake
|
|
20
|
+
counts as seen at the next prompt in that same session. A session is woken only within 30 minutes of a human prompt,
|
|
21
|
+
and at most 6 times an hour. With no such session the ask becomes one macOS notification, which gives up after 3
|
|
22
|
+
seconds and logs only an error code, and the ask waits for the next session start or prompt. Hook text is fixed and
|
|
23
|
+
carries validated ids only; the AI reads the ask itself with the Gateway show tools, which mark its text as untrusted.
|
|
24
|
+
|
|
25
|
+
The Gateway sends the ask keys on the wait only while asks are on for this runner. When a wait result comes without
|
|
26
|
+
them, every stored delivery ends: it is dropped from `asks.json` with no report, and the Gateway's Slack fallback
|
|
27
|
+
covers it. A stored delivery is forgotten an hour after it arrived, by which time the fallback (at most 15 minutes) has
|
|
28
|
+
long run.
|
|
29
|
+
|
|
30
|
+
Integrations are installed only on request, with `setup --integrations` or `integrations install`; setup never asks
|
|
31
|
+
about them. An app already installed in the directory the environment names is skipped and reported installed, without
|
|
32
|
+
rewriting its files:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx mtech-ai-local-runner setup --integrations claude,codex
|
|
36
|
+
npx mtech-ai-local-runner integrations status
|
|
37
|
+
npx mtech-ai-local-runner integrations install codex
|
|
38
|
+
npx mtech-ai-local-runner integrations remove claude
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Claude Code gets the skills-directory plugin `~/.claude/skills/mtec-ai-gateway-asks/`, which loads as
|
|
42
|
+
`mtec-ai-gateway-asks@skills-dir` in a new session or after `/reload-plugins`; `settings.json` is not touched. Codex gets
|
|
43
|
+
four handlers appended to `~/.codex/hooks.json` after a backup of that file; trust them once with `/hooks`. A
|
|
44
|
+
`hooks.json` that is not valid JSON, or whose hooks do not have the shape Codex reads, is left as it is and the install
|
|
45
|
+
reports `CODEX_HOOKS_JSON_INVALID`. `uninstall` removes both integrations, the shim, `asks.json` and any temporary
|
|
46
|
+
`asks.json.*.tmp` file a crash left behind, and it also works on a setup that never finished.
|
|
47
|
+
|
|
48
|
+
`CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured. The LaunchAgent that runs the daemon gets only `PATH`, so every
|
|
49
|
+
install adds the directory it used to a list per app in `integrations.json` in the state root, and the daemon,
|
|
50
|
+
`doctor`, `integrations remove` and `uninstall` look in every recorded directory as well as in the environment. A
|
|
51
|
+
directory leaves the list only when nothing of the runner's is left in it, so installing into a second directory never
|
|
52
|
+
orphans the first, and the shim stays while any directory still has the hooks. When no absolute home directory can be
|
|
53
|
+
found, the command reports `INTEGRATION_HOME_UNKNOWN` for that app and writes nothing; set `HOME`, or `CLAUDE_CONFIG_DIR`
|
|
54
|
+
and `CODEX_HOME`.
|
|
55
|
+
|
|
56
|
+
On a fresh Mac without an admitted executor, or one already set up as a messenger, `setup` now completes in messenger
|
|
57
|
+
mode. It registers with no adapter capability and stores `mode: "messenger"` in `config.json`. The daemon then runs only
|
|
58
|
+
the session-open loop, for session opens and asks, and sends its own heartbeat at start, every 600 seconds and whenever
|
|
59
|
+
the installed integrations change. It has no jobs loop, no adapter probe and no PreToolUse self-test, and `manual-codex`
|
|
60
|
+
and `publication-recover` are refused with `MESSENGER_MODE_HAS_NO_EXECUTOR`. A full runner works as before and now
|
|
61
|
+
reports `mode: "full"` in its heartbeat. `doctor` reports the mode, the hub and each integration, including whether
|
|
62
|
+
Claude Code loads the plugin; for a messenger it requires neither an adapter nor the PreToolUse self-test.
|
|
63
|
+
|
|
64
|
+
Setup never turns a Mac set up as a full runner into a messenger. With no usable adapter it fails as 0.1.47 did, before
|
|
65
|
+
the login, and leaves `config.json`, the registration and the LaunchAgent as they were. Fix the adapter, or run
|
|
66
|
+
`uninstall` and then `setup` to set the Mac up as a messenger.
|
|
67
|
+
|
|
68
|
+
No ask is delivered until the Gateway's `AI_OPS_GATEWAY_WORK_ASK_AI_FIRST_ENABLED` switch is on and the employee is on
|
|
69
|
+
its pilot list. Until then an existing runner works as before: setup never turns a full runner into a messenger and
|
|
70
|
+
installs no integration it was not asked for. The one change without the switch is that setup on a fresh Mac without an
|
|
71
|
+
admitted executor completes as a messenger instead of failing. Deploy the Gateway receivers before upgrading a runner.
|
|
72
|
+
|
|
73
|
+
## 0.1.48: local_edit takes the complete file, not a patch
|
|
74
|
+
|
|
75
|
+
Read the existing file first, then pass its complete desired new contents in
|
|
76
|
+
`local_edit.content`, including unchanged text. The operation replaces the whole
|
|
77
|
+
file or creates it; it never applies a diff. Recognizable apply_patch directives,
|
|
78
|
+
unified hunk lines and diff headers return `DENIED_PATCH_CONTENT` before a write.
|
|
79
|
+
The isolated writer enforces the same guard. Literal patch blocks in a document
|
|
80
|
+
are also rejected by this conservative API; ordinary source string literals and
|
|
81
|
+
YAML separators remain accepted. No partial old/new edit tool is introduced.
|
|
82
|
+
|
|
83
|
+
Gateway and Runner must agree on the new tool inventory hash. Package runtime
|
|
84
|
+
receipts change, while the reviewed Codex CLI release manifest identity stays
|
|
85
|
+
unchanged. Keep existing -2G allowed paths; deploy the matching Gateway registry
|
|
86
|
+
and exact runtime pins before switching a daemon. The candidate contains the
|
|
87
|
+
newer development ask/messenger code documented in the Unreleased section and
|
|
88
|
+
the 0.1.46/0.1.47 fixes; no integration is installed merely by preparing it.
|
|
89
|
+
|
|
90
|
+
## 0.1.47: Claude security host wiring remains gated
|
|
91
|
+
|
|
92
|
+
Claude now shares the job security session and checks a live opaque executable
|
|
93
|
+
identity before launch. Account/control overlaps and aliases fail before control
|
|
94
|
+
creation; preparation failure and cancellation revoke authority. A trusted
|
|
95
|
+
host/probe/identity tuple is an in-process composition seam, not an environment
|
|
96
|
+
switch or a receipt loaded from disk. Fake-data tests do not enable production:
|
|
97
|
+
exact-release/native-policy, managed-input and credential/IPC/network evidence
|
|
98
|
+
and production verifier registration remain outstanding. Existing login, settings
|
|
99
|
+
and executable path are preserved. This release neither upgrades nor starts an
|
|
100
|
+
installed daemon. See `docs/ai-ops-gateway/claude-execution-admission-remaining.md`
|
|
101
|
+
in the Gateway repository for the consolidated verification boundary.
|
|
102
|
+
|
|
1
103
|
## 0.1.46: login and execution are separate
|
|
2
104
|
|
|
3
105
|
A locally logged-in account may still be blocked by security admission. New
|
|
@@ -283,8 +385,11 @@ npx mtech-ai-local-runner setup
|
|
|
283
385
|
The package stores safe runner configuration locally and keeps OAuth refresh credentials in macOS Keychain when available.
|
|
284
386
|
|
|
285
387
|
Public npm installation requires no npm account or publishing OTP. Setup requires
|
|
286
|
-
the employee's own Gateway OAuth login and a
|
|
287
|
-
CLI.
|
|
388
|
+
the employee's own Gateway OAuth login and, for a full runner, a supported,
|
|
389
|
+
authenticated selected CLI. Since the AI-first ask delivery release (Unreleased,
|
|
390
|
+
above), a fresh Mac without an admitted executor is set up as a messenger
|
|
391
|
+
instead; a Mac already set up as a full runner still fails setup without one.
|
|
392
|
+
Codex uses the employee's ChatGPT account. Claude requires its own login
|
|
288
393
|
and a separately verified host admission; it is currently not admitted.
|
|
289
394
|
|
|
290
395
|
For the current unattended Codex pilot, an administrator must separately install
|
|
@@ -74,45 +74,49 @@ export class ClaudeCodeAdapter {
|
|
|
74
74
|
throw new Error("adapter has already been started");
|
|
75
75
|
this.started = true;
|
|
76
76
|
this.p1 = ctx.approvedTaskPackage?.verification?.mode === 'runner_observed_v1';
|
|
77
|
-
const generation = ctx.generation;
|
|
78
|
-
if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
|
|
79
|
-
throw new Error("JOB_SECURITY_REJECTED");
|
|
80
|
-
const assertAdmission = () => {
|
|
81
|
-
const repositories = ctx.taskPackage?.repositories;
|
|
82
|
-
if (!Array.isArray(repositories) || repositories.length !== 1 || repositories[0]?.base_commit_sha !== this.options.securityPolicy?.pin.sourceSha)
|
|
83
|
-
throw new Error('JOB_SECURITY_REJECTED');
|
|
84
|
-
requireJobSecurity(this.options.securityPolicy, { adapterId: "claude_code_cli", adapterVersion: "1", jobId: this.options.jobId ?? ctx.bindingId, modelId: resolvedModelId, generation });
|
|
85
|
-
if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
|
|
86
|
-
|| ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
|
|
87
|
-
throw new Error("SECURITY_REQUIREMENTS_REJECTED");
|
|
88
|
-
requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
|
|
89
|
-
};
|
|
90
|
-
assertAdmission();
|
|
91
|
-
if (!ctx.securityBinding)
|
|
92
|
-
throw new Error('SECURITY_REQUIREMENTS_REJECTED');
|
|
93
|
-
this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
94
|
-
const pkg = ctx.taskPackage;
|
|
95
|
-
const cwd = this.options.cwd;
|
|
96
|
-
const rel = cwd ? path.relative(path.resolve(this.options.worktreeRoot), path.resolve(cwd)) : "";
|
|
97
|
-
if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
|
|
98
|
-
throw new Error("trusted cwd must live outside the assigned worktree");
|
|
99
|
-
const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
|
|
100
|
-
if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
|
|
101
|
-
throw new Error("refusing to start: no positive remaining contract budget was supplied");
|
|
102
|
-
}
|
|
103
|
-
if (!this.options.worktreeRoot) {
|
|
104
|
-
throw new Error("refusing to start: no assigned worktree was supplied");
|
|
105
|
-
}
|
|
106
|
-
if (!ctx.sessionId) {
|
|
107
|
-
throw new Error("refusing to start: no runner-generated session ID was supplied");
|
|
108
|
-
}
|
|
109
|
-
const hookCommand = this.requireTrustedHookCommand();
|
|
110
|
-
const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
|
|
111
|
-
if (this.cancelled)
|
|
112
|
-
throw new Error('adapter startup cancelled');
|
|
113
77
|
let acquired;
|
|
114
78
|
this.startupAcquisition = new Promise(resolve => { acquired = resolve; });
|
|
115
79
|
try {
|
|
80
|
+
const generation = ctx.generation;
|
|
81
|
+
if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
|
|
82
|
+
throw new Error("JOB_SECURITY_REJECTED");
|
|
83
|
+
if (this.options.prepareSecurity)
|
|
84
|
+
await this.options.prepareSecurity(ctx);
|
|
85
|
+
if (this.cancelled)
|
|
86
|
+
throw new Error('adapter startup cancelled');
|
|
87
|
+
const assertAdmission = () => {
|
|
88
|
+
const repositories = ctx.taskPackage?.repositories;
|
|
89
|
+
if (!Array.isArray(repositories) || repositories.length !== 1 || repositories[0]?.base_commit_sha !== this.options.securityPolicy?.pin.sourceSha)
|
|
90
|
+
throw new Error('JOB_SECURITY_REJECTED');
|
|
91
|
+
requireJobSecurity(this.options.securityPolicy, { adapterId: "claude_code_cli", adapterVersion: "1", jobId: this.options.jobId ?? ctx.bindingId, modelId: resolvedModelId, generation });
|
|
92
|
+
if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
|
|
93
|
+
|| ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
|
|
94
|
+
throw new Error("SECURITY_REQUIREMENTS_REJECTED");
|
|
95
|
+
requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
|
|
96
|
+
};
|
|
97
|
+
assertAdmission();
|
|
98
|
+
if (!ctx.securityBinding)
|
|
99
|
+
throw new Error('SECURITY_REQUIREMENTS_REJECTED');
|
|
100
|
+
this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
101
|
+
const pkg = ctx.taskPackage;
|
|
102
|
+
const cwd = this.options.cwd;
|
|
103
|
+
const rel = cwd ? path.relative(path.resolve(this.options.worktreeRoot), path.resolve(cwd)) : "";
|
|
104
|
+
if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
|
|
105
|
+
throw new Error("trusted cwd must live outside the assigned worktree");
|
|
106
|
+
const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
|
|
107
|
+
if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
|
|
108
|
+
throw new Error("refusing to start: no positive remaining contract budget was supplied");
|
|
109
|
+
}
|
|
110
|
+
if (!this.options.worktreeRoot) {
|
|
111
|
+
throw new Error("refusing to start: no assigned worktree was supplied");
|
|
112
|
+
}
|
|
113
|
+
if (!ctx.sessionId) {
|
|
114
|
+
throw new Error("refusing to start: no runner-generated session ID was supplied");
|
|
115
|
+
}
|
|
116
|
+
const hookCommand = this.requireTrustedHookCommand();
|
|
117
|
+
const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
|
|
118
|
+
if (this.cancelled)
|
|
119
|
+
throw new Error('adapter startup cancelled');
|
|
116
120
|
if (!this.options.createTools || !this.options.writeLocalMcpConfig)
|
|
117
121
|
throw new Error("job-local tool setup is required");
|
|
118
122
|
this.tools = await this.options.createTools(ctx, pkg, ctx.approvedTaskPackage ?? {});
|
|
@@ -166,6 +170,9 @@ export class ClaudeCodeAdapter {
|
|
|
166
170
|
...allowlistedEnv(this.options.env),
|
|
167
171
|
[HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy),
|
|
168
172
|
};
|
|
173
|
+
await this.options.assertCurrentInputs?.();
|
|
174
|
+
if (this.cancelled)
|
|
175
|
+
throw new Error('adapter startup cancelled');
|
|
169
176
|
assertAdmission();
|
|
170
177
|
const handle = this.options.processHost.spawn(this.options.binaryPath ?? "claude", args, {
|
|
171
178
|
cwd,
|
|
@@ -259,6 +266,7 @@ export class ClaudeCodeAdapter {
|
|
|
259
266
|
if (this.cancelPromise)
|
|
260
267
|
return this.cancelPromise;
|
|
261
268
|
this.localReadObservations();
|
|
269
|
+
this.revokeSecurity();
|
|
262
270
|
this.revokeTools(this.tools);
|
|
263
271
|
const cleanup = (async () => { await this.startupAcquisition; await this.cleanup(); })();
|
|
264
272
|
// This bounds cleanup confirmation, never admitted model runtime. Unknown
|
|
@@ -269,6 +277,18 @@ export class ClaudeCodeAdapter {
|
|
|
269
277
|
});
|
|
270
278
|
return this.cancelPromise;
|
|
271
279
|
}
|
|
280
|
+
securityRevoked = false;
|
|
281
|
+
revokeSecurity() {
|
|
282
|
+
if (this.securityRevoked)
|
|
283
|
+
return;
|
|
284
|
+
this.securityRevoked = true;
|
|
285
|
+
try {
|
|
286
|
+
this.options.revokeSecurity?.();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
this.revokeFailed = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
272
292
|
revokeTools(tools) {
|
|
273
293
|
if (!tools || this.revokedTools === tools)
|
|
274
294
|
return;
|
|
@@ -291,6 +311,7 @@ export class ClaudeCodeAdapter {
|
|
|
291
311
|
this.tools = undefined;
|
|
292
312
|
this.handle = null;
|
|
293
313
|
const operation = Promise.resolve().then(async () => {
|
|
314
|
+
this.revokeSecurity();
|
|
294
315
|
this.revokeTools(tools);
|
|
295
316
|
let failed = this.revokeFailed;
|
|
296
317
|
try {
|
|
@@ -1,15 +1,52 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import { lstat } from 'node:fs/promises';
|
|
5
|
+
import { JobSecuritySession } from '../../safety/job-security-session.js';
|
|
6
|
+
import { assertCurrentClaudeBinary, claudeBinaryLaunchPath } from '../../safety/claude-binary-identity.js';
|
|
4
7
|
import { writeFileSync } from 'node:fs';
|
|
5
8
|
import { NodeProcessGroupHost } from '../../process/node-process-group-host.js';
|
|
6
9
|
import { JobToolSession } from '../../safety/job-tool-session.js';
|
|
7
10
|
import { jobToolPolicyOptions } from '../../safety/job-tool-policy.js';
|
|
8
11
|
import { requireJobSecurity } from '../../safety/job-security-policy.js';
|
|
9
12
|
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
13
|
+
/** Resolve the existing prefix without creating a path through an account alias. */
|
|
14
|
+
async function canonicalFuture(file) {
|
|
15
|
+
let parent = file;
|
|
16
|
+
const tail = [];
|
|
17
|
+
for (;;) {
|
|
18
|
+
try {
|
|
19
|
+
await lstat(parent);
|
|
20
|
+
return path.join(await realpath(parent), ...tail);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code !== 'ENOENT')
|
|
24
|
+
throw error;
|
|
25
|
+
const next = path.dirname(parent);
|
|
26
|
+
if (next === parent)
|
|
27
|
+
throw error;
|
|
28
|
+
tail.unshift(path.basename(parent));
|
|
29
|
+
parent = next;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
10
33
|
/** Wiring only. No release verifier or admission certificate exists here. */
|
|
11
34
|
export function createClaudeCodeAdapterOptions(input) {
|
|
35
|
+
if (input.securityHost && (input.securityPolicy || input.nativeBinding || input.commandRegistry) || !!input.securityHost !== !!input.binaryIdentity)
|
|
36
|
+
throw Error('CLAUDE_SECURITY_AUTHORITY_CONFLICT');
|
|
37
|
+
const security = new JobSecuritySession(input.securityHost);
|
|
38
|
+
let securityPolicy = input.securityPolicy, disposed = false;
|
|
12
39
|
const checkout = path.resolve(input.worktreePath), state = path.resolve(input.stateRoot);
|
|
40
|
+
const account = input.env.CLAUDE_CONFIG_DIR ?? (input.env.HOME ? path.join(input.env.HOME, '.claude') : undefined);
|
|
41
|
+
if (account !== undefined && (!path.isAbsolute(account) || /[\r\n\0]/.test(account)))
|
|
42
|
+
throw Error('CLAUDE_ACCOUNT_CONTEXT_UNAVAILABLE');
|
|
43
|
+
const assertOutsideAccount = (storage, source, auth = account) => { if (auth && [storage, source].some(root => inside(path.resolve(auth), root) || inside(root, path.resolve(auth))))
|
|
44
|
+
throw Error('CLAUDE_ACCOUNT_CONTROL_OVERLAP'); };
|
|
45
|
+
assertOutsideAccount(state, checkout);
|
|
46
|
+
const revoke = () => { disposed = true; security.revoke(); securityPolicy?.revoke(); };
|
|
47
|
+
const binary = input.binaryIdentity ? claudeBinaryLaunchPath(input.binaryIdentity) : input.binaryPath;
|
|
48
|
+
if (input.binaryPath && binary !== input.binaryPath)
|
|
49
|
+
throw Error('CLAUDE_BINARY_IDENTITY_REJECTED');
|
|
13
50
|
if (inside(checkout, state))
|
|
14
51
|
throw new Error('Claude job controls must be outside the checkout');
|
|
15
52
|
const control = path.join(state, 'claude-jobs', randomUUID());
|
|
@@ -22,47 +59,91 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
22
59
|
env.TMPDIR = path.join(control, 'tmp'); // Claude owns credential access. Preserve the account location by path only;
|
|
23
60
|
// never read/copy its files or send this environment to scoped workers.
|
|
24
61
|
// https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts
|
|
25
|
-
env.CLAUDE_CONFIG_DIR =
|
|
62
|
+
env.CLAUDE_CONFIG_DIR = account;
|
|
26
63
|
if (env.CLAUDE_CONFIG_DIR && !path.isAbsolute(env.CLAUDE_CONFIG_DIR))
|
|
27
64
|
throw new Error('Claude account configuration path must be absolute');
|
|
28
65
|
return {
|
|
29
|
-
processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, securityPolicy
|
|
30
|
-
|
|
66
|
+
processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, get securityPolicy() { return securityPolicy; },
|
|
67
|
+
...(input.securityHost ? { prepareSecurity: async (ctx) => {
|
|
68
|
+
try {
|
|
69
|
+
if (disposed || !ctx.securityBinding || !account)
|
|
70
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
71
|
+
const approved = ctx.approvedTaskPackage, repo = approved?.repository;
|
|
72
|
+
securityPolicy = await security.authorize({ adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, generation: ctx.generation, modelId: ctx.resolvedModelId,
|
|
73
|
+
sourceSha: String(repo?.base_commit_sha ?? ''), requirements: approved?.security_requirements, ...ctx.securityBinding });
|
|
74
|
+
if (disposed)
|
|
75
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
76
|
+
await assertCurrentClaudeBinary(input.binaryIdentity, binary, securityPolicy.pin.binarySha256);
|
|
77
|
+
return securityPolicy;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
revoke();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
} } : {}),
|
|
84
|
+
revokeSecurity: revoke,
|
|
85
|
+
assertCurrentInputs: async () => { if (disposed)
|
|
86
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED'); if (input.binaryIdentity) {
|
|
87
|
+
if (!securityPolicy)
|
|
88
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
89
|
+
await assertCurrentClaudeBinary(input.binaryIdentity, binary, securityPolicy.pin.binarySha256);
|
|
90
|
+
} },
|
|
91
|
+
cwd, env, binaryPath: binary, hookExecutablePath: input.hookExecutablePath, hookNodeExecutablePath: input.hookNodeExecutablePath,
|
|
31
92
|
maxBudgetUsd: input.maxBudgetUsd ?? 1, workItemLabel: input.workItemLabel ?? input.jobId,
|
|
32
93
|
registeredReadTools: [], writeMcpConfig: () => { throw new Error('Gateway relay is not a model tool endpoint'); },
|
|
33
94
|
createTools: async (ctx, pkg, approved) => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
await
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
if (disposed)
|
|
96
|
+
throw Error('JOB_SECURITY_SESSION_REJECTED');
|
|
97
|
+
const policyOptions = jobToolPolicyOptions(checkout, pkg, approved, input.securityHost ? security.commands() : input.commandRegistry ?? [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' });
|
|
98
|
+
const canonicalAccount = account ? await canonicalFuture(account) : undefined;
|
|
99
|
+
assertOutsideAccount(await canonicalFuture(state), await canonicalFuture(checkout), canonicalAccount);
|
|
100
|
+
assertOutsideAccount(await canonicalFuture(control), await canonicalFuture(checkout), canonicalAccount);
|
|
101
|
+
let owned = false;
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
104
|
+
const realState = await realpath(state), realCheckout = await realpath(checkout);
|
|
105
|
+
assertOutsideAccount(realState, realCheckout, account ? await canonicalFuture(account) : undefined);
|
|
106
|
+
if (realState !== state || inside(realCheckout, realState))
|
|
107
|
+
throw new Error('Claude job controls must be outside the checkout');
|
|
108
|
+
await mkdir(path.dirname(control), { recursive: true, mode: 0o700 });
|
|
109
|
+
if (await realpath(path.dirname(control)) !== path.dirname(control))
|
|
110
|
+
throw Error('CLAUDE_CONTROL_ALIAS_REJECTED');
|
|
111
|
+
await mkdir(control, { mode: 0o700 });
|
|
112
|
+
owned = true;
|
|
113
|
+
for (const directory of [home, cwd, env.TMPDIR])
|
|
114
|
+
await mkdir(directory, { mode: 0o700 });
|
|
115
|
+
let active = true;
|
|
116
|
+
const tools = new JobToolSession({
|
|
117
|
+
policyOptions,
|
|
118
|
+
binding: () => {
|
|
119
|
+
const binding = input.securityHost ? security.binding() : input.nativeBinding?.();
|
|
120
|
+
return binding && binding.jobId === input.jobId && binding.generation === securityPolicy?.pin.generation ? binding : null;
|
|
121
|
+
},
|
|
122
|
+
active: () => {
|
|
123
|
+
if (!active)
|
|
124
|
+
return false;
|
|
125
|
+
try {
|
|
126
|
+
requireJobSecurity(securityPolicy, { adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, modelId: ctx.resolvedModelId, generation: ctx.generation, sourceSha: String(approved.repository.base_commit_sha) });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return { start: () => tools.start(), revoke: () => { active = false; tools.revoke(); }, close: async () => { active = false; revoke(); try {
|
|
135
|
+
await tools.close();
|
|
54
136
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
137
|
+
finally {
|
|
138
|
+
await rm(control, { recursive: true, force: true });
|
|
139
|
+
} } };
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
revoke();
|
|
143
|
+
if (owned)
|
|
64
144
|
await rm(control, { recursive: true, force: true });
|
|
65
|
-
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
66
147
|
},
|
|
67
148
|
writeLocalMcpConfig: endpoint => {
|
|
68
149
|
writeFileSync(config, JSON.stringify({ mcpServers: { ai_local: { type: 'http', url: endpoint.url, headers: { Authorization: `Bearer ${endpoint.token}` } } } }), { mode: 0o600, flag: 'wx' });
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ASKS_FILE } from "./store.js";
|
|
4
|
+
import { installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
|
|
5
|
+
import { APPS, isApp } from "./types.js";
|
|
6
|
+
const NEXT_STEP = {
|
|
7
|
+
claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir.",
|
|
8
|
+
codex: "Open Codex and trust the four AI Gateway hooks once with /hooks. Until then Codex skips them and the Slack message covers the gap.",
|
|
9
|
+
};
|
|
10
|
+
function errorCode(error) {
|
|
11
|
+
const code = error?.code;
|
|
12
|
+
if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
|
|
13
|
+
return code;
|
|
14
|
+
const message = error instanceof Error ? error.message.split(":")[0] : "";
|
|
15
|
+
return /^[A-Z0-9_]{1,64}$/.test(message) ? message : "INTEGRATION_FAILED";
|
|
16
|
+
}
|
|
17
|
+
/** integrations status|install|remove [claude|codex]; null for anything else. */
|
|
18
|
+
export function parseIntegrationsArguments(argv) {
|
|
19
|
+
const [action, app, ...extra] = argv;
|
|
20
|
+
if (extra.length > 0 || (action !== "status" && action !== "install" && action !== "remove"))
|
|
21
|
+
return null;
|
|
22
|
+
if (app !== undefined && !isApp(app))
|
|
23
|
+
return null;
|
|
24
|
+
return { action, apps: app === undefined ? [...APPS] : [app] };
|
|
25
|
+
}
|
|
26
|
+
/** setup --integrations claude,codex (or --integrations=claude): undefined when absent, null when malformed. */
|
|
27
|
+
export function parseSetupIntegrations(argv) {
|
|
28
|
+
const index = argv.findIndex(arg => arg === "--integrations" || arg.startsWith("--integrations="));
|
|
29
|
+
if (index < 0)
|
|
30
|
+
return undefined;
|
|
31
|
+
const value = argv[index] === "--integrations" ? argv[index + 1] : argv[index].slice("--integrations=".length);
|
|
32
|
+
const apps = (value ?? "").split(",").map(app => app.trim()).filter(app => app !== "");
|
|
33
|
+
if (apps.length === 0 || !apps.every(isApp) || new Set(apps).size !== apps.length)
|
|
34
|
+
return null;
|
|
35
|
+
return apps;
|
|
36
|
+
}
|
|
37
|
+
/** The state of one app; a status that cannot be read (no home directory, say) is reported with its error, never thrown. */
|
|
38
|
+
export async function statusOf(app, env, stateRoot, error) {
|
|
39
|
+
try {
|
|
40
|
+
return { ...await integrationStatus(app, env, stateRoot), ...(error === undefined ? {} : { error }) };
|
|
41
|
+
}
|
|
42
|
+
catch (failure) {
|
|
43
|
+
return { app, state: "unreadable", path: "", error: error ?? errorCode(failure) };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function attempt(app, env, stateRoot, change) {
|
|
47
|
+
try {
|
|
48
|
+
return await change();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return statusOf(app, env, stateRoot, errorCode(error));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function installIntegrations(apps, env, stateRoot) {
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const app of apps) {
|
|
57
|
+
const result = await attempt(app, env, stateRoot, () => installIntegration(app, env, stateRoot));
|
|
58
|
+
results.push(result.error === undefined ? { ...result, action: NEXT_STEP[app] } : result);
|
|
59
|
+
}
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* uninstall: removes both integrations, then the shim once nothing uses it, asks.json and the store's temporary files
|
|
64
|
+
* that a crash left behind (asks.json.<pid>.<uuid>.tmp), which can hold raw session ids.
|
|
65
|
+
*/
|
|
66
|
+
export async function removeAsksInstall(env, stateRoot) {
|
|
67
|
+
const results = [];
|
|
68
|
+
for (const app of APPS)
|
|
69
|
+
results.push(await attempt(app, env, stateRoot, () => removeIntegration(app, env, stateRoot)));
|
|
70
|
+
// A hook left behind (an unreadable Codex hooks file) keeps its shim, which then prints nothing and exits 0.
|
|
71
|
+
await removeShimIfUnused(env, stateRoot).catch(() => false);
|
|
72
|
+
await rm(join(stateRoot, ASKS_FILE), { force: true });
|
|
73
|
+
for (const name of await readdir(stateRoot).catch(() => [])) {
|
|
74
|
+
if (name.startsWith(`${ASKS_FILE}.`) && name.endsWith(".tmp"))
|
|
75
|
+
await rm(join(stateRoot, name), { force: true });
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
export async function runIntegrationsCommand(argv, io) {
|
|
80
|
+
const parsed = parseIntegrationsArguments(argv);
|
|
81
|
+
if (parsed === null) {
|
|
82
|
+
io.stderr("Usage: mtec-ai-local-runner integrations status|install|remove [claude|codex]");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
let results;
|
|
86
|
+
if (parsed.action === "install") {
|
|
87
|
+
results = await installIntegrations(parsed.apps, io.env, io.stateRoot);
|
|
88
|
+
}
|
|
89
|
+
else if (parsed.action === "remove") {
|
|
90
|
+
results = [];
|
|
91
|
+
for (const app of parsed.apps)
|
|
92
|
+
results.push(await attempt(app, io.env, io.stateRoot, () => removeIntegration(app, io.env, io.stateRoot)));
|
|
93
|
+
await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
|
|
97
|
+
}
|
|
98
|
+
io.stdout(JSON.stringify({ integrations: results }));
|
|
99
|
+
return results.some(result => result.error !== undefined) ? 1 : 0;
|
|
100
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { statusOf } from "./commands.js";
|
|
4
|
+
import { hubSocketPath } from "./hub.js";
|
|
5
|
+
import { MAX_LINE_BYTES, encodeLine } from "./hub-protocol.js";
|
|
6
|
+
import { CLAUDE_PLUGIN_NAME } from "./integrations.js";
|
|
7
|
+
import { APPS } from "./types.js";
|
|
8
|
+
const PLUGIN_ID = `${CLAUDE_PLUGIN_NAME}@skills-dir`;
|
|
9
|
+
const CLAUDE_NOT_LOADED = `Claude Code does not load ${PLUGIN_ID}. Managed settings can block skills-directory plugins `
|
|
10
|
+
+ "(strictPluginOnlyCustomization, a strictKnownMarketplaces list without the skills-dir source, or blockedMarketplaces): "
|
|
11
|
+
+ "ask your administrator, or install the plugin from a directory marketplace your organization allows (claude plugin marketplace add <dir>). "
|
|
12
|
+
+ "A Claude Code release without skills-directory plugins does not load it either.";
|
|
13
|
+
const CODEX_TRUST = "Codex runs these hooks only after you trust them once with /hooks; until then the Slack message covers each ask.";
|
|
14
|
+
/** Runs the Claude CLI with the environment doctor was given on top of the process's own (CLAUDE_CONFIG_DIR, for one). */
|
|
15
|
+
const defaultRun = (env) => (file, args) => new Promise((resolve, reject) => {
|
|
16
|
+
execFile(file, args, { encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024, env: { ...process.env, ...env } }, (error, stdout) => (error ? reject(error) : resolve({ stdout })));
|
|
17
|
+
});
|
|
18
|
+
/** The three counts of a hub status answer, or null when it is anything else. */
|
|
19
|
+
function parseStatus(value) {
|
|
20
|
+
if (value === null || typeof value !== "object")
|
|
21
|
+
return null;
|
|
22
|
+
const { deliveries, live_sessions: liveSessions, watchers } = value;
|
|
23
|
+
const counts = [deliveries, liveSessions, watchers];
|
|
24
|
+
if (!counts.every(count => typeof count === "number" && Number.isSafeInteger(count) && count >= 0))
|
|
25
|
+
return null;
|
|
26
|
+
return { deliveries: deliveries, live_sessions: liveSessions, watchers: watchers };
|
|
27
|
+
}
|
|
28
|
+
/** The running hub's counts (its status op), or null when nothing answers with a status within the time limit. Never rejects. */
|
|
29
|
+
export function hubStatus(stateRoot, timeoutMs = 1000) {
|
|
30
|
+
return new Promise(resolve => {
|
|
31
|
+
let socket;
|
|
32
|
+
let buffer = "";
|
|
33
|
+
let done = false;
|
|
34
|
+
const finish = (value) => {
|
|
35
|
+
if (done)
|
|
36
|
+
return;
|
|
37
|
+
done = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
socket?.destroy();
|
|
40
|
+
resolve(value);
|
|
41
|
+
};
|
|
42
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
socket = connect(hubSocketPath(stateRoot));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
finish(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
socket.setEncoding("utf8");
|
|
51
|
+
socket.on("error", () => finish(null));
|
|
52
|
+
socket.on("close", () => finish(null));
|
|
53
|
+
socket.on("data", (chunk) => {
|
|
54
|
+
buffer += chunk;
|
|
55
|
+
const newline = buffer.indexOf("\n");
|
|
56
|
+
if (newline < 0) {
|
|
57
|
+
if (buffer.length > MAX_LINE_BYTES)
|
|
58
|
+
finish(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
63
|
+
finish(response.ok === true ? parseStatus(response.status) : null);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
finish(null);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
socket.write(encodeLine({ v: 1, op: "status" }));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** true or false from `claude plugin list --json`; null when the Claude CLI cannot tell. Never rejects. */
|
|
73
|
+
export async function claudePluginLoaded(env, run = defaultRun(env)) {
|
|
74
|
+
try {
|
|
75
|
+
const { stdout } = await run(env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY ?? "claude", ["plugin", "list", "--json"]);
|
|
76
|
+
const plugins = JSON.parse(stdout);
|
|
77
|
+
if (!Array.isArray(plugins))
|
|
78
|
+
return null;
|
|
79
|
+
const plugin = plugins.find(entry => entry?.id === PLUGIN_ID);
|
|
80
|
+
return plugin !== undefined && plugin.enabled !== false;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The asks section of doctor. The integrations go through statusOf, which reads the dir the environment names and the dir
|
|
88
|
+
* install recorded, and reports an app whose home cannot be found (INTEGRATION_HOME_UNKNOWN) on its own entry.
|
|
89
|
+
*/
|
|
90
|
+
export async function asksDoctorReport(input) {
|
|
91
|
+
const [status, statuses] = await Promise.all([hubStatus(input.stateRoot), Promise.all(APPS.map(app => statusOf(app, input.env, input.stateRoot)))]);
|
|
92
|
+
const integrations = [];
|
|
93
|
+
for (const report of statuses) {
|
|
94
|
+
if (report.state !== "installed") {
|
|
95
|
+
integrations.push(report);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (report.app === "codex") {
|
|
99
|
+
integrations.push({ ...report, action: CODEX_TRUST });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const loaded = await claudePluginLoaded(input.env, input.run);
|
|
103
|
+
integrations.push({ ...report, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
|
|
104
|
+
}
|
|
105
|
+
return { mode: input.mode, hub: status === null ? { status: "not_running" } : { status: "running", ...status }, integrations };
|
|
106
|
+
}
|