companion-spawn-adapter.mjs
162 lines 4.5 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Companion App — Phase 5 runtime spawn adapter.
3 *
4 * This module owns process launch and health probing only. It imports no session,
5 * OAuth, keychain, vault, or canister authority and accepts no authority-bearing
6 * object in its public surface.
7 */
8
9 import { spawn as nodeSpawn } from 'node:child_process';
10 import path from 'node:path';
11
12 export const SPAWN_ADAPTER_REASONS = Object.freeze({
13 INVALID_BINARY_PATH: 'invalid_binary_path',
14 INVALID_MODEL_PATH: 'invalid_model_path',
15 INVALID_PORT: 'invalid_port',
16 INVALID_RAM_LIMIT: 'invalid_ram_limit',
17 SPAWN_FAILED: 'spawn_failed',
18 });
19
20 export const SECRET_ENV_KEY_PATTERNS = Object.freeze([
21 /SESSION_SECRET/i,
22 /(^|_)JWT($|_)/i,
23 /TOKEN/i,
24 /REFRESH/i,
25 /API_KEY/i,
26 /KEYCHAIN/i,
27 /SECRET/i,
28 /PASSWORD/i,
29 /CREDENTIAL/i,
30 ]);
31
32 const ENV_ALLOWLIST = new Set([
33 'HOME',
34 'TMPDIR',
35 'TEMP',
36 'TMP',
37 'PATH',
38 'LANG',
39 'LC_ALL',
40 'LC_CTYPE',
41 'SYSTEMROOT',
42 'WINDIR',
43 ]);
44
45 /**
46 * @param {unknown} value
47 * @param {string} reason
48 * @returns {string}
49 */
50 function requireAbsolutePath(value, reason) {
51 if (typeof value !== 'string' || value.length === 0 || !path.isAbsolute(value)) {
52 throw new TypeError(reason);
53 }
54 return value;
55 }
56
57 /**
58 * Create a minimal child environment with known secret-bearing keys stripped.
59 * @param {Record<string, string|undefined>} sourceEnv
60 * @returns {Record<string, string>}
61 */
62 export function createScrubbedRuntimeEnv(sourceEnv = process.env) {
63 const clean = {};
64 for (const [key, value] of Object.entries(sourceEnv)) {
65 if (typeof value !== 'string') continue;
66 if (!ENV_ALLOWLIST.has(key)) continue;
67 if (SECRET_ENV_KEY_PATTERNS.some((pattern) => pattern.test(key))) continue;
68 clean[key] = value;
69 }
70 return clean;
71 }
72
73 /**
74 * Build a shell-free argv array for the bundled runtime.
75 * @param {{ modelPath: string, port: number, maxRamBytes: number, socketPath?: string }} opts
76 * @returns {string[]}
77 */
78 export function buildRuntimeArgv(opts) {
79 const modelPath = requireAbsolutePath(opts?.modelPath, SPAWN_ADAPTER_REASONS.INVALID_MODEL_PATH);
80 const port = opts?.port;
81 const maxRamBytes = opts?.maxRamBytes;
82 if (!Number.isInteger(port) || port < 1 || port > 65535) {
83 throw new TypeError(SPAWN_ADAPTER_REASONS.INVALID_PORT);
84 }
85 if (!Number.isFinite(maxRamBytes) || maxRamBytes <= 0) {
86 throw new TypeError(SPAWN_ADAPTER_REASONS.INVALID_RAM_LIMIT);
87 }
88 const argv = [
89 '--host',
90 '127.0.0.1',
91 '--port',
92 String(port),
93 '--model',
94 modelPath,
95 '--max-ram-bytes',
96 String(Math.floor(maxRamBytes)),
97 ];
98 if (typeof opts.socketPath === 'string' && opts.socketPath.length > 0) {
99 argv.push('--unix-socket', opts.socketPath);
100 }
101 return argv;
102 }
103
104 /**
105 * Create the Phase 5 runtime spawn/kill/health adapter.
106 * @param {{ spawn?: typeof nodeSpawn, fetch?: typeof globalThis.fetch, env?: Record<string,string|undefined> }} [deps]
107 */
108 export function createCompanionSpawnAdapter(deps = {}) {
109 const spawnFn = deps.spawn ?? nodeSpawn;
110 const fetchFn = deps.fetch ?? globalThis.fetch;
111 const sourceEnv = deps.env ?? process.env;
112
113 return {
114 async spawn(opts) {
115 const binaryPath = requireAbsolutePath(opts?.binaryPath, SPAWN_ADAPTER_REASONS.INVALID_BINARY_PATH);
116 const argv = buildRuntimeArgv(opts);
117 let child;
118 try {
119 child = spawnFn(binaryPath, argv, {
120 shell: false,
121 detached: false,
122 stdio: ['ignore', 'ignore', 'ignore'],
123 env: createScrubbedRuntimeEnv(sourceEnv),
124 });
125 } catch {
126 throw new Error(SPAWN_ADAPTER_REASONS.SPAWN_FAILED);
127 }
128 if (!child || !Number.isInteger(child.pid)) {
129 throw new Error(SPAWN_ADAPTER_REASONS.SPAWN_FAILED);
130 }
131 return {
132 pid: child.pid,
133 port: opts.port,
134 async kill() {
135 if (typeof child.kill === 'function' && !child.killed) child.kill('SIGTERM');
136 },
137 };
138 },
139
140 async healthCheck(handle) {
141 if (!handle || !Number.isInteger(handle.port) || typeof fetchFn !== 'function') return false;
142 for (const endpoint of ['/v1/models', '/api/tags']) {
143 try {
144 const res = await fetchFn(`http://127.0.0.1:${handle.port}${endpoint}`, {
145 method: 'GET',
146 headers: { Accept: 'application/json' },
147 });
148 if (res && res.ok) return true;
149 } catch {
150 // Try the alternate health endpoint below.
151 }
152 }
153 return false;
154 },
155
156 async reclaimStaleRuntime(handle) {
157 if (!handle || typeof handle.kill !== 'function') return false;
158 await handle.kill();
159 return true;
160 },
161 };
162 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago