@ran-sh/dsh-crew 0.5.7 → 1.0.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.
package/src/hub/index.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // and serves the one-click installer endpoints for the settings page.
5
5
 
6
6
  import { randomUUID } from 'node:crypto';
7
- import { existsSync, readFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { join, isAbsolute } from 'node:path';
9
9
  import { homedir } from 'node:os';
10
10
  import { createShardWriter, readMergedStatus } from '../status-shard.mjs';
@@ -39,6 +39,11 @@ import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs
39
39
  import { createProviderHealthStore } from '../provider-health.mjs';
40
40
  import { planProviderDelete } from '../provider-lifecycle.mjs';
41
41
  import { createProviderDeleteFileHooks } from '../provider-delete-adapters.mjs';
42
+ import { buildCredentialReferenceInventory } from '../credential-reference-inventory.mjs';
43
+ import { executeCredentialPurge, planCredentialPurge } from '../credential-lifecycle.mjs';
44
+ import { createCredentialPurgeFileHooks } from '../credential-purge-adapters.mjs';
45
+ import { normalizeCredentialPurgeState, recordCredentialPurgeOutcome } from '../credential-purge-state.mjs';
46
+ import { buildRuntimeReadinessSnapshot } from '../runtime-readiness-snapshot.mjs';
42
47
 
43
48
  // policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
44
49
  // here is safe for the profile-realm discipline: it never pulls in package
@@ -100,6 +105,7 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
100
105
  // the profile realm): a valid dispatch role set.
101
106
  const ROLES = { worker: true, reviewer: true };
102
107
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
108
+ const CREDENTIAL_PURGE_STATE_FILE = join(CONFIG_DIR, 'credential-purge-lifecycle.json');
103
109
 
104
110
  function readProviderLifecycleState() {
105
111
  try {
@@ -109,6 +115,22 @@ function readProviderLifecycleState() {
109
115
  }
110
116
  }
111
117
 
118
+ function readCredentialPurgeState() {
119
+ try { return normalizeCredentialPurgeState(JSON.parse(readFileSync(CREDENTIAL_PURGE_STATE_FILE, 'utf8'))); } catch { return normalizeCredentialPurgeState(); }
120
+ }
121
+
122
+ function writeCredentialPurgeState(state) {
123
+ mkdirSync(CONFIG_DIR, { recursive: true });
124
+ const temp = `${CREDENTIAL_PURGE_STATE_FILE}.${process.pid}.${Date.now()}.tmp`;
125
+ try {
126
+ writeFileSync(temp, JSON.stringify(normalizeCredentialPurgeState(state), null, 2) + '\n', 'utf8');
127
+ renameSync(temp, CREDENTIAL_PURGE_STATE_FILE);
128
+ } catch (error) {
129
+ try { rmSync(temp, { force: true }); } catch {}
130
+ throw error;
131
+ }
132
+ }
133
+
112
134
  function providerInventoryPolicy(config) {
113
135
  return {
114
136
  worker: config?.worker?.model_policy ?? {
@@ -140,13 +162,31 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
140
162
  }
141
163
  } catch {}
142
164
  const lifecycle = readProviderLifecycleState();
143
- return buildProviderInventory({
165
+ const credentialPurge = readCredentialPurgeState();
166
+ const inventory = buildProviderInventory({
144
167
  catalog,
145
168
  declarations,
146
169
  policy: providerInventoryPolicy(config),
147
170
  tombstones: lifecycle.tombstones,
148
171
  activeJobs: hub.list(),
149
172
  });
173
+ return {
174
+ ...inventory,
175
+ credential_history_refs: Object.values(lifecycle.transactions ?? {})
176
+ .flatMap((transaction) => Array.isArray(transaction?.credential_refs) ? transaction.credential_refs : []),
177
+ credential_purged_refs: Object.keys(credentialPurge.purged),
178
+ credential_purge_unverified: Object.entries(credentialPurge.unverified).map(([referenceId, record]) => ({
179
+ reference_id: referenceId,
180
+ transaction_id: record.transaction_id,
181
+ state: record.state,
182
+ })),
183
+ lifecycle_transactions: Object.entries(lifecycle.transactions ?? {}).slice(-64).map(([transactionId, transaction]) => ({
184
+ transaction_id: transactionId,
185
+ provider_id: transaction.provider_id,
186
+ state: transaction.state,
187
+ ...(transaction.expected_revision ? { expected_revision: transaction.expected_revision } : {}),
188
+ })),
189
+ };
150
190
  }
151
191
 
152
192
  function readProviderProfileRevision() {
@@ -165,12 +205,26 @@ export function hubCanonicalEvents(job = {}) {
165
205
  const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
166
206
  const atStart = job.startedAt ?? null;
167
207
  const atEnd = job.endedAt ?? atStart;
168
- const definitions = [
169
- ['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
170
- ['job.started', atStart, {}],
171
- ['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
172
- [role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
173
- ];
208
+ const definitions = [
209
+ ['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
210
+ ['job.started', atStart, {}],
211
+ ['runtime.bound', atStart, {
212
+ execution_plane: job.execution_context?.execution_plane ?? null,
213
+ profile: job.execution_context?.profile ?? null,
214
+ listen_port: Number.isInteger(job.execution_context?.listen_port) ? job.execution_context.listen_port : null,
215
+ runtime_id: job.execution_context?.runtime_id ?? null,
216
+ }],
217
+ ['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
218
+ ['model.admitted', atStart, {
219
+ provider: job.provider ?? null,
220
+ model: job.model ?? null,
221
+ health_state: job.health_state ?? 'unprobed',
222
+ reason_code: job.health_reason_code ?? null,
223
+ observed_at: Number.isFinite(job.health_observed_at) ? job.health_observed_at : null,
224
+ }],
225
+ [role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
226
+ ['agent.created', atStart, { run_id: job.id }],
227
+ ];
174
228
  if (job.status !== 'running') {
175
229
  definitions.push([
176
230
  role === 'reviewer' ? 'review.completed' : 'worker.completed',
@@ -319,7 +373,7 @@ export class WorkerRegistry { constructor(ctx) {
319
373
  * `role` (worker | reviewer) records who does the work; `tier` remains the
320
374
  * legacy model-class slot. Reviewer-role jobs always use the pro slot.
321
375
  */
322
- async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context, allow_no_changes }) {
376
+ async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context, allow_no_changes, ingress = 'direct-3210' }) {
323
377
  // role is only honored when the caller explicitly names it; a legacy
324
378
  // tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
325
379
  const hasRole = role === 'worker' || role === 'reviewer';
@@ -455,6 +509,7 @@ export class WorkerRegistry { constructor(ctx) {
455
509
  profile: runtime.profile,
456
510
  listen_port: runtime.listen_port,
457
511
  runtime_id: runtime.runtime_id,
512
+ ingress: ingress === 'official-3080' ? 'official-3080' : 'direct-3210',
458
513
  };
459
514
  })(),
460
515
  allow_no_changes: allow_no_changes === true,
@@ -895,10 +950,15 @@ export async function apply(ctx) {
895
950
  const disposers = [];
896
951
  const PROVIDER_DELETE_PLAN_TTL_MS = 10 * 60 * 1000;
897
952
  const providerDeletePlans = new Map();
953
+ const credentialPurgePlans = new Map();
898
954
  const rememberProviderDeletePlan = (plan) => {
899
955
  providerDeletePlans.set(plan.plan_id, { plan, created_at: Date.now() });
900
956
  while (providerDeletePlans.size > 32) providerDeletePlans.delete(providerDeletePlans.keys().next().value);
901
957
  };
958
+ const rememberCredentialPurgePlan = (plan) => {
959
+ credentialPurgePlans.set(plan.plan_id, { plan, created_at: Date.now() });
960
+ while (credentialPurgePlans.size > 32) credentialPurgePlans.delete(credentialPurgePlans.keys().next().value);
961
+ };
902
962
 
903
963
  // Multimodal bridge: register describe_image / generate_image for the DS
904
964
  // model. Config is read per call so settings-page edits apply live; the
@@ -987,7 +1047,8 @@ export async function apply(ctx) {
987
1047
  // as its raw default (pro-only + no tier used to spawn flash).
988
1048
  const resolved = resolveHubSpawnPayload(payload, () => hub.getConfig?.() ?? {});
989
1049
  if (!resolved.ok) return sendJson(res, 400, { ok: false, error: resolved.error, code: resolved.code });
990
- const job = await hub.spawn(resolved.payload);
1050
+ const ingress = req.headers?.['x-dsh-crew-ingress'] === 'official-3080' ? 'official-3080' : 'direct-3210';
1051
+ const job = await hub.spawn({ ...resolved.payload, ingress });
991
1052
  return sendJson(res, 200, { ok: true, job: hub.view(job) });
992
1053
  }
993
1054
  if (req.method === 'POST' && parts.length === 2 && parts[1] === 'cancel') {
@@ -1067,16 +1128,27 @@ export async function apply(ctx) {
1067
1128
  const workspaceReadiness = requestedWorkspaceId && !requestedContext
1068
1129
  ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
1069
1130
  : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
1070
- const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
1071
- const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
1072
- const contract = buildExtensionContract({
1073
- config,
1074
- readinessMatrix: { rows: [
1075
- { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
1076
- catalogStatus,
1077
- modelExecution,
1078
- reviewerExecution,
1079
- ] },
1131
+ const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
1132
+ const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
1133
+ const readinessMatrix = { rows: [
1134
+ { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
1135
+ catalogStatus,
1136
+ modelExecution,
1137
+ reviewerExecution,
1138
+ { id: 'provider_lifecycle_consistent', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' },
1139
+ ] };
1140
+ const readinessSnapshot = buildRuntimeReadinessSnapshot({
1141
+ runtime: getHubRuntimeIdentity(),
1142
+ readinessMatrix,
1143
+ selections: { worker: liveJobs.find((job) => job?.role === 'worker') ?? null, reviewer: liveJobs.find((job) => job?.role === 'reviewer') ?? null },
1144
+ health: hub.healthStore.list(),
1145
+ jobs: liveJobs,
1146
+ workspace: workspaceReadiness,
1147
+ });
1148
+ const contract = buildExtensionContract({
1149
+ config,
1150
+ readinessMatrix,
1151
+ readinessSnapshot,
1080
1152
  workspace: workspaceReadiness,
1081
1153
  profiles,
1082
1154
  runtime: getHubRuntimeIdentity(),
@@ -1195,6 +1267,91 @@ export async function apply(ctx) {
1195
1267
  },
1196
1268
  }));
1197
1269
 
1270
+ // Credential references are a separate, secret-free inventory. Provider
1271
+ // deletion can report these references, but never implies their purge.
1272
+ disposers.push(webServer.register({
1273
+ kind: 'exact', path: `${ROUTE_BASE}/credential-references`,
1274
+ handler: async (req, res) => {
1275
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1276
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1277
+ try {
1278
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1279
+ const snapshot = await readProviderInventorySnapshot(hub, ctx, config);
1280
+ const credentials = buildCredentialReferenceInventory({ providers: snapshot.records, additional_refs: snapshot.credential_history_refs, purged_refs: snapshot.credential_purged_refs });
1281
+ return sendJson(res, 200, { ok: true, ...credentials, unverified_purges: snapshot.credential_purge_unverified, runtime: getHubRuntimeIdentity() });
1282
+ } catch {
1283
+ return sendJson(res, 503, { ok: false, code: 'CREDENTIAL_REFERENCE_INVENTORY_UNAVAILABLE' });
1284
+ }
1285
+ },
1286
+ }));
1287
+
1288
+ disposers.push(webServer.register({
1289
+ kind: 'prefix', path: `${ROUTE_BASE}/credential-references`,
1290
+ handler: async (req, res) => {
1291
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1292
+ let url;
1293
+ try { url = new URL(req.url, 'http://localhost'); } catch { return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_PLAN_INVALID' }); }
1294
+ const parts = url.pathname.slice(`${ROUTE_BASE}/credential-references`.length).split('/').filter(Boolean);
1295
+ try {
1296
+ const referenceId = decodeURIComponent(parts[0] ?? '');
1297
+ const body = await readBody(req);
1298
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1299
+ const snapshot = await readProviderInventorySnapshot(hub, ctx, config);
1300
+ const credentials = buildCredentialReferenceInventory({ providers: snapshot.records, additional_refs: snapshot.credential_history_refs, purged_refs: snapshot.credential_purged_refs });
1301
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'purge-plan') {
1302
+ const planned = planCredentialPurge({ inventory: credentials, referenceId, expectedRevision: body?.expected_revision });
1303
+ if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
1304
+ rememberCredentialPurgePlan(planned.plan);
1305
+ return sendJson(res, 200, { ok: true, plan: planned.plan });
1306
+ }
1307
+ if (req.method === 'DELETE' && parts.length === 1) {
1308
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_CONFIRM_REQUIRED' });
1309
+ const stored = credentialPurgePlans.get(body?.plan_id);
1310
+ if (!stored || Date.now() - stored.created_at > PROVIDER_DELETE_PLAN_TTL_MS || stored.plan.reference_id !== referenceId) {
1311
+ credentialPurgePlans.delete(body?.plan_id);
1312
+ return sendJson(res, 409, { ok: false, code: 'CREDENTIAL_PURGE_PLAN_EXPIRED' });
1313
+ }
1314
+ const current = planCredentialPurge({ inventory: credentials, referenceId, expectedRevision: stored.plan.expected_revision });
1315
+ if (!current.ok) return sendJson(res, 409, { ok: false, code: current.code });
1316
+ if (current.plan.expected_revision !== stored.plan.expected_revision) return sendJson(res, 409, { ok: false, code: 'CREDENTIAL_REFERENCE_CHANGED' });
1317
+ credentialPurgePlans.delete(body.plan_id);
1318
+ const adapter = createCredentialPurgeFileHooks({
1319
+ credentialsFile: join(CONFIG_DIR, 'harness', '.credentials.yaml'),
1320
+ crewHome: join(CONFIG_DIR, 'harness'),
1321
+ });
1322
+ const result = await executeCredentialPurge(current.plan, {
1323
+ recheck: async (id) => {
1324
+ const freshSnapshot = await readProviderInventorySnapshot(hub, ctx, config);
1325
+ const freshCredentials = buildCredentialReferenceInventory({ providers: freshSnapshot.records, additional_refs: freshSnapshot.credential_history_refs, purged_refs: freshSnapshot.credential_purged_refs });
1326
+ const freshPlan = planCredentialPurge({ inventory: freshCredentials, referenceId: id });
1327
+ if (!freshPlan.ok) return { ok: false };
1328
+ const freshRecord = freshCredentials.records.find((entry) => entry.reference_id === id);
1329
+ return {
1330
+ ok: true,
1331
+ revision: freshPlan.plan.expected_revision,
1332
+ orphan: freshRecord?.orphan === true,
1333
+ ownership: freshRecord?.ownership,
1334
+ purge_capability: freshRecord?.purge_capability,
1335
+ };
1336
+ },
1337
+ ...(adapter.ok ? { purge: adapter.purge, verify: adapter.verify } : {}),
1338
+ });
1339
+ if (result.state === 'PURGED' || result.state === 'VERIFIED') {
1340
+ try {
1341
+ writeCredentialPurgeState(recordCredentialPurgeOutcome(readCredentialPurgeState(), current.plan, result));
1342
+ } catch {
1343
+ return sendJson(res, 503, { ok: false, code: 'CREDENTIAL_PURGE_AUDIT_FAILED', state: result.state, reference_id: referenceId });
1344
+ }
1345
+ }
1346
+ return sendJson(res, result.ok ? 200 : 503, result);
1347
+ }
1348
+ return sendJson(res, 404, { ok: false, code: 'CREDENTIAL_REFERENCE_ENDPOINT_NOT_FOUND' });
1349
+ } catch (error) {
1350
+ return sendJson(res, 400, { ok: false, code: boundedMachineCodeFromError(error) ?? 'CREDENTIAL_PURGE_PLAN_INVALID' });
1351
+ }
1352
+ },
1353
+ }));
1354
+
1198
1355
  // Deletion is two-phase: this endpoint only computes a revision-bound,
1199
1356
  // secret-free impact plan. A later mutation endpoint must present that
1200
1357
  // plan and perform restart/verification through an owned supervisor.
@@ -90,7 +90,7 @@ export async function hubStatus({ force = false } = {}) {
90
90
  endpoint: 'runtime',
91
91
  });
92
92
  }
93
- return cacheStatus(evaluateHubHandshake(runtime.body));
93
+ return cacheStatus(evaluateHubHandshake(runtime.body, { strictProduction: true }));
94
94
  } catch {
95
95
  return cacheStatus(EMPTY_STATUS);
96
96
  }