@principles/workbuddy-adapter 0.2.17 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codec/input-decoder.js +3 -1
- package/dist/pd-hook.js +28 -5
- package/dist/worker/workspace-worker.d.ts +6 -1
- package/dist/worker/workspace-worker.js +24 -3
- package/package.json +6 -5
- package/plugin/.codebuddy-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +10 -0
- package/plugin/mcp/launcher.cjs +347 -0
|
@@ -201,7 +201,9 @@ export function decodeWorkbuddyInput(raw) {
|
|
|
201
201
|
// consumedOptionalString's fail-loud: SPEC §8.6 forbids metadata failure
|
|
202
202
|
// from blocking the host conversation, and a bad model string degrades to
|
|
203
203
|
// the legacy budget instead of erroring the hook. Dynamic support is
|
|
204
|
-
// therefore
|
|
204
|
+
// therefore name-only: the id is derived ONLY when every catalog entry for
|
|
205
|
+
// that id agrees on one window (`pi_catalog_model_id`); a contradiction
|
|
206
|
+
// falls back with `catalog_ambiguous_model_id` — never a guessed capacity.
|
|
205
207
|
const rawModel = own(raw, 'model');
|
|
206
208
|
const modelId = typeof rawModel === 'string' && rawModel.trim().length > 0 ? rawModel : undefined;
|
|
207
209
|
const modelContext = modelId !== undefined ? { modelId } : undefined;
|
package/dist/pd-hook.js
CHANGED
|
@@ -37,7 +37,7 @@ import path from 'node:path';
|
|
|
37
37
|
import process from 'node:process';
|
|
38
38
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
39
39
|
import { appendEventLogLine, redactTelemetryString, isRuleHostEvaluatedEventData } from '@principles/core/runtime-v2';
|
|
40
|
-
import { createProductionHostRuntime, createWorkbuddyRuleContextProvider, loadPdConfigForPlugin, parseOwnerStatementPrefix,
|
|
40
|
+
import { createProductionHostRuntime, createWorkbuddyRuleContextProvider, loadPdConfigForPlugin, parseOwnerStatementPrefix, assessWorkbuddyCorrectionCandidate, resolveSharedCorrectionKeywordStoreForDetection, admitTrustedWorkbuddyLiveCorrection, ensureGovernanceDiagnosticianTask } from '@principles/host-runtime';
|
|
41
41
|
import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
|
|
42
42
|
import { WorkBuddyHooksHostAdapter } from './host-adapter.js';
|
|
43
43
|
import { WORKBUDDY_TOOL_SEMANTICS } from './tool-semantics.js';
|
|
@@ -280,9 +280,10 @@ export async function processWorkbuddyHookInvocation(rawStdin, channelEventName,
|
|
|
280
280
|
typeof prompt === 'string' && !explicitReserved) {
|
|
281
281
|
// AUDIT-P3-01: 预检与 admitTrustedWorkbuddyLiveCorrection 内部使用同一份
|
|
282
282
|
// 共享 live 词库(seed ∪ learned)——预检不会漏掉学习词,也不会比准入
|
|
283
|
-
// 更宽。startsWith
|
|
284
|
-
|
|
285
|
-
|
|
283
|
+
// 更宽。startsWith 预过滤保持(SPEC P-5);歧义候选只落 pending,
|
|
284
|
+
// 同步 hook 内零 LLM。
|
|
285
|
+
const matched = assessWorkbuddyCorrectionCandidate(prompt, event.context.sessionId, resolveSharedCorrectionKeywordStoreForDetection(resolution.workspaceDir));
|
|
286
|
+
if (matched.kind !== 'none') {
|
|
286
287
|
const admitted = admitTrustedWorkbuddyLiveCorrection({
|
|
287
288
|
workspaceDir: resolution.workspaceDir,
|
|
288
289
|
rawEvent: parsed,
|
|
@@ -321,13 +322,35 @@ export async function processWorkbuddyHookInvocation(rawStdin, channelEventName,
|
|
|
321
322
|
catch { /* Evidence-log failure must not block the prompt. */ }
|
|
322
323
|
}
|
|
323
324
|
}
|
|
325
|
+
else if (outcome.disposition === 'pending_llm_review') {
|
|
326
|
+
// Ambiguous Stage-1 candidate persisted for the worker's
|
|
327
|
+
// bounded Stage-2 step. No Pain, no task, no quota consumed.
|
|
328
|
+
try {
|
|
329
|
+
appendEventLogLine(path.join(resolution.workspaceDir, '.state'), {
|
|
330
|
+
ts: new Date().toISOString(),
|
|
331
|
+
type: 'workbuddy_keyword_pending_review',
|
|
332
|
+
category: 'detected',
|
|
333
|
+
sessionId: event.context.sessionId,
|
|
334
|
+
data: { hostKind: 'workbuddy', outcome: outcome.disposition, duplicate: outcome.duplicate },
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch { /* Evidence-log failure must not block the prompt. */ }
|
|
338
|
+
}
|
|
339
|
+
else if (outcome.disposition === 'abandoned') {
|
|
340
|
+
// P1 review round 3: an automatic correction needs REAL
|
|
341
|
+
// persisted behavior evidence. WorkBuddy has no observation
|
|
342
|
+
// store, so the shared ingress correctly settles as
|
|
343
|
+
// observation_only — no Pain, no task, no quota, and no
|
|
344
|
+
// fabricated `pain_event:` trace.
|
|
345
|
+
autoDiagnostics.push(diagnostic('skipped', 'workbuddy_keyword_observation_only:' + outcome.reason, 'No persisted behavior evidence exists for this automatic correction; no Pain was created.'));
|
|
346
|
+
}
|
|
324
347
|
else if (outcome.disposition === 'rate_limited') {
|
|
325
348
|
autoDiagnostics.push(diagnostic('degraded', 'workbuddy_keyword_rate_limited', 'Per-session strong signal limit reached; no new Pain was created.'));
|
|
326
349
|
}
|
|
327
350
|
}
|
|
328
351
|
}
|
|
329
352
|
}
|
|
330
|
-
else if (matched.reason === 'prompt_too_large') {
|
|
353
|
+
else if (matched.reason === 'prompt_too_large' && matched.kind === 'none') {
|
|
331
354
|
autoDiagnostics.push(diagnostic('degraded', 'workbuddy_keyword_prompt_too_large', 'Oversized prompt was not scanned or persisted as a keyword signal.'));
|
|
332
355
|
}
|
|
333
356
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AdmitBareHostPainsResult, type InternalizationConsumerCycleOutcome, type ConsumerCycleLogger, type ReconcileGovernanceContinuationResult } from '@principles/host-runtime';
|
|
1
|
+
import { type AdmitBareHostPainsResult, type InternalizationConsumerCycleOutcome, type ConsumerCycleLogger, type ReconcileGovernanceContinuationResult, type SignalLlmClassifier, type Stage2CorrectionCycleReport } from '@principles/host-runtime';
|
|
2
2
|
export type WorkbuddyWorkerMode = 'ready' | 'manual_action_required' | 'paused' | 'degraded';
|
|
3
3
|
export interface WorkbuddyWorkerCycleStepReport {
|
|
4
4
|
readonly reconcile: ReconcileGovernanceContinuationResult;
|
|
@@ -17,6 +17,8 @@ export interface WorkbuddyWorkerCycleStepReport {
|
|
|
17
17
|
readonly nextAction?: string;
|
|
18
18
|
} | null;
|
|
19
19
|
readonly downstream: InternalizationConsumerCycleOutcome | null;
|
|
20
|
+
/** Phase B Stage-2 review step (always last, ≤2 candidates / ≤60s aggregate). */
|
|
21
|
+
readonly stage2?: Stage2CorrectionCycleReport;
|
|
20
22
|
}
|
|
21
23
|
export interface WorkbuddyWorkerCycleResult {
|
|
22
24
|
readonly workspaceDir: string;
|
|
@@ -32,6 +34,9 @@ export interface WorkbuddyWorkerCycleOptions {
|
|
|
32
34
|
readonly emitEvent?: (event: string, payloadJson: string) => void;
|
|
33
35
|
/** Cap on diagnostician candidates inspected per cycle (default 5). */
|
|
34
36
|
readonly diagnosticianCandidateLimit?: number;
|
|
37
|
+
/** Stage-2 classifier injection (tests); undefined = resolve from config. */
|
|
38
|
+
readonly stage2Classifier?: SignalLlmClassifier | null;
|
|
39
|
+
readonly stage2AggregateBudgetMs?: number;
|
|
35
40
|
}
|
|
36
41
|
/**
|
|
37
42
|
* Run ONE bounded worker cycle for a canonical governance workspace. Never
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import fs from 'node:fs';
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import { computeFeatureFlagsFromConfig, createRuntimeStateHandle, createPainSignalBridge, isRetryWaitBackoffElapsed, PrincipleTreeLedgerAdapter, resolveDiagnosticianCapability, } from '@principles/core/runtime-v2';
|
|
36
|
-
import { admitBareHostPains, loadPdConfigForPlugin, loadFeatureFlagFromConfig, reconcileGovernanceContinuation, runInternalizationConsumerCycle, saveHostToolDeclaration, } from '@principles/host-runtime';
|
|
36
|
+
import { admitBareHostPains, loadPdConfigForPlugin, loadFeatureFlagFromConfig, reconcileGovernanceContinuation, runInternalizationConsumerCycle, runStage2CorrectionCycle, createSignalClassifierForWorkspace, saveHostToolDeclaration, } from '@principles/host-runtime';
|
|
37
37
|
import { WORKBUDDY_TOOL_SEMANTICS, WORKBUDDY_TOOL_SEMANTIC_MAPPINGS } from '../tool-semantics.js';
|
|
38
38
|
const WORKER_OWNER = 'companion-worker';
|
|
39
39
|
const DEFAULT_DIAG_CANDIDATE_LIMIT = 5;
|
|
@@ -205,16 +205,37 @@ export async function runWorkbuddyWorkspaceWorkerCycle(options) {
|
|
|
205
205
|
toolSemantics: WORKBUDDY_TOOL_SEMANTICS,
|
|
206
206
|
hostKinds: ['workbuddy'],
|
|
207
207
|
});
|
|
208
|
+
// Step 7 (SPEC v0.3.1 §2) — Stage-2 correction review, ALWAYS last.
|
|
209
|
+
const stage2Resolution = options.stage2Classifier === undefined
|
|
210
|
+
? createSignalClassifierForWorkspace(workspaceDir)
|
|
211
|
+
: null;
|
|
212
|
+
const stage2Classifier = options.stage2Classifier !== undefined
|
|
213
|
+
? options.stage2Classifier
|
|
214
|
+
: (stage2Resolution?.ok === true ? stage2Resolution.classifier : null);
|
|
215
|
+
const stage2ClassifierState = options.stage2Classifier === null
|
|
216
|
+
? 'disabled'
|
|
217
|
+
: stage2Classifier === null
|
|
218
|
+
? (stage2Resolution !== null && !stage2Resolution.ok && stage2Resolution.readiness === 'disabled' ? 'disabled' : 'unavailable')
|
|
219
|
+
: 'used';
|
|
220
|
+
const stage2 = await runStage2CorrectionCycle({
|
|
221
|
+
workspaceDir,
|
|
222
|
+
classifier: stage2Classifier,
|
|
223
|
+
classifierState: stage2ClassifierState,
|
|
224
|
+
...(options.stage2AggregateBudgetMs !== undefined ? { aggregateBudgetMs: options.stage2AggregateBudgetMs } : {}),
|
|
225
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
226
|
+
onEvent: emitEvent,
|
|
227
|
+
});
|
|
208
228
|
const degradedReason = !reconcile.ok ? `reconcile:${reconcile.reason ?? 'failed'}`
|
|
209
229
|
: !admission.ok ? `admission:${admission.reason ?? 'failed'}`
|
|
210
230
|
: diagnostician?.status === 'failed' && diagnostician.errorCategory !== 'lease_conflict' ? `diagnostician_failed:${diagnostician.message ?? 'max_attempts_exceeded'}`
|
|
211
231
|
: diagnostician?.status === 'degraded' ? `diagnostician_degraded:${diagnostician.message ?? 'unknown'}`
|
|
212
232
|
: downstream.skipReason === 'runtime_config_error' || downstream.skipReason === 'cycle_error' || downstream.skipReason === 'config_malformed' ? `downstream:${downstream.skipReason}`
|
|
213
|
-
:
|
|
233
|
+
: !stage2.ok ? `stage2:${stage2.reason ?? 'failed'}`
|
|
234
|
+
: undefined;
|
|
214
235
|
return {
|
|
215
236
|
...base,
|
|
216
237
|
mode: degradedReason !== undefined ? 'degraded' : 'ready',
|
|
217
238
|
...(degradedReason !== undefined ? { reason: degradedReason, nextAction: 'Inspect the governance workspace .pd/config.yaml runtime profile and the per-step report above; the worker retries automatically on the next cycle.' } : {}),
|
|
218
|
-
report: { reconcile, admission, diagnostician, downstream },
|
|
239
|
+
report: { reconcile, admission, diagnostician, downstream, stage2 },
|
|
219
240
|
};
|
|
220
241
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@principles/workbuddy-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "WorkBuddy host adapter for Principles Disciple — implements the HostAdapter interface for WorkBuddy's Git Bash command-hook model (ADR-0020 third host, SPEC workbuddy-host-adapter v0.3.0).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"lint": "eslint \"src/**/*.ts\""
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@principles/core": "^2.
|
|
46
|
-
"@principles/host-runtime": "^0.
|
|
47
|
-
"@principles/install-layout": "^0.4.
|
|
45
|
+
"@principles/core": "^2.1.1",
|
|
46
|
+
"@principles/host-runtime": "^0.15.1",
|
|
47
|
+
"@principles/install-layout": "^0.4.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^26.4.1",
|
|
@@ -60,5 +60,6 @@
|
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
|
63
|
-
}
|
|
63
|
+
},
|
|
64
|
+
"gitHead": "32312b70d5b4f8a661949680fe765e91077e5c16"
|
|
64
65
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "principles-disciple",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Principles Disciple — Owner-governed behavior internalization for AI agents (WorkBuddy host adapter, ADR-0020 third host). Requires explicit setup and host.workbuddy feature enablement; inactive until then.",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Principles Disciple — Owner-governed behavior internalization for AI agents (WorkBuddy host adapter, ADR-0020 third host). Requires explicit setup and host.workbuddy feature enablement; inactive until then. Ships the read-only pd-recall MCP server (Gate 4).",
|
|
5
5
|
"defaultEnabled": false
|
|
6
6
|
}
|
package/plugin/.mcp.json
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PD MCP launcher — SPEC-PD-MCP-GATE4-001 §3.3/§3.5 (Gate 4-A freeze).
|
|
4
|
+
*
|
|
5
|
+
* This is the CANONICAL copy. The WorkBuddy plugin payload
|
|
6
|
+
* (packages/workbuddy-adapter/plugin/mcp/launcher.cjs) and the OpenClaw
|
|
7
|
+
* plugin payload (packages/openclaw-plugin/mcp/launcher.cjs) carry
|
|
8
|
+
* byte-identical copies — pinned by the pd-mcp-server architecture guard
|
|
9
|
+
* test G4-7. Change it here first, then propagate.
|
|
10
|
+
*
|
|
11
|
+
* The host starts this launcher via the plugin's PD-owned MCP config
|
|
12
|
+
* (Codex: .mcp.json with cwd = installed plugin root; WorkBuddy:
|
|
13
|
+
* ${CODEBUDDY_PLUGIN_ROOT} placeholders; OpenClaw: plugin-root-relative
|
|
14
|
+
* args). It reads the PD-owned machine-local `mcp-binding.json` beside the
|
|
15
|
+
* plugin payload root, validates it strictly against the payload's own host
|
|
16
|
+
* marker and the installed package bytes, injects the trusted env, and runs
|
|
17
|
+
* the shared server entry IN-PROCESS — one process per host, and the host
|
|
18
|
+
* owns this PID's lifecycle (no daemon, no extra children).
|
|
19
|
+
*
|
|
20
|
+
* Binding security (Gate 4 P2): the binding is untrusted input. The host is
|
|
21
|
+
* derived from the payload marker beside this launcher (never from the
|
|
22
|
+
* binding itself), the entry must equal the canonical path for that host's
|
|
23
|
+
* runtime layout, symlinks may not escape the recorded runtime directory, and
|
|
24
|
+
* the installed package identity/version must agree with the binding and the
|
|
25
|
+
* plugin's runtime pin. A binding copied from another host fails closed.
|
|
26
|
+
*
|
|
27
|
+
* Fail-closed: a missing/invalid binding, host mismatch, workspace, or server
|
|
28
|
+
* entry exits
|
|
29
|
+
* 1 with ONE sanitized stderr JSON line BEFORE any MCP traffic — the host
|
|
30
|
+
* reports the server as failed to start (honest, recoverable state). No
|
|
31
|
+
* cwd/HOME fallback exists by design (SPEC §3.5).
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const os = require('os');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const { pathToFileURL } = require('url');
|
|
39
|
+
|
|
40
|
+
const BINDING_SCHEMA = 'pd.mcp-binding.v1';
|
|
41
|
+
// Ownership stamp (SPEC §7.5/§11.3): installers and `pd health` prove that a
|
|
42
|
+
// registered `mcp/launcher.cjs` really is PD's launcher by finding the stamp
|
|
43
|
+
// `pd-mcp-launcher.v1` in this file's bytes plus the binding schema above —
|
|
44
|
+
// never by the file name alone. Keep the stamp literally present.
|
|
45
|
+
const KNOWN_HOSTS = ['codex', 'openclaw', 'workbuddy'];
|
|
46
|
+
// The payload marker that identifies which host owns THIS launcher copy.
|
|
47
|
+
const HOST_MARKERS = [
|
|
48
|
+
['codex', path.join('.codex-plugin', 'plugin.json')],
|
|
49
|
+
['workbuddy', path.join('.codebuddy-plugin', 'plugin.json')],
|
|
50
|
+
['openclaw', 'openclaw.plugin.json'],
|
|
51
|
+
];
|
|
52
|
+
// Canonical server entry per host, relative to the binding's runtimeDir:
|
|
53
|
+
// Codex installs into its plugin-data npm runtime (node_modules layout);
|
|
54
|
+
// OpenClaw/WorkBuddy bind to the shared ~/.pd/runtime component layout.
|
|
55
|
+
const SERVER_ENTRY_SEGMENTS = {
|
|
56
|
+
codex: ['node_modules', '@principles', 'pd-mcp-server', 'dist', 'index.js'],
|
|
57
|
+
openclaw: ['pd-mcp-server', 'dist', 'index.js'],
|
|
58
|
+
workbuddy: ['pd-mcp-server', 'dist', 'index.js'],
|
|
59
|
+
};
|
|
60
|
+
const SERVER_PACKAGE_NAME = '@principles/pd-mcp-server';
|
|
61
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
62
|
+
|
|
63
|
+
function fail(reason, nextAction) {
|
|
64
|
+
process.stderr.write(JSON.stringify({ launcher: 'pd-mcp', status: 'failed', reason, nextAction }) + '\n');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isRecord(value) {
|
|
69
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readJson(file) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
75
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function samePath(left, right) {
|
|
82
|
+
const a = path.resolve(left);
|
|
83
|
+
const b = path.resolve(right);
|
|
84
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isInside(parent, child) {
|
|
88
|
+
const relative = path.relative(parent, child);
|
|
89
|
+
return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PD_CONFIG_FILENAME = 'principles-disciple.json';
|
|
93
|
+
|
|
94
|
+
/** A declared workspace must be one fixed, real directory — never HOME or a drive root. */
|
|
95
|
+
function isUsableWorkspaceDir(candidate) {
|
|
96
|
+
if (typeof candidate !== 'string' || candidate.length === 0 || !path.isAbsolute(candidate)) return false;
|
|
97
|
+
const resolved = path.resolve(candidate);
|
|
98
|
+
if (samePath(resolved, os.homedir())) return false;
|
|
99
|
+
if (samePath(resolved, path.parse(resolved).root)) return false;
|
|
100
|
+
try {
|
|
101
|
+
return fs.statSync(resolved).isDirectory();
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** First trustworthy `workspace` pointer among the PD config files (rg-1: untrusted files). */
|
|
108
|
+
function readConfigWorkspace(files) {
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(raw);
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(parsed) || !Object.hasOwn(parsed, 'workspace')) continue;
|
|
123
|
+
const value = parsed.workspace;
|
|
124
|
+
if (typeof value !== 'string' || value.trim().length === 0) continue;
|
|
125
|
+
return value.trim();
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Re-derive the OWNER-DECLARED workspace exactly as the plugin's MCP binding
|
|
132
|
+
* writer does (env `PD_WORKSPACE_DIR` → `OPENCLAW_WORKSPACE` → PD config files
|
|
133
|
+
* under HOME). Deliberately NO `cwd`, HOME-as-workspace, agent, or host-default
|
|
134
|
+
* inference: those describe where a session runs, not which state tree owns
|
|
135
|
+
* governance for every agent of the host (SPEC §3.5).
|
|
136
|
+
*/
|
|
137
|
+
function resolveDeclaredWorkspaceDir() {
|
|
138
|
+
const home = os.homedir();
|
|
139
|
+
const readers = [
|
|
140
|
+
() => process.env.PD_WORKSPACE_DIR,
|
|
141
|
+
() => process.env.OPENCLAW_WORKSPACE,
|
|
142
|
+
() => readConfigWorkspace([
|
|
143
|
+
path.join(home, '.openclaw', PD_CONFIG_FILENAME),
|
|
144
|
+
path.join(home, '.principles', PD_CONFIG_FILENAME),
|
|
145
|
+
]),
|
|
146
|
+
];
|
|
147
|
+
let sawDeclaration = false;
|
|
148
|
+
for (const read of readers) {
|
|
149
|
+
let value;
|
|
150
|
+
try {
|
|
151
|
+
value = read();
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (typeof value !== 'string' || value.trim().length === 0) continue;
|
|
156
|
+
sawDeclaration = true;
|
|
157
|
+
if (isUsableWorkspaceDir(value.trim())) return { workspaceDir: path.resolve(value.trim()) };
|
|
158
|
+
}
|
|
159
|
+
return { reason: sawDeclaration ? 'workspace_declaration_invalid' : 'workspace_declaration_missing' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pluginRoot = path.join(__dirname, '..');
|
|
163
|
+
|
|
164
|
+
// rc-1/rc-3: the payload marker is the host authority. Exactly one marker
|
|
165
|
+
// must exist; ambiguity (tampered/copied payload) is never resolved by guess.
|
|
166
|
+
const detectedHosts = HOST_MARKERS
|
|
167
|
+
.filter(([, marker]) => {
|
|
168
|
+
try {
|
|
169
|
+
return fs.statSync(path.join(pluginRoot, marker)).isFile();
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.map(([markerHost]) => markerHost);
|
|
175
|
+
if (detectedHosts.length !== 1) {
|
|
176
|
+
fail(
|
|
177
|
+
detectedHosts.length === 0 ? 'host_marker_missing' : `host_marker_ambiguous:${detectedHosts.join(',')}`,
|
|
178
|
+
'Reinstall the PD plugin from its marketplace source; the host payload marker is missing or ambiguous.',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const host = detectedHosts[0];
|
|
182
|
+
|
|
183
|
+
// rc-1/rc-3/rc-5: the binding file is read as untrusted; every field is
|
|
184
|
+
// validated before use and unknown content is never coerced.
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(path.join(pluginRoot, 'mcp-binding.json'), 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
fail('binding_missing', 'Re-run the PD host setup (Codex: $pd-control setup) so mcp-binding.json is written into the plugin directory.');
|
|
190
|
+
}
|
|
191
|
+
let binding;
|
|
192
|
+
try {
|
|
193
|
+
binding = JSON.parse(raw);
|
|
194
|
+
} catch {
|
|
195
|
+
fail('binding_unreadable', 'Re-run the PD host setup to regenerate a valid mcp-binding.json.');
|
|
196
|
+
}
|
|
197
|
+
if (!isRecord(binding)) {
|
|
198
|
+
fail('binding_invalid', 'Re-run the PD host setup to regenerate a valid mcp-binding.json.');
|
|
199
|
+
}
|
|
200
|
+
if (binding.schemaVersion !== BINDING_SCHEMA) {
|
|
201
|
+
// rc-8/rc-9: this value is untrusted and the reason lands in host logs —
|
|
202
|
+
// report a bounded identifier, never the raw string.
|
|
203
|
+
const schemaKind = typeof binding.schemaVersion === 'string'
|
|
204
|
+
? binding.schemaVersion.replace(/[^A-Za-z0-9._-]/g, '').slice(0, 60)
|
|
205
|
+
: 'non-string';
|
|
206
|
+
fail(`binding_schema_unsupported:${schemaKind}`, 'The plugin and the PD installation come from different generations. Re-run the PD host setup after updating PD.');
|
|
207
|
+
}
|
|
208
|
+
if (!KNOWN_HOSTS.includes(binding.host)) {
|
|
209
|
+
fail('binding_host_invalid', 'Re-run the PD host setup; the host marker is written by the PD installer and must not be edited.');
|
|
210
|
+
}
|
|
211
|
+
// A binding copied from another host's payload would make this launcher pass
|
|
212
|
+
// the wrong workspace and entry to the server — fail closed instead.
|
|
213
|
+
if (binding.host !== host) {
|
|
214
|
+
fail(`binding_host_mismatch:${binding.host}!=${host}`, 'This mcp-binding.json belongs to a different host payload. Re-run the PD host setup for THIS host.');
|
|
215
|
+
}
|
|
216
|
+
if (typeof binding.workspaceDir !== 'string' || !path.isAbsolute(binding.workspaceDir)) {
|
|
217
|
+
fail('binding_workspace_invalid', 'Re-run the PD host setup so the binding records the absolute governance workspace directory.');
|
|
218
|
+
}
|
|
219
|
+
if (typeof binding.runtimeDir !== 'string' || !path.isAbsolute(binding.runtimeDir)) {
|
|
220
|
+
fail('binding_runtime_dir_invalid', 'Re-run the PD host setup so the binding records the absolute pinned-runtime directory.');
|
|
221
|
+
}
|
|
222
|
+
if (typeof binding.serverEntry !== 'string' || !path.isAbsolute(binding.serverEntry)) {
|
|
223
|
+
fail('binding_server_entry_invalid', 'Re-run the PD host setup so the binding records the absolute MCP server entry path.');
|
|
224
|
+
}
|
|
225
|
+
if (!isRecord(binding.versions) || typeof binding.versions.mcpServer !== 'string' || !SEMVER.test(binding.versions.mcpServer)) {
|
|
226
|
+
fail('binding_versions_invalid', 'Re-run the PD host setup to regenerate the binding version record.');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Canonical path: the entry must be exactly the documented server entry for
|
|
230
|
+
// this host's runtime layout — not merely an existing JavaScript file.
|
|
231
|
+
const expectedEntry = path.join(binding.runtimeDir, ...SERVER_ENTRY_SEGMENTS[host]);
|
|
232
|
+
if (!samePath(binding.serverEntry, expectedEntry)) {
|
|
233
|
+
fail(`server_entry_not_canonical:${host}`, 'The binding points outside the documented MCP server entry. Re-run the PD host setup; do not edit mcp-binding.json by hand.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let realRuntimeDir;
|
|
237
|
+
try {
|
|
238
|
+
realRuntimeDir = fs.realpathSync(binding.runtimeDir);
|
|
239
|
+
} catch {
|
|
240
|
+
fail('runtime_dir_missing', 'The pinned PD runtime is not installed. Re-run the PD host setup to install it.');
|
|
241
|
+
}
|
|
242
|
+
if (!fs.statSync(realRuntimeDir).isDirectory()) {
|
|
243
|
+
fail('runtime_dir_missing', 'The pinned PD runtime directory is not a directory. Re-run the PD host setup to repair it.');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let realEntry;
|
|
247
|
+
try {
|
|
248
|
+
realEntry = fs.realpathSync(expectedEntry);
|
|
249
|
+
} catch {
|
|
250
|
+
fail('server_entry_missing', 'The pinned PD MCP server is not installed. Re-run the PD host setup to install the pinned runtime.');
|
|
251
|
+
}
|
|
252
|
+
// Symlink/junction escape defence: an entry that resolves outside the
|
|
253
|
+
// recorded runtime tree can never be the installed component.
|
|
254
|
+
if (!isInside(realRuntimeDir, realEntry)) {
|
|
255
|
+
fail('server_entry_escape', 'The MCP server entry resolves outside the pinned runtime directory. Re-run the PD host setup; if it repeats, reinstall PD.');
|
|
256
|
+
}
|
|
257
|
+
if (!fs.statSync(realEntry).isFile()) {
|
|
258
|
+
fail('server_entry_missing', 'The pinned PD MCP server entry is not a file. Re-run the PD host setup to install the pinned runtime.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Package identity/version: the entry must belong to the installed package,
|
|
262
|
+
// and both the binding and (Codex) the plugin runtime pin must agree with the
|
|
263
|
+
// bytes on disk. A stale or forged binding cannot silently launch another
|
|
264
|
+
// version of the server.
|
|
265
|
+
const packageManifest = readJson(path.join(path.dirname(path.dirname(realEntry)), 'package.json'));
|
|
266
|
+
if (packageManifest === undefined || packageManifest.name !== SERVER_PACKAGE_NAME) {
|
|
267
|
+
fail('server_package_identity_mismatch', 'The MCP server entry does not belong to @principles/pd-mcp-server. Re-run the PD host setup to reinstall the pinned component.');
|
|
268
|
+
}
|
|
269
|
+
if (typeof packageManifest.version !== 'string' || !SEMVER.test(packageManifest.version)) {
|
|
270
|
+
fail('server_package_version_invalid', 'The installed MCP server package version is unreadable. Re-run the PD host setup to reinstall the pinned component.');
|
|
271
|
+
}
|
|
272
|
+
if (binding.versions.mcpServer !== packageManifest.version) {
|
|
273
|
+
fail(`binding_version_mismatch:${binding.versions.mcpServer}!=${packageManifest.version}`, 'The binding and the installed MCP server disagree. Re-run the PD host setup so both match the runtime pin.');
|
|
274
|
+
}
|
|
275
|
+
if (host === 'codex') {
|
|
276
|
+
const pin = readJson(path.join(pluginRoot, 'runtime-version.json'));
|
|
277
|
+
if (pin === undefined || typeof pin.mcpServer !== 'string' || !SEMVER.test(pin.mcpServer) || typeof pin.core !== 'string') {
|
|
278
|
+
fail('runtime_pin_invalid', 'The plugin runtime pin is missing or malformed. Reinstall the PD plugin, then re-run $pd-control setup.');
|
|
279
|
+
}
|
|
280
|
+
if (pin.mcpServer !== packageManifest.version) {
|
|
281
|
+
fail(`runtime_pin_mismatch:mcpServer:${pin.mcpServer}!=${packageManifest.version}`, 'The installed MCP server no longer matches the plugin runtime pin. Re-run $pd-control setup to realign the runtime.');
|
|
282
|
+
}
|
|
283
|
+
if (typeof binding.versions.core === 'string' && binding.versions.core !== pin.core) {
|
|
284
|
+
fail(`runtime_pin_mismatch:core:${binding.versions.core}!=${pin.core}`, 'The binding core version no longer matches the plugin runtime pin. Re-run $pd-control setup to realign the runtime.');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let workspaceStat;
|
|
289
|
+
try {
|
|
290
|
+
workspaceStat = fs.statSync(binding.workspaceDir);
|
|
291
|
+
} catch {
|
|
292
|
+
fail('binding_workspace_missing', 'The bound governance workspace no longer exists. Re-install PD for this host or re-run setup with the correct workspace.');
|
|
293
|
+
}
|
|
294
|
+
if (!workspaceStat.isDirectory()) {
|
|
295
|
+
fail('binding_workspace_not_directory', 'Re-run the PD host setup so the binding points at the governance workspace directory.');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── OpenClaw workspace-declaration gate (SPEC §3.5, final safety closure) ──
|
|
299
|
+
// The OpenClaw binding is HOST-WIDE while agents have per-agent workspaces, so
|
|
300
|
+
// a binding may only be trusted while it still matches the workspace the Owner
|
|
301
|
+
// EXPLICITLY declared. The plugin's startup health check cannot be the last
|
|
302
|
+
// line of defence: it runs after registration and may fail, skip, or lag a
|
|
303
|
+
// workspace switch. Re-derive the declaration HERE, at launch, and refuse
|
|
304
|
+
// otherwise — a stale binding must never serve the previous workspace's
|
|
305
|
+
// evidence to another agent. Codex/WorkBuddy have a single user-level workspace
|
|
306
|
+
// and their binding writer is the authority; the gate is OpenClaw-only.
|
|
307
|
+
if (host === 'openclaw') {
|
|
308
|
+
const declared = resolveDeclaredWorkspaceDir();
|
|
309
|
+
if (declared.workspaceDir === undefined) {
|
|
310
|
+
fail(
|
|
311
|
+
declared.reason,
|
|
312
|
+
'pd-recall refuses to start because the PD governance workspace is not explicitly declared. ' +
|
|
313
|
+
'Declare ONE PD workspace (set PD_WORKSPACE_DIR or OPENCLAW_WORKSPACE, or add ' +
|
|
314
|
+
'{"workspace":"<absolute dir>"} to ~/.openclaw/principles-disciple.json), then restart the OpenClaw gateway.',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (!samePath(declared.workspaceDir, binding.workspaceDir)) {
|
|
318
|
+
fail(
|
|
319
|
+
'binding_workspace_not_declared',
|
|
320
|
+
'mcp-binding.json does not match the explicitly declared PD workspace (a workspace switch was never committed). ' +
|
|
321
|
+
'Restart the OpenClaw gateway so the PD plugin rewrites the binding for the declared workspace.',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The binding is the ONLY authority for the business workspace (SPEC §3.5):
|
|
327
|
+
// it overrides any inherited value and the server validates it fail-closed.
|
|
328
|
+
process.env.PD_MCP_WORKSPACE_DIR = binding.workspaceDir;
|
|
329
|
+
process.env.PD_MCP_HOST = host;
|
|
330
|
+
|
|
331
|
+
// Run the shared server entry in-process (single process per host). Nothing
|
|
332
|
+
// above imports the server, so a rejected binding never emits MCP traffic.
|
|
333
|
+
import(pathToFileURL(realEntry).href)
|
|
334
|
+
.then((mod) => {
|
|
335
|
+
if (mod === null || typeof mod !== 'object' || typeof mod.main !== 'function') {
|
|
336
|
+
fail('server_entry_invalid', 'The pinned MCP server entry does not export main(). Re-run the PD host setup.');
|
|
337
|
+
}
|
|
338
|
+
return mod.main(process.env).then((code) => {
|
|
339
|
+
if (code !== 0) process.exit(code);
|
|
340
|
+
// code 0: the server owns the event loop from here (stdio transport).
|
|
341
|
+
});
|
|
342
|
+
})
|
|
343
|
+
.catch((error) => {
|
|
344
|
+
// Sanitized: category only — no message/stack/path leak to host logs.
|
|
345
|
+
const kind = error !== null && typeof error === 'object' && typeof error.name === 'string' ? error.name : 'Error';
|
|
346
|
+
fail(`server_startup_failed:${kind.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 60)}`, 'Re-run the PD host setup; if it repeats, run the PD host health check (pd health).');
|
|
347
|
+
});
|