@michelj/context-guard 0.4.3 → 0.6.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/Coordinator.md +88 -0
- package/Executor.md +53 -0
- package/README.md +89 -224
- package/README.zh-CN.md +89 -224
- package/SKILL.md +26 -684
- package/THIRD_PARTY_NOTICES.md +47 -0
- package/Tester.md +53 -0
- package/agents/openai.yaml +2 -2
- package/bin/build-runtime.mjs +96 -0
- package/bin/context-guard-skill.js +399 -78
- package/bin/postinstall.js +2 -2
- package/hooks.json +89 -13
- package/licenses/JSONParse-MIT.txt +24 -0
- package/licenses/Marked-MIT.txt +44 -0
- package/licenses/Portless-Apache-2.0.txt +201 -0
- package/package.json +35 -6
- package/prototype/LICENSES/Marked-MIT.txt +44 -0
- package/prototype/LICENSES/Ready-redistribution.txt +14 -0
- package/prototype/attachments.mjs +75 -0
- package/prototype/coordinator-markdown.mjs +283 -0
- package/prototype/coordinator-working-blot.mjs +124 -0
- package/prototype/vendor/marked.mjs +2189 -0
- package/prototype/workbench-app.js +5197 -0
- package/prototype/workbench-data.js +33 -0
- package/prototype/workbench-sync.mjs +898 -0
- package/prototype/workbench.css +1050 -0
- package/prototype/workbench.html +211 -0
- package/prototype/working-blot-atlas.png +0 -0
- package/references/agent-handoff.md +40 -0
- package/references/claude-runtime.md +120 -0
- package/references/cloud-sync-interface.md +66 -0
- package/references/design-current.md +14 -0
- package/references/map-mount.md +41 -0
- package/references/map-read.md +50 -0
- package/references/memory-definition.md +120 -0
- package/references/memory-filesystem-v2/Bug.en.md +162 -0
- package/references/memory-filesystem-v2/Bug.md +162 -0
- package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
- package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
- package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
- package/references/memory-filesystem-v2/Idea.en.md +36 -0
- package/references/memory-filesystem-v2/Idea.md +36 -0
- package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
- package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
- package/references/memory-filesystem-v2/README.md +60 -0
- package/references/memory-filesystem-v2/Todo.en.md +137 -0
- package/references/memory-filesystem-v2/Todo.md +137 -0
- package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
- package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
- package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
- package/references/named-workbench.md +124 -0
- package/references/plan-review.md +12 -0
- package/references/server-memory.md +276 -0
- package/references/test-check.md +7 -0
- package/references/user-reply.md +38 -0
- package/references/workbench-interface.md +531 -0
- package/roles.md +13 -0
- package/scripts/context_guard.py +1366 -7602
- package/scripts/context_guard_hook.py +1960 -711
- package/scripts/map_owns.py +699 -0
- package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
- package/scripts/shared/filesystem-v2.mjs +430 -0
- package/scripts/shared/io.mjs +117 -0
- package/scripts/shared/map-model.mjs +506 -0
- package/scripts/shared/memory-schema.mjs +13 -0
- package/scripts/shared/protocol-blobs.mjs +112 -0
- package/scripts/shared/protocol-map.mjs +146 -0
- package/scripts/shared/protocol-snapshots.mjs +84 -0
- package/scripts/shared/protocol-store.mjs +624 -0
- package/scripts/shared/protocol-workflow.mjs +226 -0
- package/scripts/shared/protocol.mjs +125 -0
- package/scripts/shared/vendor/jsonparse.cjs +413 -0
- package/scripts/workbench/access.mjs +496 -0
- package/scripts/workbench/attachments.mjs +92 -0
- package/scripts/workbench/browser-login.mjs +78 -0
- package/scripts/workbench/claude-runtime.mjs +372 -0
- package/scripts/workbench/cli.mjs +980 -0
- package/scripts/workbench/device-heartbeat.mjs +72 -0
- package/scripts/workbench/hook-status.mjs +38 -0
- package/scripts/workbench/inbox.mjs +155 -0
- package/scripts/workbench/journal.mjs +56 -0
- package/scripts/workbench/memory-merge.mjs +65 -0
- package/scripts/workbench/memory.mjs +252 -0
- package/scripts/workbench/named-proxy.mjs +108 -0
- package/scripts/workbench/named.mjs +152 -0
- package/scripts/workbench/portless-routes.mjs +51 -0
- package/scripts/workbench/project.mjs +327 -0
- package/scripts/workbench/projections.mjs +68 -0
- package/scripts/workbench/protocol-client.mjs +165 -0
- package/scripts/workbench/protocol-delivery.mjs +133 -0
- package/scripts/workbench/protocol-device.mjs +316 -0
- package/scripts/workbench/protocol-events.mjs +53 -0
- package/scripts/workbench/protocol-repository.mjs +58 -0
- package/scripts/workbench/reconcile.mjs +244 -0
- package/scripts/workbench/registry.mjs +111 -0
- package/scripts/workbench/runtime.mjs +54 -0
- package/scripts/workbench/server.mjs +1171 -0
- package/scripts/workbench/store.mjs +243 -0
- package/scripts/workbench/sync-coordinator.mjs +518 -0
- package/scripts/workbench/sync.mjs +86 -0
- package/references/context-template.md +0 -341
- package/references/feature-chain-methodology.md +0 -228
- package/references/register-template.md +0 -85
- package/references/task-case-template.md +0 -63
- package/tests/BC-20260618-063.sh +0 -116
- package/tests/BC-20260618-065.sh +0 -66
- package/tests/BC-20260626-080.sh +0 -48
- package/tests/BC-20260626-081.sh +0 -40
- package/tests/BC-20260626-082.sh +0 -32
- package/tests/BC-20260626-083.sh +0 -66
- package/tests/BC-20260627-084.sh +0 -74
- package/tests/BC-20260630-086.sh +0 -50
- package/tests/BC-20260630-087.sh +0 -103
- package/tests/BC-20260630-088.sh +0 -32
- package/tests/BC-20260630-089.sh +0 -63
- package/tests/BC-20260701-090.sh +0 -84
- package/tests/BC-20260702-096.sh +0 -48
- package/tests/BC-20260706-098.sh +0 -66
- package/tests/BC-20260707-099.sh +0 -47
- package/tests/BC-20260707-100.sh +0 -46
- package/tests/BC-20260707-101.sh +0 -47
- package/tests/BC-20260707-102.sh +0 -68
- package/tests/BC-20260707-103.sh +0 -59
- package/tests/BC-20260707-104.sh +0 -103
- package/tests/BC-20260707-105.sh +0 -109
- package/tests/BC-20260707-106.sh +0 -80
- package/tests/BC-20260707-107.sh +0 -74
- package/tests/BC-20260707-108.sh +0 -48
- package/tests/BC-20260707-109.sh +0 -56
- package/tests/BC-20260707-110.sh +0 -71
- package/tests/BC-20260707-111.sh +0 -70
- package/tests/BC-20260707-112.sh +0 -45
- package/tests/BC-20260707-113.sh +0 -73
- package/tests/BC-20260707-115.sh +0 -77
- package/tests/BC-20260707-116.sh +0 -77
- package/tests/BC-20260707-118.sh +0 -115
- package/tests/BC-20260707-119.sh +0 -47
- package/tests/BC-20260707-120.sh +0 -60
- package/tests/BC-20260707-121.sh +0 -66
- package/tests/BC-20260707-122.sh +0 -48
- package/tests/BC-20260707-123.sh +0 -43
- package/tests/BC-20260707-124.sh +0 -56
- package/tests/BC-20260707-125.sh +0 -64
- package/tests/BC-20260707-126.sh +0 -80
- package/tests/BC-20260707-127.sh +0 -88
- package/tests/BC-20260707-129.sh +0 -59
- package/tests/BC-20260707-130.sh +0 -69
- package/tests/BC-20260707-131.sh +0 -140
- package/tests/BC-20260707-132.sh +0 -150
- package/tests/BC-20260707-133.sh +0 -70
- package/tests/BC-20260708-136.sh +0 -210
- package/tests/BC-20260708-137.sh +0 -106
- package/tests/BC-20260708-138.sh +0 -168
- package/tests/BC-20260708-139.sh +0 -79
- package/tests/BC-20260709-002.sh +0 -63
- package/tests/BC-20260709-003.sh +0 -239
- package/tests/BC-20260709-006.sh +0 -76
- package/tests/BC-20260709-008.sh +0 -168
- package/tests/BC-20260710-001.sh +0 -61
- package/tests/BC-20260710-002.sh +0 -111
- package/tests/npm-install-smoke.sh +0 -53
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { atomicWrite, encode, readJSON } from '../shared/io.mjs';
|
|
7
|
+
import { RouteStore } from './portless-routes.mjs';
|
|
8
|
+
import { compatibleRuntime } from './runtime.mjs';
|
|
9
|
+
import { DeviceHeartbeat } from './device-heartbeat.mjs';
|
|
10
|
+
|
|
11
|
+
const secret = () => randomBytes(32).toString('base64url');
|
|
12
|
+
const PROXY_RUNTIME_SCHEMA = 4;
|
|
13
|
+
export async function backendIdentity(route) {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(`http://127.0.0.1:${route.port}/__context_guard/health`, { signal: AbortSignal.timeout(1000), redirect: 'error' });
|
|
16
|
+
const value = await response.json();
|
|
17
|
+
return response.ok && compatibleRuntime(value) && value.instance === route.instance
|
|
18
|
+
&& (value.namedRoot || value.root) === route.root
|
|
19
|
+
&& (!route.runtimeSchema || value.runtimeSchema === route.runtimeSchema)
|
|
20
|
+
&& (!route.projectKey || value.projectId === route.projectKey);
|
|
21
|
+
} catch { return false; }
|
|
22
|
+
}
|
|
23
|
+
function headers(input) {
|
|
24
|
+
const omitted = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ...String(input.connection || '').toLowerCase().split(',').map(s => s.trim())]);
|
|
25
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => !omitted.has(key) && !key.startsWith('x-context-guard-')));
|
|
26
|
+
}
|
|
27
|
+
export async function startNamedProxy({ dir, port = 1355 } = {}) {
|
|
28
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
const routes = new RouteStore(dir), instance = secret(), adminToken = secret();
|
|
30
|
+
const deviceHeartbeat = new DeviceHeartbeat({ dir, onTick: async () => {
|
|
31
|
+
if ((await readJSON(path.join(dir, 'proxy.json'), null))?.instance !== instance) setImmediate(() => close());
|
|
32
|
+
} });
|
|
33
|
+
routes.loadRoutes(); // Fail closed on corrupted persisted state, without overwriting it.
|
|
34
|
+
let base, ready = false;
|
|
35
|
+
const sockets = new Set();
|
|
36
|
+
const send = (res, status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
|
|
37
|
+
const server = http.createServer(async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
// The state rename precedes its directory fsync. A launcher can discover
|
|
40
|
+
// this listener during that await, before initialization has completed.
|
|
41
|
+
if (!ready) return send(res, 503, { error: 'Proxy initializing' });
|
|
42
|
+
if (req.headers.host === new URL(base).host) {
|
|
43
|
+
if (req.headers.origin && req.headers.origin !== base) return send(res, 403, { error: 'Origin rejected' });
|
|
44
|
+
if (req.url === '/__cg_proxy/health' && req.method === 'GET') return send(res, 200, { kind: 'context-guard-named', version: 1, runtimeSchema: PROXY_RUNTIME_SCHEMA, capabilities: ['project-key-routes', 'device-heartbeat'], instance,
|
|
45
|
+
heartbeat: { batches: deviceHeartbeat.batches, errors: deviceHeartbeat.errors, lastSentAt: deviceHeartbeat.lastSentAt } });
|
|
46
|
+
if (req.headers.authorization !== `Bearer ${adminToken}`) return send(res, 401, { error: 'Local proxy capability required' });
|
|
47
|
+
if (req.url === '/__cg_proxy/stop' && req.method === 'POST') {
|
|
48
|
+
send(res, 202, { stopping: true }); setImmediate(() => close()); return;
|
|
49
|
+
}
|
|
50
|
+
if (req.url === '/__cg_proxy/routes' && req.method === 'POST') {
|
|
51
|
+
let text = ''; for await (const chunk of req) { text += chunk; if (text.length > 16384) return send(res, 413, { error: 'Body too large' }); }
|
|
52
|
+
const input = JSON.parse(text);
|
|
53
|
+
const { replace = null, ...route } = input;
|
|
54
|
+
// Validate before probing; do not let the management endpoint probe arbitrary hosts.
|
|
55
|
+
const { validRoute } = await import('./portless-routes.mjs');
|
|
56
|
+
if (!validRoute(route)) return send(res, 400, { error: 'Invalid route' });
|
|
57
|
+
if (!await backendIdentity(route)) return send(res, 409, { error: 'Backend identity changed' });
|
|
58
|
+
routes.addRoute(route, { replace });
|
|
59
|
+
return send(res, 200, { registered: true });
|
|
60
|
+
}
|
|
61
|
+
return send(res, 404, { error: 'Unknown proxy endpoint' });
|
|
62
|
+
}
|
|
63
|
+
const route = routes.loadRoutes().find(r => req.headers.host === `${r.hostname}:${server.address().port}`);
|
|
64
|
+
if (!route) return send(res, 404, { error: 'Unknown project hostname' });
|
|
65
|
+
const origin = `http://${req.headers.host}`;
|
|
66
|
+
if (req.headers.origin && req.headers.origin !== origin) return send(res, 403, { error: 'Origin rejected' });
|
|
67
|
+
if (!req.url.startsWith('/') || req.url.startsWith('//')) return send(res, 400, { error: 'Origin-form URL required' });
|
|
68
|
+
if (!await backendIdentity(route)) return send(res, 502, { error: 'Project backend unavailable or replaced; run context-guard workbench again' });
|
|
69
|
+
if (req.destroyed || res.destroyed) return;
|
|
70
|
+
const outgoing = http.request({ hostname: '127.0.0.1', port: route.port, method: req.method, path: req.url,
|
|
71
|
+
headers: { ...headers(req.headers), host: req.headers.host, 'x-context-guard-proxy': route.proxyToken } }, upstream => {
|
|
72
|
+
res.writeHead(upstream.statusCode, headers(upstream.headers)); upstream.pipe(res);
|
|
73
|
+
upstream.on('error', () => res.destroy());
|
|
74
|
+
});
|
|
75
|
+
outgoing.on('error', () => { if (!res.headersSent) send(res, 502, { error: 'Backend connection failed' }); else res.destroy(); });
|
|
76
|
+
req.on('aborted', () => outgoing.destroy()); res.on('close', () => outgoing.destroy());
|
|
77
|
+
req.pipe(outgoing);
|
|
78
|
+
} catch { if (!res.headersSent) send(res, 409, { error: 'Route rejected or route store invalid; inspect local configuration' }); else res.destroy(); }
|
|
79
|
+
});
|
|
80
|
+
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
|
|
81
|
+
server.on('upgrade', (_req, socket) => socket.destroy()); // Workbench uses SSE, not WebSockets.
|
|
82
|
+
server.requestTimeout = 15000; server.headersTimeout = 10000;
|
|
83
|
+
for (let attempt = 0; ; attempt++) {
|
|
84
|
+
try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port ? port + attempt : 0, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); }); break; }
|
|
85
|
+
catch (e) { server.removeAllListeners('error'); if (e.code !== 'EADDRINUSE' || !port || attempt >= 20) throw e; }
|
|
86
|
+
}
|
|
87
|
+
base = `http://127.0.0.1:${server.address().port}`;
|
|
88
|
+
const state = { version: 1, runtimeSchema: PROXY_RUNTIME_SCHEMA, instance, pid: process.pid, base, adminToken };
|
|
89
|
+
const stateFile = path.join(dir, 'proxy.json');
|
|
90
|
+
try { await atomicWrite(stateFile, encode(state)); } catch (e) { server.close(); throw e; }
|
|
91
|
+
let closing;
|
|
92
|
+
const close = () => closing ||= (async () => {
|
|
93
|
+
ready = false;
|
|
94
|
+
await deviceHeartbeat.close();
|
|
95
|
+
const stopped = new Promise(resolve => server.close(resolve));
|
|
96
|
+
for (const socket of sockets) socket.destroy(); await stopped;
|
|
97
|
+
if ((await readJSON(stateFile, null))?.instance === instance) await fs.unlink(stateFile);
|
|
98
|
+
})();
|
|
99
|
+
ready = true;
|
|
100
|
+
deviceHeartbeat.start();
|
|
101
|
+
return { state, server, close };
|
|
102
|
+
}
|
|
103
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
104
|
+
try {
|
|
105
|
+
const running = await startNamedProxy({ dir: process.argv[2], port: Number(process.argv[3] || 1355) });
|
|
106
|
+
process.on('SIGTERM', () => running.close()); process.on('SIGINT', () => running.close());
|
|
107
|
+
} catch (e) { console.error(e.message); process.exitCode = 1; }
|
|
108
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
7
|
+
import { readJSON, pause } from '../shared/io.mjs';
|
|
8
|
+
import { projectId, projectName, resolveProject } from './project.mjs';
|
|
9
|
+
import { compatibleRuntime, WORKBENCH_RUNTIME_SCHEMA } from './runtime.mjs';
|
|
10
|
+
import { globalWorkbenchDirectory, registeredProject, rememberProject } from './registry.mjs';
|
|
11
|
+
import { RouteStore } from './portless-routes.mjs';
|
|
12
|
+
|
|
13
|
+
export const namedDirectory = globalWorkbenchDirectory;
|
|
14
|
+
const PROXY_RUNTIME_SCHEMA = 4;
|
|
15
|
+
function alive(pid) { if (!Number.isInteger(pid) || pid < 1) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code !== 'ESRCH'; } }
|
|
16
|
+
async function proxyProbe(state) {
|
|
17
|
+
if (!state || state.version !== 1 || !/^http:\/\/127\.0\.0\.1:\d+$/.test(state.base) || !state.adminToken) return false;
|
|
18
|
+
try { const res = await fetch(state.base + '/__cg_proxy/health', { signal: AbortSignal.timeout(700), redirect: 'error' }); const value = await res.json(); return res.ok && value.kind === 'context-guard-named' && value.instance === state.instance ? value : null; } catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
const compatibleProxy = value => value?.runtimeSchema === PROXY_RUNTIME_SCHEMA && value.capabilities?.includes('project-key-routes');
|
|
21
|
+
function stopProxy(state) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const target = new URL('/__cg_proxy/stop', state.base);
|
|
24
|
+
const req = http.request(target, { method: 'POST', agent: false, headers: { Authorization: `Bearer ${state.adminToken}`, Connection: 'close' } }, res => {
|
|
25
|
+
res.resume(); res.on('end', () => res.statusCode === 202 ? resolve() : reject(new Error(`Proxy upgrade rejected with HTTP ${res.statusCode}`)));
|
|
26
|
+
});
|
|
27
|
+
req.setTimeout(1500, () => req.destroy(new Error('Proxy upgrade timed out')));
|
|
28
|
+
req.on('error', reject); req.end();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function ensureNamedProxy({ dir = namedDirectory(), port = Number(process.env.CONTEXT_GUARD_NAMED_PORT || 1355) } = {}) {
|
|
32
|
+
if (!Number.isInteger(port) || port < 0 || port > 65515) throw new Error('Invalid named proxy port');
|
|
33
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
const file = path.join(dir, 'proxy.json'), lock = path.join(dir, 'startup.lock');
|
|
35
|
+
const identity = randomUUID(), deadline = Date.now() + 15000;
|
|
36
|
+
let held = false;
|
|
37
|
+
while (!held) {
|
|
38
|
+
const state = await readJSON(file, null);
|
|
39
|
+
if (compatibleProxy(await proxyProbe(state))) return state;
|
|
40
|
+
if (Date.now() > deadline) throw new Error('Named proxy startup busy; no process was replaced');
|
|
41
|
+
try { const h = await fs.open(lock, 'wx', 0o600); try { await h.writeFile(JSON.stringify({ pid: process.pid, identity })); } finally { await h.close(); } held = true; }
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e.code !== 'EEXIST') throw e;
|
|
44
|
+
const owner = await readJSON(lock, null).catch(() => null);
|
|
45
|
+
if (owner?.pid && !alive(owner.pid)) {
|
|
46
|
+
// Serialize stale-lock reclamation: a second waiter must not unlink a
|
|
47
|
+
// new live owner's lock after the first waiter has already reclaimed it.
|
|
48
|
+
const reap = lock + '.reap';
|
|
49
|
+
try {
|
|
50
|
+
await fs.mkdir(reap);
|
|
51
|
+
try { const current = await readJSON(lock, null).catch(() => null); if (current?.pid && !alive(current.pid)) await fs.unlink(lock).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
|
|
52
|
+
finally { await fs.rmdir(reap); }
|
|
53
|
+
} catch (e) { if (e.code !== 'EEXIST') throw e; await pause(50); }
|
|
54
|
+
}
|
|
55
|
+
else await pause(50);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
let state = await readJSON(file, null);
|
|
60
|
+
let probe = await proxyProbe(state);
|
|
61
|
+
if (compatibleProxy(probe)) return state;
|
|
62
|
+
if (probe && state?.adminToken && alive(state.pid)) {
|
|
63
|
+
await stopProxy(state);
|
|
64
|
+
while (Date.now() < deadline && alive(state.pid) && (await readJSON(file, null))?.instance === state.instance) await pause(25);
|
|
65
|
+
if (alive(state.pid) && (await readJSON(file, null))?.instance === state.instance) throw new Error('Older named proxy did not stop safely; no replacement was started');
|
|
66
|
+
state = await readJSON(file, null);
|
|
67
|
+
probe = await proxyProbe(state);
|
|
68
|
+
if (compatibleProxy(probe)) return state;
|
|
69
|
+
}
|
|
70
|
+
if (state && alive(state.pid)) throw new Error('Existing proxy process is not healthy; inspect it before restarting');
|
|
71
|
+
const log = await fs.open(path.join(dir, 'proxy.log'), 'a', 0o600);
|
|
72
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./named-proxy.mjs', import.meta.url)), dir, String(port)], { detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd] });
|
|
73
|
+
child.unref(); await log.close();
|
|
74
|
+
while (Date.now() < deadline) { await pause(60); state = await readJSON(file, null); if (compatibleProxy(await proxyProbe(state))) return state; }
|
|
75
|
+
throw new Error('Named proxy failed to start; inspect its private proxy.log');
|
|
76
|
+
} finally { if ((await readJSON(lock, null))?.identity === identity) await fs.unlink(lock); }
|
|
77
|
+
}
|
|
78
|
+
export async function namedWorkbench(state, request, { name, dir, port } = {}) {
|
|
79
|
+
const project = await resolveProject(state.root);
|
|
80
|
+
const registryDir = dir || namedDirectory();
|
|
81
|
+
const registered = await registeredProject(project, { dir: registryDir });
|
|
82
|
+
const saved = await readJSON(project.kind === 'git' ? path.join(project.sharedDir, 'named-entry.json') : path.join(state.root, '.codex/context/private/named-entry.json'), null);
|
|
83
|
+
const doc = await readJSON(path.join(state.root, '.codex/context/map.json'), {});
|
|
84
|
+
const chosen = name || saved?.name || registered?.name || projectName(doc.project || path.basename(state.root), state.root);
|
|
85
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(chosen)) throw new Error('Use a lowercase DNS name (letters, digits, hyphens; 1–63 characters)');
|
|
86
|
+
const capabilities = await fetch(new URL('/__context_guard/health', state.url), { signal: AbortSignal.timeout(1000) }).then(r => r.json());
|
|
87
|
+
if (!capabilities.namedEntry || !compatibleRuntime(capabilities)) throw new Error('The running workbench needs an upgrade for verified named URLs. Save drafts and use the explicit migration flow; it was not restarted');
|
|
88
|
+
const proxy = await ensureNamedProxy({ dir: registryDir, port });
|
|
89
|
+
const hostname = `${chosen}.localhost`, origin = `http://${hostname}:${new URL(proxy.base).port}`;
|
|
90
|
+
// Stable, domain-separated capability for concurrent first registration.
|
|
91
|
+
const proxyToken = saved?.proxyToken || createHash('sha256').update(`named-forwarding:${state.adminToken}`).digest('base64url');
|
|
92
|
+
const identityRoot = capabilities.namedRoot || state.root;
|
|
93
|
+
const route = { hostname, root: identityRoot, projectId: projectId(identityRoot), projectKey: project.projectId, instance: state.instance, runtimeSchema: WORKBENCH_RUNTIME_SCHEMA, port: Number(new URL(state.url).port), proxyToken };
|
|
94
|
+
const previous = new RouteStore(registryDir).loadRoutes().find(item => item.hostname === hostname) || null;
|
|
95
|
+
let replace = null;
|
|
96
|
+
if (previous && (previous.root !== route.root || previous.projectId !== route.projectId)) {
|
|
97
|
+
const knownRoot = registered?.roots?.includes(previous.root);
|
|
98
|
+
const sameGitProject = await resolveProject(previous.root).then(item => item.projectId === project.projectId).catch(() => false);
|
|
99
|
+
if (previous.projectKey === project.projectId || knownRoot || sameGitProject) {
|
|
100
|
+
replace = { hostname: previous.hostname, root: previous.root, projectId: previous.projectId, instance: previous.instance };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const response = await fetch(proxy.base + '/__cg_proxy/routes', { method: 'POST', headers: { Authorization: `Bearer ${proxy.adminToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...route, ...(replace ? { replace } : {}) }), signal: AbortSignal.timeout(5000), redirect: 'error' });
|
|
104
|
+
if (!response.ok) throw new Error('Project name registration failed; name may belong to another project');
|
|
105
|
+
// Claim the name before changing the backend's accepted origin. A collision
|
|
106
|
+
// must not break the project's previously working URL.
|
|
107
|
+
await request(state, '/api/named-entry', { method: 'POST', body: { origin, name: chosen, proxyToken } });
|
|
108
|
+
const result = { url: origin + '/prototype/workbench.html', projectRoot: state.root, proxyPort: Number(new URL(proxy.base).port) };
|
|
109
|
+
await verifyWorkbenchUrl(result.url, { projectId: state.projectId, instance: state.instance });
|
|
110
|
+
await rememberProject(project, { dir: registryDir, name: chosen, origin, state: { ...state, ...capabilities } });
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readWorkbenchHealth(url) {
|
|
115
|
+
let response, value;
|
|
116
|
+
try {
|
|
117
|
+
const target = new URL('/__context_guard/health', url);
|
|
118
|
+
// Node 18 delegates *.localhost to DNS on some platforms even though the
|
|
119
|
+
// browser-facing name is required to resolve to loopback. Probe the local
|
|
120
|
+
// proxy directly while preserving its host-based project routing.
|
|
121
|
+
if (target.protocol === 'http:' && target.hostname.endsWith('.localhost')) {
|
|
122
|
+
const result = await new Promise((resolve, reject) => {
|
|
123
|
+
const request = http.get({ hostname: '127.0.0.1', port: target.port, path: target.pathname + target.search, headers: { host: target.host } }, incoming => {
|
|
124
|
+
let text = '';
|
|
125
|
+
incoming.on('data', chunk => text += chunk);
|
|
126
|
+
incoming.on('end', () => {
|
|
127
|
+
try { resolve({ ok: incoming.statusCode >= 200 && incoming.statusCode < 300, value: JSON.parse(text) }); }
|
|
128
|
+
catch (error) { reject(error); }
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
request.setTimeout(1500, () => request.destroy(new Error('timeout')));
|
|
132
|
+
request.on('error', reject);
|
|
133
|
+
});
|
|
134
|
+
response = result;
|
|
135
|
+
value = result.value;
|
|
136
|
+
} else {
|
|
137
|
+
response = await fetch(target, { signal: AbortSignal.timeout(1500), redirect: 'error' });
|
|
138
|
+
value = await response.json();
|
|
139
|
+
}
|
|
140
|
+
} catch (cause) {
|
|
141
|
+
throw new Error(`Workbench URL verification failed: ${cause.message}`);
|
|
142
|
+
}
|
|
143
|
+
return { response, value };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function verifyWorkbenchUrl(url, expected) {
|
|
147
|
+
const { response, value } = await readWorkbenchHealth(url);
|
|
148
|
+
if (!response.ok || !compatibleRuntime(value) || value.projectId !== expected.projectId || value.instance !== expected.instance) {
|
|
149
|
+
throw new Error('Workbench URL resolves to a different project, instance, or runtime');
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Derived from Portless 0.15.6 src/routes.ts, Copyright 2025 Vercel Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Modified for Context Guard: HTTP-only route schema, single daemon writer,
|
|
4
|
+
// atomic private storage, strict validation; no force/kill, tunnels or PID pruning.
|
|
5
|
+
// See THIRD_PARTY_NOTICES.md and licenses/Portless-Apache-2.0.txt.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
export function validRoute(r) {
|
|
11
|
+
return r && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.localhost$/.test(r.hostname)
|
|
12
|
+
&& Number.isInteger(r.port) && r.port > 0 && r.port < 65536
|
|
13
|
+
&& typeof r.root === 'string' && path.isAbsolute(r.root)
|
|
14
|
+
&& /^[a-f0-9]{24}$/.test(r.projectId) && typeof r.instance === 'string' && r.instance.length >= 20
|
|
15
|
+
&& (r.runtimeSchema === undefined || Number.isInteger(r.runtimeSchema) && r.runtimeSchema > 0)
|
|
16
|
+
&& typeof r.proxyToken === 'string' && r.proxyToken.length >= 32
|
|
17
|
+
&& (r.projectKey === undefined || /^(?:git|folder)-[a-f0-9]{20}$/.test(r.projectKey));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameIdentity(left, right) {
|
|
21
|
+
return left && right && left.hostname === right.hostname && left.root === right.root
|
|
22
|
+
&& left.projectId === right.projectId && left.instance === right.instance;
|
|
23
|
+
}
|
|
24
|
+
export class RouteStore {
|
|
25
|
+
constructor(dir) { this.routesPath = path.join(dir, 'routes.json'); }
|
|
26
|
+
loadRoutes() {
|
|
27
|
+
let text;
|
|
28
|
+
try { text = fs.readFileSync(this.routesPath, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
29
|
+
const routes = JSON.parse(text);
|
|
30
|
+
if (!Array.isArray(routes) || !routes.every(validRoute) || new Set(routes.map(r => r.hostname)).size !== routes.length) throw new Error('Invalid named-workbench route store; restore the file before registering routes');
|
|
31
|
+
return routes;
|
|
32
|
+
}
|
|
33
|
+
saveRoutes(routes) {
|
|
34
|
+
const temp = `${this.routesPath}.${randomUUID()}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
const fd = fs.openSync(temp, 'wx', 0o600);
|
|
37
|
+
try { fs.writeFileSync(fd, JSON.stringify(routes)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
38
|
+
fs.renameSync(temp, this.routesPath);
|
|
39
|
+
} finally { try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
|
|
40
|
+
}
|
|
41
|
+
addRoute(route, { replace = null } = {}) {
|
|
42
|
+
if (!validRoute(route)) throw new Error('Invalid named-workbench route');
|
|
43
|
+
const routes = this.loadRoutes(), previous = routes.find(r => r.hostname === route.hostname);
|
|
44
|
+
// Project identity, not PID reuse, controls name ownership. Never kill an owner.
|
|
45
|
+
const sameProject = previous?.projectKey && route.projectKey && previous.projectKey === route.projectKey;
|
|
46
|
+
if (previous && (previous.projectId !== route.projectId || previous.root !== route.root) && !sameProject && !sameIdentity(previous, replace)) {
|
|
47
|
+
throw new Error('Name belongs to another project; choose a different --name');
|
|
48
|
+
}
|
|
49
|
+
this.saveRoutes([...routes.filter(r => r.hostname !== route.hostname), route]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { atomicWrite, encode, readJSON, withFileLock, hash } from '../shared/io.mjs';
|
|
7
|
+
import { MapError } from '../shared/map-model.mjs';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const digest = value => createHash('sha256').update(String(value)).digest('hex');
|
|
11
|
+
export const nonInteractiveGitEnvironment = (parent = process.env) => ({ ...parent, GIT_TERMINAL_PROMPT: '0' });
|
|
12
|
+
export const optionalGitMiss = error => Number.isInteger(error?.code) && error.code !== 0 && !error.killed && error.signal == null;
|
|
13
|
+
|
|
14
|
+
async function git(root, args, { optional = false } = {}) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
17
|
+
cwd: root,
|
|
18
|
+
env: nonInteractiveGitEnvironment(),
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
timeout: 10000,
|
|
22
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
23
|
+
});
|
|
24
|
+
return stdout.trim();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (optional && optionalGitMiss(error)) return '';
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function refreshMain(project) {
|
|
32
|
+
if (project.kind !== 'git' || project.bindingRequired) {
|
|
33
|
+
return { status: project.kind === 'git' ? 'binding-required' : 'local-folder', branch: project.mainBranch || '', sha: project.mainSha || '', checkedAt: new Date().toISOString() };
|
|
34
|
+
}
|
|
35
|
+
let status = 'ready', error = '';
|
|
36
|
+
if (project.binding?.main?.mode === 'remote') {
|
|
37
|
+
try { await git(project.worktreeRoot, ['fetch', '--quiet', project.binding.main.remote, project.mainBranch]); }
|
|
38
|
+
catch (cause) { status = 'offline'; error = cause.code || 'FETCH_FAILED'; }
|
|
39
|
+
}
|
|
40
|
+
const current = await resolveProject(project.worktreeRoot);
|
|
41
|
+
return {
|
|
42
|
+
status,
|
|
43
|
+
branch: current.mainBranch,
|
|
44
|
+
ref: current.mainRef,
|
|
45
|
+
sha: current.mainSha,
|
|
46
|
+
github: current.github,
|
|
47
|
+
checkedAt: new Date().toISOString(),
|
|
48
|
+
...(error ? { error } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function githubRepository(remote) {
|
|
53
|
+
const value = String(remote || '').trim();
|
|
54
|
+
const match = value.match(/^(?:https?:\/\/|ssh:\/\/git@|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
return { owner: match[1], repo: match[2], slug: `${match[1]}/${match[2]}` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function defaultBranch(root, remote = 'origin') {
|
|
60
|
+
const symbolic = await git(root, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], { optional: true });
|
|
61
|
+
if (symbolic.startsWith(`${remote}/`)) return symbolic.slice(remote.length + 1);
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function worktreeMetadata(root, main = null) {
|
|
66
|
+
const mainBranch = main?.branch || '';
|
|
67
|
+
const mainRef = main?.ref || '';
|
|
68
|
+
const [head, currentBranch, rawGitDir, mainSha] = await Promise.all([
|
|
69
|
+
git(root, ['rev-parse', 'HEAD'], { optional: true }),
|
|
70
|
+
git(root, ['branch', '--show-current'], { optional: true }),
|
|
71
|
+
git(root, ['rev-parse', '--path-format=absolute', '--git-dir'], { optional: true }),
|
|
72
|
+
mainRef ? git(root, ['rev-parse', '--verify', '--quiet', `${mainRef}^{commit}`], { optional: true }) : '',
|
|
73
|
+
]);
|
|
74
|
+
const gitDir = rawGitDir ? await fs.realpath(rawGitDir).catch(() => path.resolve(root, rawGitDir)) : '';
|
|
75
|
+
return { branch: currentBranch, head, gitDir, mainBranch, mainRef, mainSha };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const projectBindingPath = project => path.join(project.sharedDir, 'project-binding.json');
|
|
79
|
+
export const sessionBindingsPath = project => project.kind === 'git'
|
|
80
|
+
? path.join(project.sharedDir, 'workbench-bindings.json')
|
|
81
|
+
: path.join(project.worktreeRoot, '.codex/context/sessions/workbench-bindings.json');
|
|
82
|
+
|
|
83
|
+
function mainTarget({ mode = 'remote', remote = 'origin', branch } = {}) {
|
|
84
|
+
const name = String(branch || '').trim();
|
|
85
|
+
if (!name) throw new Error('A main branch is required');
|
|
86
|
+
if (mode === 'local') return { mode, remote: '', branch: name, ref: `refs/heads/${name}` };
|
|
87
|
+
const remoteName = String(remote || 'origin').trim();
|
|
88
|
+
if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) throw new Error('Invalid Git remote name');
|
|
89
|
+
return { mode: 'remote', remote: remoteName, branch: name, ref: `refs/remotes/${remoteName}/${name}` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function resolveProject(openedRoot) {
|
|
93
|
+
const requestedRoot = await fs.realpath(path.resolve(openedRoot));
|
|
94
|
+
const topLevel = await git(requestedRoot, ['rev-parse', '--show-toplevel'], { optional: true });
|
|
95
|
+
if (!topLevel) {
|
|
96
|
+
const projectId = `folder-${digest(requestedRoot).slice(0, 20)}`;
|
|
97
|
+
return {
|
|
98
|
+
projectId,
|
|
99
|
+
kind: 'folder',
|
|
100
|
+
openedRoot: requestedRoot,
|
|
101
|
+
worktreeRoot: requestedRoot,
|
|
102
|
+
worktreeId: `worktree-${digest(requestedRoot).slice(0, 20)}`,
|
|
103
|
+
commonDir: null,
|
|
104
|
+
sharedDir: path.join(requestedRoot, '.codex/context/private/project-workbench'),
|
|
105
|
+
remote: '',
|
|
106
|
+
github: null,
|
|
107
|
+
bindingRequired: false,
|
|
108
|
+
binding: { source: 'folder', main: null },
|
|
109
|
+
// Once Git has rejected this directory, three more Git processes cannot
|
|
110
|
+
// provide repository metadata. Keep the same empty folder contract.
|
|
111
|
+
branch: '', head: '', gitDir: '', mainBranch: '', mainRef: '', mainSha: '',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const worktreeRoot = await fs.realpath(topLevel);
|
|
115
|
+
let commonDir = await git(worktreeRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { optional: true });
|
|
116
|
+
if (!commonDir) {
|
|
117
|
+
const relative = await git(worktreeRoot, ['rev-parse', '--git-common-dir']);
|
|
118
|
+
commonDir = path.resolve(worktreeRoot, relative);
|
|
119
|
+
}
|
|
120
|
+
commonDir = await fs.realpath(commonDir);
|
|
121
|
+
const projectId = `git-${digest(commonDir).slice(0, 20)}`;
|
|
122
|
+
const sharedDir = path.join(commonDir, 'context-guard');
|
|
123
|
+
const stored = await readJSON(path.join(sharedDir, 'project-binding.json'), null);
|
|
124
|
+
if (stored && (stored.projectId !== projectId || !stored.main?.branch)) throw new Error('Invalid project binding; repair it explicitly, do not recreate it');
|
|
125
|
+
const originUrl = await git(worktreeRoot, ['config', '--get', 'remote.origin.url'], { optional: true });
|
|
126
|
+
const originGithub = githubRepository(originUrl);
|
|
127
|
+
const automaticBranch = originGithub ? await defaultBranch(worktreeRoot) : '';
|
|
128
|
+
const automatic = automaticBranch ? mainTarget({ remote: 'origin', branch: automaticBranch }) : null;
|
|
129
|
+
const selected = stored?.projectId === projectId && stored?.main?.branch ? mainTarget(stored.main) : automatic;
|
|
130
|
+
const remote = selected?.mode === 'remote'
|
|
131
|
+
? await git(worktreeRoot, ['config', '--get', `remote.${selected.remote}.url`], { optional: true })
|
|
132
|
+
: '';
|
|
133
|
+
const github = githubRepository(remote);
|
|
134
|
+
const metadata = await worktreeMetadata(worktreeRoot, selected);
|
|
135
|
+
return {
|
|
136
|
+
projectId,
|
|
137
|
+
kind: 'git',
|
|
138
|
+
openedRoot: requestedRoot,
|
|
139
|
+
worktreeRoot,
|
|
140
|
+
// The Git administration directory survives a supported `git worktree move`
|
|
141
|
+
// and changes when a different worktree is later created at the same path.
|
|
142
|
+
// An absolute checkout path has the opposite behaviour and caused stale
|
|
143
|
+
// bindings to look current after path reuse.
|
|
144
|
+
worktreeId: `worktree-${digest(metadata.gitDir || worktreeRoot).slice(0, 20)}`,
|
|
145
|
+
commonDir,
|
|
146
|
+
sharedDir,
|
|
147
|
+
remote,
|
|
148
|
+
github,
|
|
149
|
+
bindingRequired: !selected || !metadata.mainSha,
|
|
150
|
+
binding: selected ? { source: stored?.projectId === projectId ? 'explicit' : 'github-default', main: selected } : null,
|
|
151
|
+
...metadata,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function ensureProjectBinding(project) {
|
|
156
|
+
if (project.kind !== 'git' || !project.binding || project.binding.source === 'explicit') return project;
|
|
157
|
+
await atomicWrite(projectBindingPath(project), encode({ v: 1, projectId: project.projectId, main: project.binding.main, source: project.binding.source, updatedAt: new Date().toISOString() }));
|
|
158
|
+
return { ...project, binding: { ...project.binding, source: 'explicit' } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function saveMainBinding(openedRoot, options) {
|
|
162
|
+
const project = await resolveProject(openedRoot);
|
|
163
|
+
if (project.kind !== 'git') throw new Error('Main branch binding requires a Git project');
|
|
164
|
+
const target = mainTarget(options);
|
|
165
|
+
const valid = await git(project.worktreeRoot, ['check-ref-format', '--branch', target.branch], { optional: true });
|
|
166
|
+
if (valid !== target.branch) throw new Error('Invalid main branch name');
|
|
167
|
+
if (target.mode === 'remote') {
|
|
168
|
+
const remoteUrl = await git(project.worktreeRoot, ['remote', 'get-url', target.remote], { optional: true });
|
|
169
|
+
if (!remoteUrl) throw new Error(`Unknown Git remote: ${target.remote}`);
|
|
170
|
+
if (!await git(project.worktreeRoot, ['rev-parse', '--verify', '--quiet', `${target.ref}^{commit}`], { optional: true })) {
|
|
171
|
+
await git(project.worktreeRoot, ['fetch', '--quiet', target.remote, target.branch]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!await git(project.worktreeRoot, ['rev-parse', '--verify', '--quiet', `${target.ref}^{commit}`], { optional: true })) throw new Error(`Main ref does not resolve: ${target.ref}`);
|
|
175
|
+
await atomicWrite(projectBindingPath(project), encode({ v: 1, projectId: project.projectId, main: target, source: 'user', updatedAt: new Date().toISOString() }));
|
|
176
|
+
return resolveProject(openedRoot);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function bindingStatus(project, sessionId = '') {
|
|
180
|
+
const bindings = await readJSON(sessionBindingsPath(project), { sessions: {} });
|
|
181
|
+
const session = sessionId ? bindings?.sessions?.[sessionId] || null : null;
|
|
182
|
+
let state = 'unbound';
|
|
183
|
+
if (session) {
|
|
184
|
+
if (session.projectId !== project.projectId) state = 'project-mismatch';
|
|
185
|
+
else if (session.worktreeId === project.worktreeId) state = session.worktreeRoot === project.worktreeRoot ? 'current' : 'moved';
|
|
186
|
+
else if (!session.gitDir && session.worktreeRoot === project.worktreeRoot) state = 'current-legacy';
|
|
187
|
+
else {
|
|
188
|
+
const target = await fs.realpath(session.worktreeRoot || '').catch(() => null);
|
|
189
|
+
state = target ? 'other-worktree' : 'stale';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
projectId: project.projectId,
|
|
194
|
+
kind: project.kind,
|
|
195
|
+
main: project.binding?.main || null,
|
|
196
|
+
bindingRequired: project.bindingRequired,
|
|
197
|
+
workbenchState: project.kind === 'git' ? path.join(project.sharedDir, 'workbench.json') : path.join(project.worktreeRoot, '.codex/context/private/workbench.json'),
|
|
198
|
+
session: session
|
|
199
|
+
? { ...session, state, bound: ['current', 'current-legacy', 'moved'].includes(state), pathChanged: state === 'moved', migrationNeeded: state === 'current-legacy' }
|
|
200
|
+
: { bound: false, state, sessionId },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function projectPreferences(project, language) {
|
|
205
|
+
const file = path.join(project.sharedDir, 'preferences.json');
|
|
206
|
+
return withFileLock(file + '.lock', async () => {
|
|
207
|
+
const stored = await readJSON(file, null);
|
|
208
|
+
if (await readJSON(path.join(project.sharedDir, 'memory-client.json'), null)) {
|
|
209
|
+
const { memoryRequest } = await import('./memory.mjs');
|
|
210
|
+
const remote = (await memoryRequest(project, 'preferences')).preferences;
|
|
211
|
+
const chosen = language === undefined ? remote?.language || stored?.record_language : language;
|
|
212
|
+
if (!chosen) return { record_language: 'unset', display_language: 'auto' };
|
|
213
|
+
if (!['zh', 'en'].includes(chosen)) throw new Error('Language must be zh or en');
|
|
214
|
+
const result = remote?.language === chosen ? remote : (await memoryRequest(project, 'preferences', { operationId: `language:${remote?.version || 'initial'}:${chosen}`, baseVersion: remote?.version || null, language: chosen })).snapshot;
|
|
215
|
+
const value = { record_language: result.language, display_language: result.language, serverVersion: result.version };
|
|
216
|
+
await atomicWrite(file, encode(value)); return value;
|
|
217
|
+
}
|
|
218
|
+
if (language !== undefined) {
|
|
219
|
+
if (!['zh', 'en'].includes(language)) throw new Error('Language must be zh or en');
|
|
220
|
+
const next = { ...stored, record_language: language, display_language: language, updatedAt: new Date().toISOString() };
|
|
221
|
+
await atomicWrite(file, encode(next));
|
|
222
|
+
const verified = await readJSON(file);
|
|
223
|
+
if (verified.record_language !== language) throw new Error('Language persistence verification failed');
|
|
224
|
+
return verified;
|
|
225
|
+
}
|
|
226
|
+
if (stored) {
|
|
227
|
+
if (!['zh', 'en'].includes(stored.record_language)) throw new Error('Invalid shared language configuration');
|
|
228
|
+
return stored;
|
|
229
|
+
}
|
|
230
|
+
const values = new Set();
|
|
231
|
+
for (const root of await listWorktrees(project)) {
|
|
232
|
+
const value = await readJSON(path.join(root, '.codex/context/preferences.json'), null);
|
|
233
|
+
if (['zh', 'en'].includes(value?.record_language)) values.add(value.record_language);
|
|
234
|
+
}
|
|
235
|
+
if (values.size > 1) throw Object.assign(new Error('Confirmed language settings conflict; ask which project language to retain'), { code: 'LANGUAGE_CONFLICT' });
|
|
236
|
+
if (!values.size) return { record_language: 'unset', display_language: 'auto' };
|
|
237
|
+
const value = { record_language: [...values][0], display_language: [...values][0], migrated: true };
|
|
238
|
+
await atomicWrite(file, encode(value));
|
|
239
|
+
return readJSON(file);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function sameProject(leftRoot, rightRoot) {
|
|
244
|
+
const [left, right] = await Promise.all([resolveProject(leftRoot), resolveProject(rightRoot)]);
|
|
245
|
+
return left.projectId === right.projectId;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function listWorktrees(project) {
|
|
249
|
+
if (project.kind !== 'git') return [project.worktreeRoot];
|
|
250
|
+
const text = await git(project.worktreeRoot, ['worktree', 'list', '--porcelain']);
|
|
251
|
+
const roots = [];
|
|
252
|
+
for (const line of text.split(/\r?\n/)) {
|
|
253
|
+
if (!line.startsWith('worktree ')) continue;
|
|
254
|
+
const root = await fs.realpath(line.slice('worktree '.length)).catch(() => null);
|
|
255
|
+
if (root) roots.push(root);
|
|
256
|
+
}
|
|
257
|
+
return [...new Set(roots)];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function mainWorktree(project) {
|
|
261
|
+
if (project.kind !== 'git' || !project.mainBranch) return null;
|
|
262
|
+
const text = await git(project.worktreeRoot, ['worktree', 'list', '--porcelain']);
|
|
263
|
+
let candidate = null;
|
|
264
|
+
for (const line of `${text}\n`.split(/\r?\n/)) {
|
|
265
|
+
if (line.startsWith('worktree ')) candidate = line.slice('worktree '.length);
|
|
266
|
+
else if (line === `branch refs/heads/${project.mainBranch}` && candidate) {
|
|
267
|
+
return fs.realpath(candidate).catch(() => null);
|
|
268
|
+
} else if (!line && candidate) candidate = null;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function sessionBinding(project, sessionId, { workbenchUrl = null, role = 'executor' } = {}) {
|
|
274
|
+
return {
|
|
275
|
+
sessionId,
|
|
276
|
+
role,
|
|
277
|
+
projectId: project.projectId,
|
|
278
|
+
worktreeId: project.worktreeId,
|
|
279
|
+
gitDir: project.gitDir || null,
|
|
280
|
+
worktreeRoot: project.worktreeRoot,
|
|
281
|
+
branch: project.branch,
|
|
282
|
+
head: project.head,
|
|
283
|
+
mainBranch: project.mainBranch,
|
|
284
|
+
baseMainSha: project.mainSha,
|
|
285
|
+
workbenchState: project.kind === 'git' ? path.join(project.sharedDir, 'workbench.json') : path.join(project.worktreeRoot, '.codex/context/private/workbench.json'),
|
|
286
|
+
...(workbenchUrl ? { workbenchUrl } : {}),
|
|
287
|
+
updatedAt: new Date().toISOString(),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const bindingPath = root => path.join(root, '.codex/context/private/project-binding.json');
|
|
292
|
+
async function commonDir(root) {
|
|
293
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--git-common-dir'], { cwd: root, env: nonInteractiveGitEnvironment(), windowsHide: true, timeout: 5000 });
|
|
294
|
+
return fs.realpath(path.resolve(root, stdout.trim()));
|
|
295
|
+
}
|
|
296
|
+
export async function resolveProjectRoot(root) {
|
|
297
|
+
root = await fs.realpath(path.resolve(root));
|
|
298
|
+
const binding = await readJSON(bindingPath(root), null);
|
|
299
|
+
if (!binding) return root;
|
|
300
|
+
if (binding.version !== 1 || !path.isAbsolute(binding.projectRoot || '')) throw new MapError('INVALID_BINDING', 'Invalid workbench project binding');
|
|
301
|
+
const target = await fs.realpath(binding.projectRoot);
|
|
302
|
+
if (target === root || await readJSON(bindingPath(target), null)) throw new MapError('INVALID_BINDING', 'Workbench binding chains are not supported');
|
|
303
|
+
if (await commonDir(root) !== await commonDir(target)) throw new MapError('INVALID_BINDING', 'Bound worktrees must belong to the same local Git repository');
|
|
304
|
+
await fs.access(path.join(target, '.codex/context/map.json'));
|
|
305
|
+
return target;
|
|
306
|
+
}
|
|
307
|
+
export async function bindProject(root, target, { keepLocal = false } = {}) {
|
|
308
|
+
root = await fs.realpath(root); target = await fs.realpath(target);
|
|
309
|
+
if (root === target || await resolveProjectRoot(target) !== target) throw new MapError('INVALID_BINDING', 'Select an unbound, different project worktree');
|
|
310
|
+
if (await commonDir(root) !== await commonDir(target)) throw new MapError('INVALID_BINDING', 'Different Git projects cannot share this binding');
|
|
311
|
+
await fs.access(path.join(target, '.codex/context/map.json'));
|
|
312
|
+
const existing = await readJSON(bindingPath(root), null);
|
|
313
|
+
if (existing && existing.projectRoot !== target) throw new MapError('BINDING_EXISTS', 'An existing binding cannot be silently replaced');
|
|
314
|
+
const localMap = await fs.stat(path.join(root, '.codex/context/map.json')).catch(e => e.code === 'ENOENT' ? null : Promise.reject(e));
|
|
315
|
+
if (localMap && !keepLocal) throw new MapError('LOCAL_MAP_EXISTS', 'Local Map is preserved. Pass --keep-local to confirm keeping its Session seed; no Map is merged or deleted');
|
|
316
|
+
// Do not change an active worktree's data source under its service or pages.
|
|
317
|
+
const state = await readJSON(path.join(root, '.codex/context/private/workbench.json'), null);
|
|
318
|
+
if (state?.pid) { let alive = true; try { process.kill(state.pid, 0); } catch (e) { if (e.code === 'ESRCH') alive = false; }
|
|
319
|
+
if (alive) throw new MapError('WORKBENCH_ACTIVE', 'Stop this worktree workbench before binding it'); }
|
|
320
|
+
await atomicWrite(bindingPath(root), encode({ version: 1, projectRoot: target, boundAt: new Date().toISOString() }));
|
|
321
|
+
return { root, projectRoot: target, localMapPreserved: !!localMap };
|
|
322
|
+
}
|
|
323
|
+
export const projectId = root => hash(root).slice(0, 24);
|
|
324
|
+
export function projectName(value, root) {
|
|
325
|
+
const name = String(value || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63).replace(/-+$/g, '');
|
|
326
|
+
return name || `project-${projectId(root).slice(0, 12)}`;
|
|
327
|
+
}
|