forgeos 0.1.0-alpha.67 → 0.1.0-alpha.68
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/.agents/skills/forge-agent-fabric/SKILL.md +137 -0
- package/.agents/skills/forge-agent-fabric/references/managed-workflows.md +95 -0
- package/.agents/skills/forge-agent-fabric/scripts/fabric.mjs +46 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +6 -0
- package/docs/agent-fabric.md +9 -1
- package/docs/architecture/agent-fabric/PORTABLE_PROJECTS_PLAN.md +136 -0
- package/docs/architecture/agent-fabric/README.md +153 -0
- package/package.json +5 -1
- package/scripts/install-agent-fabric-skill.mjs +105 -0
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/agent-fabric/local-task-server.ts +39 -11
- package/src/forge/agent-fabric/managed-run-store.ts +13 -3
- package/src/forge/agent-fabric/project-profile.ts +50 -0
- package/src/forge/agent-fabric/project-registry.ts +120 -0
- package/src/forge/agent-fabric/project-runtime.ts +85 -0
- package/src/forge/agent-memory/mcp.ts +58 -12
- package/src/forge/cli/fabric.ts +42 -8
- package/src/forge/cli/main.ts +264 -259
- package/src/forge/cli/parse.ts +3582 -3574
- package/src/forge/version.ts +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { basename, dirname, resolve, sep } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
function sourceInventory(directory) {
|
|
10
|
+
const files = [];
|
|
11
|
+
function visit(base, relative = '') {
|
|
12
|
+
for (const entry of readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
13
|
+
if (!relative && ['runtime.json', 'installation.json'].includes(entry.name)) continue;
|
|
14
|
+
const name = `${relative}/${entry.name}`;
|
|
15
|
+
if (entry.isDirectory()) visit(resolve(base, entry.name), name);
|
|
16
|
+
else files.push(name.slice(1));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
visit(directory);
|
|
20
|
+
return files;
|
|
21
|
+
}
|
|
22
|
+
function sourceDigest(directory, inventory) {
|
|
23
|
+
const hash = createHash('sha256');
|
|
24
|
+
for (const name of inventory) {
|
|
25
|
+
const file = resolve(directory, name);
|
|
26
|
+
try {
|
|
27
|
+
const stat = lstatSync(file);
|
|
28
|
+
hash.update(JSON.stringify([name, stat.isSymbolicLink() ? 'link' : 'file']));
|
|
29
|
+
hash.update(stat.isSymbolicLink() ? readlinkSync(file) : readFileSync(file));
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
return hash.digest('hex');
|
|
33
|
+
}
|
|
34
|
+
function canonicalPath(path) {
|
|
35
|
+
try { return realpathSync(path); } catch {
|
|
36
|
+
const parent = dirname(path);
|
|
37
|
+
if (parent === path) throw new Error(`Cannot resolve destination: ${path}`);
|
|
38
|
+
return resolve(canonicalPath(parent), basename(path));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const known = new Set(['--dest', '--runtime', '--dry-run', '--replace', '--help']);
|
|
44
|
+
const options = {};
|
|
45
|
+
for (let index = 0; index < args.length; index++) {
|
|
46
|
+
const flag = args[index];
|
|
47
|
+
if (!known.has(flag)) throw new Error(`Unknown option: ${flag}`);
|
|
48
|
+
if (flag === '--dest' || flag === '--runtime') {
|
|
49
|
+
if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`${flag} requires a path`);
|
|
50
|
+
options[flag] = args[++index];
|
|
51
|
+
} else options[flag] = true;
|
|
52
|
+
}
|
|
53
|
+
if (options['--help']) {
|
|
54
|
+
process.stdout.write('Install portable Agent Fabric skill: --dest <skills-root> --runtime <forge.mjs> --dry-run --replace\nDefaults: ~/.agents/skills and this package runtime. No MCP or hooks are configured.\n');
|
|
55
|
+
} else {
|
|
56
|
+
const source = resolve(packageRoot, '.agents/skills/forge-agent-fabric');
|
|
57
|
+
const skillsRoot = resolve(options['--dest'] || resolve(homedir(), '.agents/skills'));
|
|
58
|
+
const target = resolve(skillsRoot, 'forge-agent-fabric');
|
|
59
|
+
const runtime = realpathSync(resolve(options['--runtime'] || resolve(packageRoot, 'bin/forge.mjs')));
|
|
60
|
+
const marker = resolve(target, 'installation.json');
|
|
61
|
+
let installed = {};
|
|
62
|
+
let owned = false;
|
|
63
|
+
if (existsSync(marker)) {
|
|
64
|
+
try { installed = JSON.parse(readFileSync(marker, 'utf8')); owned = installed.installer === 'forgeos-agent-fabric-skill'; } catch {}
|
|
65
|
+
}
|
|
66
|
+
if (existsSync(target) && !owned && !options['--replace']) {
|
|
67
|
+
throw new Error(`Existing skill is not managed by this installer: ${target}. Review it and use --replace to preserve a backup and replace it.`);
|
|
68
|
+
}
|
|
69
|
+
const normalize = (path) => process.platform === 'win32' ? path.toLowerCase() : path;
|
|
70
|
+
const canonicalSource = normalize(canonicalPath(source));
|
|
71
|
+
const canonicalTarget = normalize(canonicalPath(target));
|
|
72
|
+
if (canonicalTarget === canonicalSource || canonicalTarget.startsWith(`${canonicalSource}${sep}`) || canonicalSource.startsWith(`${canonicalTarget}${sep}`)) {
|
|
73
|
+
throw new Error('Installation destination must differ from the packaged source skill.');
|
|
74
|
+
}
|
|
75
|
+
const inventory = sourceInventory(source);
|
|
76
|
+
const digest = sourceDigest(source, inventory);
|
|
77
|
+
if (!digest) throw new Error('Packaged skill contains unreadable source files.');
|
|
78
|
+
let existingRuntime = {};
|
|
79
|
+
try { existingRuntime = JSON.parse(readFileSync(resolve(target, 'runtime.json'), 'utf8')); } catch {}
|
|
80
|
+
const unchanged = owned && installed.sourceDigest === digest && sourceDigest(target, inventory) === digest && existingRuntime.cliPath === runtime && existingRuntime.nodeExecutable === process.execPath;
|
|
81
|
+
const result = { target, cliPath: runtime, nodeExecutable: process.execPath, dryRun: !!options['--dry-run'], replacesExisting: existsSync(target), unchanged };
|
|
82
|
+
if (!options['--dry-run'] && !unchanged) {
|
|
83
|
+
// Stage outside the discovery root so incomplete or backup copies are not skills.
|
|
84
|
+
const stagingRoot = resolve(dirname(skillsRoot), 'skill-backups');
|
|
85
|
+
mkdirSync(stagingRoot, { recursive: true });
|
|
86
|
+
const staged = resolve(stagingRoot, `forge-agent-fabric-stage-${randomUUID()}`);
|
|
87
|
+
cpSync(source, staged, { recursive: true, verbatimSymlinks: true });
|
|
88
|
+
writeFileSync(resolve(staged, 'runtime.json'), `${JSON.stringify({ cliPath: runtime, nodeExecutable: process.execPath }, null, 2)}\n`);
|
|
89
|
+
writeFileSync(resolve(staged, 'installation.json'), `${JSON.stringify({ installer: 'forgeos-agent-fabric-skill', version: 1, sourceDigest: digest }, null, 2)}\n`);
|
|
90
|
+
mkdirSync(skillsRoot, { recursive: true });
|
|
91
|
+
if (existsSync(target)) {
|
|
92
|
+
result.backup = resolve(stagingRoot, `forge-agent-fabric-backup-${randomUUID()}`);
|
|
93
|
+
renameSync(target, result.backup);
|
|
94
|
+
}
|
|
95
|
+
try { renameSync(staged, target); } catch (error) {
|
|
96
|
+
if (result.backup && !existsSync(target)) renameSync(result.backup, target);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
process.stderr.write(`${error.message}\n`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.
|
|
1
|
+
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.68","releaseId":"forgeos@0.1.0-alpha.68+unknown","schemaVersion":"0.1.0"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.68 input=eb85ce79777e8854119a312edd29262099578bdd5f8be82c03b5f0caae80f391 content=146d6370b923fb349aebc9c7cab538dab545ebbadccabe2f67f16e4018c91fb9
|
|
2
2
|
export const releaseManifest = {
|
|
3
3
|
"defaultProvider": "local",
|
|
4
4
|
"diagnostics": [],
|
|
@@ -19,7 +19,7 @@ export const releaseManifest = {
|
|
|
19
19
|
"custom"
|
|
20
20
|
],
|
|
21
21
|
"packageName": "forgeos",
|
|
22
|
-
"packageVersion": "0.1.0-alpha.
|
|
23
|
-
"releaseId": "forgeos@0.1.0-alpha.
|
|
22
|
+
"packageVersion": "0.1.0-alpha.68",
|
|
23
|
+
"releaseId": "forgeos@0.1.0-alpha.68+unknown",
|
|
24
24
|
"schemaVersion": "0.1.0"
|
|
25
25
|
} as const;
|
|
@@ -10,7 +10,8 @@ import { AttachedTaskError } from "./attached-task-contract.ts";
|
|
|
10
10
|
import { AgentFabricError, isAgentFabricError } from "./errors.ts";
|
|
11
11
|
import { LocalTaskService, type LocalTaskStatus } from "./local-task-service.ts";
|
|
12
12
|
import type { LocalMemoryEntry } from "./local-intelligence.ts";
|
|
13
|
-
import { localFabricPath } from "./local-paths.ts";
|
|
13
|
+
import { localFabricPath } from "./local-paths.ts";
|
|
14
|
+
import { applyFabricProfile } from "./project-profile.ts";
|
|
14
15
|
|
|
15
16
|
const MAX_REQUEST_BYTES = 40 * 1024;
|
|
16
17
|
const ENDPOINT_FILENAME = "owner-endpoint.json";
|
|
@@ -51,7 +52,7 @@ function processIsAlive(pid: number): boolean {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function readEndpoint(repositoryRoot: string): OwnerEndpoint | null {
|
|
55
|
+
function readEndpoint(repositoryRoot: string, pruneStale = true): OwnerEndpoint | null {
|
|
55
56
|
const path = endpointPath(repositoryRoot);
|
|
56
57
|
if (!existsSync(path)) return null;
|
|
57
58
|
if (lstatSync(path).isSymbolicLink()) {
|
|
@@ -73,7 +74,7 @@ function readEndpoint(repositoryRoot: string): OwnerEndpoint | null {
|
|
|
73
74
|
if (!processIsAlive(item.pid!)) {
|
|
74
75
|
// A killed owner cannot hold PGlite. Remove only the exact endpoint bytes
|
|
75
76
|
// read above; a replacement means another owner is starting.
|
|
76
|
-
if (readFileSync(path).equals(bytes)) unlinkSync(path);
|
|
77
|
+
if (pruneStale && readFileSync(path).equals(bytes)) unlinkSync(path);
|
|
77
78
|
return null;
|
|
78
79
|
}
|
|
79
80
|
return item as OwnerEndpoint;
|
|
@@ -141,11 +142,33 @@ function dispatchMemory(service: LocalTaskService, action: LocalMemoryAction, re
|
|
|
141
142
|
return { deleted: service.forgetMemory(request.id) };
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
export interface LocalTaskOwnerServer {
|
|
145
|
+
export interface LocalTaskOwnerServer {
|
|
145
146
|
repositoryRoot: string;
|
|
146
147
|
port: number;
|
|
147
148
|
close(): Promise<void>;
|
|
148
|
-
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Authenticated and read-only. Endpoint secrets never leave this module. */
|
|
152
|
+
export async function probeLocalOwner(repositoryRoot: string): Promise<{ repositoryRoot: string; pid: number; port: number } | null> {
|
|
153
|
+
const endpoint = readEndpoint(realpathSync(repositoryRoot), false);
|
|
154
|
+
if (!endpoint) return null;
|
|
155
|
+
const request = (action: string, body: object) => fetch(`http://127.0.0.1:${endpoint.port}/v1/${action}`, {
|
|
156
|
+
method: "POST", headers: { Authorization: `Bearer ${endpoint.token}`, "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify(body), redirect: "manual", signal: AbortSignal.timeout(2000),
|
|
158
|
+
});
|
|
159
|
+
const response = await request("owner-health", {});
|
|
160
|
+
if (response.ok) {
|
|
161
|
+
const health = await response.json() as { ok?: boolean; repositoryRoot?: string; pid?: number };
|
|
162
|
+
if (!health.ok || health.repositoryRoot !== endpoint.repositoryRoot || health.pid !== endpoint.pid) throw new Error("Agent Fabric owner identity does not match its endpoint");
|
|
163
|
+
} else {
|
|
164
|
+
// Owners started before portable integration already expose this read-only action.
|
|
165
|
+
if (response.status !== 404) throw new Error("Agent Fabric owner health check failed");
|
|
166
|
+
const legacy = await request("run-status", { runId: "fabric-owner-health-probe" });
|
|
167
|
+
const value = await legacy.json() as { code?: string };
|
|
168
|
+
if (legacy.status !== 400 || value.code !== "AF_RUN_NOT_FOUND") throw new Error("Agent Fabric owner cannot be authenticated; inspect it before restarting");
|
|
169
|
+
}
|
|
170
|
+
return { repositoryRoot: endpoint.repositoryRoot, pid: endpoint.pid, port: endpoint.port };
|
|
171
|
+
}
|
|
149
172
|
|
|
150
173
|
/** One PGlite owner shared by CLI clients and the read/proposal MCP adapter. */
|
|
151
174
|
export async function serveLocalTasks(
|
|
@@ -174,10 +197,14 @@ export async function serveLocalTasks(
|
|
|
174
197
|
listener = createServer((request, response: ServerResponse) => {
|
|
175
198
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
176
199
|
response.setHeader("Cache-Control", "no-store");
|
|
177
|
-
if (!published || !authorized(request, published)) {
|
|
200
|
+
if (!published || !authorized(request, published)) {
|
|
178
201
|
response.writeHead(403).end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (request.method === "POST" && request.url === "/v1/owner-health") {
|
|
205
|
+
response.writeHead(200).end(JSON.stringify({ ok: true, repositoryRoot: root, pid: process.pid }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
181
208
|
const action = request.url?.slice("/v1/".length) as LocalTaskAction | LocalMemoryAction | AttachedTaskAction | ManagedRunAction;
|
|
182
209
|
if (request.method !== "POST" || !request.url?.startsWith("/v1/") ||
|
|
183
210
|
(!isAttachedTaskAction(action) && !isManagedRunAction(action) && !["propose", "status", "evidence", "review", "run", "cancel", "reconcile", "verify", "recover-verification", "review-result", "memory-add", "memory-list", "memory-delete"].includes(action))) {
|
|
@@ -303,9 +330,10 @@ export async function requestAttachedTask(repositoryRoot: string, action: Attach
|
|
|
303
330
|
}
|
|
304
331
|
|
|
305
332
|
/** Managed runs always use the single authenticated owner, including bounded event waits. */
|
|
306
|
-
export async function requestManagedRun(repositoryRoot: string, action: ManagedRunAction,
|
|
307
|
-
body: Record<string, unknown>): Promise<unknown> {
|
|
308
|
-
if (!isManagedRunAction(action)) throw new AgentFabricError("AF_INVALID_STATE", "Unknown managed run action");
|
|
333
|
+
export async function requestManagedRun(repositoryRoot: string, action: ManagedRunAction,
|
|
334
|
+
body: Record<string, unknown>): Promise<unknown> {
|
|
335
|
+
if (!isManagedRunAction(action)) throw new AgentFabricError("AF_INVALID_STATE", "Unknown managed run action");
|
|
336
|
+
if (action === "run-start") body = await applyFabricProfile(repositoryRoot, body);
|
|
309
337
|
const endpoint = readEndpoint(realpathSync(repositoryRoot));
|
|
310
338
|
if (!endpoint) throw new AgentFabricError("AF_INVALID_STATE", "Agent Fabric local owner is not running; start forge fabric serve");
|
|
311
339
|
let response: Response;
|
|
@@ -13,6 +13,16 @@ interface RecordFile { state: ManagedRunState; receipts: Receipt[] }
|
|
|
13
13
|
export interface ManagedTransactionOptions { requestId?: string; fingerprint?: string; expectedVersion?: number }
|
|
14
14
|
const MAX_RECORD = 32 * 1024 * 1024;
|
|
15
15
|
const missing = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
16
|
+
async function readLock(path: string): Promise<string> {
|
|
17
|
+
const deadline = Date.now() + 1000;
|
|
18
|
+
for (;;) {
|
|
19
|
+
try { return await readFile(path, "utf8"); }
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (!["EPERM", "EACCES", "EBUSY"].includes((error as NodeJS.ErrnoException).code ?? "") || Date.now() >= deadline) throw error;
|
|
22
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
16
26
|
const statuses = ["preparing", "running", "paused", "blocked", "canceling", "canceled", "publishing", "completed", "failed"];
|
|
17
27
|
/** Windows readers/AV may briefly hold the destination; never unlink its atomic predecessor. */
|
|
18
28
|
async function replaceRecord(temp: string, destination: string): Promise<void> {
|
|
@@ -95,10 +105,10 @@ export class ManagedRunStore {
|
|
|
95
105
|
try {
|
|
96
106
|
while (true) {
|
|
97
107
|
await assertAttachedSafePath(this.root, lock);
|
|
98
|
-
try { await link(candidate, lock); return async () => { await assertAttachedSafePath(this.root, lock); if (await
|
|
99
|
-
catch (error) { if ((error as NodeJS.ErrnoException).code
|
|
108
|
+
try { await link(candidate, lock); return async () => { await assertAttachedSafePath(this.root, lock); if (await readLock(lock) !== ownerBytes) managedFail("AF_RUN_LOCK", "Lock ownership changed"); await unlink(lock); }; }
|
|
109
|
+
catch (error) { if (!["EEXIST", "EPERM", "EACCES", "EBUSY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; }
|
|
100
110
|
try {
|
|
101
|
-
assert((await lstat(lock)).isFile(), "Unsupported lock"); const raw = await
|
|
111
|
+
assert((await lstat(lock)).isFile(), "Unsupported lock"); const raw = await readLock(lock), previous = JSON.parse(raw);
|
|
102
112
|
if (Number.isSafeInteger(previous.pid) && previous.pid > 0 && !alive(previous.pid)) {
|
|
103
113
|
await assertAttachedSafePath(this.root, claim); reclaimDeadGuard(claim); let claimed = false;
|
|
104
114
|
try { await link(candidate, claim); claimed = true; if (readFileSync(lock, "utf8") === raw) unlinkSync(lock); }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { localFabricPath } from "./local-paths.ts";
|
|
4
|
+
import type { ManagedRunSpec } from "./managed-run-contract.ts";
|
|
5
|
+
|
|
6
|
+
export interface FabricProjectProfile {
|
|
7
|
+
schemaVersion: 1;
|
|
8
|
+
maxConcurrency?: number;
|
|
9
|
+
environment?: ManagedRunSpec["environment"];
|
|
10
|
+
verificationCommands?: string[][];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** An optional declarative profile; reading it never executes project commands. */
|
|
14
|
+
export async function readFabricProfile(root: string): Promise<FabricProjectProfile | null> {
|
|
15
|
+
localFabricPath(root);
|
|
16
|
+
const path = join(root, ".forge", "fabric.json");
|
|
17
|
+
let stat;
|
|
18
|
+
try { stat = await lstat(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
19
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 16 * 1024) throw new Error("Invalid Agent Fabric project profile file");
|
|
20
|
+
const value = JSON.parse(await readFile(path, "utf8")) as FabricProjectProfile;
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1 || Object.keys(value).some(key => !["schemaVersion", "maxConcurrency", "environment", "verificationCommands"].includes(key))) throw new Error("Invalid Agent Fabric project profile");
|
|
22
|
+
if (value.maxConcurrency !== undefined && (!Number.isSafeInteger(value.maxConcurrency) || value.maxConcurrency < 1 || value.maxConcurrency > 4)) throw new Error("Profile maxConcurrency must be between 1 and 4");
|
|
23
|
+
const env = value.environment;
|
|
24
|
+
if (env !== undefined) {
|
|
25
|
+
if (!env || typeof env !== "object" || Array.isArray(env) || Object.keys(env).some(key => !["mode", "ignoreScripts", "registry", "timeoutMs"].includes(key))) throw new Error("Invalid profile environment");
|
|
26
|
+
if (env.mode !== undefined && !["auto", "none"].includes(env.mode)) throw new Error("Invalid profile environment mode");
|
|
27
|
+
if (env.ignoreScripts !== undefined && typeof env.ignoreScripts !== "boolean") throw new Error("Invalid profile ignoreScripts");
|
|
28
|
+
if (env.timeoutMs !== undefined && (!Number.isSafeInteger(env.timeoutMs) || env.timeoutMs < 100 || env.timeoutMs > 1800000)) throw new Error("Invalid profile environment deadline");
|
|
29
|
+
if (env.registry !== undefined) {
|
|
30
|
+
if (typeof env.registry !== "string") throw new Error("Invalid profile registry");
|
|
31
|
+
const url = new URL(env.registry);
|
|
32
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Profile registry requires HTTPS without embedded credentials");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (value.verificationCommands !== undefined && (!Array.isArray(value.verificationCommands) || value.verificationCommands.length > 20 || value.verificationCommands.some(argv => !Array.isArray(argv) || !argv.length || argv.length > 40 || argv.some(arg => typeof arg !== "string" || !arg || arg.length > 4096 || arg.includes("\0"))))) throw new Error("Invalid profile verification commands");
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Explicit per-run values win. Suggested checks are never silently appended to a DAG. */
|
|
40
|
+
export async function applyFabricProfile(root: string, spec: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
41
|
+
const profile = await readFabricProfile(root);
|
|
42
|
+
if (!profile) return spec;
|
|
43
|
+
const result = structuredClone(spec);
|
|
44
|
+
if (profile.environment && (result.environment === undefined || (result.environment && typeof result.environment === "object" && !Array.isArray(result.environment)))) result.environment = { ...profile.environment, ...(result.environment as object ?? {}) };
|
|
45
|
+
if (profile.maxConcurrency && result.workflow && typeof result.workflow === "object" && !Array.isArray(result.workflow)) {
|
|
46
|
+
const workflow = result.workflow as Record<string, unknown>;
|
|
47
|
+
if (workflow.limits === undefined || (workflow.limits && typeof workflow.limits === "object" && !Array.isArray(workflow.limits))) workflow.limits = { maxConcurrency: profile.maxConcurrency, ...(workflow.limits as object ?? {}) };
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
export interface FabricProject { id: string; root: string }
|
|
10
|
+
export interface FabricRegistryOptions { registryDirectory?: string; id?: string }
|
|
11
|
+
const validId = (id: string) => /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(id);
|
|
12
|
+
const identity = (root: string) => process.platform === "win32" ? root.toLowerCase() : root;
|
|
13
|
+
const absent = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
14
|
+
const busy = (error: unknown) => ["EEXIST", "EPERM", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "");
|
|
15
|
+
|
|
16
|
+
/** Recover only a validated startup lock whose process demonstrably no longer exists. */
|
|
17
|
+
export async function recoverFabricLock(path: string): Promise<boolean> {
|
|
18
|
+
const recovery = `${path}.recovery`;
|
|
19
|
+
await checkPath(recovery);
|
|
20
|
+
try { await writeFile(recovery, JSON.stringify({ pid: process.pid }), { flag: "wx", mode: 0o600 }); }
|
|
21
|
+
catch (error) { if (busy(error)) return false; throw error; }
|
|
22
|
+
try {
|
|
23
|
+
let contents: string;
|
|
24
|
+
try {
|
|
25
|
+
const stat = await lstat(path);
|
|
26
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024) throw new Error("Invalid Agent Fabric startup lock");
|
|
27
|
+
contents = await readFile(path, "utf8");
|
|
28
|
+
} catch (error) { if (absent(error)) return true; if (busy(error)) return false; throw error; }
|
|
29
|
+
if (!contents) return false; // Another process may still be completing exclusive creation.
|
|
30
|
+
let pid: unknown;
|
|
31
|
+
try { pid = (JSON.parse(contents) as { pid?: unknown })?.pid; } catch { throw new Error("Invalid Agent Fabric startup lock"); }
|
|
32
|
+
if (!Number.isSafeInteger(pid) || (pid as number) <= 0) throw new Error("Invalid Agent Fabric startup lock pid");
|
|
33
|
+
try { process.kill(pid as number, 0); return false; }
|
|
34
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false; }
|
|
35
|
+
// Locks are only replaced after removal. Compare bytes before deleting our observed lock.
|
|
36
|
+
try { if (await readFile(path, "utf8") !== contents) return false; await unlink(path); return true; }
|
|
37
|
+
catch (error) { if (absent(error)) return true; throw error; }
|
|
38
|
+
} finally { await unlink(recovery); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Git is the project authority, including when invoked from a monorepo subdirectory. */
|
|
42
|
+
export async function resolveFabricRoot(directory: string): Promise<string> {
|
|
43
|
+
const cwd = await realpath(resolve(directory));
|
|
44
|
+
const { stdout } = await execute("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 16_384 });
|
|
45
|
+
const root = await realpath(stdout.trim());
|
|
46
|
+
await execute("git", ["rev-parse", "--verify", "HEAD"], { cwd: root, windowsHide: true, timeout: 10_000, maxBuffer: 16_384 });
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function fabricRegistryDirectory(options: FabricRegistryOptions = {}): string {
|
|
51
|
+
return resolve(options.registryDirectory ?? join(homedir(), ".forge", "agent-fabric"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function checkPath(path: string): Promise<void> {
|
|
55
|
+
const parent = dirname(path);
|
|
56
|
+
if (parent !== path) await checkPath(parent);
|
|
57
|
+
const stat = await lstat(path).catch(error => { if (absent(error)) return undefined; throw error; });
|
|
58
|
+
if (stat?.isSymbolicLink()) throw new Error("Agent Fabric registry cannot traverse symbolic links");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readRegistry(directory: string): Promise<FabricProject[]> {
|
|
62
|
+
const path = join(directory, "projects.json");
|
|
63
|
+
await checkPath(path);
|
|
64
|
+
let data: Buffer;
|
|
65
|
+
try { const stat = await lstat(path); if (!stat.isFile() || stat.size > 256 * 1024) throw new Error("Invalid Agent Fabric registry file"); data = await readFile(path); }
|
|
66
|
+
catch (error) { if (absent(error)) return []; throw error; }
|
|
67
|
+
const value = JSON.parse(data.toString("utf8")) as { schemaVersion?: unknown; projects?: unknown };
|
|
68
|
+
if (!value || value.schemaVersion !== 1 || !Array.isArray(value.projects) || value.projects.length > 1000) throw new Error("Invalid Agent Fabric project registry");
|
|
69
|
+
const entries = value.projects as FabricProject[];
|
|
70
|
+
const ids = new Set<string>(), roots = new Set<string>();
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!entry || Object.keys(entry).sort().join(",") !== "id,root" || typeof entry.id !== "string" || !validId(entry.id) || typeof entry.root !== "string" || !isAbsolute(entry.root) || ids.has(entry.id) || roots.has(identity(entry.root))) throw new Error("Invalid or duplicate Agent Fabric project registration");
|
|
73
|
+
ids.add(entry.id); roots.add(identity(entry.root));
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function listFabricProjects(options: FabricRegistryOptions = {}): Promise<FabricProject[]> {
|
|
79
|
+
return readRegistry(fabricRegistryDirectory(options));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function registerFabricProject(directory: string, options: FabricRegistryOptions = {}): Promise<FabricProject> {
|
|
83
|
+
const root = await resolveFabricRoot(directory);
|
|
84
|
+
const registry = fabricRegistryDirectory(options);
|
|
85
|
+
const id = options.id ?? `${basename(root).replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^[^a-zA-Z0-9]+/, "").slice(0, 48) || "project"}-${createHash("sha256").update(identity(root)).digest("hex").slice(0, 12)}`;
|
|
86
|
+
if (!validId(id)) throw new Error("Invalid Agent Fabric project id");
|
|
87
|
+
await checkPath(registry); await mkdir(registry, { recursive: true, mode: 0o700 });
|
|
88
|
+
const lock = join(registry, "projects.lock");
|
|
89
|
+
const deadline = Date.now() + 5000;
|
|
90
|
+
for (;;) {
|
|
91
|
+
await checkPath(lock);
|
|
92
|
+
try { await writeFile(lock, JSON.stringify({ pid: process.pid }), { flag: "wx", mode: 0o600 }); break; }
|
|
93
|
+
catch (error) { if (!busy(error)) throw error; if (Date.now() >= deadline) throw new Error("Agent Fabric project registry is busy; retry or inspect projects.lock and projects.lock.recovery after their owners exit"); if (await recoverFabricLock(lock)) continue; await new Promise(resolve => setTimeout(resolve, 30)); }
|
|
94
|
+
}
|
|
95
|
+
const stage = join(registry, `projects-${randomUUID()}.tmp`);
|
|
96
|
+
try {
|
|
97
|
+
const entries = await readRegistry(registry);
|
|
98
|
+
const sameRoot = entries.find(entry => identity(entry.root) === identity(root));
|
|
99
|
+
if (sameRoot) { if (options.id && sameRoot.id !== options.id) throw new Error("Project already registered with another id"); return sameRoot; }
|
|
100
|
+
if (entries.some(entry => entry.id === id)) throw new Error("Project id already belongs to another repository");
|
|
101
|
+
if (entries.length >= 1000) throw new Error("Agent Fabric project registry is full");
|
|
102
|
+
const project = { id, root }; entries.push(project); entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
103
|
+
await writeFile(stage, `${JSON.stringify({ schemaVersion: 1, projects: entries }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
104
|
+
await rename(stage, join(registry, "projects.json"));
|
|
105
|
+
return project;
|
|
106
|
+
} finally {
|
|
107
|
+
try { await unlink(stage).catch(error => { if (!absent(error)) throw error; }); }
|
|
108
|
+
finally { if (await readFile(lock, "utf8") === JSON.stringify({ pid: process.pid })) await unlink(lock); }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Recheck the registration so moved paths or replaced symlinks cannot redirect a run. */
|
|
113
|
+
export async function resolveFabricProject(id: string, options: FabricRegistryOptions = {}): Promise<string> {
|
|
114
|
+
if (!validId(id)) throw new Error("Invalid Agent Fabric project id");
|
|
115
|
+
const project = (await listFabricProjects(options)).find(entry => entry.id === id);
|
|
116
|
+
if (!project) throw new Error("Agent Fabric project is not registered");
|
|
117
|
+
const root = await resolveFabricRoot(project.root);
|
|
118
|
+
if (identity(root) !== identity(project.root)) throw new Error("Agent Fabric registered repository root changed; register its new location explicitly");
|
|
119
|
+
return root;
|
|
120
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn, execFile } from "node:child_process";
|
|
2
|
+
import { lstat, mkdir, open, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { localFabricPath } from "./local-paths.ts";
|
|
6
|
+
import { probeLocalOwner } from "./local-task-server.ts";
|
|
7
|
+
import { recoverFabricLock, resolveFabricRoot } from "./project-registry.ts";
|
|
8
|
+
import { readFabricProfile } from "./project-profile.ts";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const cliPath = fileURLToPath(new URL("../../../bin/forge.mjs", import.meta.url));
|
|
12
|
+
export interface FabricOwnerOptions { cliPath?: string; nodeExecutable?: string; startupTimeoutMs?: number }
|
|
13
|
+
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
export async function fabricProjectDoctor(directory: string) {
|
|
16
|
+
const root = await resolveFabricRoot(directory);
|
|
17
|
+
const profile = await readFabricProfile(root);
|
|
18
|
+
const owner = await probeLocalOwner(root).then(value => ({ running: value !== null, ...(value ?? {}) })).catch(error => ({ running: false, error: error instanceof Error ? error.message : "Owner probe failed" }));
|
|
19
|
+
const sdkAvailable = await import("@openai/codex-sdk").then(module => typeof module.Codex === "function").catch(() => false);
|
|
20
|
+
const auth = await execute("codex", ["login", "status"], { windowsHide: true, timeout: 5000, maxBuffer: 8192 }).then(() => "authenticated" as const).catch(() => "not_verified" as const);
|
|
21
|
+
return { ok: sdkAvailable && !("error" in owner), repositoryRoot: root,
|
|
22
|
+
runtime: { cliPath, nodeExecutable: process.execPath, sdkAvailable }, owner, profile,
|
|
23
|
+
codexAuthentication: auth, managedExecution: true, hooksRequired: false,
|
|
24
|
+
nextActions: [...(!sdkAvailable ? ["Install the selected Forge runtime's dependencies"] : []), ...(!owner.running ? ["forge fabric ensure-owner --json"] : []), ...(auth !== "authenticated" ? ["codex login status"] : [])] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Start only an owner, never a worker, and reuse an authenticated owner for this exact root. */
|
|
28
|
+
export async function ensureFabricOwner(directory: string, options: FabricOwnerOptions = {}): Promise<{ repositoryRoot: string; pid: number; port: number; started: boolean }> {
|
|
29
|
+
const root = await resolveFabricRoot(directory);
|
|
30
|
+
const existing = await probeLocalOwner(root);
|
|
31
|
+
if (existing) return { ...existing, started: false };
|
|
32
|
+
const state = localFabricPath(root);
|
|
33
|
+
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
34
|
+
const lock = localFabricPath(root, "owner-start.lock");
|
|
35
|
+
const deadline = Date.now() + (options.startupTimeoutMs ?? 30_000);
|
|
36
|
+
for (;;) {
|
|
37
|
+
try { await writeFile(lock, JSON.stringify({ pid: process.pid }), { flag: "wx", mode: 0o600 }); break; }
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!["EEXIST", "EPERM", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
|
40
|
+
const peer = await probeLocalOwner(root); if (peer) return { ...peer, started: false };
|
|
41
|
+
// Never recover a live or unverified process's lock solely on a timer.
|
|
42
|
+
if (Date.now() >= deadline) throw new Error("Agent Fabric owner startup is busy; inspect owner-start.lock and owner-start.lock.recovery after their processes exit");
|
|
43
|
+
if (await recoverFabricLock(lock)) continue;
|
|
44
|
+
await delay(100);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const peer = await probeLocalOwner(root); if (peer) return { ...peer, started: false };
|
|
49
|
+
const logPath = localFabricPath(root, "owner.log");
|
|
50
|
+
const stat = await lstat(logPath).catch(error => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; });
|
|
51
|
+
if (stat && !stat.isFile()) throw new Error("Agent Fabric owner log is not a regular file");
|
|
52
|
+
let node = options.nodeExecutable ?? process.execPath;
|
|
53
|
+
if (!options.nodeExecutable && process.versions.bun) node = (await execute("node", ["-p", "process.execPath"], { windowsHide: true, timeout: 5000 })).stdout.trim();
|
|
54
|
+
const log = await open(logPath, "a", 0o600);
|
|
55
|
+
let child: ReturnType<typeof spawn>;
|
|
56
|
+
let failure: string | undefined;
|
|
57
|
+
try {
|
|
58
|
+
child = spawn(node, [options.cliPath ?? cliPath, "fabric", "serve", "--json"], {
|
|
59
|
+
cwd: root, detached: true, windowsHide: true, stdio: ["ignore", log.fd, log.fd],
|
|
60
|
+
});
|
|
61
|
+
child.once("error", error => { failure = error.message; });
|
|
62
|
+
child.once("exit", (code, signal) => { failure = `owner exited (${code ?? signal})`; });
|
|
63
|
+
} finally { await log.close(); }
|
|
64
|
+
child.unref();
|
|
65
|
+
try {
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
const owner = await probeLocalOwner(root);
|
|
68
|
+
if (owner) {
|
|
69
|
+
if (owner.pid !== child.pid) child.kill();
|
|
70
|
+
return { ...owner, started: owner.pid === child.pid };
|
|
71
|
+
}
|
|
72
|
+
if (failure) throw new Error(`Agent Fabric ${failure}; inspect ${logPath}`);
|
|
73
|
+
await delay(100);
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Agent Fabric owner startup timed out; inspect ${logPath}`);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Only this exact spawned child is ours to stop if startup did not complete.
|
|
78
|
+
child.kill();
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
const contents = await readFile(lock, "utf8");
|
|
83
|
+
if (contents === JSON.stringify({ pid: process.pid })) await unlink(lock);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -7,7 +7,8 @@ import { ingestEnvelope, runAgentMemoryCommand } from "./bridge.ts";
|
|
|
7
7
|
import { normalizeAgentEvent } from "./normalize.ts";
|
|
8
8
|
import { MANAGED_RUN_ACTIONS, requestManagedRun, ATTACHED_TASK_ACTIONS, isAttachedTaskRead, requestAttachedTask, requestLocalTask } from "../agent-fabric/local-task-server.ts";
|
|
9
9
|
import { LocalChangeReviewService } from "../agent-fabric/local-change-review-service.ts";
|
|
10
|
-
import { CODEX_EVENTS, CODEX_HOOK_RUNNER_RELATIVE, CODEX_MCP_HOOK_TOOL } from "./sources/codex.ts";
|
|
10
|
+
import { CODEX_EVENTS, CODEX_HOOK_RUNNER_RELATIVE, CODEX_MCP_HOOK_TOOL } from "./sources/codex.ts";
|
|
11
|
+
import { listFabricProjects, registerFabricProject, resolveFabricProject } from "../agent-fabric/project-registry.ts";
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
const managedCommon = { runId: { type: "string" }, requestId: { type: "string" }, expectedVersion: { type: "integer", minimum: 1 } };
|
|
@@ -54,14 +55,21 @@ function managedRunSchema(action: typeof MANAGED_RUN_ACTIONS[number]): Record<st
|
|
|
54
55
|
required: ["request"], additionalProperties: false };
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
interface JsonRpcRequest {
|
|
58
|
+
interface JsonRpcRequest {
|
|
58
59
|
jsonrpc?: "2.0";
|
|
59
60
|
id?: string | number | null;
|
|
60
61
|
method: string;
|
|
61
62
|
params?: Record<string, unknown>;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FabricMcpOptions { registryDirectory?: string }
|
|
66
|
+
|
|
67
|
+
function projectAwareSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
|
68
|
+
return { ...schema, properties: { ...(schema.properties as Record<string, unknown>),
|
|
69
|
+
projectId: { type: "string", minLength: 1, description: "Registered project identifier. Omit to use the MCP server's original workspace." } } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function handleMcpRequest(workspaceRoot: string, request: JsonRpcRequest, options: FabricMcpOptions = {}): Promise<Record<string, unknown> | null> {
|
|
65
73
|
if (request.method.startsWith("notifications/")) {
|
|
66
74
|
return null;
|
|
67
75
|
}
|
|
@@ -75,7 +83,15 @@ export async function handleMcpRequest(workspaceRoot: string, request: JsonRpcRe
|
|
|
75
83
|
}
|
|
76
84
|
if (request.method === "tools/list") {
|
|
77
85
|
return response(request.id, {
|
|
78
|
-
tools: [
|
|
86
|
+
tools: [
|
|
87
|
+
{ name: "fabric_project_register", description: "Explicitly register a Git project root for isolated Agent Fabric routing. Does not start workers.",
|
|
88
|
+
inputSchema: { type: "object", properties: { root: { type: "string", minLength: 1, description: "Absolute path to the Git project or one of its subdirectories." }, id: { type: "string", minLength: 1 } }, required: ["root"], additionalProperties: false } },
|
|
89
|
+
{ name: "fabric_project_list", description: "List registered Agent Fabric projects and their canonical roots.",
|
|
90
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
91
|
+
{ name: "fabric_project_doctor", description: "Diagnose the selected project's runtime and owner without starting workers.",
|
|
92
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
93
|
+
{ name: "fabric_owner_start", description: "Ensure the selected project's isolated owner is running. Does not start a workflow or model.",
|
|
94
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
79
95
|
...MANAGED_RUN_ACTIONS.map((action) => ({
|
|
80
96
|
name: `fabric_${action.replaceAll("-", "_")}`,
|
|
81
97
|
description: action === "run-start"
|
|
@@ -190,7 +206,8 @@ export async function handleMcpRequest(workspaceRoot: string, request: JsonRpcRe
|
|
|
190
206
|
additionalProperties: false,
|
|
191
207
|
},
|
|
192
208
|
},
|
|
193
|
-
]
|
|
209
|
+
].map((tool) => tool.name.startsWith("fabric_") && tool.name !== "fabric_project_register" && tool.name !== "fabric_project_list"
|
|
210
|
+
? { ...tool, inputSchema: projectAwareSchema(tool.inputSchema) } : tool),
|
|
194
211
|
});
|
|
195
212
|
}
|
|
196
213
|
if (request.method === "tools/call") {
|
|
@@ -199,9 +216,18 @@ export async function handleMcpRequest(workspaceRoot: string, request: JsonRpcRe
|
|
|
199
216
|
const args = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments)
|
|
200
217
|
? params.arguments as Record<string, unknown>
|
|
201
218
|
: {};
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
219
|
+
let toolRoot = workspaceRoot;
|
|
220
|
+
const toolArgs = { ...args };
|
|
221
|
+
if (name.startsWith("fabric_") && name !== "fabric_project_register" && name !== "fabric_project_list" && "projectId" in toolArgs) {
|
|
222
|
+
if (typeof toolArgs.projectId !== "string" || !toolArgs.projectId.trim()) throw new Error("projectId must be a non-empty registered project identifier");
|
|
223
|
+
toolRoot = await resolveFabricProject(toolArgs.projectId, options);
|
|
224
|
+
delete toolArgs.projectId;
|
|
225
|
+
}
|
|
226
|
+
const toolResult = await runTool(toolRoot, name, toolArgs, options);
|
|
227
|
+
const result = typeof args.projectId === "string" && name.startsWith("fabric_") && toolResult && typeof toolResult === "object"
|
|
228
|
+
? { ...toolResult, projectContext: { id: args.projectId, root: toolRoot } } : toolResult;
|
|
229
|
+
if (name !== CODEX_MCP_HOOK_TOOL) {
|
|
230
|
+
await logMcpToolCall(toolRoot, name, args, "completed").catch(() => undefined);
|
|
205
231
|
}
|
|
206
232
|
return response(request.id, {
|
|
207
233
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
@@ -242,7 +268,25 @@ export async function runMcpServe(workspaceRoot: string): Promise<number> {
|
|
|
242
268
|
return 0;
|
|
243
269
|
}
|
|
244
270
|
|
|
245
|
-
async function runTool(workspaceRoot: string, name: string, args: Record<string, unknown
|
|
271
|
+
async function runTool(workspaceRoot: string, name: string, args: Record<string, unknown>, options: FabricMcpOptions): Promise<unknown> {
|
|
272
|
+
if (name === "fabric_project_register") {
|
|
273
|
+
if (Object.keys(args).some(key => key !== "root" && key !== "id") || typeof args.root !== "string" || !isAbsolute(args.root) ||
|
|
274
|
+
("id" in args && (typeof args.id !== "string" || !args.id.trim()))) throw new Error("fabric_project_register requires an absolute root and optional id");
|
|
275
|
+
return { ok: true, project: await registerFabricProject(args.root, { ...options, ...(typeof args.id === "string" ? { id: args.id } : {}) }) };
|
|
276
|
+
}
|
|
277
|
+
if (name === "fabric_project_list") {
|
|
278
|
+
if (Object.keys(args).length) throw new Error("fabric_project_list accepts no arguments");
|
|
279
|
+
return { ok: true, projects: await listFabricProjects(options) };
|
|
280
|
+
}
|
|
281
|
+
if (name === "fabric_project_doctor" || name === "fabric_owner_start") {
|
|
282
|
+
if (Object.keys(args).length) throw new Error(`${name} accepts only optional projectId`);
|
|
283
|
+
const { ensureFabricOwner, fabricProjectDoctor } = await import("../agent-fabric/project-runtime.ts");
|
|
284
|
+
if (name === "fabric_project_doctor") {
|
|
285
|
+
const diagnostics = await fabricProjectDoctor(workspaceRoot);
|
|
286
|
+
return { ok: diagnostics.ok, diagnostics };
|
|
287
|
+
}
|
|
288
|
+
return { ok: true, owner: await ensureFabricOwner(workspaceRoot) };
|
|
289
|
+
}
|
|
246
290
|
const managedAction = MANAGED_RUN_ACTIONS.find((action) => name === `fabric_${action.replaceAll("-", "_")}`);
|
|
247
291
|
if (managedAction) {
|
|
248
292
|
const read = managedAction === "run-status";
|
|
@@ -310,7 +354,9 @@ async function runTool(workspaceRoot: string, name: string, args: Record<string,
|
|
|
310
354
|
schemaVersion: 1,
|
|
311
355
|
protocolKernel: "p0a_available",
|
|
312
356
|
boundedModelAdapter: "p0b_a_available",
|
|
313
|
-
codingTaskControl: "local_owner_service_required",
|
|
357
|
+
codingTaskControl: "local_owner_service_required",
|
|
358
|
+
projectRouting: { supported: true, registrationRequired: true, defaultWorkspace: realpathSync(workspaceRoot),
|
|
359
|
+
ownerIsolation: "per_project", hooks: "server_workspace_only", tools: ["fabric_project_register", "fabric_project_list", "fabric_project_doctor", "fabric_owner_start"] },
|
|
314
360
|
managedExecution: { supported: true, runningOwnerRequired: true, scheduler: "owner_managed",
|
|
315
361
|
executors: ["codex", "command"], startDispatchesWork: true, boundedEventWaitMs: 30_000,
|
|
316
362
|
codexMayConsumeCredits: true, controls: ["steer", "pause", "resume", "cancel", "reconcile"],
|