@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.
- package/agent/agent.ts +152 -10
- package/agent/bestPractices.ts +58 -0
- package/agent/mcpTools.ts +122 -0
- package/agent/operations.ts +10 -1
- package/agent/registryTools.ts +117 -0
- package/agent/session.ts +16 -7
- package/agent/tools/fsTools.ts +74 -27
- package/agent/tools/inspectorTool.ts +459 -0
- package/agent/toolset.ts +27 -7
- package/agent/types.ts +2 -0
- package/components/Application.ts +566 -85
- package/components/OptionsWatcher.ts +62 -2
- package/components/Scope.ts +31 -8
- package/components/componentLoader.ts +5 -1
- package/components/componentSecrets.ts +514 -55
- package/components/deploymentRecorder.ts +7 -6
- package/components/gitCredentialHelper.js +115 -0
- package/components/gitCredentialServer.ts +251 -0
- package/components/mcp/tools/application.ts +197 -23
- package/components/mcp/tools/operations.ts +1 -1
- package/components/mcp/tools/schemas/derive.ts +6 -2
- package/components/operations.js +21 -19
- package/components/operationsValidation.js +86 -21
- package/components/secretOperations.ts +110 -30
- package/config/harperConfigEnvVars.ts +90 -5
- package/config-root.schema.json +4 -0
- package/dist/agent/agent.d.ts +24 -3
- package/dist/agent/agent.js +172 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/bestPractices.d.ts +24 -0
- package/dist/agent/bestPractices.js +60 -0
- package/dist/agent/bestPractices.js.map +1 -0
- package/dist/agent/mcpTools.d.ts +24 -0
- package/dist/agent/mcpTools.js +115 -0
- package/dist/agent/mcpTools.js.map +1 -0
- package/dist/agent/operations.js +10 -1
- package/dist/agent/operations.js.map +1 -1
- package/dist/agent/registryTools.d.ts +45 -0
- package/dist/agent/registryTools.js +113 -0
- package/dist/agent/registryTools.js.map +1 -0
- package/dist/agent/session.js +16 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/tools/fsTools.js +70 -28
- package/dist/agent/tools/fsTools.js.map +1 -1
- package/dist/agent/tools/inspectorTool.d.ts +40 -0
- package/dist/agent/tools/inspectorTool.js +428 -0
- package/dist/agent/tools/inspectorTool.js.map +1 -0
- package/dist/agent/toolset.d.ts +16 -6
- package/dist/agent/toolset.js +17 -7
- package/dist/agent/toolset.js.map +1 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/components/Application.d.ts +73 -18
- package/dist/components/Application.js +475 -64
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +1 -1
- package/dist/components/OptionsWatcher.js +65 -2
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.d.ts +8 -6
- package/dist/components/Scope.js +22 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +5 -1
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/componentSecrets.d.ts +33 -7
- package/dist/components/componentSecrets.js +473 -59
- package/dist/components/componentSecrets.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +2 -2
- package/dist/components/deploymentRecorder.js +1 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/gitCredentialHelper.d.ts +1 -0
- package/dist/components/gitCredentialHelper.js +113 -0
- package/dist/components/gitCredentialHelper.js.map +1 -0
- package/dist/components/gitCredentialServer.d.ts +33 -0
- package/dist/components/gitCredentialServer.js +236 -0
- package/dist/components/gitCredentialServer.js.map +1 -0
- package/dist/components/mcp/tools/application.d.ts +19 -0
- package/dist/components/mcp/tools/application.js +180 -22
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.d.ts +13 -0
- package/dist/components/mcp/tools/operations.js +1 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/derive.js +6 -2
- package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
- package/dist/components/operations.js +23 -21
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +84 -21
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/secretOperations.d.ts +33 -11
- package/dist/components/secretOperations.js +90 -26
- package/dist/components/secretOperations.js.map +1 -1
- package/dist/config/harperConfigEnvVars.d.ts +21 -0
- package/dist/config/harperConfigEnvVars.js +95 -5
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/Resource.d.ts +19 -0
- package/dist/resources/Resource.js +93 -2
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.d.ts +9 -0
- package/dist/resources/Table.js +256 -50
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/read.js +28 -25
- package/dist/resources/analytics/read.js.map +1 -1
- package/dist/resources/defineResource.d.ts +180 -0
- package/dist/resources/defineResource.js +505 -0
- package/dist/resources/defineResource.js.map +1 -0
- package/dist/resources/defineTable.d.ts +221 -0
- package/dist/resources/defineTable.js +227 -0
- package/dist/resources/defineTable.js.map +1 -0
- package/dist/resources/jsonSchemaTypes.d.ts +2 -0
- package/dist/resources/jsonSchemaTypes.js +12 -4
- package/dist/resources/jsonSchemaTypes.js.map +1 -1
- package/dist/resources/openApi.js +69 -13
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/search.js +6 -8
- package/dist/resources/search.js.map +1 -1
- package/dist/server/graphqlQuerying.js +4 -2
- package/dist/server/graphqlQuerying.js.map +1 -1
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +35 -15
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +8 -5
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +23 -0
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +21 -0
- package/dist/utility/errors/hdbError.js +23 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/validation/configValidator.js +22 -6
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +33 -0
- package/npm-shrinkwrap.json +9197 -15236
- package/package.json +5 -3
- package/resources/DESIGN.md +42 -15
- package/resources/Resource.ts +99 -2
- package/resources/Table.ts +275 -71
- package/resources/analytics/read.ts +30 -25
- package/resources/defineResource.ts +651 -0
- package/resources/defineTable.ts +407 -0
- package/resources/jsonSchemaTypes.ts +12 -4
- package/resources/openApi.ts +68 -16
- package/resources/search.ts +5 -8
- package/server/graphqlQuerying.ts +4 -2
- package/server/http.ts +40 -16
- package/server/serverHelpers/serverUtilities.ts +19 -6
- package/server/serverHelpers/uwsServer.ts +24 -0
- package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
- package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
- package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
- package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
- package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
- package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
- package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
- package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
- package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
- package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
- package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
- package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
- package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
- package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
- package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/errors/hdbError.ts +32 -0
- 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
|
-
|
|
329
|
-
|
|
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
|
|
336
|
-
export interface
|
|
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
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
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
|
|
377
|
-
if (!Array.isArray(
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
444
|
-
|
|
519
|
+
export async function resolveCredentials(
|
|
520
|
+
credentials: any[] | undefined,
|
|
445
521
|
component: string,
|
|
446
522
|
options: { waitMs?: number } = {}
|
|
447
|
-
): Promise<
|
|
448
|
-
if (!Array.isArray(
|
|
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 (!
|
|
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:
|
|
455
|
-
for (const entry of
|
|
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(
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/config-root.schema.json
CHANGED
|
@@ -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
|
},
|
package/dist/agent/agent.d.ts
CHANGED
|
@@ -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.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {};
|