@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
package/agent/agent.ts CHANGED
@@ -8,17 +8,25 @@
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
  */
15
16
 
16
17
  import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
17
18
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
19
+ import * as env from '../utility/environment/environmentManager.ts';
18
20
  import harperLogger from '../utility/logging/harper_logger.ts';
19
21
  import { Models } from '../resources/models/Models.ts';
22
+ import type { AuthedUser } from '../components/mcp/toolRegistry.ts';
23
+ import { workers } from '../server/threads/manageThreads.js';
20
24
  import { composeToolset } from './toolset.ts';
25
+ import { buildInspectorTools } from './tools/inspectorTool.ts';
26
+ import { buildBestPracticeTool, loadBestPracticesOverview } from './bestPractices.ts';
27
+ import { composeRegistryTools, ensureOperationsToolsRegistered } from './registryTools.ts';
21
28
  import { buildOperations } from './operations.ts';
29
+ import { registerAgentMcpTools } from './mcpTools.ts';
22
30
  import { runAgent, _resetInFlightForTests } from './loop.ts';
23
31
  import { appendMessage, getSession } from './session.ts';
24
32
  import type { AgentConfig, AgentScopes, AgentTool } from './types.ts';
@@ -37,6 +45,8 @@ const DEFAULT_CONFIG: AgentConfig = {
37
45
  interface StartOpts {
38
46
  server: {
39
47
  registerOperation: (def: { name: string; execute: (op: any) => any | Promise<any> }) => void;
48
+ /** Resolve a Harper user (with role/permissions) by username. Password/request unused here. */
49
+ getUser?: (username: string, password: string | null, request: unknown) => Promise<AuthedUser> | AuthedUser;
40
50
  };
41
51
  // Component-level config plumbed by componentLoader (`...componentConfig`).
42
52
  enabled?: boolean;
@@ -48,6 +58,7 @@ interface StartOpts {
48
58
  allowDestructive?: boolean;
49
59
  user?: string;
50
60
  componentsScope?: string;
61
+ systemPromptAppend?: string;
51
62
  }
52
63
 
53
64
  export async function startOnMainThread(opts: StartOpts): Promise<void> {
@@ -64,11 +75,63 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> {
64
75
  const models = new Models();
65
76
  const abortControllers = new Map<string, AbortController>();
66
77
  let liveConfig: AgentConfig = config;
67
- let composed = composeToolset({
68
- allowDestructive: liveConfig.allowDestructive,
69
- onFollowup: handleFollowup,
78
+
79
+ // Populate the Operations MCP profile on the main thread. `agentIdentity` resolves the enforcement
80
+ // identity fresh on each call (so a live role change is honored, and an unresolvable non-default
81
+ // user fails closed); the startup snapshot below is only for `visibleTo` listing.
82
+ ensureOperationsToolsRegistered();
83
+ const agentIdentity = () => resolveAgentIdentity(opts.server, liveConfig.user);
84
+ let registryTools = await composeRegistryToolsForListing(agentIdentity);
85
+
86
+ // Operator-only V8 inspector tools. Debug config is read once here — enabling threads_debug opens the
87
+ // worker inspector ports at thread boot, so it can't be toggled without a restart anyway. Worker
88
+ // count is a live closure over the pool, so attaches range-check against the current worker set.
89
+ const inspectorTools = buildInspectorTools({
90
+ debugEnabled: env.get(CONFIG_PARAMS.THREADS_DEBUG) !== false,
91
+ startingPort: (env.get(CONFIG_PARAMS.THREADS_DEBUG_STARTINGPORT) as number | undefined) ?? undefined,
92
+ host: (env.get(CONFIG_PARAMS.THREADS_DEBUG_HOST) as string | undefined) ?? '127.0.0.1',
93
+ getWorkerCount: () => workers.length,
70
94
  });
71
95
 
96
+ // Harper best-practices skill (@harperfast/skills): the SKILL.md overview goes in the system prompt;
97
+ // the `harper_best_practice` tool serves the detailed rules on demand. Both degrade to nothing if the
98
+ // package isn't resolvable, so the agent still runs without it.
99
+ const bestPracticesOverview = loadBestPracticesOverview();
100
+ const bestPracticeTool = buildBestPracticeTool();
101
+ const extraTools: AgentTool[] = bestPracticeTool ? [bestPracticeTool] : [];
102
+
103
+ // The grounding + best-practices portion of the system prompt is static for the component's
104
+ // lifetime (scopes don't change), so build it once here; only the operator's per-run
105
+ // `systemPromptAppend` is folded on at run time in `startRun`.
106
+ const staticSystemPrompt = buildStaticSystemPrompt(scopes, bestPracticesOverview);
107
+
108
+ function compose(): ReturnType<typeof composeToolset> {
109
+ return composeToolset({
110
+ allowDestructive: liveConfig.allowDestructive,
111
+ onFollowup: handleFollowup,
112
+ inspectorTools,
113
+ registryTools,
114
+ extraTools,
115
+ });
116
+ }
117
+
118
+ let composed = compose();
119
+
120
+ // Build the listing snapshot best-effort: if the configured user can't be resolved (and isn't the
121
+ // default bootstrap user), `agentIdentity` fails closed — the agent then runs with only its
122
+ // operator-only tools until the operator fixes `agent.user`. Never falls back to an escalated list.
123
+ async function composeRegistryToolsForListing(resolveIdentity: () => Promise<AuthedUser>): Promise<AgentTool[]> {
124
+ try {
125
+ const listingUser = await resolveIdentity();
126
+ return composeRegistryTools(listingUser, resolveIdentity);
127
+ } catch (err) {
128
+ log.error?.(
129
+ `Registry tools disabled — agent.user='${liveConfig.user}' could not be resolved: ${err instanceof Error ? err.message : String(err)}`
130
+ );
131
+ return [];
132
+ }
133
+ }
134
+
72
135
  // Only warn when the operator explicitly configured `maxCostUsd`. Logging on the default
73
136
  // every boot would flood the log without telling anyone anything actionable.
74
137
  if (opts.maxCostUsd !== undefined) {
@@ -109,6 +172,7 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> {
109
172
  autoApprove: liveConfig.autoApprove,
110
173
  signal: controller.signal,
111
174
  generateOpts: { model: liveConfig.model },
175
+ systemPrompt: composeSystemPrompt(staticSystemPrompt, liveConfig.systemPromptAppend),
112
176
  })
113
177
  .catch((err) => log.error?.(`Agent run failed for ${sessionId}: ${(err as Error)?.message ?? err}`))
114
178
  .finally(() => {
@@ -137,10 +201,7 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> {
137
201
  const previousAllowDestructive = liveConfig.allowDestructive;
138
202
  liveConfig = { ...liveConfig, ...patch };
139
203
  if (liveConfig.allowDestructive !== previousAllowDestructive) {
140
- composed = composeToolset({
141
- allowDestructive: liveConfig.allowDestructive,
142
- onFollowup: handleFollowup,
143
- });
204
+ composed = compose();
144
205
  }
145
206
  // NOTE: an already in-flight run captured its toolset (and autoApprove) at start, so flipping
146
207
  // allowDestructive here only affects subsequent runs — the live loop finishes on its existing
@@ -158,9 +219,89 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> {
158
219
  });
159
220
  for (const op of operations) opts.server.registerOperation(op);
160
221
 
222
+ // Expose the agent over MCP: register curated agent tools into the registry AFTER the ops exist.
223
+ // (The generic operations-profile walk ran before this, so allow-listing the agent ops wouldn't
224
+ // surface them — see mcpTools.ts.) They only reach clients when the MCP surface is enabled.
225
+ registerAgentMcpTools(operations);
226
+
161
227
  log.info?.(`Agent component initialized with ${composed.tools.length} tools`);
162
228
  }
163
229
 
230
+ /**
231
+ * Grounding prompt so the agent knows what it is, where its files live, and how a Harper app is
232
+ * shaped. The filesystem tools take a `root` scope enum, so we describe the scopes rather than
233
+ * spelling out path-prefixing rules.
234
+ *
235
+ * We deliberately do NOT enumerate individual tool names here — the model already receives the full
236
+ * tool schema from the SDK, and duplicating names in the prompt drifts as tools are added/removed and
237
+ * invites hallucinated calls. This describes the tool *surface* at a category level only.
238
+ *
239
+ * The built-in grounding and best-practices overview are static for the component's lifetime, so the
240
+ * caller precomputes them once (`buildStaticSystemPrompt`) and only the operator
241
+ * `agent.systemPromptAppend` — which can change via `set_agent_config` without a restart — is folded
242
+ * in per run by `composeSystemPrompt`.
243
+ */
244
+ function buildStaticSystemPrompt(scopes: AgentScopes, bestPracticesOverview?: string): string {
245
+ const parts = [
246
+ 'You are the built-in Harper agent, running on the main thread inside a live Harper server.',
247
+ 'You operate this instance for an operator through the tools provided to you: Harper database/cluster operations, scoped filesystem tools, HTTP fetch against this server, followup scheduling, and (when available) V8 inspector tools for debugging worker threads plus a Harper best-practices lookup. Consult the provided tool schemas for the exact set and their parameters.',
248
+ '',
249
+ 'Filesystem scopes (the fs tools take a `root` naming one of these; paths are relative to it):',
250
+ `- components — the app source directory, your only WRITE scope: ${scopes.componentsRoot}`,
251
+ `- logs — read-only: ${scopes.logDir}`,
252
+ `- config — read-only: ${scopes.configDir}`,
253
+ '',
254
+ 'A Harper app is a component directory under the components dir. Define tables/resources in a schema (GraphQL `.graphql` with `@table`/`@export`, or `config.yaml` + resource files). After writing or changing component files, deploy/restart as needed for them to load, then verify by querying the REST endpoint via an HTTP fetch against this server.',
255
+ 'Prefer the operations tools for database/cluster actions; use the filesystem tools for app source. When designing schemas or building app logic, consult the Harper best practices below and read the relevant rule via the best-practice tool. Be concise and verify your work.',
256
+ ];
257
+ if (bestPracticesOverview) {
258
+ parts.push('', '=== Harper best practices (overview) ===', bestPracticesOverview);
259
+ }
260
+ return parts.join('\n');
261
+ }
262
+
263
+ /** Fold the operator's per-run `systemPromptAppend` onto the precomputed static base. */
264
+ function composeSystemPrompt(staticPrompt: string, append?: string): string {
265
+ if (typeof append === 'string' && append.trim()) {
266
+ return `${staticPrompt}\n\n=== Operator instructions ===\n${append.trim()}`;
267
+ }
268
+ return staticPrompt;
269
+ }
270
+
271
+ /**
272
+ * Resolve the identity the agent's tool calls run as. Registry tools are enforced
273
+ * (`hdb_user` at call time) against this user, so an operator who sets `agent.user`
274
+ * to a restricted role gets a narrowed surface — and a later role change is honored,
275
+ * because this runs per call rather than once at startup.
276
+ *
277
+ * Failure policy — fail closed, with one bounded exception:
278
+ * - If `agent.user` resolves to a permissioned user, use it.
279
+ * - If it can't be resolved AND it's the *default* `hdb_agent` bootstrap user,
280
+ * fall back to a super_user identity (the system user isn't provisioned yet —
281
+ * #626 defers creating it). This is the documented default-agent behavior.
282
+ * - If it can't be resolved and the operator configured a *non-default* user,
283
+ * throw. Silently escalating a misconfigured/transient restricted account to
284
+ * super_user is the vulnerability we refuse to introduce.
285
+ */
286
+ export async function resolveAgentIdentity(server: StartOpts['server'], username: string): Promise<AuthedUser> {
287
+ let resolved: AuthedUser | undefined;
288
+ if (typeof server.getUser === 'function') {
289
+ try {
290
+ const user = await server.getUser(username, null, null);
291
+ if (user?.role?.permission) resolved = user;
292
+ } catch (err) {
293
+ log.warn?.(`Failed to resolve agent.user='${username}': ${err instanceof Error ? err.message : String(err)}`);
294
+ }
295
+ }
296
+ if (resolved) return resolved;
297
+
298
+ if (username === DEFAULT_CONFIG.user) {
299
+ log.warn?.(`Default agent user '${username}' is not provisioned yet; using super_user bootstrap identity`);
300
+ return { username, role: { permission: { super_user: true } } };
301
+ }
302
+ throw new Error(`agent.user '${username}' could not be resolved to a permissioned user; failing closed`);
303
+ }
304
+
164
305
  function resolveScopes(
165
306
  config: AgentConfig,
166
307
  getConfigPath: (param: string) => string | undefined,
@@ -194,6 +335,7 @@ function mergeConfig(opts: StartOpts): AgentConfig {
194
335
  ...(opts.allowDestructive !== undefined && { allowDestructive: !!opts.allowDestructive }),
195
336
  ...(opts.user !== undefined && { user: String(opts.user) }),
196
337
  ...(opts.componentsScope !== undefined && { componentsScope: String(opts.componentsScope) }),
338
+ ...(opts.systemPromptAppend !== undefined && { systemPromptAppend: String(opts.systemPromptAppend) }),
197
339
  };
198
340
  }
199
341
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Harper best-practices knowledge for the built-in agent (#626).
3
+ *
4
+ * Sources the `harper-best-practices` skill from the `@harperfast/skills`
5
+ * package (versioned with the release, no drift). The package exposes the skill
6
+ * content directly as module exports — `skillSummary` (the SKILL.md overview),
7
+ * `ruleNames` (the rule index), and `rules` (name → markdown body) — so we read
8
+ * from those rather than groveling around the installed package on disk.
9
+ *
10
+ * Progressive disclosure, mirroring how the skill is meant to be used:
11
+ * - the SKILL.md overview (rule index + when-to-use) is injected into the
12
+ * agent's system prompt so it always knows *which* practices exist;
13
+ * - the detailed rule bodies are pulled on demand via the
14
+ * `harper_best_practice` tool, so the agent only spends context on the
15
+ * guidance relevant to the task instead of carrying all ~20 rules.
16
+ */
17
+
18
+ import { ruleNames, rules, skillSummary } from '@harperfast/skills';
19
+ import type { AgentTool } from './types.ts';
20
+
21
+ /** The SKILL.md overview (rule index + guidance), for injection into the system prompt. Undefined if unavailable. */
22
+ export function loadBestPracticesOverview(): string | undefined {
23
+ return skillSummary || undefined;
24
+ }
25
+
26
+ /**
27
+ * A tool that lists the best-practice rules (no arg) or returns one rule's full body (`rule` arg).
28
+ * Returns undefined when the skill package ships no rules, so the caller simply omits it.
29
+ */
30
+ export function buildBestPracticeTool(): AgentTool | undefined {
31
+ if (!ruleNames?.length) return undefined;
32
+ return {
33
+ def: {
34
+ name: 'harper_best_practice',
35
+ description:
36
+ 'Read a Harper best-practices rule for detailed guidance and code examples — schema design, relationships, automatic/REST APIs, authentication, custom resources, caching, vector indexing, TypeScript type-stripping, deployment, logging, and more. Call with no `rule` to list the available rule names; then call again with a `rule` to read it. Consult these before designing schemas or building app logic.',
37
+ parameters: {
38
+ type: 'object',
39
+ properties: {
40
+ rule: {
41
+ type: 'string',
42
+ description: 'Rule name without extension, e.g. "adding-tables-with-schemas". Omit to list all rules.',
43
+ },
44
+ },
45
+ },
46
+ },
47
+ handler: async (args: any) => {
48
+ const rule = args?.rule ? String(args.rule).trim() : '';
49
+ if (!rule) return { rules: [...ruleNames] };
50
+ // A plain map lookup — an unknown or malformed name simply misses, so there's no
51
+ // path-traversal surface to guard against.
52
+ const content = (rules as Record<string, string>)[rule];
53
+ if (content == null)
54
+ throw new Error(`No such best-practice rule: '${rule}'. Call with no rule to list available rules.`);
55
+ return { rule, content };
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Expose the built-in agent over MCP (#626).
3
+ *
4
+ * The generic MCP operations profile (#617) can surface these ops too, but only
5
+ * when an operator opts them into `mcp.operations.allow` and with generic,
6
+ * schema-inferred descriptions. We register a *curated* agent tool set directly
7
+ * into the registry instead: exposed by default (no allow-list entry needed),
8
+ * with hand-written descriptions and annotations tuned for driving the agent.
9
+ *
10
+ * Each tool dispatches through the SAME path the generic operations profile uses
11
+ * (`makeOperationToolHandler` → `chooseOperation` + `processLocalTransaction`), so
12
+ * Harper's `verifyPerms` runs at call time and enforces the `requiresSuperUser: true`
13
+ * declared on each agent op — plus any scoped `operations: ['agent_*']` delegation
14
+ * role. Listing mirrors that boundary via `canRoleInvokeOperation`. A direct
15
+ * `op.execute(body)` call is NOT used: it would bypass `verifyPerms` entirely and
16
+ * let any authenticated MCP user drive the super_user agent.
17
+ */
18
+
19
+ import { addTool, canRoleInvokeOperation, type AuthedUser } from '../components/mcp/toolRegistry.ts';
20
+ import { makeOperationToolHandler } from '../components/mcp/tools/operations.ts';
21
+ import harperLogger from '../utility/logging/harper_logger.ts';
22
+ import type { OperationDefinition } from '../server/serverHelpers/serverUtilities.ts';
23
+
24
+ const log = harperLogger.loggerWithTag('agent');
25
+
26
+ interface AgentMcpToolMeta {
27
+ description: string;
28
+ inputSchema: object;
29
+ destructive?: boolean;
30
+ readOnly?: boolean;
31
+ }
32
+
33
+ // Curated client-facing surface for driving the agent. `set_agent_config` is intentionally excluded
34
+ // — it's an operator/config action, not something an MCP client should reach.
35
+ const AGENT_MCP_TOOLS: Record<string, AgentMcpToolMeta> = {
36
+ agent_prompt: {
37
+ description:
38
+ 'Send a prompt to the built-in Harper agent. Starts a new session, or continues one when session_id is given. Returns { session_id, status }; poll get_agent_session for the transcript and result.',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ message: { type: 'string', description: 'The instruction/prompt for the agent.' },
43
+ session_id: { type: 'string', description: 'Optional existing session id to continue the conversation.' },
44
+ },
45
+ required: ['message'],
46
+ },
47
+ destructive: true, // the agent may take actions in response
48
+ },
49
+ get_agent_session: {
50
+ description:
51
+ 'Read a built-in-agent session: its status, full transcript (messages, tool calls and results), and any pending approvals.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: { session_id: { type: 'string' } },
55
+ required: ['session_id'],
56
+ },
57
+ readOnly: true,
58
+ },
59
+ list_agent_sessions: {
60
+ description: 'List built-in-agent sessions, most recent first.',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: { limit: { type: 'integer', minimum: 1, description: 'Max sessions to return (default 100).' } },
64
+ },
65
+ readOnly: true,
66
+ },
67
+ approve_agent_action: {
68
+ description:
69
+ 'Approve or deny a pending agent tool call (when autoApprove is off), then resume the run. Get the approval_id from get_agent_session.pendingApprovals.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ session_id: { type: 'string' },
74
+ approval_id: { type: 'string' },
75
+ approved: { type: 'boolean', description: 'true to approve (default), false to deny.' },
76
+ },
77
+ required: ['session_id', 'approval_id'],
78
+ },
79
+ destructive: true,
80
+ },
81
+ cancel_agent_run: {
82
+ description: 'Cancel an in-progress agent run for a session.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: { session_id: { type: 'string' } },
86
+ required: ['session_id'],
87
+ },
88
+ destructive: true,
89
+ },
90
+ };
91
+
92
+ /**
93
+ * Register the curated agent tools into the MCP registry (operations profile). Call after the agent
94
+ * operations are registered. Safe to call whether or not the MCP HTTP surface is enabled — the tools
95
+ * simply sit in the registry until an MCP client lists them.
96
+ */
97
+ export function registerAgentMcpTools(operations: OperationDefinition[]): void {
98
+ const registered = new Set(operations.map((op) => op.name));
99
+ let count = 0;
100
+ for (const [name, meta] of Object.entries(AGENT_MCP_TOOLS)) {
101
+ if (!registered.has(name)) continue; // op not registered (shouldn't happen) — skip rather than expose a dead tool
102
+ const annotations = meta.destructive ? { destructiveHint: true } : meta.readOnly ? { readOnlyHint: true } : {};
103
+ addTool({
104
+ name,
105
+ description: meta.description,
106
+ inputSchema: meta.inputSchema,
107
+ profile: 'operations',
108
+ ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
109
+ // List only for users the op's role-level policy would let call it (super_user, or a
110
+ // scoped `operations: ['agent_*']` delegation role). verifyPerms enforces the same at
111
+ // call time inside makeOperationToolHandler — this predicate keeps hidden tools out of
112
+ // the listing, not out of reach.
113
+ visibleTo: (user: AuthedUser) => canRoleInvokeOperation(user, name),
114
+ // Route through the standard operation dispatch (chooseOperation + processLocalTransaction),
115
+ // so verifyPerms runs and the op's requiresSuperUser gate is actually enforced — a direct
116
+ // op.execute() would skip it. Same handler the generic operations profile uses.
117
+ handler: makeOperationToolHandler(name),
118
+ });
119
+ count++;
120
+ }
121
+ log.info?.(`Agent MCP tools registered: ${count}`);
122
+ }
@@ -134,7 +134,16 @@ async function approveAgentAction(op: any, deps: OperationDeps) {
134
134
 
135
135
  async function setAgentConfig(op: any, deps: OperationDeps) {
136
136
  const patch: Partial<AgentConfig> = {};
137
- for (const key of ['enabled', 'provider', 'model', 'maxTurns', 'maxCostUsd', 'autoApprove', 'allowDestructive']) {
137
+ for (const key of [
138
+ 'enabled',
139
+ 'provider',
140
+ 'model',
141
+ 'maxTurns',
142
+ 'maxCostUsd',
143
+ 'autoApprove',
144
+ 'allowDestructive',
145
+ 'systemPromptAppend',
146
+ ]) {
138
147
  if (op?.[key] !== undefined) (patch as any)[key] = op[key];
139
148
  }
140
149
  return deps.setConfig(patch);
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Registry tool composition for the built-in agent (#626).
3
+ *
4
+ * Folds the unified MCP tool registry (#615) — specifically the Operations
5
+ * profile (#617) — into the agent's toolset. The agent consumes the *same*
6
+ * registry the MCP HTTP server exposes, RBAC-filtered for its configured user,
7
+ * so operators get one tool surface with one permission model.
8
+ *
9
+ * Two-step, mirroring the registry's own security model:
10
+ * 1. `visibleTo(agentUser)` narrows *listing* — the LLM only sees tools it is
11
+ * likely allowed to call. This is NOT a security boundary.
12
+ * 2. Real enforcement runs in the operation handler at call time: each call
13
+ * dispatches through Harper's operations runtime with `hdb_user` set to the
14
+ * agent's configured identity, so `verifyPerms` gates it regardless of what
15
+ * the LLM was shown.
16
+ *
17
+ * The Application profile (#618) is intentionally out of scope here; it can fold
18
+ * in the same way once per-Resource tool generation is wired for the agent.
19
+ */
20
+
21
+ import { registerOperationsTools } from '../components/mcp/tools/operations.ts';
22
+ import {
23
+ snapshotProfileTools,
24
+ type AuthedUser,
25
+ type ToolCallContext,
26
+ type ToolDef as RegistryToolDef,
27
+ type ToolResult,
28
+ } from '../components/mcp/toolRegistry.ts';
29
+ import harperLogger from '../utility/logging/harper_logger.ts';
30
+ import type { AgentTool, AgentToolContext } from './types.ts';
31
+
32
+ const log = harperLogger.loggerWithTag('agent');
33
+
34
+ let operationsRegistered = false;
35
+
36
+ /**
37
+ * Populate the Operations profile on the main thread. Idempotent: guarded so a
38
+ * second call (e.g. an `agent.enabled` flip via `set_agent_config`) is a no-op
39
+ * rather than re-walking `OPERATION_FUNCTION_MAP` and re-logging. The registry's
40
+ * `addTool` is `Map.set`-backed, so this stays correct whether or not the
41
+ * operator also enabled the `mcp:` HTTP surface.
42
+ */
43
+ export function ensureOperationsToolsRegistered(): void {
44
+ if (operationsRegistered) return;
45
+ registerOperationsTools();
46
+ operationsRegistered = true;
47
+ }
48
+
49
+ /**
50
+ * Adapt every Operations-profile registry tool into the agent's {@link AgentTool}
51
+ * shape. Call {@link ensureOperationsToolsRegistered} first so the profile is
52
+ * populated.
53
+ *
54
+ * Two identities, deliberately split:
55
+ * - `listingUser` filters `visibleTo` at compose time — which tools the LLM is
56
+ * *shown*. Listing is not a security boundary, so a startup snapshot is fine.
57
+ * - `resolveIdentity()` is called *per tool call* to set `hdb_user`, so the
58
+ * operation is enforced against the agent user's *current* role. This is what
59
+ * honors a role revocation/change without a restart — the enforcement identity
60
+ * is never cached in the handler closure.
61
+ */
62
+ export function composeRegistryTools(listingUser: AuthedUser, resolveIdentity: () => Promise<AuthedUser>): AgentTool[] {
63
+ const defs = snapshotProfileTools('operations');
64
+ const out: AgentTool[] = [];
65
+ for (const def of defs) {
66
+ if (!def.visibleTo(listingUser)) continue;
67
+ out.push(adaptRegistryTool(def, resolveIdentity));
68
+ }
69
+ log.info?.(`Agent composed ${out.length} operations tool(s) for user '${listingUser?.username ?? 'unknown'}'`);
70
+ return out;
71
+ }
72
+
73
+ function adaptRegistryTool(def: RegistryToolDef, resolveIdentity: () => Promise<AuthedUser>): AgentTool {
74
+ return {
75
+ def: { name: def.name, description: def.description, parameters: def.inputSchema },
76
+ // The loop's approval gate keys off `destructive`; the registry expresses the same
77
+ // intent as the MCP `destructiveHint` annotation.
78
+ destructive: def.annotations?.destructiveHint === true,
79
+ handler: async (args: object, ctx: AgentToolContext) => {
80
+ // Resolve the enforcement identity fresh so a live role change is honored. If the
81
+ // configured user can no longer be resolved, `resolveIdentity` throws (fail closed) —
82
+ // the loop turns that into a recoverable tool error rather than running with stale or
83
+ // escalated privileges.
84
+ const user = await resolveIdentity();
85
+ const context: ToolCallContext = {
86
+ user,
87
+ profile: 'operations',
88
+ sessionId: ctx.sessionId,
89
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
90
+ };
91
+ const result = await def.handler(args, context);
92
+ // The loop records a tool observation from the return value and turns a thrown error
93
+ // into a structured `{ ok: false, error }` note for the next model turn. Mapping
94
+ // `isError` → throw keeps a Harper operation failure a recoverable observation rather
95
+ // than aborting the whole run.
96
+ if (result.isError) throw new Error(errorText(result));
97
+ return result.structuredContent ?? textOf(result);
98
+ },
99
+ };
100
+ }
101
+
102
+ function textOf(result: ToolResult): string {
103
+ return (result.content ?? [])
104
+ .filter((c) => c.type === 'text' && typeof c.text === 'string')
105
+ .map((c) => c.text)
106
+ .join('\n');
107
+ }
108
+
109
+ function errorText(result: ToolResult): string {
110
+ const text = textOf(result);
111
+ return text || 'operation failed';
112
+ }
113
+
114
+ /** Test-only: reset the one-time registration guard between specs. */
115
+ export function _resetRegistryToolsForTests(): void {
116
+ operationsRegistered = false;
117
+ }
package/agent/session.ts CHANGED
@@ -60,7 +60,13 @@ export async function createSession(opts: CreateSessionOpts): Promise<AgentSessi
60
60
  createdAt: now,
61
61
  updatedAt: now,
62
62
  };
63
- await getAgentSessionTable().primaryStore.put(row.session_id, row);
63
+ // Write through the Resource-level `put` (transactional), NOT `primaryStore.put`: the transactional
64
+ // path stages the version/local-timestamp metadata so the record is stored with the metadata prefix.
65
+ // A raw `primaryStore.put` writes a prefix-less record, which — when it begins with classic shared-
66
+ // structure record-id #2 (byte 0x42 == 66) — is misread by the RocksDB decode heuristic as a
67
+ // timestamp-prefixed record (8 bytes stripped → corrupt → "Could not find typed structure"). See
68
+ // RecordEncoder.ts:328-335.
69
+ await getAgentSessionTable().put(row);
64
70
  return row;
65
71
  }
66
72
 
@@ -102,7 +108,7 @@ export function appendMessage(sessionId: string, message: AgentMessage): Promise
102
108
  const session = await requireSession(sessionId);
103
109
  session.messages.push(message);
104
110
  session.updatedAt = Date.now();
105
- await getAgentSessionTable().primaryStore.put(sessionId, session);
111
+ await getAgentSessionTable().put(session);
106
112
  return session;
107
113
  });
108
114
  }
@@ -113,7 +119,7 @@ export function setStatus(sessionId: string, status: AgentRunStatus, lastError?:
113
119
  session.status = status;
114
120
  session.lastError = lastError;
115
121
  session.updatedAt = Date.now();
116
- await getAgentSessionTable().primaryStore.put(sessionId, session);
122
+ await getAgentSessionTable().put(session);
117
123
  return session;
118
124
  });
119
125
  }
@@ -128,7 +134,7 @@ export function addPendingApproval(
128
134
  session.pendingApprovals.push(entry);
129
135
  session.status = 'awaiting_approval';
130
136
  session.updatedAt = Date.now();
131
- await getAgentSessionTable().primaryStore.put(sessionId, session);
137
+ await getAgentSessionTable().put(session);
132
138
  return entry;
133
139
  });
134
140
  }
@@ -142,7 +148,7 @@ export function markApprovalConsumed(sessionId: string, approvalId: string): Pro
142
148
  if (entry.consumed) return;
143
149
  entry.consumed = true;
144
150
  session.updatedAt = Date.now();
145
- await getAgentSessionTable().primaryStore.put(sessionId, session);
151
+ await getAgentSessionTable().put(session);
146
152
  });
147
153
  }
148
154
 
@@ -162,7 +168,7 @@ export function resolveApproval(sessionId: string, approvalId: string, approved:
162
168
  session.status = 'idle';
163
169
  }
164
170
  session.updatedAt = Date.now();
165
- await getAgentSessionTable().primaryStore.put(sessionId, session);
171
+ await getAgentSessionTable().put(session);
166
172
  return entry;
167
173
  });
168
174
  }
@@ -170,7 +176,10 @@ export function resolveApproval(sessionId: string, approvalId: string, approved:
170
176
  async function requireSession(sessionId: string): Promise<AgentSessionRow> {
171
177
  const session = await getSession(sessionId);
172
178
  if (!session) throw new Error(`No agent session ${sessionId}`);
173
- return session;
179
+ // The store returns a frozen record, so the read-modify-write mutators below (status, messages,
180
+ // approvals) would throw "Cannot assign to read only property" if they mutated it in place.
181
+ // Return a mutable deep copy; the mutator writes it back with `put`.
182
+ return structuredClone(session);
174
183
  }
175
184
 
176
185
  /**