@harperfast/harper 5.2.0-alpha.4 → 5.2.0-alpha.6

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.
Files changed (173) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dist/agent/agent.d.ts +24 -3
  28. package/dist/agent/agent.js +172 -10
  29. package/dist/agent/agent.js.map +1 -1
  30. package/dist/agent/bestPractices.d.ts +24 -0
  31. package/dist/agent/bestPractices.js +60 -0
  32. package/dist/agent/bestPractices.js.map +1 -0
  33. package/dist/agent/mcpTools.d.ts +24 -0
  34. package/dist/agent/mcpTools.js +115 -0
  35. package/dist/agent/mcpTools.js.map +1 -0
  36. package/dist/agent/operations.js +10 -1
  37. package/dist/agent/operations.js.map +1 -1
  38. package/dist/agent/registryTools.d.ts +45 -0
  39. package/dist/agent/registryTools.js +113 -0
  40. package/dist/agent/registryTools.js.map +1 -0
  41. package/dist/agent/session.js +16 -7
  42. package/dist/agent/session.js.map +1 -1
  43. package/dist/agent/tools/fsTools.js +70 -28
  44. package/dist/agent/tools/fsTools.js.map +1 -1
  45. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  46. package/dist/agent/tools/inspectorTool.js +428 -0
  47. package/dist/agent/tools/inspectorTool.js.map +1 -0
  48. package/dist/agent/toolset.d.ts +16 -6
  49. package/dist/agent/toolset.js +17 -7
  50. package/dist/agent/toolset.js.map +1 -1
  51. package/dist/agent/types.d.ts +2 -0
  52. package/dist/components/Application.d.ts +73 -18
  53. package/dist/components/Application.js +475 -64
  54. package/dist/components/Application.js.map +1 -1
  55. package/dist/components/OptionsWatcher.d.ts +1 -1
  56. package/dist/components/OptionsWatcher.js +65 -2
  57. package/dist/components/OptionsWatcher.js.map +1 -1
  58. package/dist/components/Scope.d.ts +8 -6
  59. package/dist/components/Scope.js +22 -6
  60. package/dist/components/Scope.js.map +1 -1
  61. package/dist/components/componentLoader.js +5 -1
  62. package/dist/components/componentLoader.js.map +1 -1
  63. package/dist/components/componentSecrets.d.ts +33 -7
  64. package/dist/components/componentSecrets.js +473 -59
  65. package/dist/components/componentSecrets.js.map +1 -1
  66. package/dist/components/deploymentRecorder.d.ts +2 -2
  67. package/dist/components/deploymentRecorder.js +1 -1
  68. package/dist/components/deploymentRecorder.js.map +1 -1
  69. package/dist/components/gitCredentialHelper.d.ts +1 -0
  70. package/dist/components/gitCredentialHelper.js +113 -0
  71. package/dist/components/gitCredentialHelper.js.map +1 -0
  72. package/dist/components/gitCredentialServer.d.ts +33 -0
  73. package/dist/components/gitCredentialServer.js +236 -0
  74. package/dist/components/gitCredentialServer.js.map +1 -0
  75. package/dist/components/mcp/tools/application.d.ts +19 -0
  76. package/dist/components/mcp/tools/application.js +180 -22
  77. package/dist/components/mcp/tools/application.js.map +1 -1
  78. package/dist/components/mcp/tools/operations.d.ts +13 -0
  79. package/dist/components/mcp/tools/operations.js +1 -0
  80. package/dist/components/mcp/tools/operations.js.map +1 -1
  81. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  82. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  83. package/dist/components/operations.js +23 -21
  84. package/dist/components/operations.js.map +1 -1
  85. package/dist/components/operationsValidation.js +84 -21
  86. package/dist/components/operationsValidation.js.map +1 -1
  87. package/dist/components/secretOperations.d.ts +33 -11
  88. package/dist/components/secretOperations.js +90 -26
  89. package/dist/components/secretOperations.js.map +1 -1
  90. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  91. package/dist/config/harperConfigEnvVars.js +95 -5
  92. package/dist/config/harperConfigEnvVars.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/resources/Resource.d.ts +19 -0
  97. package/dist/resources/Resource.js +93 -2
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/Table.d.ts +9 -0
  100. package/dist/resources/Table.js +256 -50
  101. package/dist/resources/Table.js.map +1 -1
  102. package/dist/resources/analytics/read.js +28 -25
  103. package/dist/resources/analytics/read.js.map +1 -1
  104. package/dist/resources/defineResource.d.ts +180 -0
  105. package/dist/resources/defineResource.js +505 -0
  106. package/dist/resources/defineResource.js.map +1 -0
  107. package/dist/resources/defineTable.d.ts +221 -0
  108. package/dist/resources/defineTable.js +227 -0
  109. package/dist/resources/defineTable.js.map +1 -0
  110. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  111. package/dist/resources/jsonSchemaTypes.js +12 -4
  112. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  113. package/dist/resources/openApi.js +69 -13
  114. package/dist/resources/openApi.js.map +1 -1
  115. package/dist/resources/search.js +6 -8
  116. package/dist/resources/search.js.map +1 -1
  117. package/dist/server/graphqlQuerying.js +4 -2
  118. package/dist/server/graphqlQuerying.js.map +1 -1
  119. package/dist/server/http.d.ts +12 -0
  120. package/dist/server/http.js +35 -15
  121. package/dist/server/http.js.map +1 -1
  122. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  123. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +23 -0
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/utility/errors/hdbError.d.ts +21 -0
  127. package/dist/utility/errors/hdbError.js +23 -1
  128. package/dist/utility/errors/hdbError.js.map +1 -1
  129. package/dist/validation/configValidator.js +22 -6
  130. package/dist/validation/configValidator.js.map +1 -1
  131. package/index.ts +33 -0
  132. package/npm-shrinkwrap.json +9197 -15236
  133. package/package.json +5 -3
  134. package/resources/DESIGN.md +42 -15
  135. package/resources/Resource.ts +99 -2
  136. package/resources/Table.ts +275 -71
  137. package/resources/analytics/read.ts +30 -25
  138. package/resources/defineResource.ts +651 -0
  139. package/resources/defineTable.ts +407 -0
  140. package/resources/jsonSchemaTypes.ts +12 -4
  141. package/resources/openApi.ts +68 -16
  142. package/resources/search.ts +5 -8
  143. package/server/graphqlQuerying.ts +4 -2
  144. package/server/http.ts +40 -16
  145. package/server/serverHelpers/serverUtilities.ts +19 -6
  146. package/server/serverHelpers/uwsServer.ts +24 -0
  147. package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
  148. package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
  149. package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
  150. package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
  151. package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
  152. package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
  153. package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
  154. package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
  155. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
  156. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
  157. package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
  158. package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
  159. package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
  160. package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
  161. package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
  162. package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
  163. package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
  164. package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
  165. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
  166. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
  167. package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
  168. package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
  169. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
  170. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
  171. package/studio/web/index.html +1 -1
  172. package/utility/errors/hdbError.ts +32 -0
  173. package/validation/configValidator.ts +23 -6
@@ -23,6 +23,7 @@ import * as validator from './operationsValidation.js';
23
23
  import { getSecretCustody } from '../resources/secretDecryptor.ts';
24
24
  import { encryptEnvelope, parseEnvelopeFields } from '../utility/secretEnvelope.ts';
25
25
  import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
26
+ import { normalizeGitHost, type ResolvedGitCredential } from './gitCredentialServer.ts';
26
27
 
27
28
  const { HTTP_STATUS_CODES } = hdbErrors;
28
29
  const SECRET_TABLE = terms.SYSTEM_TABLE_NAMES.SECRET_TABLE_NAME;
@@ -325,20 +326,41 @@ export function getSecretsPublicKey(req: any) {
325
326
  return { public_key: publicKey, fingerprint };
326
327
  }
327
328
 
328
- /** A resolved registry-auth entry as consumed by the transient .npmrc writer. */
329
- export interface ResolvedRegistryAuthEntry {
329
+ // deploy_component `credentials` is a kind-heterogeneous array: an entry's kind is implied by its
330
+ // identifying key (`registry` = npm registry auth, `host` = git host auth for a git-reference
331
+ // package, #1792), and every kind carries its credential as either a literal `token` or a `secret`
332
+ // reference. Ingest/resolve below are kind-agnostic about that token/secret half and only branch on
333
+ // the identifying key where a kind-specific detail is needed (the derived secret name).
334
+
335
+ /** A resolved registry credential as consumed by the transient .npmrc writer. */
336
+ export interface ResolvedRegistryCredential {
330
337
  registry: string;
331
338
  token: string;
332
339
  scope?: string;
333
340
  }
334
341
 
335
- /** A registry-auth entry after ingestion: a reference into hdb_secret, never a token. */
336
- export interface RegistryAuthReference {
342
+ /** A registry credential after ingestion: a reference into hdb_secret, never a token. */
343
+ export interface RegistryCredentialReference {
337
344
  registry: string;
338
345
  secret: string;
339
346
  scope?: string;
340
347
  }
341
348
 
349
+ /** A git-host credential after ingestion: a reference into hdb_secret, never a token. */
350
+ export interface GitCredentialReference {
351
+ host: string;
352
+ secret: string;
353
+ username?: string;
354
+ }
355
+
356
+ export type CredentialReference = RegistryCredentialReference | GitCredentialReference;
357
+ export type ResolvedCredential = ResolvedRegistryCredential | ResolvedGitCredential;
358
+
359
+ /** True when a credential entry (in any form) is npm registry auth rather than git host auth. */
360
+ export function isRegistryCredential(entry: any): boolean {
361
+ return entry?.registry !== undefined;
362
+ }
363
+
342
364
  /**
343
365
  * Deterministic name for the auto-minted secret backing a literal registry token: keyed by the
344
366
  * deploying component and the registry, so re-supplying (or rotating) the token on a later deploy
@@ -358,9 +380,65 @@ export function deriveRegistrySecretName(component: string, registry: string): s
358
380
  }
359
381
 
360
382
  /**
361
- * Ingest deploy_component `registryAuth` into the secrets store so a provided registry token lives
362
- * as ciphertext in the replicated, audited `hdb_secret` store (reference, not embed) rather than
363
- * travelling in the operation body. Returns the entries in reference form (`{ registry, secret }`).
383
+ * Deterministic name for the secret backing a literal git-host token, following the registry
384
+ * convention above but with a `git` kind segment: a registry entry accepts a bare host too, so
385
+ * without a distinguishing segment a git and a registry credential for the same host would derive
386
+ * the same name and silently overwrite each other's secret. Keyed by host rather than by
387
+ * repository: the credential entry identifies itself by host, so the name has to be derivable from
388
+ * the entry alone for a rotation to overwrite the same row. A per-repository credential is
389
+ * expressible today by scoping the token itself (a fine-grained PAT) rather than by splitting the
390
+ * secret name.
391
+ */
392
+ export function deriveGitSecretName(component: string, host: string): string {
393
+ const hostKey = normalizeGitHost(host).replace(/[^\w.-]+/g, '_');
394
+ const componentKey = String(component).replace(/[^\w.-]+/g, '_');
395
+ return `deploy.${componentKey}.git.${hostKey}`;
396
+ }
397
+
398
+ /**
399
+ * An entry's kind, from its identifying key. Every path that rebuilds an entry goes through this, so
400
+ * an entry of an unrecognized kind fails loudly rather than being rebuilt into a half-empty one: an
401
+ * operation-body entry is schema-validated, but a `credentials` entry read back from
402
+ * applicationConfig or an hdb_deployment row is not.
403
+ */
404
+ function credentialKind(entry: any): 'registry' | 'git' {
405
+ if (isRegistryCredential(entry)) return 'registry';
406
+ if (entry?.host !== undefined) return 'git';
407
+ throw new ClientError(
408
+ `Unsupported credential entry: expected a 'registry' (npm registry auth) or 'host' (git host auth) entry`
409
+ );
410
+ }
411
+
412
+ /** The hdb_secret name a literal-token entry seals into, by kind. */
413
+ function deriveSecretName(entry: any, component: string): string {
414
+ return credentialKind(entry) === 'registry'
415
+ ? deriveRegistrySecretName(component, entry.registry)
416
+ : deriveGitSecretName(component, entry.host);
417
+ }
418
+
419
+ /** Rebuild an entry in reference form, preserving only the fields its kind defines. */
420
+ function toReference(entry: any, secret: string): CredentialReference {
421
+ if (credentialKind(entry) === 'registry') {
422
+ return entry.scope === undefined
423
+ ? { registry: entry.registry, secret }
424
+ : { registry: entry.registry, secret, scope: entry.scope };
425
+ }
426
+ return entry.username === undefined
427
+ ? { host: entry.host, secret }
428
+ : { host: entry.host, secret, username: entry.username };
429
+ }
430
+
431
+ /** Rebuild an entry in resolved (literal token) form, preserving only the fields its kind defines. */
432
+ function toResolved(entry: any, token: string): ResolvedCredential {
433
+ return credentialKind(entry) === 'registry'
434
+ ? { registry: entry.registry, token, scope: entry.scope }
435
+ : { host: entry.host, token, username: entry.username };
436
+ }
437
+
438
+ /**
439
+ * Ingest deploy_component `credentials` into the secrets store so a provided token lives as
440
+ * ciphertext in the replicated, audited `hdb_secret` store (reference, not embed) rather than
441
+ * travelling in the operation body. Returns the entries in reference form (`{ …, secret }`).
364
442
  *
365
443
  * - A literal `{ registry, token }` entry is encrypted (via `set_secret`, custody required) under a
366
444
  * derived name granted to `component`, and returned as a reference. Overwrites an existing
@@ -373,17 +451,19 @@ export function deriveRegistrySecretName(component: string, registry: string): s
373
451
  *
374
452
  * Runs on the deploying main thread where custody is registered.
375
453
  */
376
- export async function ingestRegistryAuth(req: any, registryAuth: any[] | undefined, component: string): Promise<any[]> {
377
- if (!Array.isArray(registryAuth) || registryAuth.length === 0) return registryAuth ?? [];
454
+ export async function ingestCredentials(req: any, credentials: any[] | undefined, component: string): Promise<any[]> {
455
+ if (!Array.isArray(credentials) || credentials.length === 0) return credentials ?? [];
378
456
  const custody = getSecretCustody();
379
457
  const out: any[] = [];
380
- for (const entry of registryAuth) {
458
+ for (const entry of credentials) {
381
459
  // Already a reference (or nothing to seal without custody): leave as-is.
382
460
  if (entry.secret !== undefined || !custody) {
383
461
  out.push(entry);
384
462
  continue;
385
463
  }
386
- const name = deriveRegistrySecretName(component, entry.registry);
464
+ // The derived secret name is the one kind-specific detail on this path; each kind derives its
465
+ // name from its own identifying key.
466
+ const name = deriveSecretName(entry, component);
387
467
  // Reuse set_secret's seal-and-store path (encrypt with custody, grant to the component, audit
388
468
  // the mutation). The deploy request is already super_user, which set_secret requires.
389
469
  await setSecret({
@@ -394,11 +474,7 @@ export async function ingestRegistryAuth(req: any, registryAuth: any[] | undefin
394
474
  grants: [component],
395
475
  processEnv: false,
396
476
  });
397
- out.push(
398
- entry.scope === undefined
399
- ? { registry: entry.registry, secret: name }
400
- : { registry: entry.registry, secret: name, scope: entry.scope }
401
- );
477
+ out.push(toReference(entry, name));
402
478
  }
403
479
  return out;
404
480
  }
@@ -416,7 +492,7 @@ async function waitForSecretRow(table: any, name: string, waitMs: number): Promi
416
492
  }
417
493
 
418
494
  /**
419
- * Resolve deploy_component `registryAuth` entries that reference a stored secret
495
+ * Resolve deploy_component `credentials` entries that reference a stored secret
420
496
  * (`{ registry, secret }`) into concrete token entries (`{ registry, token }`) by decrypting the
421
497
  * named hdb_secret row on this thread. Entries that already carry a literal `token` pass through
422
498
  * unchanged, so this is a no-op for the token-only fallback form.
@@ -440,36 +516,40 @@ async function waitForSecretRow(table: any, name: string, waitMs: number): Promi
440
516
  * (OSS core, or a custody key not held here) a referenced secret cannot be resolved and the deploy
441
517
  * fails loudly rather than silently installing without auth.
442
518
  */
443
- export async function resolveRegistryAuth(
444
- registryAuth: any[] | undefined,
519
+ export async function resolveCredentials(
520
+ credentials: any[] | undefined,
445
521
  component: string,
446
522
  options: { waitMs?: number } = {}
447
- ): Promise<ResolvedRegistryAuthEntry[] | undefined> {
448
- if (!Array.isArray(registryAuth) || registryAuth.length === 0) return registryAuth;
523
+ ): Promise<ResolvedCredential[] | undefined> {
524
+ if (!Array.isArray(credentials) || credentials.length === 0) return credentials;
525
+ // Reject an unrecognized kind before anything consumes the array, including on the token-only fast
526
+ // path below: a caller that silently drops an entry it does not understand would install without
527
+ // the credential the operator asked for.
528
+ for (const entry of credentials) if (entry) credentialKind(entry);
449
529
  // Token-only requests must not require custody or a provisioned store — keep the fast path pure.
450
- if (!registryAuth.some((entry) => entry && entry.secret !== undefined)) return registryAuth;
530
+ if (!credentials.some((entry) => entry && entry.secret !== undefined)) return credentials;
451
531
 
452
532
  const waitMs = options.waitMs ?? 0;
453
533
  const table = secretTable();
454
- const resolved: ResolvedRegistryAuthEntry[] = [];
455
- for (const entry of registryAuth) {
534
+ const resolved: ResolvedCredential[] = [];
535
+ for (const entry of credentials) {
456
536
  if (!entry) continue; // parity with the fast-path guard above; validated entries are never null
457
537
  if (entry.secret === undefined) {
458
- resolved.push({ registry: entry.registry, token: entry.token, scope: entry.scope });
538
+ resolved.push(toResolved(entry, entry.token));
459
539
  continue;
460
540
  }
461
541
  const name: string = entry.secret;
462
542
  const row = waitMs > 0 ? await waitForSecretRow(table, name, waitMs) : await table.get(name);
463
543
  if (!row) {
464
544
  throw new ClientError(
465
- `registryAuth references secret '${name}', which does not exist`,
545
+ `credential entry references secret '${name}', which does not exist`,
466
546
  HTTP_STATUS_CODES.NOT_FOUND
467
547
  );
468
548
  }
469
549
  const grants = Array.isArray(row.grants) ? row.grants : [];
470
550
  if (!row.processEnv && !grants.includes(component)) {
471
551
  throw new ClientError(
472
- `registryAuth secret '${name}' is not granted to component '${component}' ` +
552
+ `credential secret '${name}' is not granted to component '${component}' ` +
473
553
  `(grant it with grant_secret, or set it processEnv:true)`,
474
554
  HTTP_STATUS_CODES.FORBIDDEN
475
555
  );
@@ -479,7 +559,7 @@ export async function resolveRegistryAuth(
479
559
  // Server-state condition, not a client-fixable request: the same body would resolve once
480
560
  // custody comes up, so report it retryable (503) rather than the ClientError default 400.
481
561
  throw new ClientError(
482
- `secrets custody is not initialized on this node; cannot resolve registryAuth secret '${name}'`,
562
+ `secrets custody is not initialized on this node; cannot resolve credential secret '${name}'`,
483
563
  HTTP_STATUS_CODES.SERVICE_UNAVAILABLE
484
564
  );
485
565
  }
@@ -488,11 +568,11 @@ export async function resolveRegistryAuth(
488
568
  token = custody.decrypt(row.envelope);
489
569
  } catch (error) {
490
570
  throw new ClientError(
491
- `Failed to decrypt registryAuth secret '${name}': ${(error as Error).message}`,
571
+ `Failed to decrypt credential secret '${name}': ${(error as Error).message}`,
492
572
  HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR
493
573
  );
494
574
  }
495
- resolved.push({ registry: entry.registry, token, scope: entry.scope });
575
+ resolved.push(toResolved(entry, token));
496
576
  }
497
577
  return resolved;
498
578
  }
@@ -28,6 +28,7 @@ import * as path from 'node:path';
28
28
  import * as crypto from 'node:crypto';
29
29
  import { cloneDeep } from 'lodash';
30
30
  import { getBackupDirPath } from './configHelpers.ts';
31
+ import * as hdbTerms from '../utility/hdbTerms.ts';
31
32
 
32
33
  const STATE_FILE_NAME = '.harper-config-state.json';
33
34
 
@@ -305,7 +306,18 @@ export function filterArgsAgainstRuntimeConfig(args: Record<string, any>): Recor
305
306
  /**
306
307
  * Flatten nested object to dot-notation paths
307
308
  */
308
- function flattenObject(obj: ConfigObject, prefix = ''): Record<string, any> {
309
+ // One warning per distinct path per process — flattenObject runs on every env apply.
310
+ const warnedEmptyObjectPaths = new Set<string>();
311
+ function warnEmptyObjectDropped(path: string): void {
312
+ if (warnedEmptyObjectPaths.has(path)) return;
313
+ warnedEmptyObjectPaths.add(path);
314
+ getLogger().warn?.(
315
+ `[env-config] '${path}' is an empty object in env-var config and carries no settings; ` +
316
+ `it will not appear in the resolved config. Set an explicit value (e.g. '${path}.enabled: true').`
317
+ );
318
+ }
319
+
320
+ function flattenObject(obj: ConfigObject, prefix = '', warnOnEmptyDrop = true): Record<string, any> {
309
321
  const result: Record<string, any> = {};
310
322
 
311
323
  for (const key in obj) {
@@ -315,8 +327,16 @@ function flattenObject(obj: ConfigObject, prefix = ''): Record<string, any> {
315
327
  const newKey = prefix ? `${prefix}.${key}` : key;
316
328
 
317
329
  if (isPlainObject(value) && !isDirectiveObject(value)) {
318
- // Recurse for nested objects
319
- Object.assign(result, flattenObject(value, newKey));
330
+ // Recurse for nested objects. An EMPTY object contributes no leaves — that is
331
+ // load-bearing for removal semantics (`http: {}` in SET_CONFIG means "no
332
+ // overrides under http", restoring originals) — but it also means a bare
333
+ // `componentName: {}` carries no signal at all, which has silently confused
334
+ // users (#1618). Warn once per path so the drop is visible; use an explicit
335
+ // value (e.g. `enabled: true`) to convey presence. The base config file is
336
+ // exempt (warnOnEmptyDrop=false): its empty objects are user content and are
337
+ // restored after composition rather than dropped (#1726 review).
338
+ if (Object.keys(value).length === 0 && warnOnEmptyDrop) warnEmptyObjectDropped(newKey);
339
+ Object.assign(result, flattenObject(value, newKey, warnOnEmptyDrop));
320
340
  } else {
321
341
  // Store primitive, array, or directive ({ $union: [...] }) as a leaf
322
342
  result[newKey] = value;
@@ -326,6 +346,34 @@ function flattenObject(obj: ConfigObject, prefix = ''): Record<string, any> {
326
346
  return result;
327
347
  }
328
348
 
349
+ /**
350
+ * Re-add empty-object paths from the base config file that `flattenObject` dropped
351
+ * during composition. The base file is user content, not an override layer: a bare
352
+ * `componentName: {}` there is a real (empty) scope declaration, unlike an env-layer
353
+ * `{}` which means "no overrides here". Only paths the composition did not otherwise
354
+ * populate are restored — an env layer that set or replaced the path wins.
355
+ */
356
+ function restoreBaseEmptyObjects(source: ConfigObject, target: ConfigObject): void {
357
+ for (const key in source) {
358
+ if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
359
+ const value = source[key];
360
+ if (!isPlainObject(value) || isDirectiveObject(value)) continue;
361
+ if (Object.keys(value).length === 0) {
362
+ if (!(key in target)) target[key] = {};
363
+ } else if (key in target) {
364
+ // Recurse only while the composed side is still an object; a non-object env
365
+ // replacement wins over the base subtree.
366
+ if (isPlainObject(target[key])) restoreBaseEmptyObjects(value, target[key] as ConfigObject);
367
+ } else {
368
+ // The subtree produced no leaves at all (its only content was empty objects,
369
+ // possibly nested) — rebuild just the empty-object skeleton.
370
+ const skeleton: ConfigObject = {};
371
+ restoreBaseEmptyObjects(value, skeleton);
372
+ if (Object.keys(skeleton).length > 0) target[key] = skeleton;
373
+ }
374
+ }
375
+ }
376
+
329
377
  /**
330
378
  * Get nested value by dot-notation path
331
379
  */
@@ -738,24 +786,61 @@ function cleanupRemovedEnvVar(
738
786
  */
739
787
  export function composeConfigFromEnv(base: ConfigObject = {}): ConfigObject {
740
788
  const result: ConfigObject = {};
789
+ const baseLayer = cloneDeep(base);
741
790
  const layers: (ConfigObject | null)[] = [
742
791
  parseConfigEnvVar(process.env.HARPER_DEFAULT_CONFIG, 'HARPER_DEFAULT_CONFIG'),
743
- cloneDeep(base),
792
+ baseLayer,
744
793
  parseConfigEnvVar(process.env.HARPER_CONFIG, 'HARPER_CONFIG'),
745
794
  parseConfigEnvVar(process.env.HARPER_SET_CONFIG, 'HARPER_SET_CONFIG'),
746
795
  ];
747
796
 
748
797
  for (const layer of layers) {
749
798
  if (!layer) continue;
750
- for (const [p, value] of Object.entries(flattenObject(layer))) {
799
+ for (const [p, value] of Object.entries(flattenObject(layer, '', layer !== baseLayer))) {
751
800
  // directive leaves compose against the value accumulated by prior layers
752
801
  setNestedValue(result, p, resolveLeafValue(getNestedValue(result, p), value, p));
753
802
  }
754
803
  }
755
804
 
805
+ // The base file's empty objects are user content (e.g. a bare `componentName: {}`
806
+ // scope) — restore the ones composition didn't otherwise populate (#1726 review).
807
+ restoreBaseEmptyObjects(baseLayer, result);
808
+
756
809
  return result;
757
810
  }
758
811
 
812
+ /** True when any config-shaping env var (HARPER_DEFAULT_CONFIG / HARPER_CONFIG / HARPER_SET_CONFIG) is set. */
813
+ export function hasConfigEnvVars(): boolean {
814
+ return Boolean(process.env.HARPER_DEFAULT_CONFIG || process.env.HARPER_CONFIG || process.env.HARPER_SET_CONFIG);
815
+ }
816
+
817
+ /**
818
+ * Overlay runtime env config onto a base root-config object, hiding the env-var names and
819
+ * the composition rules from callers. Returns `base` unchanged when no config env vars are
820
+ * set (a true no-op — callers can invoke it on every root-config read without branching).
821
+ * A missing/non-object `base` (e.g. the install window before the config file is written)
822
+ * is treated as an empty base. Throws (via composeConfigFromEnv) on malformed env-var JSON.
823
+ */
824
+ export function overlayRootEnvConfig(base: ConfigObject | undefined): ConfigObject | undefined {
825
+ if (!hasConfigEnvVars()) return base;
826
+ return composeConfigFromEnv(isPlainObject(base) ? base : {});
827
+ }
828
+
829
+ /**
830
+ * True when `filePath` names THE root Harper config file (current or legacy name), as
831
+ * opposed to a component/application `config.yaml`. Filename-only by design: an exact
832
+ * path comparison against the resolved root-config path misclassifies watchers in any
833
+ * environment where a real config instance is resolved (including the unit harness).
834
+ * FALLBACK ONLY: real component loads thread the loader's authoritative `isRoot` through
835
+ * `Scope` → `OptionsWatcher(…, isRootConfig)`, so this heuristic applies just to direct
836
+ * constructions (tests, ad-hoc callers), where a root-named app config file is a known,
837
+ * accepted false positive.
838
+ */
839
+ export function isRootConfigFilename(filePath: string): boolean {
840
+ const name = path.basename(filePath);
841
+ return name === hdbTerms.HARPER_CONFIG_FILE || name === hdbTerms.HDB_CONFIG_FILE;
842
+ }
843
+
759
844
  /**
760
845
  * True if a config-state file exists with tracked env-var snapshots. Callers use this to
761
846
  * decide whether applyRuntimeEnvConfig must run even when no config env vars are currently
@@ -649,6 +649,10 @@
649
649
  "componentsScope": {
650
650
  "type": "string",
651
651
  "description": "Filesystem scope for component edits, relative to rootPath. Default: ./components"
652
+ },
653
+ "systemPromptAppend": {
654
+ "type": "string",
655
+ "description": "Operator text appended to the agent's system prompt, after the built-in grounding and the Harper best-practices overview. Tunable at runtime via set_agent_config."
652
656
  }
653
657
  }
654
658
  },
@@ -8,16 +8,20 @@
8
8
  * is realized lazily on first use, and the loop runs in-process.
9
9
  *
10
10
  * The component intentionally avoids `handleApplication`: it has nothing
11
- * worker-thread-shaped to do. Operator-only tools (FS, schedule, fetch) are
12
- * inline; registry-backed tools (#615/#617/#618) will fold in via toolset.ts
13
- * once those land.
11
+ * worker-thread-shaped to do. Two tool sources compose: operator-only tools
12
+ * (FS, schedule, fetch, and the V8 inspector) that are inline, and RBAC-filtered
13
+ * registry tools (#615/#617) drained for the agent's configured user via
14
+ * `registryTools.ts`.
14
15
  */
16
+ import type { AuthedUser } from '../components/mcp/toolRegistry.ts';
15
17
  interface StartOpts {
16
18
  server: {
17
19
  registerOperation: (def: {
18
20
  name: string;
19
21
  execute: (op: any) => any | Promise<any>;
20
22
  }) => void;
23
+ /** Resolve a Harper user (with role/permissions) by username. Password/request unused here. */
24
+ getUser?: (username: string, password: string | null, request: unknown) => Promise<AuthedUser> | AuthedUser;
21
25
  };
22
26
  enabled?: boolean;
23
27
  provider?: string;
@@ -28,8 +32,25 @@ interface StartOpts {
28
32
  allowDestructive?: boolean;
29
33
  user?: string;
30
34
  componentsScope?: string;
35
+ systemPromptAppend?: string;
31
36
  }
32
37
  export declare function startOnMainThread(opts: StartOpts): Promise<void>;
38
+ /**
39
+ * Resolve the identity the agent's tool calls run as. Registry tools are enforced
40
+ * (`hdb_user` at call time) against this user, so an operator who sets `agent.user`
41
+ * to a restricted role gets a narrowed surface — and a later role change is honored,
42
+ * because this runs per call rather than once at startup.
43
+ *
44
+ * Failure policy — fail closed, with one bounded exception:
45
+ * - If `agent.user` resolves to a permissioned user, use it.
46
+ * - If it can't be resolved AND it's the *default* `hdb_agent` bootstrap user,
47
+ * fall back to a super_user identity (the system user isn't provisioned yet —
48
+ * #626 defers creating it). This is the documented default-agent behavior.
49
+ * - If it can't be resolved and the operator configured a *non-default* user,
50
+ * throw. Silently escalating a misconfigured/transient restricted account to
51
+ * super_user is the vulnerability we refuse to introduce.
52
+ */
53
+ export declare function resolveAgentIdentity(server: StartOpts['server'], username: string): Promise<AuthedUser>;
33
54
  /** Test-only: reset module state between specs. */
34
55
  export declare function _resetForTests(): void;
35
56
  export {};