@ran-sh/dsh-crew 2.2.1 → 2.2.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -61,7 +61,7 @@ function PanelHeader({ title, eyebrow, description, href, linkText, children })
61
61
  //#endregion
62
62
  //#region src/client/quick-panel.tsx
63
63
  const API = "/_dsh/dsh-crew";
64
- const FULL = "http://127.0.0.1:3210/";
64
+ const FULL = "http://127.0.0.1:3210/_dsh/dsh-crew/control-plane";
65
65
  const RESTART_KEYS = /* @__PURE__ */ new Set([]);
66
66
  const T = {
67
67
  zh: {
@@ -4,6 +4,10 @@ import { readFileSync } from 'node:fs';
4
4
 
5
5
  const PREFIX = '/_dsh/dsh-crew';
6
6
  const BACKEND = 'http://127.0.0.1:3210';
7
+ // The control plane's session belongs to the hub's own process, so the only usable
8
+ // entry is the hub's redirect: it mints the session and 302s into it. Advertising
9
+ // the bare port here sent operators to "dsh web authentication required".
10
+ const CONTROL_PLANE = `${BACKEND}${PREFIX}/control-plane`;
7
11
  const REVISION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).dshCrewFrontendRevision ?? null;
8
12
 
9
13
  function trusted(req) {
@@ -21,7 +25,7 @@ export function registerFrontend(ctx, { fetchImpl = globalThis.fetch } = {}) {
21
25
  const disposers = [];
22
26
  disposers.push(webServer.register({ kind: 'exact', path: `${PREFIX}/bridge-status`, handler(req, res) {
23
27
  if (!trusted(req)) return send(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
24
- send(res, 200, { ok: true, surface: 'official-bridge', ui_role: 'quick-controls', frontend_revision: REVISION, full_control_plane_url: `${BACKEND}/` });
28
+ send(res, 200, { ok: true, surface: 'official-bridge', ui_role: 'quick-controls', frontend_revision: REVISION, full_control_plane_url: CONTROL_PLANE });
25
29
  } }));
26
30
  for (const suffix of ['/quick-config', '/quick-status']) {
27
31
  disposers.push(webServer.register({ kind: 'exact', path: `${PREFIX}${suffix}`, async handler(req, res) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew-web-bridge",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "main": "./entry.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -15,11 +15,13 @@ import { crewDshHome, crewProfileDir, CREW_PROFILE_NAME, claudeIntegrationLine }
15
15
  import {
16
16
  ensureCrewDshRuntime,
17
17
  ensureCrewPluginRegistration,
18
+ ensureCrewWebProfile,
18
19
  resolveDshCli,
19
20
  describeDshCli,
20
21
  removeCrewPluginRegistration,
21
22
  TARGET_DSH_VERSION,
22
23
  } from '../src/dsh-cli-runtime.mjs';
24
+ import { integrationRoot } from '../src/install/crew-paths.mjs';
23
25
 
24
26
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
25
27
 
@@ -146,6 +148,8 @@ export async function setupInstall({
146
148
  home = homedir(),
147
149
  installer = realInstaller,
148
150
  depRunner = null,
151
+ ensureRuntime = null,
152
+ ensureWebProfile = null,
149
153
  } = {}) {
150
154
  log('DSH Crew installer');
151
155
  if (!checkRoot(root)) return { ok: false, error: `not a dsh-crew checkout: ${root}` };
@@ -196,8 +200,15 @@ export async function setupInstall({
196
200
  error: `Crew runtime ${dsh.version} does not match ${TARGET_DSH_VERSION}; use the transactional update path (dsh-crew update) to migrate the cohort`,
197
201
  };
198
202
  }
199
- if (!dryRun && (!dsh || dsh.kind === 'npx-local')) {
200
- const boot = ensureCrewDshRuntime({ home });
203
+ if (!dryRun && (!dsh || !dsh.reusable)) {
204
+ // The Windows launcher only starts a Crew-owned CLI entry: a global
205
+ // official dsh or a transient npx download cannot pass its preflight, so
206
+ // the launcher would report "DSH CLI was not found" on a machine that has
207
+ // a perfectly good global dsh. A machine without a reusable runtime
208
+ // provisions one here — before the launcher ever runs — and the web
209
+ // profile scaffold below completes what that preflight and the supervisor
210
+ // require. Idempotent: a matching reusable runtime is reused as-is.
211
+ const boot = (ensureRuntime ?? ensureCrewDshRuntime)({ home });
201
212
  if (boot.ok) dsh = boot.cli;
202
213
  else log(`- reusable Crew DSH runtime unavailable (${boot.code ?? 'unknown'}); trying compatibility fallback`);
203
214
  }
@@ -219,11 +230,27 @@ export async function setupInstall({
219
230
  return { ok: false, error: 'DSH profile link failed' };
220
231
  }
221
232
  mark(log, true, `DSH crew profile linked offline (dedicated Crew DSH_HOME, profile dsh-crew; ${registration.changed ? 'updated' : 'already current'})`);
233
+ // The launcher preflight and the supervisor both require
234
+ // <crew DSH_HOME>/profiles/web; without it a fresh install deadlocked at
235
+ // the preflight ("The Harness web profile is missing") and the supervisor
236
+ // heartbeat timed out. Same idempotent scaffold the runtime step creates
237
+ // on the npx-managed path.
238
+ const webProfile = (ensureWebProfile ?? ensureCrewWebProfile)({ home });
239
+ if (!webProfile.ok) {
240
+ log(`✗ Crew web profile bootstrap failed (${webProfile.code ?? 'unknown'})`);
241
+ return { ok: false, error: 'Crew web profile bootstrap failed' };
242
+ }
243
+ if (webProfile.created) mark(log, true, 'Crew web profile scaffold created');
222
244
  }
223
245
 
246
+ // The host integrations must be written against the same root readiness
247
+ // derives (integrationRoot) — see activateRelease. After registration the
248
+ // link resolves to this checkout, so this is the loader link path.
249
+ const integrationWriteRoot = integrationRoot({ home, root, name });
250
+
224
251
  if (dryRun) mark(log, true, 'Codex Desktop integration (dry-run)');
225
252
  else {
226
- const r = installer.installCodex ? installer.installCodex({ home, root }) : realInstaller.installCodex({ home, root });
253
+ const r = installer.installCodex ? installer.installCodex({ home, root: integrationWriteRoot }) : realInstaller.installCodex({ home, root: integrationWriteRoot });
227
254
  mark(log, r.ok !== false, r.ok === false ? `Codex Desktop integration failed: ${(r.actions ?? []).join('; ')}` : 'Codex Desktop integration');
228
255
  if (r.ok === false) return { ok: false, error: 'Codex integration failed' };
229
256
  }
@@ -231,8 +258,8 @@ export async function setupInstall({
231
258
  if (dryRun) mark(log, true, 'ZCode integration (dry-run)');
232
259
  else {
233
260
  const r = installer.installZCode
234
- ? installer.installZCode({ home, root })
235
- : realInstaller.installZCode({ home, root });
261
+ ? installer.installZCode({ home, root: integrationWriteRoot })
262
+ : realInstaller.installZCode({ home, root: integrationWriteRoot });
236
263
  if (r.ok === false) {
237
264
  mark(log, false, `ZCode integration failed (${r.code ?? 'unknown'})`);
238
265
  return { ok: false, error: 'ZCode integration failed' };
@@ -253,7 +280,7 @@ export async function setupInstall({
253
280
  }
254
281
 
255
282
  if (commandExists('claude')) {
256
- const r = await runClaudeIntegrationStep({ dryRun, log, root, home, installer });
283
+ const r = await runClaudeIntegrationStep({ dryRun, log, root: integrationWriteRoot, home, installer });
257
284
  if (!r.ok) return r;
258
285
  } else log('- Claude Code not detected, skipped');
259
286
 
@@ -332,7 +359,10 @@ export async function setupUninstall({
332
359
  export async function setupStatus({ log = console.log, root = ROOT, home = homedir(), installer = realInstaller } = {}) {
333
360
  const st = installer.installStatus ? installer.installStatus({ home, root }) : realInstaller.installStatus({ home, root });
334
361
  const integrationLabel = (entry) => !entry?.installed ? 'not installed' : entry.ready === true ? 'installed' : 'needs repair';
335
- const claude = integrationLabel(st?.claude);
362
+ // A host without the Claude CLI has no install to repair: the settings are
363
+ // registered best-effort and the CLI step that creates the plugin snapshot is
364
+ // skipped, so the footprint can never be ready there.
365
+ const claude = st?.claude?.host_detected === false ? 'not installed' : integrationLabel(st?.claude);
336
366
  const codex = integrationLabel(st?.codex);
337
367
  const zcode = integrationLabel(st?.zcode);
338
368
  const startupState = installer.windowsStartupStatus
@@ -9,7 +9,12 @@ import { PanelHeader, PanelStyles } from './panel-chrome';
9
9
  // (/_dsh/dsh-crew/quick-config, /quick-status). Runtime maintenance stays on 3210.
10
10
 
11
11
  const API = '/_dsh/dsh-crew';
12
- const FULL = 'http://127.0.0.1:3210/';
12
+ // The control plane needs its own session, and this panel runs in a DIFFERENT
13
+ // harness process (the desktop app, or the 3080 frontend): its connection service
14
+ // cannot mint a token for 3210, so linking the bare port landed the operator on
15
+ // "dsh web authentication required". The hub's own endpoint mints the session and
16
+ // redirects, so no token is ever published to a page or a file.
17
+ const FULL = 'http://127.0.0.1:3210/_dsh/dsh-crew/control-plane';
13
18
 
14
19
  type ModelEntry = { provider: string; model: string };
15
20
  type QuickConfig = {
@@ -220,14 +220,29 @@ export function ensureCrewDshRuntime({
220
220
  platform = process.platform,
221
221
  comspec = process.env.ComSpec ?? 'cmd.exe',
222
222
  env = process.env,
223
+ read = readFileSync,
224
+ write = writeFileSync,
225
+ log = () => {},
223
226
  } = {}) {
224
- const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
227
+ const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false, read });
225
228
  // Reuse only when the installed runtime already matches the requested
226
229
  // cohort. A stale cohort (e.g. an online 0.1.1-rc.2 runtime) must never be
227
230
  // mistaken for the target, and it must never be upgraded in place under a
228
231
  // live hub.
229
232
  if (existing?.kind === 'crew-runtime' && existing?.version === version) {
230
- return { ok: true, cli: existing, reused: true };
233
+ // The core matching is not enough: a tree whose family drifted boots that
234
+ // core against siblings from another cohort and dies on the first import
235
+ // the newer ones dropped. Report it as the cohort mismatch it is so the
236
+ // caller runs the staged (re)install instead of handing it to the launcher.
237
+ const cohort = verifyCrewRuntimeCohort({ root: crewDshRuntimeRoot({ home }), version, read });
238
+ if (cohort.ok) return { ok: true, cli: existing, reused: true };
239
+ return {
240
+ ok: false,
241
+ code: 'DSH_RUNTIME_COHORT_MISMATCH',
242
+ error: `Crew runtime family drifted from ${version}: ${describeCohortDrift(cohort.drift)}`,
243
+ installed: existing.version ?? null,
244
+ target: version,
245
+ };
231
246
  }
232
247
  if (existing?.kind === 'crew-runtime') {
233
248
  return {
@@ -251,13 +266,14 @@ export function ensureCrewDshRuntime({
251
266
  ? ['add', '--dir', runtimeRoot, '--ignore-scripts', packageSpec]
252
267
  : ['install', '--prefix', runtimeRoot, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
253
268
  const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
254
- const result = runner(invocation.command, invocation.args, {
269
+ const runInstall = () => runner(invocation.command, invocation.args, {
255
270
  encoding: 'utf8',
256
271
  stdio: ['ignore', 'pipe', 'pipe'],
257
272
  shell: invocation.shell,
258
273
  env: { ...env },
259
274
  });
260
- const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
275
+ const result = runInstall();
276
+ const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false, read });
261
277
  if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
262
278
  return {
263
279
  ok: false,
@@ -278,7 +294,30 @@ export function ensureCrewDshRuntime({
278
294
  target: version,
279
295
  };
280
296
  }
281
- return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
297
+ // The core is pinned by the install spec, but npm resolves its siblings from
298
+ // the core's caret ranges: a registry that published a newer prerelease of the
299
+ // family produces a mixed tree here, so lock and re-verify the family before
300
+ // this runtime can be reported reusable.
301
+ const locked = enforceCohortLock({ root: runtimeRoot, version, runInstall, read, write, log });
302
+ if (locked.failed) {
303
+ return {
304
+ ok: false,
305
+ code: 'DSH_RUNTIME_INSTALL_FAILED',
306
+ error: 'Crew-owned DSH runtime install failed',
307
+ status: locked.failed.status ?? -1,
308
+ stderrTail: String(locked.failed.stderr || locked.failed.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
309
+ };
310
+ }
311
+ if (!locked.ok) {
312
+ return {
313
+ ok: false,
314
+ code: 'DSH_RUNTIME_COHORT_INCONSISTENT',
315
+ error: `installed Crew runtime family does not match ${version}: ${describeCohortDrift(locked.drift)}`,
316
+ cohort: { drift: locked.drift },
317
+ target: version,
318
+ };
319
+ }
320
+ return { ok: true, cli, reused: false, version: cli.version, runtimeRoot, ...(locked.repairs ? { cohortRepaired: true } : {}) };
282
321
  }
283
322
 
284
323
  export function describeDshCli(cli) {
@@ -287,6 +326,104 @@ export function describeDshCli(cli) {
287
326
  return `${cli.kind}${version}`;
288
327
  }
289
328
 
329
+ // ---- installed cohort identity ---------------------------------------------
330
+ // The runtime tree is a COHORT, not a single package: @deepseek-ai/dsh plus the
331
+ // @deepseek-ai/dsh-* family it loads. The install pins the core exactly, but npm
332
+ // still resolves the family from the core's own caret ranges, so a registry that
333
+ // has published a newer prerelease of the family yields a MIXED tree — core
334
+ // alpha.1 with alpha.2 siblings — that installs cleanly and then dies at boot:
335
+ // the alpha.1 core imports exports the alpha.2 siblings no longer provide. Both
336
+ // the reuse gate and the install therefore judge every family member, and a tree
337
+ // that drifted is locked with overrides and reinstalled once before anything is
338
+ // reported reusable.
339
+
340
+ // A family package may carry its own nested copy of a sibling, and node
341
+ // resolves that copy FIRST for its parent — so a nested copy at the wrong
342
+ // version breaks the parent exactly like a top-level one. npm does produce
343
+ // these (a reconcile against a mixed tree nested whole subtrees), so the walk
344
+ // follows family packages down through their own node_modules and stops before
345
+ // it can descend into unrelated trees.
346
+ const COHORT_NESTED_DEPTH = 3;
347
+
348
+ function installedCohortEntries(root, read = readFileSync) {
349
+ const entries = [];
350
+ const visit = (scope, prefix, depth) => {
351
+ let names;
352
+ try { names = readdirSync(scope); } catch { return; }
353
+ for (const name of names) {
354
+ if (name !== 'dsh' && !name.startsWith('dsh-')) continue;
355
+ const dir = join(scope, name);
356
+ entries.push({ name: `@deepseek-ai/${name}`, version: readPackageVersionAt(dir, read), scope: prefix || 'node_modules' });
357
+ if (depth >= COHORT_NESTED_DEPTH) continue;
358
+ const nested = join(dir, 'node_modules', '@deepseek-ai');
359
+ if (existsSync(nested)) visit(nested, `${prefix ? `${prefix}/` : ''}${name}/node_modules`, depth + 1);
360
+ }
361
+ };
362
+ visit(join(root, 'node_modules', '@deepseek-ai'), '', 0);
363
+ return entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
364
+ }
365
+
366
+ /**
367
+ * Judge every installed copy of the family against the pinned cohort version.
368
+ * `drift` names each copy whose on-disk version differs (an unreadable manifest
369
+ * reads as null), so callers can report the exact packages instead of a bare
370
+ * mismatch.
371
+ */
372
+ export function verifyCrewRuntimeCohort({ root, version, read = readFileSync } = {}) {
373
+ const entries = installedCohortEntries(root, read);
374
+ const drift = entries.filter((entry) => entry.version !== version);
375
+ return { ok: entries.length > 0 && drift.length === 0, entries, drift };
376
+ }
377
+
378
+ function describeCohortDrift(drift) {
379
+ return drift
380
+ .map((entry) => (entry.scope && entry.scope !== 'node_modules'
381
+ ? `${entry.name}@${entry.version ?? 'missing'} (nested in ${entry.scope})`
382
+ : `${entry.name}@${entry.version ?? 'missing'}`))
383
+ .join(', ');
384
+ }
385
+
386
+ // Lock the whole family to the cohort version. npm v11 rejects an override that
387
+ // shares a name with a direct dependency unless the spec repeats it verbatim, so
388
+ // the core keeps its own exact dependency entry and is left out of `overrides`;
389
+ // pnpm reads overrides from its own field, so both are written.
390
+ function writeCohortOverrides({ root, version, names, read = readFileSync, write = writeFileSync }) {
391
+ const overrides = {};
392
+ for (const name of names) {
393
+ if (name === DSH_CLI_PACKAGE) continue;
394
+ overrides[name] = version;
395
+ }
396
+ if (Object.keys(overrides).length === 0) return false;
397
+ const manifestFile = join(root, 'package.json');
398
+ let manifest = null;
399
+ try { manifest = JSON.parse(read(manifestFile, 'utf8')); } catch { manifest = null; }
400
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) manifest = {};
401
+ const next = {
402
+ ...manifest,
403
+ dependencies: { ...(manifest.dependencies ?? {}), [DSH_CLI_PACKAGE]: version },
404
+ overrides,
405
+ pnpm: { ...(manifest.pnpm ?? {}), overrides },
406
+ };
407
+ write(manifestFile, `${JSON.stringify(next, null, 2)}\n`);
408
+ return true;
409
+ }
410
+
411
+ // Prove the freshly installed tree is a coherent cohort. A drifting family is
412
+ // locked with overrides and reinstalled exactly ONCE; a tree that still drifts is
413
+ // reported to the caller, never handed to the launcher as reusable.
414
+ function enforceCohortLock({ root, version, runInstall, read = readFileSync, write = writeFileSync, log = () => {} }) {
415
+ const observed = verifyCrewRuntimeCohort({ root, version, read });
416
+ if (observed.ok) return { ok: true, repairs: 0 };
417
+ if (!writeCohortOverrides({ root, version, names: observed.entries.map((entry) => entry.name), read, write })) {
418
+ return { ok: false, drift: observed.drift };
419
+ }
420
+ log(` DSH cohort drifted (${describeCohortDrift(observed.drift)}); locking the family with overrides`);
421
+ const result = runInstall();
422
+ if (result?.status !== 0) return { ok: false, failed: result };
423
+ const after = verifyCrewRuntimeCohort({ root, version, read });
424
+ return after.ok ? { ok: true, repairs: 1 } : { ok: false, drift: after.drift };
425
+ }
426
+
290
427
  // Shared pnpm/npm install of the pinned DSH cohort into a specific root
291
428
  // directory. pnpm materializes ABSOLUTE-path .cmd shims and junctions that
292
429
  // point at the install root's own .pnpm store, so the tree must be installed
@@ -305,6 +442,9 @@ export function installDshInto({
305
442
  platform = process.platform,
306
443
  comspec = process.env.ComSpec ?? 'cmd.exe',
307
444
  env = process.env,
445
+ read = readFileSync,
446
+ write = writeFileSync,
447
+ log = () => {},
308
448
  }) {
309
449
  const pnpm = pnpmCommand ?? findCommand('pnpm');
310
450
  const npm = npmCommand ?? findCommand('npm');
@@ -317,12 +457,13 @@ export function installDshInto({
317
457
  ? ['add', '--dir', root, '--ignore-scripts', packageSpec]
318
458
  : ['install', '--prefix', root, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
319
459
  const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
320
- const result = runner(invocation.command, invocation.args, {
460
+ const runInstall = () => runner(invocation.command, invocation.args, {
321
461
  encoding: 'utf8',
322
462
  stdio: ['ignore', 'pipe', 'pipe'],
323
463
  shell: invocation.shell,
324
464
  env: { ...env },
325
465
  });
466
+ const result = runInstall();
326
467
  if (result.status !== 0) {
327
468
  return {
328
469
  ok: false,
@@ -336,7 +477,7 @@ export function installDshInto({
336
477
  if (!exists(moduleEntry)) {
337
478
  return { ok: false, code: 'DSH_RUNTIME_INSTALL_INCOMPLETE', error: 'runtime entry missing after install' };
338
479
  }
339
- const installedVersion = packageVersion(moduleEntry);
480
+ const installedVersion = packageVersion(moduleEntry, read);
340
481
  if (installedVersion !== version) {
341
482
  return {
342
483
  ok: false,
@@ -346,7 +487,26 @@ export function installDshInto({
346
487
  target: version,
347
488
  };
348
489
  }
349
- return { ok: true, root, version: installedVersion };
490
+ const locked = enforceCohortLock({ root, version, runInstall, read, write, log });
491
+ if (locked.failed) {
492
+ return {
493
+ ok: false,
494
+ code: 'DSH_RUNTIME_INSTALL_FAILED',
495
+ error: 'Crew runtime install failed',
496
+ status: locked.failed.status ?? -1,
497
+ stderrTail: String(locked.failed.stderr || locked.failed.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
498
+ };
499
+ }
500
+ if (!locked.ok) {
501
+ return {
502
+ ok: false,
503
+ code: 'DSH_RUNTIME_COHORT_INCONSISTENT',
504
+ error: `installed Crew runtime family does not match ${version}: ${describeCohortDrift(locked.drift)}`,
505
+ cohort: { drift: locked.drift },
506
+ target: version,
507
+ };
508
+ }
509
+ return { ok: true, root, version: installedVersion, ...(locked.repairs ? { cohortRepaired: true } : {}) };
350
510
  }
351
511
 
352
512
  // Staged cohort migration: install the target cohort into a versioned
@@ -1084,3 +1244,44 @@ export function removeCrewPluginRegistration({ home = homedir(), name, profileRo
1084
1244
  if (linkStat) unlinkSync(linkPath);
1085
1245
  return { ok: true, removed: true };
1086
1246
  }
1247
+
1248
+ /**
1249
+ * Idempotently materialize the Crew-owned Harness `web` profile scaffold.
1250
+ *
1251
+ * `ensureCrewDshRuntime` supplies the CLI but nothing else a managed 3210/3080
1252
+ * boot needs: the Windows launcher preflight and the supervisor both require
1253
+ * `<crew DSH_HOME>/profiles/web/package.json`, and a fresh install that never
1254
+ * created it deadlocked (supervisor heartbeat timeout -> pending handoff ->
1255
+ * every later update refused). The scaffold is the same shape the plugin
1256
+ * registration writes for the dsh-crew profile: an existing valid manifest is
1257
+ * left untouched, a missing one is created from CREW_PROFILE_DEFAULT_BUNDLES,
1258
+ * and a corrupt one fails closed instead of being overwritten.
1259
+ */
1260
+ export function ensureCrewWebProfile({
1261
+ home = homedir(),
1262
+ profileName = 'web',
1263
+ defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES,
1264
+ } = {}) {
1265
+ if (typeof profileName !== 'string' || !/^[A-Za-z0-9._-]+$/u.test(profileName)) {
1266
+ return { ok: false, code: 'INVALID_PROFILE_NAME' };
1267
+ }
1268
+ const profileRoot = join(crewDshHome({ home }), 'profiles', profileName);
1269
+ const profile = readProfileManifestForRegistration(profileRoot, { create: true, defaultBundles });
1270
+ if (profile.ok === false) return { ok: false, code: profile.code, profileRoot };
1271
+ let scaffoldChanged = false;
1272
+ try {
1273
+ scaffoldChanged = ensureProfileScaffold(profileRoot);
1274
+ if (profile.created) {
1275
+ writeFileSync(profile.profileManifest, JSON.stringify(profile.manifest, null, 2) + '\n');
1276
+ }
1277
+ } catch (error) {
1278
+ return { ok: false, code: 'CREW_WEB_PROFILE_SCAFFOLD_FAILED', error: String(error?.message ?? error), profileRoot };
1279
+ }
1280
+ return {
1281
+ ok: true,
1282
+ profileRoot,
1283
+ profileManifest: profile.profileManifest,
1284
+ created: profile.created === true,
1285
+ changed: profile.created === true || scaffoldChanged,
1286
+ };
1287
+ }
package/src/hub/index.mjs CHANGED
@@ -40,7 +40,7 @@ import { raceWaiters } from '../removable-waiter.mjs';
40
40
  import { buildProviderInventory } from '../provider-inventory.mjs';
41
41
  import { buildProviderLayerMigrationPlan, executeProviderLayerMigration } from '../provider-layer-migration.mjs';
42
42
  import { inspectProviderProfile, readProviderDeclarations, readProviderMaterialization } from '../provider-profile-store.mjs';
43
- import { inspectProviderSettings, readHarnessDefault, readProviderSettingsDeclarations, readProviderSettingsMaterialization } from '../provider-settings-store.mjs';
43
+ import { hasHarnessDefaultBlock, inspectProviderSettings, readHarnessDefault, readProviderSettingsDeclarations, readProviderSettingsMaterialization } from '../provider-settings-store.mjs';
44
44
  import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs';
45
45
  import { createProviderHealthStore } from '../provider-health.mjs';
46
46
  import { planProviderDelete } from '../provider-lifecycle.mjs';
@@ -251,6 +251,26 @@ export function hasCompleteProviderDeclarationEvidence(evidence) {
251
251
  && Object.values(evidence.sources ?? {}).every((source) => source?.present !== true || !source.code);
252
252
  }
253
253
 
254
+ /**
255
+ * Decide what the Harness default authority evidence means. A persisted
256
+ * `agent-default-model` that disagrees with the live default is a real
257
+ * inconsistency and fails closed; a live default with nothing persisted is the
258
+ * normal state of a home whose model comes from configuration (the profile and
259
+ * base layers) — and the state every settings mutation materializes FROM, so
260
+ * demanding a persisted copy first deadlocked the very migration that writes one.
261
+ */
262
+ export function projectHarnessDefaultEvidence({ liveDefault = null, persistedDefault = null, persistedDefaultDeclared = false } = {}) {
263
+ if (!liveDefault && !persistedDefault) return { ok: true, source: 'none' };
264
+ if (liveDefault && persistedDefault) {
265
+ return liveDefault.provider === persistedDefault.provider && liveDefault.model === persistedDefault.model
266
+ ? { ok: true, source: 'harness-settings' }
267
+ : { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
268
+ }
269
+ return persistedDefaultDeclared
270
+ ? { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' }
271
+ : { ok: true, source: 'harness-config' };
272
+ }
273
+
254
274
  export function hasAvailableProviderLifecycleEvidence(evidence) {
255
275
  return evidence?.ok === true;
256
276
  }
@@ -340,6 +360,7 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
340
360
  } catch { declarationEvidence.ok = false; declarationEvidence.sources.profile = { present: true, code: 'PROVIDER_SOURCE_UNAVAILABLE' }; }
341
361
  const settingsFile = join(CONFIG_DIR, 'harness', 'settings.yaml');
342
362
  let settingsDefault = null;
363
+ let persistedDefaultDeclared = false;
343
364
  let defaultEvidence = { ok: true, source: 'none' };
344
365
  try {
345
366
  declarationEvidence.sources.settings = { present: existsSync(settingsFile) };
@@ -352,8 +373,15 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
352
373
  }
353
374
  else { declarationEvidence.ok = false; declarationEvidence.sources.settings.code = parsed.code; }
354
375
  const parsedDefault = readHarnessDefault(source);
355
- if (parsedDefault.ok) settingsDefault = parsedDefault;
356
- else defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' };
376
+ if (parsedDefault.ok) {
377
+ settingsDefault = parsedDefault;
378
+ persistedDefaultDeclared = true;
379
+ } else if (hasHarnessDefaultBlock(source)) {
380
+ // The block exists but cannot be read: fail closed on the mutation
381
+ // authority rather than treating the layer as absent.
382
+ defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' };
383
+ persistedDefaultDeclared = true;
384
+ }
357
385
  }
358
386
  } catch {
359
387
  declarationEvidence.ok = false;
@@ -363,9 +391,8 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
363
391
  const liveDefault = catalog.harness_default && typeof catalog.harness_default.provider === 'string' && typeof catalog.harness_default.model === 'string'
364
392
  ? { provider: catalog.harness_default.provider, model: catalog.harness_default.model } : null;
365
393
  const persistedDefault = settingsDefault ? { provider: settingsDefault.provider, model: settingsDefault.model } : null;
366
- if (liveDefault || persistedDefault) {
367
- if (!liveDefault || !persistedDefault) defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
368
- else if (liveDefault.provider !== persistedDefault.provider || liveDefault.model !== persistedDefault.model) defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
394
+ if (defaultEvidence.ok) {
395
+ defaultEvidence = projectHarnessDefaultEvidence({ liveDefault, persistedDefault, persistedDefaultDeclared });
369
396
  }
370
397
  // The persisted agent-default-model is the mutation authority. Keep it in
371
398
  // the inventory even when the live selection is stale; the evidence gate
@@ -1438,6 +1465,30 @@ export async function apply(ctx) {
1438
1465
  handler: (req, res) => sendJson(res, 200, { ok: true, service: 'dsh-crew-hub' }),
1439
1466
  }));
1440
1467
 
1468
+ // Operator entry into the full control plane. Its session token is minted per
1469
+ // process, and every surface that links here (the desktop app's panel, the
1470
+ // 3080 panel) runs in a different harness process whose connection service
1471
+ // cannot mint one for this port — linking the bare port landed the operator on
1472
+ // "dsh web authentication required". Only the hub itself can sign its own
1473
+ // session, so it redirects; no token is written to a page, a file or a log.
1474
+ // Loopback-only, because the Location carries a session credential.
1475
+ disposers.push(webServer.register({
1476
+ kind: 'exact', path: `${ROUTE_BASE}/control-plane`,
1477
+ handler: (req, res) => {
1478
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1479
+ const runtime = getHubRuntimeIdentity();
1480
+ const base = `http://127.0.0.1:${Number(runtime?.listen_port) > 0 ? runtime.listen_port : 3210}/`;
1481
+ let target = base;
1482
+ try {
1483
+ const connection = webCtx.get?.('connection');
1484
+ const authenticated = connection?.authenticatedUrl?.(base);
1485
+ if (typeof authenticated === 'string' && authenticated.startsWith('http://')) target = authenticated;
1486
+ } catch { /* the bare control plane stays the honest fallback */ }
1487
+ res.writeHead(302, { location: target, 'cache-control': 'no-store' });
1488
+ res.end();
1489
+ },
1490
+ }));
1491
+
1441
1492
  disposers.push(webServer.register({
1442
1493
  kind: 'prefix', path: `${ROUTE_BASE}/jobs`,
1443
1494
  handler: async (req, res) => {
@@ -9,7 +9,7 @@
9
9
  // the check disagreed about what "the installed server" is, and only the
10
10
  // installer was right.
11
11
 
12
- import { existsSync, realpathSync } from 'node:fs';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
15
 
@@ -60,21 +60,26 @@ export function loaderLinkPath({ home = homedir(), name } = {}) {
60
60
  return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
61
61
  }
62
62
 
63
- function sameDirectory(left, right) {
64
- try { return realpathSync(left) === realpathSync(right); } catch { return false; }
65
- }
66
-
67
63
  /**
68
64
  * The root the host integrations were installed from.
69
65
  *
70
- * Readiness must judge the integrations against the path they were actually
71
- * written with. That path is the loader link when it resolves to this release,
72
- * and the release directory itself otherwise — a machine whose link is missing
73
- * or points somewhere else is reported against the release, which is exactly
74
- * the mismatch the operator needs to see.
66
+ * The integrations embed the loader link path itself, and the link is what the
67
+ * host loads through: whichever payload it currently resolves to IS the live
68
+ * one, and an upgrade re-points the link so the same embedded path keeps
69
+ * resolving to the new release. Readiness therefore judges the integrations
70
+ * against the link whenever it resolves to a readable payload of this package,
71
+ * and falls back to the caller's root only when the link is broken or carries
72
+ * a different package. Gating on whether the link happens to realpath to the
73
+ * caller's root made the writer (link-rooted) and the validator
74
+ * (release-rooted) disagree about the same machine, and every clean install
75
+ * read back as "needs repair".
75
76
  */
76
77
  export function integrationRoot({ home = homedir(), root, name } = {}) {
77
78
  const link = loaderLinkPath({ home, name });
78
79
  if (!link || !root || !existsSync(link)) return root;
79
- return sameDirectory(link, root) ? link : root;
80
+ try {
81
+ const payload = JSON.parse(readFileSync(join(link, 'package.json'), 'utf8'));
82
+ if (payload?.name === name) return link;
83
+ } catch { /* broken or foreign link payload */ }
84
+ return root;
80
85
  }