@phyzess/pappra 0.0.0-stage → 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +80 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/integrations/skills/pappra/SKILL.md +68 -0
- package/assets/integrations/skills/pappra/references/hosts.md +30 -0
- package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
- package/assets/integrations/skills/pappra/references/operations.md +14 -0
- package/assets/manifest.json +4 -0
- package/dist/app/collaboration.d.ts +170 -0
- package/dist/app/collaboration.js +1650 -0
- package/dist/app/doctor.d.ts +9 -0
- package/dist/app/doctor.js +1030 -0
- package/dist/app/init.d.ts +143 -0
- package/dist/app/init.js +1111 -0
- package/dist/cli/commands/code.d.ts +16 -0
- package/dist/cli/commands/code.js +127 -0
- package/dist/cli/commands/collaboration.d.ts +13 -0
- package/dist/cli/commands/collaboration.js +186 -0
- package/dist/cli/commands/continuity.d.ts +29 -0
- package/dist/cli/commands/continuity.js +133 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +133 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +57 -0
- package/dist/cli/commands/host.d.ts +13 -0
- package/dist/cli/commands/host.js +125 -0
- package/dist/cli/commands/init.d.ts +51 -0
- package/dist/cli/commands/init.js +668 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +597 -0
- package/dist/code/budget.d.ts +14 -0
- package/dist/code/budget.js +39 -0
- package/dist/code/exec.d.ts +23 -0
- package/dist/code/exec.js +133 -0
- package/dist/code/identity.d.ts +13 -0
- package/dist/code/identity.js +214 -0
- package/dist/code/index.d.ts +6 -0
- package/dist/code/index.js +3 -0
- package/dist/code/providers/codegraph.d.ts +59 -0
- package/dist/code/providers/codegraph.js +567 -0
- package/dist/code/registration.d.ts +9 -0
- package/dist/code/registration.js +293 -0
- package/dist/code/service.d.ts +53 -0
- package/dist/code/service.js +633 -0
- package/dist/code/text.d.ts +3 -0
- package/dist/code/text.js +202 -0
- package/dist/collab/admission.d.ts +450 -0
- package/dist/collab/admission.js +797 -0
- package/dist/collab/canonical.d.ts +26 -0
- package/dist/collab/canonical.js +136 -0
- package/dist/collab/contracts.d.ts +73 -0
- package/dist/collab/contracts.js +76 -0
- package/dist/collab/envelope.d.ts +52 -0
- package/dist/collab/envelope.js +241 -0
- package/dist/collab/fingerprint.d.ts +7 -0
- package/dist/collab/fingerprint.js +58 -0
- package/dist/collab/handoff.d.ts +350 -0
- package/dist/collab/handoff.js +513 -0
- package/dist/collab/index.d.ts +9 -0
- package/dist/collab/index.js +8 -0
- package/dist/collab/lifecycle.d.ts +449 -0
- package/dist/collab/lifecycle.js +534 -0
- package/dist/collab/messages.d.ts +148 -0
- package/dist/collab/messages.js +583 -0
- package/dist/collab/notify.d.ts +47 -0
- package/dist/collab/notify.js +140 -0
- package/dist/collab/preflight.d.ts +31 -0
- package/dist/collab/preflight.js +271 -0
- package/dist/collab/resources.d.ts +119 -0
- package/dist/collab/resources.js +451 -0
- package/dist/collab/store.d.ts +143 -0
- package/dist/collab/store.js +606 -0
- package/dist/collab/types.d.ts +354 -0
- package/dist/collab/types.js +1 -0
- package/dist/collab/validation.d.ts +10 -0
- package/dist/collab/validation.js +303 -0
- package/dist/continuity/context.d.ts +24 -0
- package/dist/continuity/context.js +179 -0
- package/dist/continuity/index.d.ts +3 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.d.ts +37 -0
- package/dist/continuity/paths.js +258 -0
- package/dist/continuity/service.d.ts +59 -0
- package/dist/continuity/service.js +1258 -0
- package/dist/hosts/claude/native.d.ts +79 -0
- package/dist/hosts/claude/native.js +853 -0
- package/dist/hosts/codex/native.d.ts +345 -0
- package/dist/hosts/codex/native.js +1528 -0
- package/dist/hosts/codex/protocol.d.ts +51 -0
- package/dist/hosts/codex/protocol.js +42 -0
- package/dist/hosts/detect.d.ts +28 -0
- package/dist/hosts/detect.js +73 -0
- package/dist/hosts/index.d.ts +10 -0
- package/dist/hosts/index.js +10 -0
- package/dist/hosts/integration/bundle.d.ts +16 -0
- package/dist/hosts/integration/bundle.js +81 -0
- package/dist/hosts/integration/hook-entry.d.ts +1 -0
- package/dist/hosts/integration/hook-entry.js +68 -0
- package/dist/hosts/integration/hook.d.ts +32 -0
- package/dist/hosts/integration/hook.js +781 -0
- package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
- package/dist/hosts/integration/pi-extension-runtime.js +89 -0
- package/dist/hosts/integration/pi-template.d.ts +3 -0
- package/dist/hosts/integration/pi-template.js +5 -0
- package/dist/hosts/integration/service.d.ts +41 -0
- package/dist/hosts/integration/service.js +1381 -0
- package/dist/hosts/integration/support.d.ts +36 -0
- package/dist/hosts/integration/support.js +41 -0
- package/dist/hosts/integration/templates.d.ts +22 -0
- package/dist/hosts/integration/templates.js +70 -0
- package/dist/hosts/pi/protocol.d.ts +22 -0
- package/dist/hosts/pi/protocol.js +28 -0
- package/dist/hosts/pi/query-result.d.ts +22 -0
- package/dist/hosts/pi/query-result.js +710 -0
- package/dist/hosts/pi/rpc.d.ts +937 -0
- package/dist/hosts/pi/rpc.js +1621 -0
- package/dist/hosts/pilot.d.ts +485 -0
- package/dist/hosts/pilot.js +593 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/infra/fs.d.ts +27 -0
- package/dist/infra/fs.js +130 -0
- package/dist/infra/git.d.ts +60 -0
- package/dist/infra/git.js +358 -0
- package/dist/infra/owned-process-group.d.ts +173 -0
- package/dist/infra/owned-process-group.js +571 -0
- package/dist/install/apply.d.ts +65 -0
- package/dist/install/apply.js +663 -0
- package/dist/install/index.d.ts +5 -0
- package/dist/install/index.js +5 -0
- package/dist/install/ownership.d.ts +85 -0
- package/dist/install/ownership.js +430 -0
- package/dist/install/plan.d.ts +39 -0
- package/dist/install/plan.js +97 -0
- package/dist/install/rollback.d.ts +18 -0
- package/dist/install/rollback.js +544 -0
- package/dist/install/runtime.d.ts +76 -0
- package/dist/install/runtime.js +1145 -0
- package/dist/knowledge/catalog.d.ts +47 -0
- package/dist/knowledge/catalog.js +308 -0
- package/dist/knowledge/context.d.ts +28 -0
- package/dist/knowledge/context.js +61 -0
- package/dist/knowledge/index.d.ts +3 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/journal.d.ts +16 -0
- package/dist/knowledge/journal.js +109 -0
- package/dist/knowledge/lifecycle.d.ts +8 -0
- package/dist/knowledge/lifecycle.js +1001 -0
- package/dist/knowledge/paths.d.ts +37 -0
- package/dist/knowledge/paths.js +236 -0
- package/dist/knowledge/refs.d.ts +36 -0
- package/dist/knowledge/refs.js +267 -0
- package/dist/knowledge/service.d.ts +21 -0
- package/dist/knowledge/service.js +393 -0
- package/dist/knowledge/types.d.ts +171 -0
- package/dist/knowledge/types.js +1 -0
- package/dist/knowledge/write.d.ts +8 -0
- package/dist/knowledge/write.js +1223 -0
- package/dist/package-info.d.ts +6 -0
- package/dist/package-info.js +19 -0
- package/dist/schemas/code.d.ts +377 -0
- package/dist/schemas/code.js +145 -0
- package/dist/schemas/collaboration.d.ts +115 -0
- package/dist/schemas/collaboration.js +106 -0
- package/dist/schemas/continuity.d.ts +315 -0
- package/dist/schemas/continuity.js +199 -0
- package/dist/schemas/diagnostics.d.ts +28 -0
- package/dist/schemas/diagnostics.js +27 -0
- package/dist/schemas/doctor.d.ts +157 -0
- package/dist/schemas/doctor.js +75 -0
- package/dist/schemas/document.d.ts +644 -0
- package/dist/schemas/document.js +296 -0
- package/dist/schemas/errors.d.ts +177 -0
- package/dist/schemas/errors.js +176 -0
- package/dist/schemas/evidence.d.ts +24 -0
- package/dist/schemas/evidence.js +16 -0
- package/dist/schemas/host.d.ts +436 -0
- package/dist/schemas/host.js +201 -0
- package/dist/schemas/index.d.ts +15 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/init.d.ts +121 -0
- package/dist/schemas/init.js +94 -0
- package/dist/schemas/install.d.ts +213 -0
- package/dist/schemas/install.js +139 -0
- package/dist/schemas/machine.d.ts +43 -0
- package/dist/schemas/machine.js +96 -0
- package/dist/schemas/outcome.d.ts +23 -0
- package/dist/schemas/outcome.js +17 -0
- package/dist/schemas/paths.d.ts +19 -0
- package/dist/schemas/paths.js +63 -0
- package/dist/schemas/workspace.d.ts +120 -0
- package/dist/schemas/workspace.js +201 -0
- package/dist/workspace/config-file.d.ts +36 -0
- package/dist/workspace/config-file.js +237 -0
- package/dist/workspace/config.d.ts +28 -0
- package/dist/workspace/config.js +79 -0
- package/dist/workspace/discover.d.ts +34 -0
- package/dist/workspace/discover.js +195 -0
- package/dist/workspace/documents.d.ts +21 -0
- package/dist/workspace/documents.js +395 -0
- package/dist/workspace/ignore.d.ts +10 -0
- package/dist/workspace/ignore.js +48 -0
- package/dist/workspace/index.d.ts +7 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/repositories.d.ts +24 -0
- package/dist/workspace/repositories.js +411 -0
- package/dist/workspace/resolve.d.ts +24 -0
- package/dist/workspace/resolve.js +268 -0
- package/dist/workspace/types.d.ts +71 -0
- package/dist/workspace/types.js +1 -0
- package/dist/workspace/validation.d.ts +18 -0
- package/dist/workspace/validation.js +197 -0
- package/package.json +105 -4
- package/skills/pappra/SKILL.md +68 -0
- package/skills/pappra/references/hosts.md +30 -0
- package/skills/pappra/references/lifecycle.md +37 -0
- package/skills/pappra/references/operations.md +14 -0
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createDiagnostic } from '../../schemas/diagnostics.js';
|
|
6
|
+
import { diagnosticCodes } from '../../schemas/errors.js';
|
|
7
|
+
import { hostCapabilitiesSchema, hostInstallManifestSchema, hostIntegrationPlanSchema, hostIntegrationResultSchema, hostNameSchema, hostVerificationSchema, } from '../../schemas/host.js';
|
|
8
|
+
import { observePath, readFileBounded, sha256Digest } from '../../infra/fs.js';
|
|
9
|
+
import { isPathInside } from '../../schemas/paths.js';
|
|
10
|
+
import { ensureStatePathBoundary } from '../../install/ownership.js';
|
|
11
|
+
import { getPackageInfo } from '../../package-info.js';
|
|
12
|
+
import { detectPassiveHosts } from '../detect.js';
|
|
13
|
+
import { installPathForBundleFile, loadHostBundle } from './bundle.js';
|
|
14
|
+
import { hostConfigRelativePath, renderHookConfigPatch } from './templates.js';
|
|
15
|
+
import { HOST_SUPPORT, isSupportedVersion, nativeEventFor, surfaceFor, } from './support.js';
|
|
16
|
+
const LIFECYCLE_EVENTS = [
|
|
17
|
+
'startup',
|
|
18
|
+
'resume',
|
|
19
|
+
'compact',
|
|
20
|
+
'checkpoint-request',
|
|
21
|
+
];
|
|
22
|
+
const BUNDLE_FILE = 'pappra-host.json';
|
|
23
|
+
const DEFAULT_MAX_CONFIG_BYTES = 1024 * 1024;
|
|
24
|
+
function now() {
|
|
25
|
+
return new Date().toISOString();
|
|
26
|
+
}
|
|
27
|
+
function packageRuntimeEntry() {
|
|
28
|
+
const packageJson = fileURLToPath(import.meta.resolve('@phyzess/pappra/package.json'));
|
|
29
|
+
return join(dirname(packageJson), 'dist', 'hosts', 'integration', 'hook-entry.js');
|
|
30
|
+
}
|
|
31
|
+
function diagnostic(code, message, source, hint) {
|
|
32
|
+
return createDiagnostic({
|
|
33
|
+
code,
|
|
34
|
+
severity: 'error',
|
|
35
|
+
message,
|
|
36
|
+
source,
|
|
37
|
+
hint,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function evidence(path, observedAt) {
|
|
41
|
+
return {
|
|
42
|
+
kind: 'file',
|
|
43
|
+
ref: path,
|
|
44
|
+
digest: null,
|
|
45
|
+
observedAt,
|
|
46
|
+
scope: 'live-owned',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async function runtimeEntryReady(path) {
|
|
50
|
+
const observed = await observePath(path);
|
|
51
|
+
return observed.kind === 'file' && observed.stats !== null;
|
|
52
|
+
}
|
|
53
|
+
function eventCapability(host, event, options) {
|
|
54
|
+
const nativeEvent = nativeEventFor(host, event);
|
|
55
|
+
const baseEvidence = [];
|
|
56
|
+
if (options.executablePath !== null)
|
|
57
|
+
baseEvidence.push(evidence(options.executablePath, options.observedAt));
|
|
58
|
+
if (options.configPath !== null)
|
|
59
|
+
baseEvidence.push(evidence(options.configPath, options.observedAt));
|
|
60
|
+
if (nativeEvent === null) {
|
|
61
|
+
return {
|
|
62
|
+
event,
|
|
63
|
+
nativeEvent: null,
|
|
64
|
+
state: 'unsupported',
|
|
65
|
+
reason: event === 'compact'
|
|
66
|
+
? 'The Pappra adapter does not implement a compact mapping for this surface.'
|
|
67
|
+
: 'Automatic checkpoint requests are not an implemented native event; use the manual P07 command.',
|
|
68
|
+
evidence: baseEvidence,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (options.version === null) {
|
|
72
|
+
return {
|
|
73
|
+
event,
|
|
74
|
+
nativeEvent,
|
|
75
|
+
state: 'unknown',
|
|
76
|
+
reason: 'Host version was not observed; native event mapping is not treated as verified.',
|
|
77
|
+
evidence: baseEvidence,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!isSupportedVersion(host, options.version)) {
|
|
81
|
+
return {
|
|
82
|
+
event,
|
|
83
|
+
nativeEvent,
|
|
84
|
+
state: 'unsupported',
|
|
85
|
+
reason: `Observed host version ${options.version} is outside the pinned ${HOST_SUPPORT[host].version} support matrix.`,
|
|
86
|
+
evidence: baseEvidence,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (!options.installed && options.configPath === null) {
|
|
90
|
+
return {
|
|
91
|
+
event,
|
|
92
|
+
nativeEvent,
|
|
93
|
+
state: 'unknown',
|
|
94
|
+
reason: 'No host executable or configuration was observed; a supplied version string is not wiring evidence.',
|
|
95
|
+
evidence: baseEvidence,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (options.wiringVerified) {
|
|
99
|
+
return {
|
|
100
|
+
event,
|
|
101
|
+
nativeEvent,
|
|
102
|
+
state: 'configured',
|
|
103
|
+
reason: 'Exact Pappra-managed wiring is present; native trust, reload and callability remain unverified.',
|
|
104
|
+
evidence: baseEvidence,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
event,
|
|
109
|
+
nativeEvent,
|
|
110
|
+
state: 'installed',
|
|
111
|
+
reason: 'Host presence/version is observed, but exact Pappra wiring is not verified for this target.',
|
|
112
|
+
evidence: baseEvidence,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function hostCapabilities(host, options) {
|
|
116
|
+
return hostCapabilitiesSchema.parse({
|
|
117
|
+
schema: 'pappra/host-capabilities/1',
|
|
118
|
+
host,
|
|
119
|
+
surface: surfaceFor(host),
|
|
120
|
+
version: options.version,
|
|
121
|
+
observedAt: options.observedAt,
|
|
122
|
+
executablePath: options.executablePath,
|
|
123
|
+
configPath: options.configPath,
|
|
124
|
+
capabilities: LIFECYCLE_EVENTS.map((event) => eventCapability(host, event, options)),
|
|
125
|
+
reloadRequired: true,
|
|
126
|
+
trustRequired: true,
|
|
127
|
+
automaticHandoff: 'unsupported',
|
|
128
|
+
limits: [
|
|
129
|
+
'Installing files and observing a version does not establish host trust, reload, callability or event delivery.',
|
|
130
|
+
'Hooks do not read transcripts, call models, fetch, index, run arbitrary commands or start a writer.',
|
|
131
|
+
'Manual checkpoint/resume remains available without hooks; automatic continuation is unsupported.',
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function configPathFor(host, hostRoot) {
|
|
136
|
+
const relative = hostConfigRelativePath(host);
|
|
137
|
+
return relative === null ? null : join(hostRoot, relative);
|
|
138
|
+
}
|
|
139
|
+
function safeRelativePath(path) {
|
|
140
|
+
return (path.length > 0 &&
|
|
141
|
+
!path.startsWith('/') &&
|
|
142
|
+
!path.includes('\\') &&
|
|
143
|
+
!path.includes('\0') &&
|
|
144
|
+
!path.split('/').includes('..'));
|
|
145
|
+
}
|
|
146
|
+
async function fileDigest(path) {
|
|
147
|
+
const read = await readFileBounded(path, DEFAULT_MAX_CONFIG_BYTES);
|
|
148
|
+
return read.ok ? read.value.digest : null;
|
|
149
|
+
}
|
|
150
|
+
function parseJsonObject(bytes, path) {
|
|
151
|
+
let value;
|
|
152
|
+
try {
|
|
153
|
+
value = JSON.parse(bytes.toString('utf8'));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
diagnostic: diagnostic(diagnosticCodes.host_config_invalid, `Host configuration is not valid JSON: ${path}`, path, 'Fix or remove the host configuration explicitly; no managed entry was written.'),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
diagnostic: diagnostic(diagnosticCodes.host_config_invalid, `Host configuration must be a JSON object: ${path}`, path, 'Fix the host configuration explicitly; no managed entry was written.'),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, value: value };
|
|
168
|
+
}
|
|
169
|
+
function managedHookCommands(entries) {
|
|
170
|
+
const commands = new Set();
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
for (const hook of entry.hooks ?? []) {
|
|
173
|
+
if (typeof hook === 'object' &&
|
|
174
|
+
hook !== null &&
|
|
175
|
+
'type' in hook &&
|
|
176
|
+
hook.type === 'command' &&
|
|
177
|
+
'command' in hook &&
|
|
178
|
+
typeof hook.command === 'string') {
|
|
179
|
+
commands.add(hook.command);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return commands;
|
|
184
|
+
}
|
|
185
|
+
function managedGroupHasUserEdits(group, commands) {
|
|
186
|
+
const hookValues = group.hooks;
|
|
187
|
+
if (!Array.isArray(hookValues))
|
|
188
|
+
return false;
|
|
189
|
+
const hasManaged = hookValues.some((hook) => isExactManagedHook(hook, commands));
|
|
190
|
+
if (!hasManaged)
|
|
191
|
+
return false;
|
|
192
|
+
const extraGroupKeys = Object.keys(group).filter((key) => key !== 'hooks');
|
|
193
|
+
if (extraGroupKeys.length > 0)
|
|
194
|
+
return true;
|
|
195
|
+
return hookValues.some((hook) => {
|
|
196
|
+
if (!isExactManagedHook(hook, commands))
|
|
197
|
+
return false;
|
|
198
|
+
const keys = Object.keys(hook);
|
|
199
|
+
return (keys.some((key) => key !== 'type' && key !== 'command' && key !== 'timeout') ||
|
|
200
|
+
!('timeout' in hook) ||
|
|
201
|
+
hook.timeout !== 1);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function isExactManagedHook(value, commands) {
|
|
205
|
+
return (typeof value === 'object' &&
|
|
206
|
+
value !== null &&
|
|
207
|
+
!Array.isArray(value) &&
|
|
208
|
+
'type' in value &&
|
|
209
|
+
value.type === 'command' &&
|
|
210
|
+
'command' in value &&
|
|
211
|
+
typeof value.command === 'string' &&
|
|
212
|
+
commands.has(value.command));
|
|
213
|
+
}
|
|
214
|
+
function managedConfig(host, existing, runtimeEntry, path) {
|
|
215
|
+
let base = {};
|
|
216
|
+
if (existing !== null) {
|
|
217
|
+
const parsed = parseJsonObject(existing, path);
|
|
218
|
+
if (!parsed.ok)
|
|
219
|
+
return parsed;
|
|
220
|
+
base = parsed.value;
|
|
221
|
+
}
|
|
222
|
+
const patch = renderHookConfigPatch(host, runtimeEntry);
|
|
223
|
+
const rawHooks = base['hooks'];
|
|
224
|
+
if (rawHooks !== undefined &&
|
|
225
|
+
(typeof rawHooks !== 'object' ||
|
|
226
|
+
rawHooks === null ||
|
|
227
|
+
Array.isArray(rawHooks))) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
diagnostic: diagnostic(diagnosticCodes.host_config_invalid, `Host hooks must be an object: ${path}`, path, 'Fix the host hooks shape explicitly; no managed entry was written.'),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const currentHooks = rawHooks === undefined ? {} : rawHooks;
|
|
234
|
+
const nextHooks = { ...currentHooks };
|
|
235
|
+
for (const [event, entries] of Object.entries(patch.hooks)) {
|
|
236
|
+
const current = nextHooks[event];
|
|
237
|
+
if (current !== undefined && !Array.isArray(current)) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
diagnostic: diagnostic(diagnosticCodes.host_config_invalid, `Host hook event ${event} must be an array: ${path}`, path, 'Fix the host hook event explicitly; no managed entry was written.'),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const groups = current === undefined ? [] : current;
|
|
244
|
+
const commands = managedHookCommands(entries);
|
|
245
|
+
const preserved = [];
|
|
246
|
+
for (const group of groups) {
|
|
247
|
+
if (typeof group !== 'object' ||
|
|
248
|
+
group === null ||
|
|
249
|
+
Array.isArray(group) ||
|
|
250
|
+
!('hooks' in group) ||
|
|
251
|
+
!Array.isArray(group.hooks)) {
|
|
252
|
+
preserved.push(group);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (managedGroupHasUserEdits(group, commands)) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Managed host hook entry was edited by the user: ${path}`, path, 'Review the edited matcher, timeout, or handler fields explicitly; Pappra preserves the edited group and does not reinstall it.'),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const remaining = group.hooks.filter((hook) => !isExactManagedHook(hook, commands));
|
|
262
|
+
if (remaining.length > 0) {
|
|
263
|
+
preserved.push({ ...group, hooks: remaining });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
nextHooks[event] = [...preserved, ...entries];
|
|
267
|
+
}
|
|
268
|
+
const next = { ...base, hooks: nextHooks };
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
bytes: Buffer.from(`${JSON.stringify(next, null, 2)}\n`),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function validateManifestIdentity(manifest, host, hostRoot, configPath, runtimeEntry) {
|
|
275
|
+
if (manifest.host !== host ||
|
|
276
|
+
resolve(manifest.hostRoot) !== hostRoot ||
|
|
277
|
+
manifest.configPath !== configPath ||
|
|
278
|
+
resolve(manifest.runtimeEntry) !== runtimeEntry) {
|
|
279
|
+
return diagnostic(diagnosticCodes.host_install_conflict, `Host manifest identity does not match the selected target: ${hostRoot}`, join(hostRoot, BUNDLE_FILE), 'Inspect the manifest and target; Pappra does not adopt a foreign installation.');
|
|
280
|
+
}
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
for (const file of manifest.files) {
|
|
283
|
+
if (!safeRelativePath(file.path) || seen.has(file.path)) {
|
|
284
|
+
return diagnostic(diagnosticCodes.host_install_conflict, `Host manifest contains an invalid or duplicate file path: ${file.path}`, join(hostRoot, BUNDLE_FILE), 'Inspect the manifest; no path is read or overwritten.');
|
|
285
|
+
}
|
|
286
|
+
seen.add(file.path);
|
|
287
|
+
}
|
|
288
|
+
if (manifest.files.some((file) => file.path === BUNDLE_FILE)) {
|
|
289
|
+
return diagnostic(diagnosticCodes.host_install_conflict, 'Host manifest must not claim itself as an installed file.', join(hostRoot, BUNDLE_FILE), 'Inspect the manifest; no path is read or overwritten.');
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
function configHasExpectedHooks(host, bytes, runtimeEntry, path) {
|
|
294
|
+
const parsed = parseJsonObject(bytes, path);
|
|
295
|
+
if (!parsed.ok)
|
|
296
|
+
return false;
|
|
297
|
+
const patch = renderHookConfigPatch(host, runtimeEntry);
|
|
298
|
+
const hooks = parsed.value['hooks'];
|
|
299
|
+
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
for (const [event, entries] of Object.entries(patch.hooks)) {
|
|
303
|
+
const groups = hooks[event];
|
|
304
|
+
if (!Array.isArray(groups))
|
|
305
|
+
return false;
|
|
306
|
+
const commands = managedHookCommands(entries);
|
|
307
|
+
const found = new Set();
|
|
308
|
+
for (const group of groups) {
|
|
309
|
+
if (typeof group !== 'object' ||
|
|
310
|
+
group === null ||
|
|
311
|
+
Array.isArray(group) ||
|
|
312
|
+
!('hooks' in group) ||
|
|
313
|
+
!Array.isArray(group.hooks)) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
for (const hook of group.hooks) {
|
|
317
|
+
if (!isExactManagedHook(hook, commands))
|
|
318
|
+
continue;
|
|
319
|
+
if (typeof hook === 'object' &&
|
|
320
|
+
hook !== null &&
|
|
321
|
+
'command' in hook &&
|
|
322
|
+
typeof hook.command === 'string') {
|
|
323
|
+
found.add(hook.command);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if ([...commands].some((command) => !found.has(command)))
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
function bundleManifestContent(bundle, hostRoot, runtimeEntry, configPath, files, observedAt) {
|
|
333
|
+
return Buffer.from(`${JSON.stringify({
|
|
334
|
+
schema: 'pappra/host-install/1',
|
|
335
|
+
host: bundle.host,
|
|
336
|
+
packageVersion: bundle.packageVersion,
|
|
337
|
+
bundleDigest: bundle.bundleDigest,
|
|
338
|
+
installedAt: observedAt,
|
|
339
|
+
hostRoot,
|
|
340
|
+
runtimeEntry,
|
|
341
|
+
configPath,
|
|
342
|
+
files: files.map((file) => ({
|
|
343
|
+
path: file.path,
|
|
344
|
+
digest: file.nextDigest,
|
|
345
|
+
kind: file.kind === 'hook-entry'
|
|
346
|
+
? 'hook'
|
|
347
|
+
: file.kind === 'manifest'
|
|
348
|
+
? 'config'
|
|
349
|
+
: file.kind,
|
|
350
|
+
})),
|
|
351
|
+
reloadRequired: true,
|
|
352
|
+
trustRequired: true,
|
|
353
|
+
}, null, 2)}\n`);
|
|
354
|
+
}
|
|
355
|
+
async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
|
|
356
|
+
let payload;
|
|
357
|
+
try {
|
|
358
|
+
payload = await loadHostBundle(host, getPackageInfo().version, {
|
|
359
|
+
runtimeEntry,
|
|
360
|
+
observedAt,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
diagnostic: diagnostic(diagnosticCodes.host_bundle_invalid, `Cannot load packaged host bundle: ${error instanceof Error ? error.message : String(error)}`, hostRoot, 'Reinstall or rebuild Pappra; no host files were written.'),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const manifestPath = join(hostRoot, BUNDLE_FILE);
|
|
370
|
+
const manifestBoundary = await assertSafeTarget(hostRoot, manifestPath);
|
|
371
|
+
if (manifestBoundary !== null) {
|
|
372
|
+
return { ok: false, diagnostic: manifestBoundary, conflict: true };
|
|
373
|
+
}
|
|
374
|
+
const existingManifestRead = await readFileBounded(manifestPath, 256 * 1024);
|
|
375
|
+
let installedManifest = null;
|
|
376
|
+
if (!existingManifestRead.ok &&
|
|
377
|
+
existingManifestRead.code !== diagnosticCodes.fileNotFound) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
conflict: true,
|
|
381
|
+
diagnostic: diagnostic(existingManifestRead.code, existingManifestRead.message, manifestPath, existingManifestRead.nextAction ?? 'Inspect the host manifest.'),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (existingManifestRead.ok) {
|
|
385
|
+
let value;
|
|
386
|
+
try {
|
|
387
|
+
value = JSON.parse(existingManifestRead.value.bytes.toString('utf8'));
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
conflict: true,
|
|
393
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Host manifest is not valid JSON and was preserved: ${manifestPath}`, manifestPath, 'Fix or remove the manifest explicitly; no managed file was overwritten.'),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const parsed = hostInstallManifestSchema.safeParse(value);
|
|
397
|
+
if (!parsed.success) {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
conflict: true,
|
|
401
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Host manifest is invalid and was preserved: ${manifestPath}`, manifestPath, 'Fix or remove the manifest explicitly; no managed file was overwritten.'),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const identityError = validateManifestIdentity(parsed.data, host, hostRoot, hostConfigRelativePath(host), runtimeEntry);
|
|
405
|
+
if (identityError !== null) {
|
|
406
|
+
return { ok: false, conflict: true, diagnostic: identityError };
|
|
407
|
+
}
|
|
408
|
+
installedManifest = parsed.data;
|
|
409
|
+
}
|
|
410
|
+
const installedDigests = new Map((installedManifest?.files ?? []).map((entry) => [entry.path, entry.digest]));
|
|
411
|
+
const contents = new Map();
|
|
412
|
+
const files = [];
|
|
413
|
+
for (const [sourcePath, bytes] of payload.contents) {
|
|
414
|
+
const path = installPathForBundleFile(host, sourcePath);
|
|
415
|
+
if (!safeRelativePath(path)) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_unsafe, `Unsafe bundle path: ${path}`, hostRoot, 'Rebuild the package; no host files were written.'),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const target = join(hostRoot, ...path.split('/'));
|
|
422
|
+
const targetBoundary = await assertSafeTarget(hostRoot, target);
|
|
423
|
+
if (targetBoundary !== null) {
|
|
424
|
+
return { ok: false, diagnostic: targetBoundary, conflict: true };
|
|
425
|
+
}
|
|
426
|
+
const existingTarget = await readFileBounded(target, 8 * 1024 * 1024);
|
|
427
|
+
if (!existingTarget.ok &&
|
|
428
|
+
existingTarget.code !== diagnosticCodes.fileNotFound) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
diagnostic: diagnostic(existingTarget.code, existingTarget.message, target, existingTarget.nextAction ?? 'Inspect the host target.'),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const beforeDigest = existingTarget.ok ? existingTarget.value.digest : null;
|
|
435
|
+
const nextDigest = sha256Digest(bytes);
|
|
436
|
+
const recordedDigest = installedDigests.get(path);
|
|
437
|
+
const userModified = beforeDigest !== null &&
|
|
438
|
+
beforeDigest !== nextDigest &&
|
|
439
|
+
(recordedDigest === undefined || beforeDigest !== recordedDigest);
|
|
440
|
+
contents.set(path, bytes);
|
|
441
|
+
files.push({
|
|
442
|
+
path,
|
|
443
|
+
kind: path.startsWith('extensions/')
|
|
444
|
+
? 'extension'
|
|
445
|
+
: path.startsWith('templates/')
|
|
446
|
+
? 'hook-entry'
|
|
447
|
+
: 'skill',
|
|
448
|
+
operation: userModified
|
|
449
|
+
? 'conflict'
|
|
450
|
+
: beforeDigest === null
|
|
451
|
+
? 'create'
|
|
452
|
+
: beforeDigest === nextDigest
|
|
453
|
+
? 'unchanged'
|
|
454
|
+
: 'replace-managed',
|
|
455
|
+
beforeDigest,
|
|
456
|
+
nextDigest,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const configPath = configPathFor(host, hostRoot);
|
|
460
|
+
if (configPath !== null) {
|
|
461
|
+
const relative = hostConfigRelativePath(host);
|
|
462
|
+
if (relative === null) {
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
diagnostic: diagnostic(diagnosticCodes.host_bundle_invalid, `Missing host config path for ${host}.`, hostRoot, 'Inspect the host adapter; no host files were written.'),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const configBoundary = await assertSafeTarget(hostRoot, configPath);
|
|
469
|
+
if (configBoundary !== null) {
|
|
470
|
+
return { ok: false, diagnostic: configBoundary, conflict: true };
|
|
471
|
+
}
|
|
472
|
+
const existing = await readFileBounded(configPath, DEFAULT_MAX_CONFIG_BYTES);
|
|
473
|
+
if (!existing.ok && existing.code !== diagnosticCodes.fileNotFound) {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
diagnostic: diagnostic(existing.code, existing.message, configPath, existing.nextAction ?? 'Inspect the host configuration.'),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const before = existing.ok ? existing.value.bytes : null;
|
|
480
|
+
const merged = managedConfig(host === 'codex' ? 'codex' : 'claude', before, runtimeEntry, configPath);
|
|
481
|
+
if (!merged.ok) {
|
|
482
|
+
return {
|
|
483
|
+
...merged,
|
|
484
|
+
conflict: merged.diagnostic.code === diagnosticCodes.host_install_conflict,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
contents.set(relative, merged.bytes);
|
|
488
|
+
files.push({
|
|
489
|
+
path: relative,
|
|
490
|
+
kind: 'config',
|
|
491
|
+
operation: before === null ? 'create' : 'merge-managed',
|
|
492
|
+
beforeDigest: before === null ? null : sha256Digest(before),
|
|
493
|
+
nextDigest: sha256Digest(merged.bytes),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
497
|
+
let installedAt = observedAt;
|
|
498
|
+
const existingManifestDigest = existingManifestRead.ok
|
|
499
|
+
? existingManifestRead.value.digest
|
|
500
|
+
: null;
|
|
501
|
+
if (installedManifest !== null &&
|
|
502
|
+
installedManifest.bundleDigest === payload.bundle.bundleDigest &&
|
|
503
|
+
installedManifest.configPath === hostConfigRelativePath(host) &&
|
|
504
|
+
installedManifest.files.length === files.length &&
|
|
505
|
+
installedManifest.files.every((entry, index) => {
|
|
506
|
+
const expected = files[index];
|
|
507
|
+
return (expected !== undefined &&
|
|
508
|
+
entry.path === expected.path &&
|
|
509
|
+
entry.digest === expected.nextDigest);
|
|
510
|
+
})) {
|
|
511
|
+
installedAt = installedManifest.installedAt;
|
|
512
|
+
}
|
|
513
|
+
const manifest = bundleManifestContent(payload.bundle, hostRoot, runtimeEntry, hostConfigRelativePath(host), files, installedAt);
|
|
514
|
+
contents.set(BUNDLE_FILE, manifest);
|
|
515
|
+
files.push({
|
|
516
|
+
path: BUNDLE_FILE,
|
|
517
|
+
kind: 'manifest',
|
|
518
|
+
operation: existingManifestDigest === null ? 'create' : 'replace-managed',
|
|
519
|
+
beforeDigest: existingManifestDigest,
|
|
520
|
+
nextDigest: sha256Digest(manifest),
|
|
521
|
+
});
|
|
522
|
+
return {
|
|
523
|
+
ok: true,
|
|
524
|
+
bundle: payload.bundle,
|
|
525
|
+
contents,
|
|
526
|
+
files,
|
|
527
|
+
configPath,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function planFromFiles(host, workspaceRoot, hostRoot, runtimeEntry, bundleDigest, files) {
|
|
531
|
+
return hostIntegrationPlanSchema.parse({
|
|
532
|
+
schema: 'pappra/host-integration/1',
|
|
533
|
+
planId: randomUUID(),
|
|
534
|
+
host,
|
|
535
|
+
workspaceRoot,
|
|
536
|
+
hostRoot,
|
|
537
|
+
runtimeEntry,
|
|
538
|
+
bundleDigest,
|
|
539
|
+
files,
|
|
540
|
+
reloadRequired: true,
|
|
541
|
+
trustRequired: true,
|
|
542
|
+
diagnostics: [],
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
async function assertSafeTarget(hostRoot, path) {
|
|
546
|
+
if (!isPathInside(hostRoot, path)) {
|
|
547
|
+
return diagnostic(diagnosticCodes.host_install_unsafe, `Host integration path escapes the selected host root: ${path}`, hostRoot, 'Choose a private host root inside the selected target.');
|
|
548
|
+
}
|
|
549
|
+
const boundary = await ensureStatePathBoundary(hostRoot, path);
|
|
550
|
+
if (!boundary.ok) {
|
|
551
|
+
return diagnostic(boundary.code, boundary.message, path, boundary.nextAction ?? 'Inspect the host target; no files were written.');
|
|
552
|
+
}
|
|
553
|
+
const rootObservation = await observePath(hostRoot);
|
|
554
|
+
if (rootObservation.kind === 'symlink' || rootObservation.kind === 'other') {
|
|
555
|
+
return diagnostic(diagnosticCodes.host_install_unsafe, `Host root is a symlink or unsupported path kind: ${hostRoot}`, hostRoot, 'Choose a real directory; no files were read or written.');
|
|
556
|
+
}
|
|
557
|
+
if (rootObservation.kind !== 'missing' &&
|
|
558
|
+
rootObservation.kind !== 'directory') {
|
|
559
|
+
return diagnostic(diagnosticCodes.host_install_unsafe, `Host root is not a directory: ${hostRoot}`, hostRoot, 'Choose a directory; no files were read or written.');
|
|
560
|
+
}
|
|
561
|
+
const finalObservation = await observePath(path);
|
|
562
|
+
if (finalObservation.kind === 'symlink' ||
|
|
563
|
+
finalObservation.kind === 'other' ||
|
|
564
|
+
finalObservation.kind === 'directory') {
|
|
565
|
+
return diagnostic(diagnosticCodes.host_install_unsafe, `Host integration target is a symlink, directory or unsupported path: ${path}`, path, 'Inspect the target; no files were read or written.');
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
async function removeOwnedTemporary(path, identity, rootReal) {
|
|
570
|
+
let parentReal;
|
|
571
|
+
try {
|
|
572
|
+
parentReal = await realpath(dirname(path));
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return path;
|
|
576
|
+
}
|
|
577
|
+
if (!isPathInside(rootReal, parentReal))
|
|
578
|
+
return path;
|
|
579
|
+
const observed = await observePath(path);
|
|
580
|
+
if (observed.kind === 'missing')
|
|
581
|
+
return null;
|
|
582
|
+
if (identity === null) {
|
|
583
|
+
return observed.kind === 'file' ? path : null;
|
|
584
|
+
}
|
|
585
|
+
if (observed.kind !== 'file' ||
|
|
586
|
+
observed.stats === null ||
|
|
587
|
+
observed.stats.dev !== identity.dev ||
|
|
588
|
+
observed.stats.ino !== identity.ino) {
|
|
589
|
+
return path;
|
|
590
|
+
}
|
|
591
|
+
const read = await readFileBounded(path, identity.expected.length + 1);
|
|
592
|
+
if (!read.ok)
|
|
593
|
+
return path;
|
|
594
|
+
const bytes = read.value.bytes;
|
|
595
|
+
const complete = identity.expected.equals(bytes);
|
|
596
|
+
const prefix = bytes.length < identity.expected.length &&
|
|
597
|
+
identity.expected.subarray(0, bytes.length).equals(bytes);
|
|
598
|
+
const prefixEligible = !identity.writeCompleted && prefix;
|
|
599
|
+
if (!complete && !prefixEligible)
|
|
600
|
+
return path;
|
|
601
|
+
try {
|
|
602
|
+
await rm(path);
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return path;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async function temporaryStillOwned(path, identity, rootReal) {
|
|
610
|
+
if (identity === null)
|
|
611
|
+
return false;
|
|
612
|
+
let parentReal;
|
|
613
|
+
try {
|
|
614
|
+
parentReal = await realpath(dirname(path));
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
if (!isPathInside(rootReal, parentReal))
|
|
620
|
+
return false;
|
|
621
|
+
const observed = await observePath(path);
|
|
622
|
+
if (observed.kind !== 'file' ||
|
|
623
|
+
observed.stats === null ||
|
|
624
|
+
observed.stats.dev !== identity.dev ||
|
|
625
|
+
observed.stats.ino !== identity.ino) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
const read = await readFileBounded(path, identity.expected.length + 1);
|
|
629
|
+
return read.ok && identity.expected.equals(read.value.bytes);
|
|
630
|
+
}
|
|
631
|
+
async function writeAtomic(path, bytes, options) {
|
|
632
|
+
const temporary = `${path}.tmp-${randomUUID()}`;
|
|
633
|
+
let owned = null;
|
|
634
|
+
const rootReal = await realpath(options.root);
|
|
635
|
+
try {
|
|
636
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
637
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
638
|
+
try {
|
|
639
|
+
const stats = await handle.stat();
|
|
640
|
+
if (!stats.isFile())
|
|
641
|
+
throw new Error('temporary is not a regular file');
|
|
642
|
+
owned = {
|
|
643
|
+
dev: stats.dev,
|
|
644
|
+
ino: stats.ino,
|
|
645
|
+
expected: bytes,
|
|
646
|
+
writeCompleted: false,
|
|
647
|
+
};
|
|
648
|
+
await handle.writeFile(bytes);
|
|
649
|
+
owned.writeCompleted = true;
|
|
650
|
+
await handle.sync();
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
await handle.close().catch(() => undefined);
|
|
654
|
+
}
|
|
655
|
+
if (!(await temporaryStillOwned(temporary, owned, rootReal))) {
|
|
656
|
+
const residual = await removeOwnedTemporary(temporary, owned, rootReal);
|
|
657
|
+
return {
|
|
658
|
+
ok: false,
|
|
659
|
+
status: 'failed',
|
|
660
|
+
residual,
|
|
661
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_partial, `Host temporary namespace, identity, or complete content changed before publication: ${path}`, temporary, residual === null
|
|
662
|
+
? 'Inspect the host root; no target was published.'
|
|
663
|
+
: `Inspect the preserved/unknown temporary at ${residual}; no target was published.`),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (options.replace) {
|
|
667
|
+
const current = await readFileBounded(path, 8 * 1024 * 1024);
|
|
668
|
+
const currentDigest = current.ok ? current.value.digest : null;
|
|
669
|
+
if (currentDigest !== options.beforeDigest) {
|
|
670
|
+
const residual = await removeOwnedTemporary(temporary, owned, rootReal);
|
|
671
|
+
return {
|
|
672
|
+
ok: false,
|
|
673
|
+
status: 'conflict',
|
|
674
|
+
residual,
|
|
675
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Host file changed at publication time; it was preserved: ${path}`, path, 'Inspect the file or replan; Pappra does not overwrite unknown user changes.'),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
await rename(temporary, path);
|
|
679
|
+
return { ok: true, residual: null };
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
await link(temporary, path);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
const code = typeof error === 'object' &&
|
|
686
|
+
error !== null &&
|
|
687
|
+
'code' in error &&
|
|
688
|
+
typeof error.code === 'string'
|
|
689
|
+
? error.code
|
|
690
|
+
: null;
|
|
691
|
+
const residual = await removeOwnedTemporary(temporary, owned, rootReal);
|
|
692
|
+
if (code === 'EEXIST') {
|
|
693
|
+
return {
|
|
694
|
+
ok: false,
|
|
695
|
+
status: 'conflict',
|
|
696
|
+
residual,
|
|
697
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Host target appeared during publication; it was preserved: ${path}`, path, 'Inspect the new target; no user file was overwritten.'),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
status: 'failed',
|
|
703
|
+
residual,
|
|
704
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_partial, `Cannot publish host integration file ${path}: ${error instanceof Error ? error.message : String(error)}`, path, 'Inspect the host root and retry; no complete manifest was claimed.'),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const residual = await removeOwnedTemporary(temporary, owned, rootReal);
|
|
708
|
+
return { ok: true, residual };
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
const residual = await removeOwnedTemporary(temporary, owned, rootReal);
|
|
712
|
+
return {
|
|
713
|
+
ok: false,
|
|
714
|
+
status: 'failed',
|
|
715
|
+
residual,
|
|
716
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_partial, `Cannot write host integration file ${path}: ${error instanceof Error ? error.message : String(error)}${residual === null ? '' : `; preserved/unknown temporary: ${residual}`}`, residual ?? path, residual === null
|
|
717
|
+
? 'Inspect the host root and retry; no complete manifest was claimed.'
|
|
718
|
+
: `Inspect the preserved/unknown temporary at ${residual}; it was not deleted.`),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function acquireHostInstallLock(hostRoot) {
|
|
723
|
+
const path = join(hostRoot, '.pappra-host.lock');
|
|
724
|
+
const safe = await assertSafeTarget(hostRoot, path);
|
|
725
|
+
if (safe !== null)
|
|
726
|
+
return { ok: false, diagnostic: safe };
|
|
727
|
+
try {
|
|
728
|
+
await mkdir(hostRoot, { recursive: true, mode: 0o700 });
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
return {
|
|
732
|
+
ok: false,
|
|
733
|
+
diagnostic: diagnostic(diagnosticCodes.host_install_partial, `Cannot create host root ${hostRoot}: ${error instanceof Error ? error.message : String(error)}`, hostRoot, 'Inspect the target; no host files were written.'),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
const token = randomUUID();
|
|
738
|
+
const rootReal = await realpath(hostRoot);
|
|
739
|
+
const handle = await open(path, 'wx', 0o600);
|
|
740
|
+
try {
|
|
741
|
+
const stats = await handle.stat();
|
|
742
|
+
await handle.writeFile(token);
|
|
743
|
+
await handle.sync();
|
|
744
|
+
return {
|
|
745
|
+
ok: true,
|
|
746
|
+
lock: { path, token, dev: stats.dev, ino: stats.ino, rootReal },
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
finally {
|
|
750
|
+
await handle.close().catch(() => undefined);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
const code = typeof error === 'object' &&
|
|
755
|
+
error !== null &&
|
|
756
|
+
'code' in error &&
|
|
757
|
+
typeof error.code === 'string'
|
|
758
|
+
? error.code
|
|
759
|
+
: null;
|
|
760
|
+
return {
|
|
761
|
+
ok: false,
|
|
762
|
+
diagnostic: diagnostic(code === 'EEXIST'
|
|
763
|
+
? diagnosticCodes.host_install_conflict
|
|
764
|
+
: diagnosticCodes.host_install_partial, `Cannot acquire host install lock ${path}: ${error instanceof Error ? error.message : String(error)}`, path, 'Inspect the lock; stale locks require explicit manual resolution.'),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async function releaseHostInstallLock(lock) {
|
|
769
|
+
let parentReal;
|
|
770
|
+
try {
|
|
771
|
+
parentReal = await realpath(dirname(lock.path));
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
if (parentReal !== lock.rootReal)
|
|
777
|
+
return false;
|
|
778
|
+
const observed = await observePath(lock.path);
|
|
779
|
+
if (observed.kind === 'missing')
|
|
780
|
+
return true;
|
|
781
|
+
if (observed.kind !== 'file' ||
|
|
782
|
+
observed.stats === null ||
|
|
783
|
+
observed.stats.dev !== lock.dev ||
|
|
784
|
+
observed.stats.ino !== lock.ino) {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
const read = await readFileBounded(lock.path, 4096);
|
|
788
|
+
if (!read.ok || read.value.bytes.toString('utf8') !== lock.token) {
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
await rm(lock.path);
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
export class HostIntegrationService {
|
|
800
|
+
#root;
|
|
801
|
+
#homeDir;
|
|
802
|
+
#pathEnv;
|
|
803
|
+
#runtimeEntry;
|
|
804
|
+
#hostVersion;
|
|
805
|
+
#selectedHosts;
|
|
806
|
+
#observedAt;
|
|
807
|
+
constructor(options) {
|
|
808
|
+
this.#root = resolve(options.root);
|
|
809
|
+
this.#homeDir = resolve(options.homeDir);
|
|
810
|
+
this.#pathEnv = options.pathEnv;
|
|
811
|
+
this.#runtimeEntry = resolve(options.runtimeEntry ?? packageRuntimeEntry());
|
|
812
|
+
this.#hostVersion = options.hostVersion ?? null;
|
|
813
|
+
this.#selectedHosts = options.selectedHosts ?? ['pi', 'codex', 'claude'];
|
|
814
|
+
this.#observedAt = options.observedAt ?? now();
|
|
815
|
+
}
|
|
816
|
+
detect() {
|
|
817
|
+
const observations = detectPassiveHosts({
|
|
818
|
+
homeDir: this.#homeDir,
|
|
819
|
+
pathEnv: this.#pathEnv,
|
|
820
|
+
selectedHosts: this.#selectedHosts,
|
|
821
|
+
observedAt: this.#observedAt,
|
|
822
|
+
});
|
|
823
|
+
return observations.map((observation) => {
|
|
824
|
+
const host = hostNameSchema.parse(observation.host);
|
|
825
|
+
return hostCapabilities(host, {
|
|
826
|
+
version: this.#hostVersion,
|
|
827
|
+
executablePath: observation.executablePath,
|
|
828
|
+
configPath: observation.configPaths[0] ?? null,
|
|
829
|
+
installed: observation.executablePath !== null,
|
|
830
|
+
wiringVerified: false,
|
|
831
|
+
observedAt: this.#observedAt,
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
describe(host) {
|
|
836
|
+
const observation = detectPassiveHosts({
|
|
837
|
+
homeDir: this.#homeDir,
|
|
838
|
+
pathEnv: this.#pathEnv,
|
|
839
|
+
selectedHosts: [host],
|
|
840
|
+
observedAt: this.#observedAt,
|
|
841
|
+
}).find((entry) => entry.host === host);
|
|
842
|
+
return hostCapabilities(host, {
|
|
843
|
+
version: this.#hostVersion,
|
|
844
|
+
executablePath: observation?.executablePath ?? null,
|
|
845
|
+
configPath: observation?.configPaths[0] ?? null,
|
|
846
|
+
installed: observation?.executablePath !== undefined &&
|
|
847
|
+
observation?.executablePath !== null,
|
|
848
|
+
wiringVerified: false,
|
|
849
|
+
observedAt: this.#observedAt,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
adapter(host) {
|
|
853
|
+
return new HostAdapter(this, host);
|
|
854
|
+
}
|
|
855
|
+
async plan(host, options = {}) {
|
|
856
|
+
const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
|
|
857
|
+
const runtimeEntry = resolve(options.runtimeEntry ?? this.#runtimeEntry);
|
|
858
|
+
const version = options.hostVersion ?? this.#hostVersion;
|
|
859
|
+
if (!isSupportedVersion(host, version)) {
|
|
860
|
+
throw new Error(`Host ${host} version ${version ?? '(unknown)'} is outside the pinned support matrix.`);
|
|
861
|
+
}
|
|
862
|
+
if (!(await runtimeEntryReady(runtimeEntry))) {
|
|
863
|
+
throw new Error(`Fixed Pappra runtime entry is missing: ${runtimeEntry}`);
|
|
864
|
+
}
|
|
865
|
+
const built = await hostPlanFiles(host, hostRoot, runtimeEntry, this.#observedAt);
|
|
866
|
+
if (!built.ok)
|
|
867
|
+
throw new Error(built.diagnostic.message);
|
|
868
|
+
return planFromFiles(host, this.#root, hostRoot, runtimeEntry, built.bundle.bundleDigest, built.files);
|
|
869
|
+
}
|
|
870
|
+
async install(host, options) {
|
|
871
|
+
let plan;
|
|
872
|
+
let contents;
|
|
873
|
+
try {
|
|
874
|
+
const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
|
|
875
|
+
const runtimeEntry = resolve(options.runtimeEntry ?? this.#runtimeEntry);
|
|
876
|
+
const version = options.hostVersion ?? this.#hostVersion;
|
|
877
|
+
if (!isSupportedVersion(host, version)) {
|
|
878
|
+
return hostIntegrationResultSchema.parse({
|
|
879
|
+
schema: 'pappra/host-integration/1',
|
|
880
|
+
ok: false,
|
|
881
|
+
status: 'unsupported',
|
|
882
|
+
plan: null,
|
|
883
|
+
appliedFiles: [],
|
|
884
|
+
residualPaths: [],
|
|
885
|
+
diagnostics: [
|
|
886
|
+
diagnostic(diagnosticCodes.host_version_unsupported, `Host ${host} version ${version ?? '(unknown)'} is outside the pinned ${HOST_SUPPORT[host].version} support matrix.`, hostRoot, 'Select a supported host version or use manual checkpoint/resume.'),
|
|
887
|
+
],
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
if (!(await runtimeEntryReady(runtimeEntry))) {
|
|
891
|
+
return hostIntegrationResultSchema.parse({
|
|
892
|
+
schema: 'pappra/host-integration/1',
|
|
893
|
+
ok: false,
|
|
894
|
+
status: 'invalid',
|
|
895
|
+
plan: null,
|
|
896
|
+
appliedFiles: [],
|
|
897
|
+
residualPaths: [],
|
|
898
|
+
diagnostics: [
|
|
899
|
+
diagnostic(diagnosticCodes.host_unavailable, `Fixed Pappra runtime entry is missing: ${runtimeEntry}`, runtimeEntry, 'Install or select a complete Pappra runtime before wiring a host.'),
|
|
900
|
+
],
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
const built = await hostPlanFiles(host, hostRoot, runtimeEntry, this.#observedAt);
|
|
904
|
+
if (!built.ok) {
|
|
905
|
+
return hostIntegrationResultSchema.parse({
|
|
906
|
+
schema: 'pappra/host-integration/1',
|
|
907
|
+
ok: false,
|
|
908
|
+
status: built.conflict === true ? 'conflict' : 'invalid',
|
|
909
|
+
plan: null,
|
|
910
|
+
appliedFiles: [],
|
|
911
|
+
residualPaths: [],
|
|
912
|
+
diagnostics: [built.diagnostic],
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
plan = planFromFiles(host, this.#root, hostRoot, runtimeEntry, built.bundle.bundleDigest, built.files);
|
|
916
|
+
contents = built.contents;
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
return hostIntegrationResultSchema.parse({
|
|
920
|
+
schema: 'pappra/host-integration/1',
|
|
921
|
+
ok: false,
|
|
922
|
+
status: 'failed',
|
|
923
|
+
plan: null,
|
|
924
|
+
appliedFiles: [],
|
|
925
|
+
residualPaths: [],
|
|
926
|
+
diagnostics: [
|
|
927
|
+
diagnostic(diagnosticCodes.host_install_partial, `Cannot plan host integration: ${error instanceof Error ? error.message : String(error)}`, this.#root, 'Inspect the host target; no complete integration was claimed.'),
|
|
928
|
+
],
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
if (!options.apply) {
|
|
932
|
+
return hostIntegrationResultSchema.parse({
|
|
933
|
+
schema: 'pappra/host-integration/1',
|
|
934
|
+
ok: true,
|
|
935
|
+
status: 'planned',
|
|
936
|
+
plan,
|
|
937
|
+
appliedFiles: [],
|
|
938
|
+
residualPaths: [],
|
|
939
|
+
diagnostics: [],
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
const applied = [];
|
|
943
|
+
const residualPaths = [];
|
|
944
|
+
const diagnostics = [];
|
|
945
|
+
const lockResult = await acquireHostInstallLock(plan.hostRoot);
|
|
946
|
+
if (!lockResult.ok) {
|
|
947
|
+
return hostIntegrationResultSchema.parse({
|
|
948
|
+
schema: 'pappra/host-integration/1',
|
|
949
|
+
ok: false,
|
|
950
|
+
status: lockResult.diagnostic.code === diagnosticCodes.host_install_conflict
|
|
951
|
+
? 'conflict'
|
|
952
|
+
: 'failed',
|
|
953
|
+
plan,
|
|
954
|
+
appliedFiles: [],
|
|
955
|
+
residualPaths: [],
|
|
956
|
+
diagnostics: [lockResult.diagnostic],
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
const conflicted = plan.files.find((file) => file.operation === 'conflict');
|
|
961
|
+
if (conflicted !== undefined) {
|
|
962
|
+
const path = join(plan.hostRoot, ...conflicted.path.split('/'));
|
|
963
|
+
diagnostics.push(diagnostic(diagnosticCodes.host_install_conflict, `Managed host file was edited since installation; it was preserved: ${path}`, path, 'Inspect the user edit and replan only after resolving ownership.'));
|
|
964
|
+
return hostIntegrationResultSchema.parse({
|
|
965
|
+
schema: 'pappra/host-integration/1',
|
|
966
|
+
ok: false,
|
|
967
|
+
status: 'conflict',
|
|
968
|
+
plan,
|
|
969
|
+
appliedFiles: [],
|
|
970
|
+
residualPaths: [],
|
|
971
|
+
diagnostics,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
for (const file of plan.files) {
|
|
975
|
+
const path = join(plan.hostRoot, ...file.path.split('/'));
|
|
976
|
+
const unsafe = await assertSafeTarget(plan.hostRoot, path);
|
|
977
|
+
if (unsafe !== null) {
|
|
978
|
+
diagnostics.push(unsafe);
|
|
979
|
+
return hostIntegrationResultSchema.parse({
|
|
980
|
+
schema: 'pappra/host-integration/1',
|
|
981
|
+
ok: false,
|
|
982
|
+
status: 'invalid',
|
|
983
|
+
plan,
|
|
984
|
+
appliedFiles: applied,
|
|
985
|
+
residualPaths: [],
|
|
986
|
+
diagnostics,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
if (file.operation === 'conflict') {
|
|
990
|
+
diagnostics.push(diagnostic(diagnosticCodes.host_install_conflict, `Managed host file was edited since installation; it was preserved: ${path}`, path, 'Inspect the user edit and replan only after resolving ownership.'));
|
|
991
|
+
return hostIntegrationResultSchema.parse({
|
|
992
|
+
schema: 'pappra/host-integration/1',
|
|
993
|
+
ok: false,
|
|
994
|
+
status: 'conflict',
|
|
995
|
+
plan,
|
|
996
|
+
appliedFiles: applied,
|
|
997
|
+
residualPaths: [],
|
|
998
|
+
diagnostics,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
const current = await readFileBounded(path, 8 * 1024 * 1024);
|
|
1002
|
+
if (!current.ok && current.code !== diagnosticCodes.fileNotFound) {
|
|
1003
|
+
diagnostics.push(diagnostic(current.code, current.message, path, current.nextAction ?? 'Inspect the host target.'));
|
|
1004
|
+
return hostIntegrationResultSchema.parse({
|
|
1005
|
+
schema: 'pappra/host-integration/1',
|
|
1006
|
+
ok: false,
|
|
1007
|
+
status: 'failed',
|
|
1008
|
+
plan,
|
|
1009
|
+
appliedFiles: applied,
|
|
1010
|
+
residualPaths: [],
|
|
1011
|
+
diagnostics,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
const before = current.ok ? current.value.digest : null;
|
|
1015
|
+
if (before !== file.beforeDigest) {
|
|
1016
|
+
diagnostics.push(diagnostic(diagnosticCodes.host_install_conflict, `Host file changed since planning; it was preserved: ${path}`, path, 'Inspect the file or replan; Pappra does not overwrite unknown user changes.'));
|
|
1017
|
+
return hostIntegrationResultSchema.parse({
|
|
1018
|
+
schema: 'pappra/host-integration/1',
|
|
1019
|
+
ok: false,
|
|
1020
|
+
status: 'conflict',
|
|
1021
|
+
plan,
|
|
1022
|
+
appliedFiles: applied,
|
|
1023
|
+
residualPaths: [],
|
|
1024
|
+
diagnostics,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
if (before === file.nextDigest) {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const bytes = contents.get(file.path);
|
|
1031
|
+
if (bytes === undefined) {
|
|
1032
|
+
diagnostics.push(diagnostic(diagnosticCodes.host_bundle_invalid, `Missing planned content for ${file.path}.`, path, 'Replan the integration; no file was written.'));
|
|
1033
|
+
return hostIntegrationResultSchema.parse({
|
|
1034
|
+
schema: 'pappra/host-integration/1',
|
|
1035
|
+
ok: false,
|
|
1036
|
+
status: 'failed',
|
|
1037
|
+
plan,
|
|
1038
|
+
appliedFiles: applied,
|
|
1039
|
+
residualPaths: [],
|
|
1040
|
+
diagnostics,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
const written = await writeAtomic(path, bytes, {
|
|
1044
|
+
replace: file.beforeDigest !== null,
|
|
1045
|
+
beforeDigest: file.beforeDigest,
|
|
1046
|
+
root: plan.hostRoot,
|
|
1047
|
+
});
|
|
1048
|
+
if (!written.ok) {
|
|
1049
|
+
diagnostics.push(written.diagnostic);
|
|
1050
|
+
if (written.residual !== null)
|
|
1051
|
+
residualPaths.push(written.residual);
|
|
1052
|
+
return hostIntegrationResultSchema.parse({
|
|
1053
|
+
schema: 'pappra/host-integration/1',
|
|
1054
|
+
ok: false,
|
|
1055
|
+
status: written.status,
|
|
1056
|
+
plan,
|
|
1057
|
+
appliedFiles: applied,
|
|
1058
|
+
residualPaths,
|
|
1059
|
+
diagnostics,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
applied.push(file.path);
|
|
1063
|
+
if (written.residual !== null)
|
|
1064
|
+
residualPaths.push(written.residual);
|
|
1065
|
+
}
|
|
1066
|
+
return hostIntegrationResultSchema.parse({
|
|
1067
|
+
schema: 'pappra/host-integration/1',
|
|
1068
|
+
ok: true,
|
|
1069
|
+
status: applied.length === 0 ? 'unchanged' : 'applied',
|
|
1070
|
+
plan,
|
|
1071
|
+
appliedFiles: applied,
|
|
1072
|
+
residualPaths,
|
|
1073
|
+
diagnostics,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
const released = await releaseHostInstallLock(lockResult.lock);
|
|
1078
|
+
if (!released) {
|
|
1079
|
+
return hostIntegrationResultSchema.parse({
|
|
1080
|
+
schema: 'pappra/host-integration/1',
|
|
1081
|
+
ok: false,
|
|
1082
|
+
status: 'partial',
|
|
1083
|
+
plan,
|
|
1084
|
+
appliedFiles: applied,
|
|
1085
|
+
residualPaths: [...residualPaths, lockResult.lock.path],
|
|
1086
|
+
diagnostics: [
|
|
1087
|
+
...diagnostics,
|
|
1088
|
+
diagnostic(diagnosticCodes.host_install_conflict, `Host install lock was preserved at ${lockResult.lock.path}`, lockResult.lock.path, 'Inspect the edited or replaced lock manually; the install result is partial because the lock was not released.'),
|
|
1089
|
+
],
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
async verify(host, options = {}) {
|
|
1095
|
+
const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
|
|
1096
|
+
const version = options.hostVersion ?? this.#hostVersion;
|
|
1097
|
+
const capabilities = this.describe(host);
|
|
1098
|
+
const checks = [];
|
|
1099
|
+
if (!isSupportedVersion(host, version)) {
|
|
1100
|
+
return hostVerificationSchema.parse({
|
|
1101
|
+
schema: 'pappra/host-verification/1',
|
|
1102
|
+
host,
|
|
1103
|
+
version,
|
|
1104
|
+
hostRoot,
|
|
1105
|
+
status: 'unsupported',
|
|
1106
|
+
bundleDigest: null,
|
|
1107
|
+
checks: [
|
|
1108
|
+
{
|
|
1109
|
+
id: 'host.version',
|
|
1110
|
+
status: 'unsupported',
|
|
1111
|
+
detail: `Version ${version ?? '(unknown)'} is outside the pinned ${HOST_SUPPORT[host].version} matrix.`,
|
|
1112
|
+
},
|
|
1113
|
+
],
|
|
1114
|
+
capabilities: capabilities.capabilities,
|
|
1115
|
+
diagnostics: [],
|
|
1116
|
+
residualPaths: [],
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
const manifest = await this.readManifest(hostRoot);
|
|
1120
|
+
if (manifest === null) {
|
|
1121
|
+
return hostVerificationSchema.parse({
|
|
1122
|
+
schema: 'pappra/host-verification/1',
|
|
1123
|
+
host,
|
|
1124
|
+
version,
|
|
1125
|
+
hostRoot,
|
|
1126
|
+
status: 'partial',
|
|
1127
|
+
bundleDigest: null,
|
|
1128
|
+
checks: [
|
|
1129
|
+
{
|
|
1130
|
+
id: 'host.manifest',
|
|
1131
|
+
status: 'error',
|
|
1132
|
+
detail: 'No Pappra host install manifest exists in the selected host root.',
|
|
1133
|
+
},
|
|
1134
|
+
],
|
|
1135
|
+
capabilities: capabilities.capabilities,
|
|
1136
|
+
diagnostics: [],
|
|
1137
|
+
residualPaths: [],
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
const expectedConfigPath = hostConfigRelativePath(host);
|
|
1141
|
+
const identityError = validateManifestIdentity(manifest, host, hostRoot, expectedConfigPath, this.#runtimeEntry);
|
|
1142
|
+
if (identityError !== null) {
|
|
1143
|
+
return hostVerificationSchema.parse({
|
|
1144
|
+
schema: 'pappra/host-verification/1',
|
|
1145
|
+
host,
|
|
1146
|
+
version,
|
|
1147
|
+
hostRoot,
|
|
1148
|
+
status: 'conflict',
|
|
1149
|
+
bundleDigest: null,
|
|
1150
|
+
checks: [
|
|
1151
|
+
{
|
|
1152
|
+
id: 'host.manifest',
|
|
1153
|
+
status: 'error',
|
|
1154
|
+
detail: identityError.message,
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
1157
|
+
capabilities: capabilities.capabilities,
|
|
1158
|
+
diagnostics: [identityError],
|
|
1159
|
+
residualPaths: [],
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
let currentBundle;
|
|
1163
|
+
try {
|
|
1164
|
+
currentBundle = await loadHostBundle(host, getPackageInfo().version, {
|
|
1165
|
+
runtimeEntry: this.#runtimeEntry,
|
|
1166
|
+
observedAt: this.#observedAt,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
return hostVerificationSchema.parse({
|
|
1171
|
+
schema: 'pappra/host-verification/1',
|
|
1172
|
+
host,
|
|
1173
|
+
version,
|
|
1174
|
+
hostRoot,
|
|
1175
|
+
status: 'partial',
|
|
1176
|
+
bundleDigest: null,
|
|
1177
|
+
checks: [
|
|
1178
|
+
{
|
|
1179
|
+
id: 'host.bundle',
|
|
1180
|
+
status: 'warning',
|
|
1181
|
+
detail: 'Current package bundle could not be loaded; manifest coverage was not read.',
|
|
1182
|
+
},
|
|
1183
|
+
],
|
|
1184
|
+
capabilities: capabilities.capabilities,
|
|
1185
|
+
diagnostics: [],
|
|
1186
|
+
residualPaths: [],
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
if (currentBundle.bundle.bundleDigest !== manifest.bundleDigest) {
|
|
1190
|
+
return hostVerificationSchema.parse({
|
|
1191
|
+
schema: 'pappra/host-verification/1',
|
|
1192
|
+
host,
|
|
1193
|
+
version,
|
|
1194
|
+
hostRoot,
|
|
1195
|
+
status: 'conflict',
|
|
1196
|
+
bundleDigest: null,
|
|
1197
|
+
checks: [
|
|
1198
|
+
{
|
|
1199
|
+
id: 'host.bundle',
|
|
1200
|
+
status: 'error',
|
|
1201
|
+
detail: 'Manifest bundle digest does not match the current package; forged or stale metadata is not verified.',
|
|
1202
|
+
},
|
|
1203
|
+
],
|
|
1204
|
+
capabilities: capabilities.capabilities,
|
|
1205
|
+
diagnostics: [],
|
|
1206
|
+
residualPaths: [],
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
const expectedFiles = new Map();
|
|
1210
|
+
for (const [sourcePath, bytes] of currentBundle.contents) {
|
|
1211
|
+
expectedFiles.set(installPathForBundleFile(host, sourcePath), sha256Digest(bytes));
|
|
1212
|
+
}
|
|
1213
|
+
const expectedPaths = new Set(expectedFiles.keys());
|
|
1214
|
+
if (expectedConfigPath !== null)
|
|
1215
|
+
expectedPaths.add(expectedConfigPath);
|
|
1216
|
+
const manifestFiles = new Map(manifest.files.map((entry) => [entry.path, entry.digest]));
|
|
1217
|
+
if (manifestFiles.size !== expectedPaths.size ||
|
|
1218
|
+
[...expectedPaths].some((path) => !manifestFiles.has(path))) {
|
|
1219
|
+
return hostVerificationSchema.parse({
|
|
1220
|
+
schema: 'pappra/host-verification/1',
|
|
1221
|
+
host,
|
|
1222
|
+
version,
|
|
1223
|
+
hostRoot,
|
|
1224
|
+
status: 'conflict',
|
|
1225
|
+
bundleDigest: null,
|
|
1226
|
+
checks: [
|
|
1227
|
+
{
|
|
1228
|
+
id: 'host.coverage',
|
|
1229
|
+
status: 'error',
|
|
1230
|
+
detail: 'Manifest file set does not exactly cover the current bundle and expected config.',
|
|
1231
|
+
},
|
|
1232
|
+
],
|
|
1233
|
+
capabilities: capabilities.capabilities,
|
|
1234
|
+
diagnostics: [],
|
|
1235
|
+
residualPaths: [],
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
for (const [path, digest] of expectedFiles) {
|
|
1239
|
+
if (manifestFiles.get(path) !== digest) {
|
|
1240
|
+
return hostVerificationSchema.parse({
|
|
1241
|
+
schema: 'pappra/host-verification/1',
|
|
1242
|
+
host,
|
|
1243
|
+
version,
|
|
1244
|
+
hostRoot,
|
|
1245
|
+
status: 'conflict',
|
|
1246
|
+
bundleDigest: null,
|
|
1247
|
+
checks: [
|
|
1248
|
+
{
|
|
1249
|
+
id: `file:${path}`,
|
|
1250
|
+
status: 'error',
|
|
1251
|
+
detail: 'Manifest digest does not match the packaged bundle file.',
|
|
1252
|
+
},
|
|
1253
|
+
],
|
|
1254
|
+
capabilities: capabilities.capabilities,
|
|
1255
|
+
diagnostics: [],
|
|
1256
|
+
residualPaths: [],
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
for (const path of expectedPaths) {
|
|
1261
|
+
const absolute = join(hostRoot, ...path.split('/'));
|
|
1262
|
+
const safe = await assertSafeTarget(hostRoot, absolute);
|
|
1263
|
+
if (safe !== null) {
|
|
1264
|
+
return hostVerificationSchema.parse({
|
|
1265
|
+
schema: 'pappra/host-verification/1',
|
|
1266
|
+
host,
|
|
1267
|
+
version,
|
|
1268
|
+
hostRoot,
|
|
1269
|
+
status: 'conflict',
|
|
1270
|
+
bundleDigest: null,
|
|
1271
|
+
checks: [
|
|
1272
|
+
{
|
|
1273
|
+
id: `file:${path}`,
|
|
1274
|
+
status: 'error',
|
|
1275
|
+
detail: safe.message,
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
capabilities: capabilities.capabilities,
|
|
1279
|
+
diagnostics: [safe],
|
|
1280
|
+
residualPaths: [],
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
let status = 'verified';
|
|
1285
|
+
for (const file of manifest.files) {
|
|
1286
|
+
const path = join(hostRoot, ...file.path.split('/'));
|
|
1287
|
+
const digest = await fileDigest(path);
|
|
1288
|
+
if (digest !== file.digest) {
|
|
1289
|
+
status = 'conflict';
|
|
1290
|
+
checks.push({
|
|
1291
|
+
id: `file:${file.path}`,
|
|
1292
|
+
status: 'error',
|
|
1293
|
+
detail: 'Installed file is missing or differs from its recorded digest.',
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
checks.push({
|
|
1298
|
+
id: `file:${file.path}`,
|
|
1299
|
+
status: 'ok',
|
|
1300
|
+
detail: 'Installed file digest matches the manifest.',
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
if (manifest.configPath !== null) {
|
|
1305
|
+
const configPath = join(hostRoot, ...manifest.configPath.split('/'));
|
|
1306
|
+
const config = await readFileBounded(configPath, DEFAULT_MAX_CONFIG_BYTES);
|
|
1307
|
+
if (!config.ok ||
|
|
1308
|
+
manifestFiles.get(manifest.configPath) !== config.value.digest ||
|
|
1309
|
+
!configHasExpectedHooks(host === 'codex' ? 'codex' : 'claude', config.value.bytes, this.#runtimeEntry, configPath)) {
|
|
1310
|
+
status = 'conflict';
|
|
1311
|
+
checks.push({
|
|
1312
|
+
id: 'host.config',
|
|
1313
|
+
status: 'error',
|
|
1314
|
+
detail: 'Host config is missing, differs from its manifest digest, or lacks the exact expected Pappra entries.',
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
checks.push({
|
|
1319
|
+
id: 'host.config',
|
|
1320
|
+
status: 'ok',
|
|
1321
|
+
detail: 'Exact Pappra-managed hook entries are present; host trust/reload remains unverified.',
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
checks.push({
|
|
1326
|
+
id: 'host.native-callability',
|
|
1327
|
+
status: 'unknown',
|
|
1328
|
+
detail: 'File wiring verified only. Native loader/handshake, trust and event delivery were not executed.',
|
|
1329
|
+
});
|
|
1330
|
+
return hostVerificationSchema.parse({
|
|
1331
|
+
schema: 'pappra/host-verification/1',
|
|
1332
|
+
host,
|
|
1333
|
+
version,
|
|
1334
|
+
hostRoot,
|
|
1335
|
+
status,
|
|
1336
|
+
bundleDigest: manifest.bundleDigest,
|
|
1337
|
+
checks,
|
|
1338
|
+
capabilities: capabilities.capabilities,
|
|
1339
|
+
diagnostics: [],
|
|
1340
|
+
residualPaths: [],
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
async readManifest(hostRoot) {
|
|
1344
|
+
const path = join(hostRoot, BUNDLE_FILE);
|
|
1345
|
+
const safe = await assertSafeTarget(hostRoot, path);
|
|
1346
|
+
if (safe !== null)
|
|
1347
|
+
return null;
|
|
1348
|
+
const read = await readFileBounded(path, 256 * 1024);
|
|
1349
|
+
if (!read.ok)
|
|
1350
|
+
return null;
|
|
1351
|
+
let value;
|
|
1352
|
+
try {
|
|
1353
|
+
value = JSON.parse(read.value.bytes.toString('utf8'));
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
const parsed = hostInstallManifestSchema.safeParse(value);
|
|
1359
|
+
return parsed.success ? parsed.data : null;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
export class HostAdapter {
|
|
1363
|
+
host;
|
|
1364
|
+
#service;
|
|
1365
|
+
constructor(service, host) {
|
|
1366
|
+
this.#service = service;
|
|
1367
|
+
this.host = host;
|
|
1368
|
+
}
|
|
1369
|
+
detect() {
|
|
1370
|
+
return this.#service.describe(this.host);
|
|
1371
|
+
}
|
|
1372
|
+
describeCapabilities() {
|
|
1373
|
+
return this.detect();
|
|
1374
|
+
}
|
|
1375
|
+
planIntegration(options = {}) {
|
|
1376
|
+
return this.#service.plan(this.host, options);
|
|
1377
|
+
}
|
|
1378
|
+
verifyIntegration(options = {}) {
|
|
1379
|
+
return this.#service.verify(this.host, options);
|
|
1380
|
+
}
|
|
1381
|
+
}
|