graphlin 0.1.3 → 0.2.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -3
- package/docs/decision-service.md +393 -0
- package/docs/extension-authoring.md +553 -0
- package/docs/model-api.md +293 -0
- package/docs/usage.md +472 -0
- package/docs/visualizer-views.md +240 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +78 -9
- package/plugin.json +4 -2
- package/runtime/architecture/analysis.mjs +344 -0
- package/runtime/architecture/controller.mjs +209 -0
- package/runtime/architecture/evidence.mjs +108 -0
- package/runtime/architecture/profile.mjs +56 -0
- package/runtime/core/evidence.mjs +43 -9
- package/runtime/core/graph.mjs +11 -6
- package/runtime/core/privacy.mjs +1 -0
- package/runtime/daemon/auth.mjs +7 -3
- package/runtime/daemon/diagnostics.mjs +1 -1
- package/runtime/daemon/extension-api.mjs +203 -0
- package/runtime/daemon/lineage.mjs +70 -0
- package/runtime/daemon/manager.mjs +9 -6
- package/runtime/daemon/model-api.mjs +728 -0
- package/runtime/daemon/model-persistence.mjs +220 -0
- package/runtime/daemon/server.mjs +81 -14
- package/runtime/daemon/settings.mjs +11 -3
- package/runtime/decisions/broker.mjs +349 -0
- package/runtime/decisions/contracts.mjs +179 -0
- package/runtime/decisions/evaluation.mjs +305 -0
- package/runtime/decisions/faults.mjs +32 -0
- package/runtime/decisions/index.mjs +818 -0
- package/runtime/decisions/profiles.mjs +93 -0
- package/runtime/decisions/questions.mjs +268 -0
- package/runtime/discovery/index.mjs +2 -0
- package/runtime/discovery/inventory.mjs +160 -0
- package/runtime/discovery/parser.mjs +40 -0
- package/runtime/discovery/structure.mjs +232 -0
- package/runtime/extensions/contracts.mjs +59 -0
- package/runtime/extensions/frame.mjs +64 -0
- package/runtime/extensions/index.mjs +9 -0
- package/runtime/extensions/manifest.mjs +95 -0
- package/runtime/extensions/packages.mjs +222 -0
- package/runtime/extensions/profiles.mjs +36 -0
- package/runtime/extensions/projection.mjs +130 -0
- package/runtime/extensions/registry.mjs +285 -0
- package/runtime/extensions/scene.mjs +105 -0
- package/runtime/extensions/sdk.d.ts +205 -0
- package/runtime/extensions/sdk.mjs +88 -0
- package/runtime/jev/index.mjs +13 -777
- package/runtime/jev/provider.mjs +101 -0
- package/runtime/jev/questions.mjs +16 -258
- package/runtime/jev/wire.mjs +17 -25
- package/runtime/model/changes.mjs +42 -0
- package/runtime/model/history.mjs +124 -0
- package/runtime/model/index.mjs +2 -0
- package/runtime/model/project-model.mjs +1020 -0
- package/runtime/model/records.mjs +240 -0
- package/runtime/pipeline.mjs +267 -55
- package/runtime/platform.mjs +254 -0
- package/runtime/visualizers/blocks.mjs +5 -0
- package/runtime/visualizers/c4.mjs +154 -0
- package/runtime/visualizers/changes.mjs +24 -0
- package/runtime/visualizers/code.mjs +5 -0
- package/runtime/visualizers/index.mjs +23 -0
- package/runtime/visualizers/structure.mjs +120 -0
- package/runtime/visualizers/timeline.mjs +66 -0
- package/runtime/web/app.js +225 -63
- package/runtime/web/extension-frame.js +128 -0
- package/runtime/web/index.html +38 -1
- package/runtime/web/model-client.js +162 -0
- package/runtime/web/platform.js +445 -0
- package/runtime/web/scene.js +111 -0
- package/runtime/web/style.css +51 -0
- package/schemas/graph.schema.json +4 -1
- package/scripts/arguments.mjs +5 -1
- package/scripts/build-packages.mjs +6 -2
- package/scripts/control.mjs +1 -1
- package/scripts/daemon.mjs +2 -1
- package/scripts/extensions.mjs +44 -0
- package/scripts/graphlin.mjs +23 -3
- package/scripts/onboarding.mjs +10 -3
- package/scripts/validate-packages.mjs +54 -8
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrameDocument, getExtensionDataProjection, validateGrant, extensionId,
|
|
3
|
+
id, digest, integer, exact, uniqueStrings, jsonBytes, EXTENSION_LIMITS,
|
|
4
|
+
} from '../extensions/index.mjs';
|
|
5
|
+
import { safeText } from '../core/privacy.mjs';
|
|
6
|
+
|
|
7
|
+
const PREFIX = '/api/extensions';
|
|
8
|
+
const HTTP_ERROR = Symbol('extension_http_error');
|
|
9
|
+
const fail = (status, code) => { throw Object.assign(new Error(code), { status, code, [HTTP_ERROR]: true }); };
|
|
10
|
+
const sameGrant = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
11
|
+
|
|
12
|
+
function reply(res, status, value) {
|
|
13
|
+
const body = jsonBytes(value, EXTENSION_LIMITS.projectionBytes);
|
|
14
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
15
|
+
res.end(body);
|
|
16
|
+
}
|
|
17
|
+
async function bodyJSON(req) {
|
|
18
|
+
if (!/^application\/json(?:;\s*charset=utf-8)?$/i.test(req.headers['content-type'] ?? '')) fail(415, 'invalid_content_type');
|
|
19
|
+
if (req.headers['content-encoding']) fail(400, 'unsupported_content_encoding');
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
const timer = setTimeout(() => req.destroy(), 2000);
|
|
23
|
+
try {
|
|
24
|
+
for await (const chunk of req) {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > 16 * 1024) fail(413, 'extension_request_limit');
|
|
27
|
+
chunks.push(chunk);
|
|
28
|
+
}
|
|
29
|
+
let value;
|
|
30
|
+
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
|
|
31
|
+
catch { fail(400, 'invalid_json'); }
|
|
32
|
+
return value;
|
|
33
|
+
} finally { clearTimeout(timer); }
|
|
34
|
+
}
|
|
35
|
+
function parameters(query, allowed) {
|
|
36
|
+
const result = {};
|
|
37
|
+
for (const [name, value] of new URLSearchParams(query)) {
|
|
38
|
+
if (!allowed.includes(name) || Object.hasOwn(result, name) || !value) fail(400, 'invalid_extension_query');
|
|
39
|
+
result[name] = value;
|
|
40
|
+
}
|
|
41
|
+
for (const field of ['scope', 'session', 'checkpoint']) {
|
|
42
|
+
if (result[field] !== undefined && !id(result[field])) fail(400, 'invalid_extension_query');
|
|
43
|
+
}
|
|
44
|
+
if (result.nonce !== undefined && !/^[A-Za-z0-9_-]{24,128}$/.test(result.nonce)) fail(400, 'invalid_frame_nonce');
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parent performs loopback/Host/Origin checks and viewer-cookie authentication.
|
|
50
|
+
* Never pass viewerAuthorized for an external read bearer or an opaque origin.
|
|
51
|
+
* getSnapshot is synchronous and reapplies current core policy, like model-api.
|
|
52
|
+
*/
|
|
53
|
+
export function createExtensionAPI({ registry, getSnapshot, projectId, runAnalysis } = {}) {
|
|
54
|
+
if (!registry || typeof getSnapshot !== 'function' || !id(projectId) ||
|
|
55
|
+
(runAnalysis !== undefined && typeof runAnalysis !== 'function')) throw new TypeError('invalid_extension_api_options');
|
|
56
|
+
|
|
57
|
+
async function requireGrant(extension, expectedDigest) {
|
|
58
|
+
const value = await registry.getGrant(extension);
|
|
59
|
+
try { validateGrant(value); } catch { fail(403, 'extension_grant_denied'); }
|
|
60
|
+
if (!value.approved || value.projectId !== projectId || value.extensionId !== extension) fail(403, 'extension_grant_denied');
|
|
61
|
+
if (expectedDigest !== undefined && value.digest !== expectedDigest) fail(409, 'extension_digest_changed');
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function snapshot(params, grant) {
|
|
65
|
+
// A session selector can address historical work even without a checkpoint.
|
|
66
|
+
if ((params.checkpoint || params.session) && !grant.history) fail(403, 'history_not_granted');
|
|
67
|
+
const raw = getSnapshot({
|
|
68
|
+
...(params.scope ? { scopeId: params.scope } : {}),
|
|
69
|
+
...(params.session ? { sessionId: params.session } : {}),
|
|
70
|
+
...(params.checkpoint ? { checkpointId: params.checkpoint } : {}),
|
|
71
|
+
persistent: false,
|
|
72
|
+
});
|
|
73
|
+
if (!raw || typeof raw !== 'object' || raw.then || raw.projectId !== projectId) fail(503, 'invalid_model_snapshot');
|
|
74
|
+
const projected = getExtensionDataProjection(
|
|
75
|
+
params.checkpoint ? { ...raw, checkpointId: params.checkpoint, replay: true } : raw, grant);
|
|
76
|
+
if (!projected) fail(403, 'extension_grant_denied');
|
|
77
|
+
return projected;
|
|
78
|
+
}
|
|
79
|
+
async function unchanged(extension, prior) {
|
|
80
|
+
const current = await requireGrant(extension, prior.digest);
|
|
81
|
+
if (!sameGrant(prior, current)) fail(403, 'extension_grant_changed');
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
async function analyze(req, res) {
|
|
85
|
+
const input = await bodyJSON(req);
|
|
86
|
+
if (!exact(input, ['id', 'digest', 'profileId', 'entityIds', 'revision']) ||
|
|
87
|
+
!extensionId(input.id) || !digest(input.digest) || !id(input.profileId) ||
|
|
88
|
+
!uniqueStrings(input.entityIds, id, 256) || !input.entityIds.length || !integer(input.revision)) {
|
|
89
|
+
fail(400, 'invalid_analysis_request');
|
|
90
|
+
}
|
|
91
|
+
const grant = await requireGrant(input.id, input.digest);
|
|
92
|
+
if (!grant.profiles?.includes(input.profileId)) fail(403, 'analysis_profile_not_granted');
|
|
93
|
+
const installed = await registry.getAssets(input.id, { digest: input.digest });
|
|
94
|
+
if (!installed.manifest.capabilities.includes('analysis.request')) fail(403, 'analysis_not_granted');
|
|
95
|
+
const profile = installed.profiles.find(value => value.id === input.profileId);
|
|
96
|
+
if (!profile) fail(403, 'analysis_profile_not_granted');
|
|
97
|
+
if (profile.selectors.fields.some(field => !grant.fields.includes(field))) fail(403, 'analysis_field_not_granted');
|
|
98
|
+
await unchanged(input.id, grant);
|
|
99
|
+
const model = snapshot({}, grant);
|
|
100
|
+
if (model.revision !== input.revision) fail(409, 'analysis_revision_changed');
|
|
101
|
+
if (!input.entityIds.every(entityId => model.entities.some(value => value.id === entityId)) ||
|
|
102
|
+
(profile.selectors.candidateIds.length &&
|
|
103
|
+
!input.entityIds.every(entityId => profile.selectors.candidateIds.includes(entityId)))) fail(403, 'analysis_candidate_not_granted');
|
|
104
|
+
if (!runAnalysis) fail(501, 'extension_analysis_unavailable');
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const abort = () => controller.abort();
|
|
107
|
+
req.once('aborted', abort);
|
|
108
|
+
res.once('close', abort);
|
|
109
|
+
try {
|
|
110
|
+
// Core owns provider calls, source consent/filtering, approval capabilities,
|
|
111
|
+
// evidence-version checks, subscriptions, and recording interpretations.
|
|
112
|
+
const result = await runAnalysis({
|
|
113
|
+
projectId, extensionId: input.id, digest: input.digest, profile,
|
|
114
|
+
entityIds: [...input.entityIds], revision: input.revision, grant, signal: controller.signal,
|
|
115
|
+
});
|
|
116
|
+
await unchanged(input.id, grant);
|
|
117
|
+
const current = snapshot({}, grant);
|
|
118
|
+
if (!result || !['accepted', 'pending', 'complete', 'unavailable'].includes(result.status)) fail(503, 'invalid_analysis_result');
|
|
119
|
+
const interpretationIds = Array.isArray(result.interpretationIds) ? result.interpretationIds.slice(0, 256)
|
|
120
|
+
.filter(value => id(value) && current.interpretations.some(item =>
|
|
121
|
+
item.id === value && item.namespace === profile.namespace)) : [];
|
|
122
|
+
if (!res.destroyed) reply(res, result.status === 'pending' || result.status === 'accepted' ? 202 : 200, {
|
|
123
|
+
status: result.status,
|
|
124
|
+
...(id(result.requestId) && safeText(result.requestId, 160) ? { requestId: result.requestId } : {}),
|
|
125
|
+
interpretationIds: [...new Set(interpretationIds)],
|
|
126
|
+
});
|
|
127
|
+
} finally {
|
|
128
|
+
req.removeListener('aborted', abort);
|
|
129
|
+
res.removeListener('close', abort);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function handle(req, res, { viewerAuthorized = false } = {}) {
|
|
133
|
+
if (typeof req.url !== 'string' ||
|
|
134
|
+
!(req.url === PREFIX || req.url.startsWith(`${PREFIX}/`) || req.url.startsWith(`${PREFIX}?`))) return false;
|
|
135
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
136
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
137
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
138
|
+
try {
|
|
139
|
+
if (viewerAuthorized !== true || req.headers.origin === 'null') fail(401, 'viewer_authorization_required');
|
|
140
|
+
if (req.url.length > 4096 || /[\s\\#]/.test(req.url) || /%(?![a-f\d]{2})/i.test(req.url)) fail(400, 'invalid_extension_route');
|
|
141
|
+
const [pathname, query = '', ...extra] = req.url.split('?');
|
|
142
|
+
if (extra.length || pathname.includes('%') || pathname.includes('//') ||
|
|
143
|
+
pathname.split('/').some(part => part === '.' || part === '..')) fail(400, 'invalid_extension_route');
|
|
144
|
+
const route = pathname.slice(PREFIX.length);
|
|
145
|
+
const read = /^\/(data|frame)\/([^/]+)$/.exec(route);
|
|
146
|
+
if (read && !extensionId(read[2])) fail(400, 'invalid_extension_id');
|
|
147
|
+
const params = parameters(query, read?.[1] === 'data' ? ['scope', 'session', 'checkpoint'] :
|
|
148
|
+
read?.[1] === 'frame' ? ['nonce'] : []);
|
|
149
|
+
if (req.method === 'GET') {
|
|
150
|
+
if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] ?? 0) !== 0) fail(400, 'unexpected_body');
|
|
151
|
+
if (route === '') { reply(res, 200, await registry.list()); return true; }
|
|
152
|
+
if (!read) fail(404, 'extension_route_not_found');
|
|
153
|
+
const extension = read[2];
|
|
154
|
+
const grant = await requireGrant(extension);
|
|
155
|
+
if (read[1] === 'data') {
|
|
156
|
+
reply(res, 200, snapshot(params, grant));
|
|
157
|
+
} else {
|
|
158
|
+
if (!params.nonce) fail(400, 'invalid_frame_nonce');
|
|
159
|
+
const assets = await registry.getAssets(extension, { digest: grant.digest });
|
|
160
|
+
await unchanged(extension, grant);
|
|
161
|
+
const frame = createFrameDocument({ ...assets, nonce: params.nonce });
|
|
162
|
+
// DENY remains on every other route. Response CSP sandboxing and
|
|
163
|
+
// frame-ancestors 'self' protect the directly opened frame document.
|
|
164
|
+
res.removeHeader('X-Frame-Options');
|
|
165
|
+
for (const [name, value] of Object.entries(frame.headers)) res.setHeader(name, value);
|
|
166
|
+
res.writeHead(200); res.end(frame.body);
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (req.method !== 'POST') fail(405, 'extension_method_not_allowed');
|
|
171
|
+
if (route === '/analysis') { await analyze(req, res); return true; }
|
|
172
|
+
if (!['/grant', '/revoke'].includes(route)) fail(404, 'extension_route_not_found');
|
|
173
|
+
const input = await bodyJSON(req);
|
|
174
|
+
if (route === '/grant') {
|
|
175
|
+
if (!exact(input, ['id', 'digest', 'fields', 'history', 'approved'], ['profiles']) ||
|
|
176
|
+
!extensionId(input.id)) fail(400, 'invalid_extension_grant');
|
|
177
|
+
const { id: extension, ...request } = input;
|
|
178
|
+
reply(res, 200, await registry.grant(extension, request));
|
|
179
|
+
} else {
|
|
180
|
+
if (!exact(input, ['id']) || !extensionId(input.id)) fail(400, 'invalid_extension_revoke');
|
|
181
|
+
reply(res, 200, await registry.revoke(input.id));
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const known = {
|
|
185
|
+
extension_not_installed: [404, 'extension_not_installed'],
|
|
186
|
+
extension_digest_changed: [409, 'extension_digest_changed'],
|
|
187
|
+
unrequested_data_field: [403, 'unrequested_data_field'],
|
|
188
|
+
unrequested_profile: [403, 'unrequested_profile'],
|
|
189
|
+
unrequested_history: [403, 'unrequested_history'],
|
|
190
|
+
unrequested_analysis: [403, 'unrequested_analysis'],
|
|
191
|
+
invalid_extension_grant: [400, 'invalid_extension_grant'],
|
|
192
|
+
MODEL_CHECKPOINT_UNAVAILABLE: [404, 'checkpoint_unavailable'],
|
|
193
|
+
MODEL_SCOPE_UNAVAILABLE: [404, 'scope_unavailable'],
|
|
194
|
+
}[error?.code];
|
|
195
|
+
const status = error?.[HTTP_ERROR] ? error.status : known?.[0] ?? 503;
|
|
196
|
+
const code = error?.[HTTP_ERROR] ? error.code : known?.[1] ?? 'extension_unavailable';
|
|
197
|
+
if (!res.headersSent && !res.destroyed) reply(res, status, { error: code });
|
|
198
|
+
else if (!res.destroyed) res.destroy();
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return { handle };
|
|
203
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { safeText } from '../core/privacy.mjs';
|
|
5
|
+
|
|
6
|
+
const TTL_MS = 2000;
|
|
7
|
+
const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
|
|
8
|
+
const BRANCH = ['symbolic-ref', '--quiet', '--short', 'HEAD'];
|
|
9
|
+
const HEAD = ['rev-parse', '--verify', '--end-of-options', 'HEAD'];
|
|
10
|
+
const validBranch = value => safeText(value, 240) && !CONTROLS.test(value) &&
|
|
11
|
+
!/[<>\\:\s]/.test(value) && !value.startsWith('/') && !value.startsWith('-');
|
|
12
|
+
|
|
13
|
+
/** Local ref metadata only. The caller owns reconciliation and model updates. */
|
|
14
|
+
export function createLineageReader({ projectRoot, projectId, execute = execFile } = {}) {
|
|
15
|
+
if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot) ||
|
|
16
|
+
Buffer.byteLength(projectRoot) > 4096 || projectRoot.includes('\0') ||
|
|
17
|
+
typeof projectId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(projectId) ||
|
|
18
|
+
typeof execute !== 'function') throw new TypeError('invalid_lineage_options');
|
|
19
|
+
let cached, expiresAt = 0, pending;
|
|
20
|
+
const result = (status, branch, head) => Object.freeze({
|
|
21
|
+
id: createHash('sha256').update(JSON.stringify([projectId, status, branch ?? null, head ?? null])).digest('hex'),
|
|
22
|
+
status, ...(branch ? { branch } : {}), ...(head ? { head } : {}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function git(args) {
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
let child, settled = false;
|
|
28
|
+
const finish = value => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } };
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
finish({ status: 'unavailable' });
|
|
31
|
+
try { child?.kill?.('SIGKILL'); } catch { /* No child details leave the reader. */ }
|
|
32
|
+
}, 800);
|
|
33
|
+
try {
|
|
34
|
+
child = execute('git', ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', ...args], {
|
|
35
|
+
cwd: projectRoot, shell: false, timeout: 750, killSignal: 'SIGKILL', maxBuffer: 4096, encoding: 'utf8',
|
|
36
|
+
env: { PATH: process.env.PATH || '/usr/bin:/bin', LC_ALL: 'C',
|
|
37
|
+
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
|
|
38
|
+
GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' },
|
|
39
|
+
}, (error, stdout, stderr) => {
|
|
40
|
+
const text = typeof stdout === 'string' ? stdout.replace(/\r?\n$/, '') : '';
|
|
41
|
+
if (!error) return finish({ status: 'ok', text });
|
|
42
|
+
if (!error.killed && error.code === 1 && !text && args === BRANCH) return finish({ status: 'detached' });
|
|
43
|
+
finish({ status: !error.killed && error.code === 128 && typeof stderr === 'string' &&
|
|
44
|
+
stderr.includes('not a git repository') ? 'not_git' : 'unavailable' });
|
|
45
|
+
});
|
|
46
|
+
} catch { finish({ status: 'unavailable' }); }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readRefs() {
|
|
51
|
+
if (CONTROLS.test(projectRoot)) return result('unavailable');
|
|
52
|
+
const branch = await git(BRANCH);
|
|
53
|
+
if (branch.status === 'not_git') return result('not_git');
|
|
54
|
+
if (branch.status !== 'detached' && (branch.status !== 'ok' || !validBranch(branch.text))) return result('unavailable');
|
|
55
|
+
const head = await git(HEAD);
|
|
56
|
+
if (head.status !== 'ok' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(head.text)) return result('unavailable');
|
|
57
|
+
// Do not combine a branch observed before checkout with another branch's HEAD.
|
|
58
|
+
const after = await git(BRANCH);
|
|
59
|
+
if (branch.status !== after.status || branch.text !== after.text) return result('unavailable');
|
|
60
|
+
return result('git', branch.status === 'ok' ? branch.text : undefined, head.text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return async function read() {
|
|
64
|
+
if (cached && Date.now() < expiresAt) return cached;
|
|
65
|
+
if (!pending) pending = readRefs().then(value => {
|
|
66
|
+
cached = value; expiresAt = Date.now() + TTL_MS; return value;
|
|
67
|
+
}).finally(() => { pending = undefined; });
|
|
68
|
+
return pending;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -33,9 +33,10 @@ function validateRuntime() {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function validateExisting(current, options) {
|
|
36
|
-
const { allowSource, persistEvidence, displayEvidence } = resolvePolicy(options, { current: current.policy });
|
|
36
|
+
const { allowSource, localSource, persistEvidence, displayEvidence } = resolvePolicy(options, { current: current.policy });
|
|
37
37
|
const { mode = 'live', port = 0 } = options;
|
|
38
38
|
if (current.mode !== mode || current.policy.transmitSource !== Boolean(allowSource) ||
|
|
39
|
+
current.policy.readSource !== Boolean(allowSource || localSource) ||
|
|
39
40
|
current.policy.persistEvidence !== Boolean(persistEvidence) ||
|
|
40
41
|
current.policy.displayEvidence !== Boolean(displayEvidence)) throw runtimeError('policy_restart_required');
|
|
41
42
|
if (port && port !== current.port) throw runtimeError('port_restart_required');
|
|
@@ -90,11 +91,11 @@ async function stopInstance(paths, instanceId) {
|
|
|
90
91
|
* instance closes or the caller aborts. Abort never stops a replacement owner.
|
|
91
92
|
*/
|
|
92
93
|
export async function runForeground({ projectRoot, dataDir = defaultDataDir(), signal,
|
|
93
|
-
allowSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}, onReady = () => {}) {
|
|
94
|
+
allowSource, localSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}, onReady = () => {}) {
|
|
94
95
|
validateRuntime();
|
|
95
96
|
if (signal?.aborted) return;
|
|
96
97
|
const paths = await projectPaths(projectRoot, dataDir);
|
|
97
|
-
const options = { allowSource, persistEvidence, displayEvidence, mode, port };
|
|
98
|
+
const options = { allowSource, localSource, persistEvidence, displayEvidence, mode, port };
|
|
98
99
|
let server, launch, interrupted;
|
|
99
100
|
const interruption = new Promise(resolve => { interrupted = resolve; });
|
|
100
101
|
const interrupt = () => interrupted();
|
|
@@ -110,7 +111,8 @@ export async function runForeground({ projectRoot, dataDir = defaultDataDir(), s
|
|
|
110
111
|
const demo = mode === 'demo' ? await import('./demo.mjs') : null;
|
|
111
112
|
try {
|
|
112
113
|
server = await startServer({ projectRoot: paths.projectRoot, dataDir: paths.dataDir, mode, port,
|
|
113
|
-
policy: {
|
|
114
|
+
policy: { readSource: Boolean(policy.localSource || policy.allowSource),
|
|
115
|
+
transmitSource: policy.allowSource, persistEvidence: policy.persistEvidence,
|
|
114
116
|
displayEvidence: policy.displayEvidence },
|
|
115
117
|
apiKey: policy.allowSource ? process.env.TYPESAFE_API_KEY ?? settings.apiKey : undefined,
|
|
116
118
|
decisionService: demo?.demoDecisionService() });
|
|
@@ -155,11 +157,11 @@ export async function runForeground({ projectRoot, dataDir = defaultDataDir(), s
|
|
|
155
157
|
// Detached operation is reserved for an explicit CLI --background request or
|
|
156
158
|
// MCP. The normal interactive CLI uses runForeground instead.
|
|
157
159
|
export async function startDaemon({ projectRoot, dataDir = defaultDataDir(), background = true,
|
|
158
|
-
allowSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}) {
|
|
160
|
+
allowSource, localSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}) {
|
|
159
161
|
validateRuntime();
|
|
160
162
|
if (background !== true) throw runtimeError('background_required');
|
|
161
163
|
const paths = await projectPaths(projectRoot, dataDir);
|
|
162
|
-
const options = { allowSource, persistEvidence, displayEvidence, mode, port };
|
|
164
|
+
const options = { allowSource, localSource, persistEvidence, displayEvidence, mode, port };
|
|
163
165
|
const existing = await daemonStatus({ projectRoot: paths.projectRoot, dataDir: paths.dataDir });
|
|
164
166
|
if (existing.running) return { ...await existingLaunch(paths, existing, options), foreground: false };
|
|
165
167
|
const settings = mode === 'demo' ? {} : await readSettings(paths);
|
|
@@ -167,6 +169,7 @@ export async function startDaemon({ projectRoot, dataDir = defaultDataDir(), bac
|
|
|
167
169
|
await projectPaths(projectRoot, dataDir, { create: true });
|
|
168
170
|
const args = [worker, '--project', paths.projectRoot, '--data-dir', paths.dataDir, '--mode', mode, '--port', String(port)];
|
|
169
171
|
if (policy.allowSource) args.push('--allow-source');
|
|
172
|
+
else if (policy.localSource) args.push('--local-source');
|
|
170
173
|
if (policy.persistEvidence) args.push('--persist-evidence');
|
|
171
174
|
if (!policy.displayEvidence) args.push('--no-display-evidence');
|
|
172
175
|
const env = { ...process.env };
|