companion-shell.mjs
277 lines 9.3 KB
Raw
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor ⚠ breaking 9 days ago
1 /**
2 * Companion App — Phase 5 orchestration shell.
3 *
4 * This binding layer composes the Phase 2–4 pure cores with Phase 5 adapters.
5 * Authority-bearing objects are held by the authority group; runtime operations
6 * receive only inert values such as URLs, paths, ports, and resource limits.
7 */
8
9 import { open, rename, rm } from 'node:fs/promises';
10 import path from 'node:path';
11
12 import {
13 LIFECYCLE_EVENTS,
14 canServeInference,
15 createIntegrityAccumulator,
16 createLifecycleState,
17 transitionLifecycle,
18 validateIntegritySpec,
19 validateSourceUrl,
20 } from './companion-runtime-manager.mjs';
21 import { selectLane } from './model-runtime-lane.mjs';
22
23 export const COMPANION_SHELL_REASONS = Object.freeze({
24 NOT_READY: 'not_ready',
25 MANIFEST_UNTRUSTED: 'manifest_untrusted',
26 INTEGRITY_FAILED: 'integrity_failed',
27 SPAWN_FAILED: 'spawn_failed',
28 HEALTH_FAILED: 'health_failed',
29 INVALID_GROUP: 'invalid_group',
30 });
31
32 const DEFAULT_HEALTH_RECENCY_MS = 15_000;
33
34 /**
35 * Build an authority group. Runtime adapters never receive this object.
36 * @param {{ keychain?: unknown, oauth?: unknown, manifestFetcher?: Function, canister?: unknown }} params
37 */
38 export function createAuthorityGroup(params = {}) {
39 return Object.freeze({
40 keychain: params.keychain ?? null,
41 oauth: params.oauth ?? null,
42 manifestFetcher: params.manifestFetcher ?? null,
43 canister: params.canister ?? null,
44 });
45 }
46
47 /**
48 * Build a runtime group from inert runtime capabilities only.
49 * @param {{ spawn: Function, download: Function, healthCheck: Function, statResources?: Function }} params
50 */
51 export function createRuntimeGroup(params = {}) {
52 if (
53 typeof params.spawn !== 'function' ||
54 typeof params.download !== 'function' ||
55 typeof params.healthCheck !== 'function'
56 ) {
57 throw new TypeError(COMPANION_SHELL_REASONS.INVALID_GROUP);
58 }
59 return Object.freeze({
60 spawn: params.spawn,
61 download: params.download,
62 healthCheck: params.healthCheck,
63 statResources: typeof params.statResources === 'function' ? params.statResources : async () => ({ ramBytes: 0, vramBytes: 0, cpuPercent: 0 }),
64 });
65 }
66
67 /**
68 * Validate that model integrity metadata came from a first-party manifest that is
69 * out-of-band from the model host.
70 * @param {{ manifestUrl: string, modelUrl: string, expectedDigest: string, expectedSizeBytes: number, allowedSourceUrls: string[] }} spec
71 */
72 export function validateManifestTrustAnchor(spec) {
73 try {
74 const manifest = new URL(spec?.manifestUrl);
75 const model = new URL(spec?.modelUrl);
76 if (manifest.protocol !== 'https:' || model.protocol !== 'https:') {
77 return { ok: false, reason: COMPANION_SHELL_REASONS.MANIFEST_UNTRUSTED };
78 }
79 if (manifest.hostname === model.hostname) {
80 return { ok: false, reason: COMPANION_SHELL_REASONS.MANIFEST_UNTRUSTED };
81 }
82 const src = validateSourceUrl(spec.modelUrl, spec.allowedSourceUrls);
83 if (!src.ok) return { ok: false, reason: src.reason };
84 const integrity = validateIntegritySpec(spec.expectedDigest, spec.expectedSizeBytes);
85 if (!integrity.ok) return { ok: false, reason: integrity.reason };
86 return { ok: true, reason: 'ok' };
87 } catch {
88 return { ok: false, reason: COMPANION_SHELL_REASONS.MANIFEST_UNTRUSTED };
89 }
90 }
91
92 /**
93 * Pure predicate for the Phase 1 `companionAvailable` seam.
94 * @param {{
95 * integrityVerified?: boolean,
96 * lifecycleState?: { state: string },
97 * lastHealthOkAt?: number|null,
98 * now: number,
99 * listenerBound?: boolean,
100 * loopbackTokenPresent?: boolean,
101 * healthRecencyMs?: number,
102 * }} state
103 */
104 export function computeCompanionAvailable(state) {
105 if (!state || typeof state.now !== 'number' || !Number.isFinite(state.now)) return false;
106 if (state.integrityVerified !== true) return false;
107 if (!canServeInference(state.lifecycleState)) return false;
108 if (state.listenerBound !== true) return false;
109 if (state.loopbackTokenPresent !== true) return false;
110 if (typeof state.lastHealthOkAt !== 'number' || !Number.isFinite(state.lastHealthOkAt)) return false;
111 const recency = state.healthRecencyMs ?? DEFAULT_HEALTH_RECENCY_MS;
112 return state.now - state.lastHealthOkAt <= recency;
113 }
114
115 /**
116 * Download to a temp file, feed every byte to the integrity accumulator, fsync,
117 * finalize, and atomically rename to the verified path.
118 * @param {{
119 * runtimeGroup: ReturnType<typeof createRuntimeGroup>,
120 * spec: { modelUrl: string, expectedDigest: string, expectedSizeBytes: number, allowedSourceUrls: string[] },
121 * tempPath: string,
122 * verifiedPath: string,
123 * }} params
124 */
125 export async function downloadVerifyAndStageModel({ runtimeGroup, spec, tempPath, verifiedPath }) {
126 const acc = createIntegrityAccumulator({
127 expectedDigest: spec.expectedDigest,
128 expectedSizeBytes: spec.expectedSizeBytes,
129 sourceUrl: spec.modelUrl,
130 allowedSourceUrls: spec.allowedSourceUrls,
131 });
132 const file = await open(tempPath, 'w', 0o600);
133 let writes = Promise.resolve();
134 try {
135 await runtimeGroup.download(spec.modelUrl, (chunk) => {
136 acc.update(chunk);
137 writes = writes.then(() => file.write(chunk));
138 });
139 await writes;
140 await file.sync();
141 } catch (err) {
142 acc.abort();
143 await file.close().catch(() => {});
144 await rm(tempPath, { force: true });
145 throw err;
146 }
147 await file.close();
148 const verdict = acc.finalize();
149 if (!verdict.ok) {
150 await rm(tempPath, { force: true });
151 throw new Error(COMPANION_SHELL_REASONS.INTEGRITY_FAILED);
152 }
153 if (!path.isAbsolute(verifiedPath)) throw new TypeError(COMPANION_SHELL_REASONS.INTEGRITY_FAILED);
154 await rename(tempPath, verifiedPath);
155 return { ok: true, verifiedPath };
156 }
157
158 /**
159 * Create the run-from-source companion shell.
160 * @param {{
161 * authorityGroup?: ReturnType<typeof createAuthorityGroup>,
162 * runtimeGroup: ReturnType<typeof createRuntimeGroup>,
163 * now?: () => number,
164 * healthRecencyMs?: number,
165 * }} params
166 */
167 export function createCompanionShell(params) {
168 const runtimeGroup = createRuntimeGroup(params?.runtimeGroup);
169 const authorityGroup = params?.authorityGroup ?? createAuthorityGroup();
170 const now = params?.now ?? (() => Date.now());
171 const healthRecencyMs = params?.healthRecencyMs ?? DEFAULT_HEALTH_RECENCY_MS;
172
173 let lifecycleState = createLifecycleState();
174 let integrityVerified = false;
175 let listenerBound = false;
176 let loopbackTokenPresent = false;
177 let lastHealthOkAt = null;
178 let handle = null;
179
180 function markUnavailable() {
181 lastHealthOkAt = null;
182 if (canServeInference(lifecycleState)) {
183 const drained = transitionLifecycle(lifecycleState, LIFECYCLE_EVENTS.DRAIN);
184 lifecycleState = drained.ok ? drained.newState : createLifecycleState();
185 }
186 }
187
188 return {
189 authorityGroup,
190 runtimeGroup,
191 get state() {
192 return {
193 lifecycleState,
194 integrityVerified,
195 listenerBound,
196 loopbackTokenPresent,
197 lastHealthOkAt,
198 };
199 },
200 setListenerBound(value) {
201 listenerBound = value === true;
202 if (!listenerBound) markUnavailable();
203 },
204 setLoopbackTokenPresent(value) {
205 loopbackTokenPresent = value === true;
206 if (!loopbackTokenPresent) markUnavailable();
207 },
208 markIntegrityVerified() {
209 integrityVerified = true;
210 },
211 markIntegrityFailed() {
212 integrityVerified = false;
213 markUnavailable();
214 },
215 companionAvailable() {
216 return computeCompanionAvailable({
217 integrityVerified,
218 lifecycleState,
219 lastHealthOkAt,
220 listenerBound,
221 loopbackTokenPresent,
222 now: now(),
223 healthRecencyMs,
224 });
225 },
226 laneCapabilities(extra = {}) {
227 return { ...extra, companionAvailable: this.companionAvailable() };
228 },
229 selectLane(preferences = {}, extraCapabilities = {}) {
230 return selectLane(this.laneCapabilities(extraCapabilities), preferences);
231 },
232 async startRuntime(spawnOpts) {
233 if (!integrityVerified) throw new Error(COMPANION_SHELL_REASONS.INTEGRITY_FAILED);
234 const started = transitionLifecycle(lifecycleState, LIFECYCLE_EVENTS.START);
235 if (!started.ok) throw new Error(COMPANION_SHELL_REASONS.NOT_READY);
236 lifecycleState = started.newState;
237 try {
238 handle = await runtimeGroup.spawn(spawnOpts);
239 } catch {
240 lifecycleState = createLifecycleState();
241 throw new Error(COMPANION_SHELL_REASONS.SPAWN_FAILED);
242 }
243 const healthy = await runtimeGroup.healthCheck(handle);
244 if (!healthy) {
245 await handle.kill?.();
246 const failed = transitionLifecycle(lifecycleState, LIFECYCLE_EVENTS.HEALTH_FAIL);
247 lifecycleState = failed.newState;
248 lastHealthOkAt = null;
249 throw new Error(COMPANION_SHELL_REASONS.HEALTH_FAILED);
250 }
251 const ready = transitionLifecycle(lifecycleState, LIFECYCLE_EVENTS.HEALTH_OK);
252 lifecycleState = ready.newState;
253 lastHealthOkAt = now();
254 return handle;
255 },
256 async healthProbe() {
257 if (!handle) {
258 markUnavailable();
259 return false;
260 }
261 const healthy = await runtimeGroup.healthCheck(handle);
262 if (!healthy) {
263 markUnavailable();
264 return false;
265 }
266 lastHealthOkAt = now();
267 return true;
268 },
269 async shutdown() {
270 markUnavailable();
271 if (handle && typeof handle.kill === 'function') await handle.kill();
272 const stopped = transitionLifecycle({ state: 'draining' }, LIFECYCLE_EVENTS.STOPPED);
273 lifecycleState = stopped.ok ? stopped.newState : createLifecycleState();
274 handle = null;
275 },
276 };
277 }
File History 1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 9 days ago