@ran-sh/dsh-crew 0.3.7 → 0.4.0

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.
@@ -0,0 +1,211 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { crewDshHome } from './install/install.mjs';
6
+ import { crewDshRuntimeModule } from './dsh-cli-runtime.mjs';
7
+
8
+ export const CREW_BRIDGE_PREFIX = '/_dsh/dsh-crew';
9
+ export const CREW_BRIDGE_TARGET = 'http://127.0.0.1:3210';
10
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
11
+ const HOP_BY_HOP = new Set([
12
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
13
+ 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
14
+ ]);
15
+
16
+ export function isLoopbackAddress(address) {
17
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
18
+ }
19
+
20
+ function isLocalHostname(hostname) {
21
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
22
+ }
23
+
24
+ export function resolveCrewBridgeTarget(env = process.env) {
25
+ const raw = env?.DSH_CREW_BRIDGE_TARGET;
26
+ if (!raw) return CREW_BRIDGE_TARGET;
27
+ try {
28
+ const target = new URL(raw);
29
+ if (target.protocol !== 'http:' || !isLocalHostname(target.hostname.toLowerCase()) || target.pathname !== '/' || target.search || target.hash) return CREW_BRIDGE_TARGET;
30
+ const port = Number(target.port);
31
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return CREW_BRIDGE_TARGET;
32
+ return target.origin;
33
+ } catch { return CREW_BRIDGE_TARGET; }
34
+ }
35
+
36
+ export function isTrustedLocalRequest(req) {
37
+ if (!isLoopbackAddress(req?.socket?.remoteAddress)) return false;
38
+ const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
39
+ if (!host) return false;
40
+ let authority;
41
+ try { authority = new URL(`http://${host}`); } catch { return false; }
42
+ if (!isLocalHostname(authority.hostname.toLowerCase())) return false;
43
+ const fetchSite = String(req?.headers?.['sec-fetch-site'] ?? '').toLowerCase();
44
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
45
+ const origin = req?.headers?.origin;
46
+ if (origin !== undefined) {
47
+ if (typeof origin !== 'string') return false;
48
+ let parsedOrigin;
49
+ try { parsedOrigin = new URL(origin); } catch { return false; }
50
+ if (!isLocalHostname(parsedOrigin.hostname.toLowerCase()) || parsedOrigin.host.toLowerCase() !== host) return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ function safeHeaders(source) {
56
+ const result = {};
57
+ for (const [rawName, rawValue] of Object.entries(source ?? {})) {
58
+ const name = rawName.toLowerCase();
59
+ if (HOP_BY_HOP.has(name) || rawValue === undefined) continue;
60
+ result[name] = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);
61
+ }
62
+ return result;
63
+ }
64
+
65
+ function sendJson(res, status, value) {
66
+ const body = Buffer.from(JSON.stringify(value));
67
+ res.writeHead(status, {
68
+ 'content-type': 'application/json; charset=utf-8',
69
+ 'content-length': String(body.length),
70
+ 'cache-control': 'no-store',
71
+ 'x-content-type-options': 'nosniff',
72
+ 'x-dsh-crew-bridge': '3080-to-3210',
73
+ });
74
+ res.end(body);
75
+ }
76
+
77
+ async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
78
+ const chunks = [];
79
+ let size = 0;
80
+ for await (const chunk of req) {
81
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
82
+ size += value.length;
83
+ if (size > limit) throw Object.assign(new Error('request too large'), { code: 'BODY_TOO_LARGE' });
84
+ chunks.push(value);
85
+ }
86
+ return Buffer.concat(chunks);
87
+ }
88
+
89
+ async function defaultHealthCheck(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
90
+ try {
91
+ const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/ping`, {
92
+ signal: AbortSignal.timeout(1_500),
93
+ headers: { accept: 'application/json' },
94
+ });
95
+ return response.ok;
96
+ } catch { return false; }
97
+ }
98
+
99
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
100
+
101
+ export function createCrewSidecarSupervisor({
102
+ home = homedir(),
103
+ exists = existsSync,
104
+ bridgeTarget = resolveCrewBridgeTarget(),
105
+ healthCheck = () => defaultHealthCheck(globalThis.fetch, bridgeTarget),
106
+ spawnImpl = spawn,
107
+ wait = delay,
108
+ maxAttempts = 120,
109
+ pollInterval = 250,
110
+ } = {}) {
111
+ let starting = null;
112
+ let runningChild = null;
113
+ const runtime = crewDshRuntimeModule({ home });
114
+ const dshHome = crewDshHome({ home });
115
+ const bridgePort = new URL(bridgeTarget).port;
116
+
117
+ async function start() {
118
+ if (await healthCheck()) return { ok: true, started: false };
119
+ if (!exists(runtime)) return { ok: false, code: 'CREW_RUNTIME_NOT_INSTALLED' };
120
+ const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
121
+ if (!childAlive) {
122
+ runningChild = spawnImpl(process.execPath, [
123
+ runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
124
+ ], {
125
+ cwd: dshHome,
126
+ env: { ...process.env, DSH_HOME: dshHome },
127
+ detached: true,
128
+ stdio: 'ignore',
129
+ windowsHide: true,
130
+ });
131
+ const ownedChild = runningChild;
132
+ ownedChild.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
133
+ ownedChild.unref?.();
134
+ }
135
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
136
+ if (await healthCheck()) return { ok: true, started: true };
137
+ await wait(pollInterval);
138
+ }
139
+ return { ok: false, code: 'CREW_BACKEND_START_TIMEOUT' };
140
+ }
141
+
142
+ return {
143
+ ensure() {
144
+ if (!starting) starting = start().finally(() => { starting = null; });
145
+ return starting;
146
+ },
147
+ };
148
+ }
149
+
150
+ const processSupervisor = createCrewSidecarSupervisor();
151
+
152
+ export async function proxyCrewRequest(req, res, {
153
+ fetchImpl = globalThis.fetch,
154
+ ensureBackend = () => processSupervisor.ensure(),
155
+ bridgeTarget = resolveCrewBridgeTarget(),
156
+ } = {}) {
157
+ if (!isTrustedLocalRequest(req)) {
158
+ sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
159
+ return;
160
+ }
161
+ let pathname;
162
+ try { pathname = new URL(req.url, 'http://127.0.0.1:3080').pathname; } catch { pathname = ''; }
163
+ if (pathname !== CREW_BRIDGE_PREFIX && !pathname.startsWith(`${CREW_BRIDGE_PREFIX}/`)) {
164
+ sendJson(res, 404, { ok: false, code: 'NOT_FOUND' });
165
+ return;
166
+ }
167
+ try {
168
+ const backend = await ensureBackend();
169
+ if (backend?.ok === false) throw new Error('backend unavailable');
170
+ const method = String(req.method ?? 'GET').toUpperCase();
171
+ const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
172
+ const response = await fetchImpl(`${bridgeTarget}${req.url}`, {
173
+ method,
174
+ headers: safeHeaders(req.headers),
175
+ body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
176
+ signal: AbortSignal.timeout(120_000),
177
+ });
178
+ const responseBody = Buffer.from(await response.arrayBuffer());
179
+ const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
180
+ headers['content-length'] = String(responseBody.length);
181
+ headers['x-dsh-crew-bridge'] = '3080-to-3210';
182
+ res.writeHead(response.status, headers);
183
+ res.end(responseBody);
184
+ } catch (error) {
185
+ if (error?.code === 'BODY_TOO_LARGE') sendJson(res, 413, { ok: false, code: 'REQUEST_TOO_LARGE' });
186
+ else sendJson(res, 503, { ok: false, code: 'CREW_BACKEND_UNAVAILABLE' });
187
+ }
188
+ }
189
+
190
+ export function registerOfficialWebBridge(ctx, options = {}) {
191
+ return ctx.inject(['webServer'], (webCtx) => {
192
+ const disposeStatus = webCtx.webServer.register({
193
+ kind: 'exact',
194
+ path: `${CREW_BRIDGE_PREFIX}/bridge-status`,
195
+ handler: (req, res) => {
196
+ if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
197
+ return sendJson(res, 200, { ok: true, mode: 'official-3080-isolated-3210' });
198
+ },
199
+ });
200
+ const disposeProxy = webCtx.webServer.register({
201
+ kind: 'prefix',
202
+ path: CREW_BRIDGE_PREFIX,
203
+ handler: (req, res) => proxyCrewRequest(req, res, options),
204
+ });
205
+ return () => { disposeProxy?.(); disposeStatus?.(); };
206
+ });
207
+ }
208
+
209
+ export async function apply(ctx) {
210
+ registerOfficialWebBridge(ctx);
211
+ }
@@ -0,0 +1,107 @@
1
+ // Versioned, narrow Worker/Reviewer profiles. Profiles configure one DSH
2
+ // delegation; they are not general Agent personas and never contain prompts or
3
+ // credentials.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+
9
+ export const ROLE_PROFILE_SCHEMA_VERSION = 1;
10
+ export const DEFAULT_ROLE_PROFILES = Object.freeze({
11
+ 'worker-default': Object.freeze({
12
+ role: 'worker', routing: 'auto', isolation: 'worktree', fallback: true,
13
+ timeout_seconds: 1800, review_strictness: 'standard',
14
+ }),
15
+ 'reviewer-default': Object.freeze({
16
+ role: 'reviewer', routing: 'stable', isolation: 'readonly', fallback: false,
17
+ timeout_seconds: 1800, review_strictness: 'strict',
18
+ }),
19
+ });
20
+
21
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
22
+ const ROLES = new Set(['worker', 'reviewer']);
23
+ const ROUTING = new Set(['auto', 'priority', 'stable']);
24
+ const ISOLATION = new Set(['worktree', 'readonly', 'shared']);
25
+ const STRICTNESS = new Set(['standard', 'strict']);
26
+
27
+ function normalizeProfile(id, raw) {
28
+ if (!ID.test(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
29
+ if (!ROLES.has(raw.role)) return null;
30
+ const base = DEFAULT_ROLE_PROFILES[`${raw.role}-default`];
31
+ const routing = raw.routing ?? base.routing;
32
+ const isolation = raw.isolation ?? base.isolation;
33
+ const reviewStrictness = raw.review_strictness ?? base.review_strictness;
34
+ const timeout = raw.timeout_seconds ?? base.timeout_seconds;
35
+ if (!ROUTING.has(routing) || !ISOLATION.has(isolation) || !STRICTNESS.has(reviewStrictness)) return null;
36
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 7200) return null;
37
+ if (raw.fallback !== undefined && typeof raw.fallback !== 'boolean') return null;
38
+ return {
39
+ role: raw.role,
40
+ routing,
41
+ isolation,
42
+ fallback: raw.fallback ?? base.fallback,
43
+ timeout_seconds: timeout,
44
+ review_strictness: reviewStrictness,
45
+ };
46
+ }
47
+
48
+ export function roleProfilesFile({ home = homedir() } = {}) {
49
+ return join(home, '.config', 'dsh-crew', 'profiles.json');
50
+ }
51
+
52
+ export function loadRoleProfiles({ home = homedir(), file = roleProfilesFile({ home }) } = {}) {
53
+ if (!existsSync(file)) {
54
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: true, source: 'defaults', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [] };
55
+ }
56
+ let raw;
57
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
58
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
59
+ }
60
+ return parseRoleProfiles(raw);
61
+ }
62
+
63
+ function parseRoleProfiles(raw) {
64
+ const errors = [];
65
+ const profiles = { ...DEFAULT_ROLE_PROFILES };
66
+ if (raw?.schema_version !== ROLE_PROFILE_SCHEMA_VERSION || !raw.profiles || typeof raw.profiles !== 'object' || Array.isArray(raw.profiles)) {
67
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
68
+ }
69
+ for (const [id, value] of Object.entries(raw.profiles)) {
70
+ if (id in DEFAULT_ROLE_PROFILES) {
71
+ const normalized = normalizeProfile(id, value);
72
+ if (!normalized || JSON.stringify(normalized) !== JSON.stringify(DEFAULT_ROLE_PROFILES[id])) {
73
+ errors.push({ code: 'PROFILE_DEFAULT_RESERVED', profile_id: id });
74
+ }
75
+ continue;
76
+ }
77
+ const profile = normalizeProfile(id, value);
78
+ if (!profile) errors.push({ code: 'PROFILE_INVALID', profile_id: ID.test(id) ? id : '<invalid>' });
79
+ else profiles[id] = profile;
80
+ }
81
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: errors.length === 0, source: 'file', profiles, errors: errors.slice(0, 32) };
82
+ }
83
+
84
+ export function saveRoleProfiles(document, { home = homedir(), file = roleProfilesFile({ home }) } = {}) {
85
+ const parsed = parseRoleProfiles(document);
86
+ if (!parsed.ok) return parsed;
87
+ const custom = Object.fromEntries(Object.entries(parsed.profiles).filter(([id]) => !(id in DEFAULT_ROLE_PROFILES)));
88
+ const payload = { schema_version: ROLE_PROFILE_SCHEMA_VERSION, profiles: custom };
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
91
+ try {
92
+ writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
93
+ renameSync(temp, file);
94
+ } catch (error) {
95
+ rmSync(temp, { force: true });
96
+ return { ...parsed, ok: false, errors: [{ code: 'PROFILE_FILE_WRITE_FAILED' }], error_code: 'PROFILE_FILE_WRITE_FAILED' };
97
+ }
98
+ return { ...parsed, source: 'file' };
99
+ }
100
+
101
+ export function resolveRoleProfile(registry, profileId, role = 'worker') {
102
+ const id = profileId ?? `${role}-default`;
103
+ const profile = registry?.profiles?.[id];
104
+ if (!profile) return { ok: false, code: 'PROFILE_NOT_FOUND', profile_id: id };
105
+ if (profile.role !== role) return { ok: false, code: 'PROFILE_ROLE_MISMATCH', profile_id: id, expected_role: role };
106
+ return { ok: true, profile_id: id, profile: { ...profile } };
107
+ }
@@ -8,7 +8,7 @@
8
8
  // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
9
  // exact same compatibility rules.
10
10
 
11
- export const RUNTIME_VERSION = '0.3.7';
11
+ export const RUNTIME_VERSION = '0.4.0';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([
@@ -21,6 +21,11 @@ export const HUB_CAPABILITIES = Object.freeze([
21
21
  'model-catalog',
22
22
  'presets',
23
23
  'config',
24
+ 'canonical-events',
25
+ 'evidence',
26
+ 'profiles',
27
+ 'workspace-context',
28
+ 'extension-contract',
24
29
  ]);
25
30
 
26
31
  // Capabilities the current MCP workflow depends on for full Hub execution.