repo-harness 0.16.1 → 0.16.2
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.es.md +2 -2
- package/README.fr.md +2 -2
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/assets/partials/03-philosophy.partial.md +6 -0
- package/assets/partials-agents/04-task-protocol.partial.md +0 -1
- package/assets/partials-agents/06-quality-safety.partial.md +1 -0
- package/assets/reference-configs/global-working-rules.md +8 -0
- package/assets/skill-version.json +6 -2
- package/assets/templates/helpers/architecture-event.ts +22 -5
- package/dist/hook-entry.js +1 -1
- package/package.json +1 -1
- package/scripts/architecture-event.ts +22 -5
- package/src/cli/commands/mcp.ts +4 -1
- package/src/cli/mcp/coding-workspaces.ts +32 -16
- package/src/cli/mcp/transports/http.ts +54 -21
package/README.es.md
CHANGED
|
@@ -467,8 +467,8 @@ repositorio adopte la misma política.
|
|
|
467
467
|
|
|
468
468
|
## Versión actual
|
|
469
469
|
|
|
470
|
-
- Paquete npm: `repo-harness@0.16.
|
|
471
|
-
- Sello de workflow generado: `repo-harness@0.16.
|
|
470
|
+
- Paquete npm: `repo-harness@0.16.2`
|
|
471
|
+
- Sello de workflow generado: `repo-harness@0.16.2+template@0.16.2`
|
|
472
472
|
- Repositorio de GitHub: `Ancienttwo/repo-harness`
|
|
473
473
|
- Notas de versión e historial: [`docs/CHANGELOG.md`](docs/CHANGELOG.md)
|
|
474
474
|
|
package/README.fr.md
CHANGED
|
@@ -462,8 +462,8 @@ adopte la même policy.
|
|
|
462
462
|
|
|
463
463
|
## Release actuelle
|
|
464
464
|
|
|
465
|
-
- Package npm : `repo-harness@0.16.
|
|
466
|
-
- Generated workflow stamp : `repo-harness@0.16.
|
|
465
|
+
- Package npm : `repo-harness@0.16.2`
|
|
466
|
+
- Generated workflow stamp : `repo-harness@0.16.2+template@0.16.2`
|
|
467
467
|
- Dépôt GitHub : `Ancienttwo/repo-harness`
|
|
468
468
|
- Notes et historique de release : [`docs/CHANGELOG.md`](docs/CHANGELOG.md)
|
|
469
469
|
|
package/README.ja.md
CHANGED
|
@@ -470,8 +470,8 @@ commit script や hooks に組み込まないでください。
|
|
|
470
470
|
|
|
471
471
|
## 現在の Release
|
|
472
472
|
|
|
473
|
-
- npm package:`repo-harness@0.16.
|
|
474
|
-
- Generated workflow stamp:`repo-harness@0.16.
|
|
473
|
+
- npm package:`repo-harness@0.16.2`
|
|
474
|
+
- Generated workflow stamp:`repo-harness@0.16.2+template@0.16.2`
|
|
475
475
|
- GitHub repository:`Ancienttwo/repo-harness`
|
|
476
476
|
- Release notes and history:[`docs/CHANGELOG.md`](docs/CHANGELOG.md)
|
|
477
477
|
|
package/README.md
CHANGED
|
@@ -450,8 +450,8 @@ repo-harness commit scripts or hooks unless that repo adopts the same policy.
|
|
|
450
450
|
|
|
451
451
|
## Current Release
|
|
452
452
|
|
|
453
|
-
- npm package: `repo-harness@0.16.
|
|
454
|
-
- Generated workflow stamp: `repo-harness@0.16.
|
|
453
|
+
- npm package: `repo-harness@0.16.2`
|
|
454
|
+
- Generated workflow stamp: `repo-harness@0.16.2+template@0.16.2`
|
|
455
455
|
- GitHub repository: `Ancienttwo/repo-harness`
|
|
456
456
|
- Release notes and history: [`docs/CHANGELOG.md`](docs/CHANGELOG.md)
|
|
457
457
|
|
package/README.zh-CN.md
CHANGED
|
@@ -457,8 +457,8 @@ policy。
|
|
|
457
457
|
|
|
458
458
|
## 当前 Release
|
|
459
459
|
|
|
460
|
-
- npm package:`repo-harness@0.16.
|
|
461
|
-
- Generated workflow stamp:`repo-harness@0.16.
|
|
460
|
+
- npm package:`repo-harness@0.16.2`
|
|
461
|
+
- Generated workflow stamp:`repo-harness@0.16.2+template@0.16.2`
|
|
462
462
|
- GitHub repository:`Ancienttwo/repo-harness`
|
|
463
463
|
- Release notes 和 history:[`docs/CHANGELOG.md`](docs/CHANGELOG.md)
|
|
464
464
|
|
|
@@ -24,6 +24,12 @@ BUG_FIX_FLOW:
|
|
|
24
24
|
3. Re-run full verification
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
#### Artifact Hygiene
|
|
28
|
+
|
|
29
|
+
- Write comments, commit messages, and PR text from the final diff only, as if discarded intermediate attempts never existed.
|
|
30
|
+
- Comments state only the non-obvious reason at the owning boundary; never restate the operation, preserve intermediate attempts, or list speculative future work.
|
|
31
|
+
- PR text states final behavior plus only the material rationale or trade-off a reviewer cannot recover from the diff; never mention never-merged states or reverted work.
|
|
32
|
+
|
|
27
33
|
Detailed playbooks:
|
|
28
34
|
- `docs/reference-configs/harness-overview.md`
|
|
29
35
|
- `docs/reference-configs/agentic-development-flow.md`
|
|
@@ -53,7 +53,6 @@ RULES:
|
|
|
53
53
|
- Promote factors only after hypothesis and backtest summary artifacts exist
|
|
54
54
|
- Run the configured factor-lab check before claiming factor-lab work is complete
|
|
55
55
|
{{/IF}}
|
|
56
|
-
|
|
57
56
|
ACTIVE_PLAN:
|
|
58
57
|
- .ai/harness/active-plan selects the current active plan only for its owning worktree; .ai/harness/active-worktree records that owner
|
|
59
58
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
- Do not silently expand scope beyond approved plan.
|
|
13
13
|
- If unexpected repo changes appear, stop and ask.
|
|
14
14
|
- Prefer modifying existing files over unnecessary file creation.
|
|
15
|
+
- Write comments, commit messages, and PR text from the final diff only: comments state only the non-obvious reason at the owning boundary; PR text states final behavior plus only rationale a reviewer cannot recover from the diff; never mention discarded attempts, reverted work, or never-merged states.
|
|
15
16
|
|
|
16
17
|
### Final Response Contract
|
|
17
18
|
1. What changed — list modified files with one-line summary each
|
|
@@ -75,6 +75,14 @@ For architecture reviews, bug hunts, risky refactors, deployment issues, auth/pa
|
|
|
75
75
|
|
|
76
76
|
Reports must be concise and grounded in files, commands, runtime behavior, observed code, or verified system state.
|
|
77
77
|
|
|
78
|
+
## Artifact Hygiene
|
|
79
|
+
|
|
80
|
+
Write comments, commit messages, and PR text from the final diff only, as if discarded intermediate attempts never existed. Session history is not documentation material.
|
|
81
|
+
|
|
82
|
+
- Comments state only the non-obvious reason at the owning boundary; never restate the operation, preserve intermediate attempts, or list speculative future work.
|
|
83
|
+
- PR text states final behavior plus only the material rationale or trade-off a reviewer cannot recover from the diff; never mention never-merged states, reverted work, or why something is absent (no "(without X)" titles, no paragraphs justifying a removal the reviewer never saw).
|
|
84
|
+
- When the user rejects an addition, the correct artifact output is the version where that addition never existed — not an annotated explanation of its removal.
|
|
85
|
+
|
|
78
86
|
## Completion Summary Rule
|
|
79
87
|
|
|
80
88
|
For non-trivial completed tasks, include a short `下一刀` section only when verified state shows a concrete next bottleneck, unresolved risk, failing check, deployment gap, review gap, or active-plan item that materially affects the user's stated goal.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.16.
|
|
3
|
-
"templateVersion": "0.16.
|
|
2
|
+
"version": "0.16.2",
|
|
3
|
+
"templateVersion": "0.16.2",
|
|
4
4
|
"skillName": "repo-harness",
|
|
5
5
|
"contractId": "tasks-first-harness-v1",
|
|
6
6
|
"compatibility": {
|
|
@@ -259,6 +259,10 @@
|
|
|
259
259
|
{
|
|
260
260
|
"version": "0.16.1",
|
|
261
261
|
"description": "Replaces the boolean `overwrite` flag on the seven MCP workflow-artifact write tools with an optional `expected_sha256` revision precondition (absent = create-only with WOULD_OVERWRITE, supplied = guarded overwrite with REVISION_CONFLICT and no current-hash echo), routes those writes through a guarded synchronous writer with lstat symlink and regular-file guards plus temp+fsync+rename+parent-fsync, returns sha256/previousSha256 on success, and rejects undeclared parameters server-side with UNKNOWN_PARAMETER while `overwrite` specifically returns RETIRED_PARAMETER naming `expected_sha256`"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"version": "0.16.2",
|
|
265
|
+
"description": "Fixes MCP runtime issues from #204, binds MCP HTTP sessions to the startup profile with fail-closed config-flip handling, publishes the architecture queue lock owner record atomically via staged hard-link creation, requires and fail-closed-validates sandbox_mode in Codex custom-agent TOMLs with EXECUTION_BOUNDARY persona de-duplication, and adds artifact-hygiene rules (final-diff-only comments and PR text) to the global working rules and generated agent contracts"
|
|
262
266
|
}
|
|
263
267
|
],
|
|
264
268
|
"generatedProjectStamp": {
|
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from "crypto";
|
|
|
3
3
|
import {
|
|
4
4
|
closeSync,
|
|
5
5
|
existsSync,
|
|
6
|
+
linkSync,
|
|
6
7
|
lstatSync,
|
|
7
8
|
mkdirSync,
|
|
8
9
|
openSync,
|
|
@@ -520,18 +521,34 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
|
|
|
520
521
|
assertSafeWriteTarget(`${requestsDir}/.write-probe`);
|
|
521
522
|
const lockFile = ".ai/harness/architecture/.architecture-queue.lock";
|
|
522
523
|
assertSafeWriteTarget(lockFile);
|
|
524
|
+
const candidateToken = createHash("sha256").update(ownerToken).digest("hex").slice(0, 16);
|
|
525
|
+
const candidateFile = `${lockFile}.${ownerPid}.${candidateToken}.tmp`;
|
|
526
|
+
assertSafeWriteTarget(candidateFile);
|
|
523
527
|
const deadline = Date.now() + 10_000;
|
|
524
528
|
while (true) {
|
|
525
529
|
try {
|
|
526
|
-
const fd = openSync(
|
|
530
|
+
const fd = openSync(candidateFile, "wx", 0o600);
|
|
527
531
|
try {
|
|
528
532
|
writeAllSync(fd, JSON.stringify({ pid: ownerPid, token: ownerToken, created_at: new Date().toISOString() }));
|
|
529
533
|
} finally {
|
|
530
534
|
closeSync(fd);
|
|
531
535
|
}
|
|
532
|
-
|
|
536
|
+
const holdBeforePublishMs = Number(process.env.REPO_HARNESS_ARCHITECTURE_HOLD_BEFORE_LOCK_PUBLISH_MS || "0");
|
|
537
|
+
if (Number.isFinite(holdBeforePublishMs) && holdBeforePublishMs > 0) {
|
|
538
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, holdBeforePublishMs);
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
linkSync(candidateFile, lockFile);
|
|
542
|
+
return lockFile;
|
|
543
|
+
} catch (error: any) {
|
|
544
|
+
if (error?.code !== "EEXIST") throw error;
|
|
545
|
+
}
|
|
533
546
|
} catch (error: any) {
|
|
534
|
-
if (error?.code !== "EEXIST"
|
|
547
|
+
if (error?.code !== "EEXIST") throw error;
|
|
548
|
+
} finally {
|
|
549
|
+
rmSync(candidateFile, { force: true });
|
|
550
|
+
}
|
|
551
|
+
if (existsSync(lockFile)) {
|
|
535
552
|
try {
|
|
536
553
|
const owner = JSON.parse(readFileSync(lockFile, "utf8"));
|
|
537
554
|
if (!processIsAlive(Number(owner.pid))) {
|
|
@@ -545,9 +562,9 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
|
|
|
545
562
|
continue;
|
|
546
563
|
}
|
|
547
564
|
}
|
|
548
|
-
if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
|
|
549
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
550
565
|
}
|
|
566
|
+
if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
|
|
567
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
551
568
|
}
|
|
552
569
|
}
|
|
553
570
|
|
package/dist/hook-entry.js
CHANGED
|
@@ -14556,7 +14556,7 @@ function resolveReviewBaseRef(repoRoot) {
|
|
|
14556
14556
|
return "HEAD";
|
|
14557
14557
|
}
|
|
14558
14558
|
}
|
|
14559
|
-
var BUNDLED_CLI_VERSION = "0.16.
|
|
14559
|
+
var BUNDLED_CLI_VERSION = "0.16.2".length > 0 ? "0.16.2" : null;
|
|
14560
14560
|
function providerCliVersion() {
|
|
14561
14561
|
if (BUNDLED_CLI_VERSION !== null)
|
|
14562
14562
|
return BUNDLED_CLI_VERSION;
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from "crypto";
|
|
|
3
3
|
import {
|
|
4
4
|
closeSync,
|
|
5
5
|
existsSync,
|
|
6
|
+
linkSync,
|
|
6
7
|
lstatSync,
|
|
7
8
|
mkdirSync,
|
|
8
9
|
openSync,
|
|
@@ -520,18 +521,34 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
|
|
|
520
521
|
assertSafeWriteTarget(`${requestsDir}/.write-probe`);
|
|
521
522
|
const lockFile = ".ai/harness/architecture/.architecture-queue.lock";
|
|
522
523
|
assertSafeWriteTarget(lockFile);
|
|
524
|
+
const candidateToken = createHash("sha256").update(ownerToken).digest("hex").slice(0, 16);
|
|
525
|
+
const candidateFile = `${lockFile}.${ownerPid}.${candidateToken}.tmp`;
|
|
526
|
+
assertSafeWriteTarget(candidateFile);
|
|
523
527
|
const deadline = Date.now() + 10_000;
|
|
524
528
|
while (true) {
|
|
525
529
|
try {
|
|
526
|
-
const fd = openSync(
|
|
530
|
+
const fd = openSync(candidateFile, "wx", 0o600);
|
|
527
531
|
try {
|
|
528
532
|
writeAllSync(fd, JSON.stringify({ pid: ownerPid, token: ownerToken, created_at: new Date().toISOString() }));
|
|
529
533
|
} finally {
|
|
530
534
|
closeSync(fd);
|
|
531
535
|
}
|
|
532
|
-
|
|
536
|
+
const holdBeforePublishMs = Number(process.env.REPO_HARNESS_ARCHITECTURE_HOLD_BEFORE_LOCK_PUBLISH_MS || "0");
|
|
537
|
+
if (Number.isFinite(holdBeforePublishMs) && holdBeforePublishMs > 0) {
|
|
538
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, holdBeforePublishMs);
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
linkSync(candidateFile, lockFile);
|
|
542
|
+
return lockFile;
|
|
543
|
+
} catch (error: any) {
|
|
544
|
+
if (error?.code !== "EEXIST") throw error;
|
|
545
|
+
}
|
|
533
546
|
} catch (error: any) {
|
|
534
|
-
if (error?.code !== "EEXIST"
|
|
547
|
+
if (error?.code !== "EEXIST") throw error;
|
|
548
|
+
} finally {
|
|
549
|
+
rmSync(candidateFile, { force: true });
|
|
550
|
+
}
|
|
551
|
+
if (existsSync(lockFile)) {
|
|
535
552
|
try {
|
|
536
553
|
const owner = JSON.parse(readFileSync(lockFile, "utf8"));
|
|
537
554
|
if (!processIsAlive(Number(owner.pid))) {
|
|
@@ -545,9 +562,9 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
|
|
|
545
562
|
continue;
|
|
546
563
|
}
|
|
547
564
|
}
|
|
548
|
-
if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
|
|
549
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
550
565
|
}
|
|
566
|
+
if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
|
|
567
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
551
568
|
}
|
|
552
569
|
}
|
|
553
570
|
|
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -229,7 +229,10 @@ export function buildMcpCommand(): Command {
|
|
|
229
229
|
.action((rawOpts: { json?: boolean }) => {
|
|
230
230
|
void runMcpAction(() => {
|
|
231
231
|
const rows = listManagedCodingWorkspaces();
|
|
232
|
-
console.log(rawOpts.json ? JSON.stringify({ workspaces: rows }, null, 2) : rows.map((row) =>
|
|
232
|
+
console.log(rawOpts.json ? JSON.stringify({ workspaces: rows }, null, 2) : rows.map((row) => {
|
|
233
|
+
const worktreeState = row.stale_reason !== null ? 'stale' : (row.dirty ? 'dirty' : 'clean');
|
|
234
|
+
return `${row.id}\t${row.branch}\t${worktreeState}\t${row.path_exists ? 'present' : 'missing'}`;
|
|
235
|
+
}).join('\n'));
|
|
233
236
|
});
|
|
234
237
|
});
|
|
235
238
|
workspaces
|
|
@@ -611,23 +611,39 @@ export class CodingWorkspaceManager {
|
|
|
611
611
|
|
|
612
612
|
export function listManagedCodingWorkspaces(env: NodeJS.ProcessEnv = process.env): Array<
|
|
613
613
|
Omit<CodingWorkspace, 'root' | 'sourceRoot' | 'integrationTargetRef'>
|
|
614
|
-
& { integrationTargetRef: string | null; dirty: boolean; path_exists: boolean }
|
|
614
|
+
& { integrationTargetRef: string | null; dirty: boolean | null; path_exists: boolean; stale_reason: string | null }
|
|
615
615
|
> {
|
|
616
|
-
return stateFile(env).workspaces.map((workspace) =>
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
616
|
+
return stateFile(env).workspaces.map((workspace) => {
|
|
617
|
+
const pathExists = existsSync(workspace.root);
|
|
618
|
+
// A recorded directory that still exists but is no longer a usable worktree
|
|
619
|
+
// is reported as one explicit stale row with an unknown dirty state. It is
|
|
620
|
+
// never skipped, never repaired, and never allowed to fail the whole list.
|
|
621
|
+
let dirty: boolean | null = pathExists ? null : false;
|
|
622
|
+
let staleReason: string | null = null;
|
|
623
|
+
if (pathExists) {
|
|
624
|
+
try {
|
|
625
|
+
dirty = isDirty(workspace.root);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
staleReason = error instanceof Error ? error.message : String(error);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
id: workspace.id,
|
|
632
|
+
repoId: workspace.repoId,
|
|
633
|
+
displayName: workspace.displayName,
|
|
634
|
+
mode: workspace.mode,
|
|
635
|
+
branch: workspace.branch,
|
|
636
|
+
baseRef: workspace.baseRef,
|
|
637
|
+
baseSha: workspace.baseSha,
|
|
638
|
+
integrationTargetRef: typeof workspace.integrationTargetRef === 'string' ? workspace.integrationTargetRef : null,
|
|
639
|
+
dirtySource: workspace.dirtySource,
|
|
640
|
+
openedAt: workspace.openedAt,
|
|
641
|
+
managed: workspace.managed,
|
|
642
|
+
path_exists: pathExists,
|
|
643
|
+
dirty,
|
|
644
|
+
stale_reason: staleReason,
|
|
645
|
+
};
|
|
646
|
+
});
|
|
631
647
|
}
|
|
632
648
|
|
|
633
649
|
export function cleanupManagedCodingWorkspace(
|
|
@@ -427,12 +427,17 @@ function acquireSession(sessions: McpSessionStore<McpHttpTransport>, sessionId:
|
|
|
427
427
|
return sessions.acquire(sessionId);
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
export function resolveMcpHttpProfile(opts: McpServerOptions, config: ReturnType<typeof loadMcpLocalConfig>): string {
|
|
431
|
+
return opts.profile ?? config?.profile ?? 'planner';
|
|
432
|
+
}
|
|
433
|
+
|
|
430
434
|
async function handleMcpPost(
|
|
431
435
|
req: Request,
|
|
432
436
|
res: Response,
|
|
433
437
|
opts: McpHttpOptions,
|
|
434
438
|
sessions: McpSessionStore<McpHttpTransport>,
|
|
435
439
|
codingRuntimes: CodingAuthorizationRuntimeStore | null,
|
|
440
|
+
startupProfile: string,
|
|
436
441
|
): Promise<void> {
|
|
437
442
|
let body: unknown;
|
|
438
443
|
try {
|
|
@@ -443,6 +448,22 @@ async function handleMcpPost(
|
|
|
443
448
|
}
|
|
444
449
|
const sessionId = sessionIdFromRequest(req);
|
|
445
450
|
if (!sessionId && isInitializeRequest(body)) {
|
|
451
|
+
// The whole HTTP enforcement layer (coding-grant middleware, OAuth
|
|
452
|
+
// requirement, host/origin pinning, per-authorization coding runtimes) is
|
|
453
|
+
// decided once from the startup profile. A local config flip after startup
|
|
454
|
+
// would otherwise let a new session resolve a different tool context behind
|
|
455
|
+
// that stale enforcement, so sessions are bound to the startup profile and
|
|
456
|
+
// a flip fails closed until the operator restarts the server.
|
|
457
|
+
const currentProfile = resolveMcpHttpProfile(opts, loadMcpLocalConfig());
|
|
458
|
+
if (currentProfile !== startupProfile) {
|
|
459
|
+
res.status(503).json({
|
|
460
|
+
error: {
|
|
461
|
+
code: 'PROFILE_CHANGED',
|
|
462
|
+
message: `The local MCP profile changed from "${startupProfile}" to "${currentProfile}" after startup; restart the MCP server to serve the new profile.`,
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
446
467
|
const authorizationId = codingRuntimes ? authorizationIdFromRequest(req) : undefined;
|
|
447
468
|
if (codingRuntimes && !authorizationId) {
|
|
448
469
|
sendOAuthUnauthorized(req, res, 'Coding authorization identity is missing');
|
|
@@ -469,24 +490,28 @@ async function handleMcpPost(
|
|
|
469
490
|
return;
|
|
470
491
|
}
|
|
471
492
|
let sessionInitialized = false;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
sessionInitialized = true;
|
|
477
|
-
},
|
|
478
|
-
}) as McpHttpTransport;
|
|
479
|
-
transport.authorizationId = authorizationId;
|
|
480
|
-
transport.onclose = () => {
|
|
481
|
-
if (transport.sessionId) sessions.delete(transport.sessionId);
|
|
482
|
-
};
|
|
483
|
-
const server = createRepoHarnessMcpServer({ ...opts, codingRuntime });
|
|
493
|
+
// Transport and server construction stay inside the try: a throw from either
|
|
494
|
+
// must still release the reservation, otherwise the slot leaks for the whole
|
|
495
|
+
// process lifetime and the store fails closed at capacity forever.
|
|
496
|
+
let transport: McpHttpTransport | undefined;
|
|
484
497
|
try {
|
|
498
|
+
transport = new StreamableHTTPServerTransport({
|
|
499
|
+
sessionIdGenerator: () => randomUUID(),
|
|
500
|
+
onsessioninitialized: (newSessionId) => {
|
|
501
|
+
reservation.commit(newSessionId, transport as McpHttpTransport);
|
|
502
|
+
sessionInitialized = true;
|
|
503
|
+
},
|
|
504
|
+
}) as McpHttpTransport;
|
|
505
|
+
transport.authorizationId = authorizationId;
|
|
506
|
+
transport.onclose = () => {
|
|
507
|
+
if (transport?.sessionId) sessions.delete(transport.sessionId);
|
|
508
|
+
};
|
|
509
|
+
const server = createRepoHarnessMcpServer({ ...opts, profile: startupProfile, codingRuntime });
|
|
485
510
|
await server.connect(transport);
|
|
486
511
|
await transport.handleRequest(req, res, body);
|
|
487
512
|
} finally {
|
|
488
513
|
reservation.release();
|
|
489
|
-
if (!sessionInitialized) await transport
|
|
514
|
+
if (!sessionInitialized) await transport?.close().catch(() => undefined);
|
|
490
515
|
}
|
|
491
516
|
return;
|
|
492
517
|
}
|
|
@@ -557,7 +582,7 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
557
582
|
const repoRoot = resolveMcpRepoRoot(opts.repo ?? '.');
|
|
558
583
|
assertNoLegacyRepoScopeMcpConfig(repoRoot);
|
|
559
584
|
const localConfig = loadMcpLocalConfig();
|
|
560
|
-
const profile = opts
|
|
585
|
+
const profile = resolveMcpHttpProfile(opts, localConfig);
|
|
561
586
|
const storedEndpoint = localConfig?.chatgpt?.endpoint;
|
|
562
587
|
const storedPublicOrigin = storedEndpoint ? new URL(storedEndpoint).origin : undefined;
|
|
563
588
|
const configuredPublicOrigin = process.env.REPO_HARNESS_MCP_PUBLIC_ORIGIN?.trim()
|
|
@@ -636,6 +661,16 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
636
661
|
codingRuntimes?.cleanupExpired();
|
|
637
662
|
}, Math.min(sessionTtlMs, 60_000));
|
|
638
663
|
cleanupTimer.unref?.();
|
|
664
|
+
// The server description reported by /health is fixed for the process: this
|
|
665
|
+
// context is built once here instead of per request, where rebuilding it
|
|
666
|
+
// re-ran `git rev-parse --show-toplevel` synchronously and blocked the event
|
|
667
|
+
// loop for seconds on a cold disk. Live state stays live: the coding grant is
|
|
668
|
+
// still re-checked per request by the middleware above, and session counters
|
|
669
|
+
// are read from the store on each response.
|
|
670
|
+
const healthContext = createMcpToolContext({ ...opts, repo: repoRoot, codingRuntime: null });
|
|
671
|
+
const healthSchemaHash = createHash('sha256')
|
|
672
|
+
.update(JSON.stringify(buildMcpToolDefinitions(healthContext.policy, { enableChatgptBrowser: opts.enableChatgptBrowser === true })))
|
|
673
|
+
.digest('hex');
|
|
639
674
|
const app = express();
|
|
640
675
|
|
|
641
676
|
app.use((req, res, next) => {
|
|
@@ -678,16 +713,14 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
678
713
|
app.get('/health', (req, res) => {
|
|
679
714
|
sessions.cleanupExpired();
|
|
680
715
|
codingRuntimes?.cleanupExpired();
|
|
681
|
-
const ctx = createMcpToolContext({ ...opts, repo: repoRoot, codingRuntime: null });
|
|
682
|
-
const tools = buildMcpToolDefinitions(ctx.policy, { enableChatgptBrowser: opts.enableChatgptBrowser === true });
|
|
683
716
|
res.json({
|
|
684
717
|
status: 'ok',
|
|
685
718
|
server: 'repo-harness-mcp',
|
|
686
719
|
package_version: repoHarnessPackageVersion(),
|
|
687
720
|
mcp_protocol: 'streamable-http',
|
|
688
|
-
profile:
|
|
689
|
-
capabilities:
|
|
690
|
-
allowed_root_count:
|
|
721
|
+
profile: healthContext.policy.profile,
|
|
722
|
+
capabilities: healthContext.policy.capabilities,
|
|
723
|
+
allowed_root_count: healthContext.policy.allowedRoots?.length ?? 0,
|
|
691
724
|
auth: authMode === 'oauth' ? (oauthPassphrase ? 'oauth' : 'missing') : (authToken ? authMode : 'missing'),
|
|
692
725
|
auth_mode: authMode,
|
|
693
726
|
public_origin: getPublicOrigin(req, configuredPublicOrigin),
|
|
@@ -698,7 +731,7 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
698
731
|
sessions_closed: sessions.metrics.closed,
|
|
699
732
|
sessions_expired: sessions.metrics.expired,
|
|
700
733
|
sessions_evicted: sessions.metrics.evicted,
|
|
701
|
-
schema_hash:
|
|
734
|
+
schema_hash: healthSchemaHash,
|
|
702
735
|
});
|
|
703
736
|
});
|
|
704
737
|
|
|
@@ -752,7 +785,7 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
752
785
|
}
|
|
753
786
|
|
|
754
787
|
app.post('/mcp', requireMcpHttpAuth(authMode, authToken, oauthProvider, configuredPublicOrigin), express.raw({ type: '*/*', limit: '1mb' }), (req, res) => {
|
|
755
|
-
handleMcpPost(req, res, { ...opts, repo: repoRoot }, sessions, codingRuntimes).catch((error: unknown) => {
|
|
788
|
+
handleMcpPost(req, res, { ...opts, repo: repoRoot }, sessions, codingRuntimes, profile).catch((error: unknown) => {
|
|
756
789
|
if (!res.headersSent) res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
757
790
|
});
|
|
758
791
|
});
|