Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 82 additions & 3 deletions agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@
* is realized lazily on first use, and the loop runs in-process.
*
* The component intentionally avoids `handleApplication`: it has nothing
* worker-thread-shaped to do. Operator-only tools (FS, schedule, fetch) are
* inline; registry-backed tools (#615/#617/#618) will fold in via toolset.ts
* once those land.
* worker-thread-shaped to do. Two tool sources compose: operator-only tools
* (FS, schedule, fetch, and the V8 inspector) that are inline, and RBAC-filtered
* registry tools (#615/#617) drained for the agent's configured user via
* `registryTools.ts`.
*/

import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
import * as env from '../utility/environment/environmentManager.ts';
import harperLogger from '../utility/logging/harper_logger.ts';
import { Models } from '../resources/models/Models.ts';
import type { AuthedUser } from '../components/mcp/toolRegistry.ts';
import { workers } from '../server/threads/manageThreads.js';
import { composeToolset } from './toolset.ts';
import { buildInspectorTools } from './tools/inspectorTool.ts';
import { composeRegistryTools, ensureOperationsToolsRegistered } from './registryTools.ts';
import { buildOperations } from './operations.ts';
import { runAgent, _resetInFlightForTests } from './loop.ts';
import { appendMessage, getSession } from './session.ts';
Expand All @@ -37,6 +43,8 @@ const DEFAULT_CONFIG: AgentConfig = {
interface StartOpts {
server: {
registerOperation: (def: { name: string; execute: (op: any) => any | Promise<any> }) => void;
/** Resolve a Harper user (with role/permissions) by username. Password/request unused here. */
getUser?: (username: string, password: string | null, request: unknown) => Promise<AuthedUser> | AuthedUser;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be token aware too? And if so, having a third parameter with an unknown type seems rather dangerous.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the other PR #1546

};
// Component-level config plumbed by componentLoader (`...componentConfig`).
enabled?: boolean;
Expand Down Expand Up @@ -64,11 +72,46 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> {
const models = new Models();
const abortControllers = new Map<string, AbortController>();
let liveConfig: AgentConfig = config;

// Populate the Operations MCP profile on the main thread. `agentIdentity` resolves the enforcement
// identity fresh on each call (so a live role change is honored, and an unresolvable non-default
// user fails closed); the startup snapshot below is only for `visibleTo` listing.
ensureOperationsToolsRegistered();
const agentIdentity = () => resolveAgentIdentity(opts.server, liveConfig.user);
let registryTools = await composeRegistryToolsForListing(agentIdentity);

// Operator-only V8 inspector tools. Debug config is read once here — enabling threads_debug opens the
// worker inspector ports at thread boot, so it can't be toggled without a restart anyway. Worker
// count is a live closure over the pool, so attaches range-check against the current worker set.
const inspectorTools = buildInspectorTools({
debugEnabled: env.get(CONFIG_PARAMS.THREADS_DEBUG) !== false,
startingPort: (env.get(CONFIG_PARAMS.THREADS_DEBUG_STARTINGPORT) as number | undefined) ?? undefined,
host: (env.get(CONFIG_PARAMS.THREADS_DEBUG_HOST) as string | undefined) ?? '127.0.0.1',
getWorkerCount: () => workers.length,
});

let composed = composeToolset({
allowDestructive: liveConfig.allowDestructive,
onFollowup: handleFollowup,
inspectorTools,
registryTools,
});

// Build the listing snapshot best-effort: if the configured user can't be resolved (and isn't the
// default bootstrap user), `agentIdentity` fails closed — the agent then runs with only its
// operator-only tools until the operator fixes `agent.user`. Never falls back to an escalated list.
async function composeRegistryToolsForListing(resolveIdentity: () => Promise<AuthedUser>): Promise<AgentTool[]> {
try {
const listingUser = await resolveIdentity();
return composeRegistryTools(listingUser, resolveIdentity);
} catch (err) {
log.error?.(
`Registry tools disabled — agent.user='${liveConfig.user}' could not be resolved: ${err instanceof Error ? err.message : String(err)}`
);
return [];
}
}

// Only warn when the operator explicitly configured `maxCostUsd`. Logging on the default
// every boot would flood the log without telling anyone anything actionable.
if (opts.maxCostUsd !== undefined) {
Expand Down Expand Up @@ -141,6 +184,8 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> {
composed = composeToolset({
allowDestructive: liveConfig.allowDestructive,
onFollowup: handleFollowup,
inspectorTools,
registryTools,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When liveConfig.user is updated via setConfig, the registryTools listing snapshot is not re-composed. This results in a stale tool listing for the LLM (which remains filtered by visibleTo for the old user), even though the enforcement identity is correctly resolved fresh per call.

Since setConfig is synchronous and composeRegistryToolsForListing is asynchronous, you can trigger the async re-composition and update the toolset once it resolves:

	function setConfig(patch: Partial<AgentConfig>): AgentConfig {
		const previousAllowDestructive = liveConfig.allowDestructive;
		const previousUser = liveConfig.user;
		liveConfig = { ...liveConfig, ...patch };

		const userChanged = liveConfig.user !== previousUser;
		const destructiveChanged = liveConfig.allowDestructive !== previousAllowDestructive;

		if (userChanged) {
			composeRegistryToolsForListing(agentIdentity)
				.then((tools) => {
					registryTools = tools;
					composed = composeToolset({
						allowDestructive: liveConfig.allowDestructive,
						onFollowup: handleFollowup,
						registryTools,
					});
				})
				.catch((err) => {
					log.error?.(`Failed to update registry tools for new user: ${err instanceof Error ? err.message : String(err)}`);
				});
		} else if (destructiveChanged) {
			composed = composeToolset({
				allowDestructive: liveConfig.allowDestructive,
				onFollowup: handleFollowup,
				registryTools,
			});
		}
		return liveConfig;
	}

});
}
// NOTE: an already in-flight run captured its toolset (and autoApprove) at start, so flipping
Expand Down Expand Up @@ -183,6 +228,40 @@ function buildSystemPrompt(scopes: AgentScopes): string {
].join('\n');
}

/**
* Resolve the identity the agent's tool calls run as. Registry tools are enforced
* (`hdb_user` at call time) against this user, so an operator who sets `agent.user`
* to a restricted role gets a narrowed surface — and a later role change is honored,
* because this runs per call rather than once at startup.
*
* Failure policy — fail closed, with one bounded exception:
* - If `agent.user` resolves to a permissioned user, use it.
* - If it can't be resolved AND it's the *default* `hdb_agent` bootstrap user,
* fall back to a super_user identity (the system user isn't provisioned yet —
* #626 defers creating it). This is the documented default-agent behavior.
* - If it can't be resolved and the operator configured a *non-default* user,
* throw. Silently escalating a misconfigured/transient restricted account to
* super_user is the vulnerability we refuse to introduce.
*/
async function resolveAgentIdentity(server: StartOpts['server'], username: string): Promise<AuthedUser> {
let resolved: AuthedUser | undefined;
if (typeof server.getUser === 'function') {
try {
const user = await server.getUser(username, null, null);
if (user?.role?.permission) resolved = user;
} catch (err) {
log.warn?.(`Failed to resolve agent.user='${username}': ${err instanceof Error ? err.message : String(err)}`);
}
}
if (resolved) return resolved;

if (username === DEFAULT_CONFIG.user) {
log.warn?.(`Default agent user '${username}' is not provisioned yet; using super_user bootstrap identity`);
return { username, role: { permission: { super_user: true } } };
}
throw new Error(`agent.user '${username}' could not be resolved to a permissioned user; failing closed`);
}

function resolveScopes(
config: AgentConfig,
getConfigPath: (param: string) => string | undefined,
Expand Down
117 changes: 117 additions & 0 deletions agent/registryTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Registry tool composition for the built-in agent (#626).
*
* Folds the unified MCP tool registry (#615) — specifically the Operations
* profile (#617) — into the agent's toolset. The agent consumes the *same*
* registry the MCP HTTP server exposes, RBAC-filtered for its configured user,
* so operators get one tool surface with one permission model.
*
* Two-step, mirroring the registry's own security model:
* 1. `visibleTo(agentUser)` narrows *listing* — the LLM only sees tools it is
* likely allowed to call. This is NOT a security boundary.
* 2. Real enforcement runs in the operation handler at call time: each call
* dispatches through Harper's operations runtime with `hdb_user` set to the
* agent's configured identity, so `verifyPerms` gates it regardless of what
* the LLM was shown.
*
* The Application profile (#618) is intentionally out of scope here; it can fold
* in the same way once per-Resource tool generation is wired for the agent.
*/

import { registerOperationsTools } from '../components/mcp/tools/operations.ts';
import {
snapshotProfileTools,
type AuthedUser,
type ToolCallContext,
type ToolDef as RegistryToolDef,
type ToolResult,
} from '../components/mcp/toolRegistry.ts';
import harperLogger from '../utility/logging/harper_logger.ts';
import type { AgentTool, AgentToolContext } from './types.ts';

const log = harperLogger.loggerWithTag('agent');

let operationsRegistered = false;

/**
* Populate the Operations profile on the main thread. Idempotent: guarded so a
* second call (e.g. an `agent.enabled` flip via `set_agent_config`) is a no-op
* rather than re-walking `OPERATION_FUNCTION_MAP` and re-logging. The registry's
* `addTool` is `Map.set`-backed, so this stays correct whether or not the
* operator also enabled the `mcp:` HTTP surface.
*/
export function ensureOperationsToolsRegistered(): void {
if (operationsRegistered) return;
registerOperationsTools();
operationsRegistered = true;
}

/**
* Adapt every Operations-profile registry tool into the agent's {@link AgentTool}
* shape. Call {@link ensureOperationsToolsRegistered} first so the profile is
* populated.
*
* Two identities, deliberately split:
* - `listingUser` filters `visibleTo` at compose time — which tools the LLM is
* *shown*. Listing is not a security boundary, so a startup snapshot is fine.
* - `resolveIdentity()` is called *per tool call* to set `hdb_user`, so the
* operation is enforced against the agent user's *current* role. This is what
* honors a role revocation/change without a restart — the enforcement identity
* is never cached in the handler closure.
*/
export function composeRegistryTools(listingUser: AuthedUser, resolveIdentity: () => Promise<AuthedUser>): AgentTool[] {
const defs = snapshotProfileTools('operations');
const out: AgentTool[] = [];
for (const def of defs) {
if (!def.visibleTo(listingUser)) continue;
out.push(adaptRegistryTool(def, resolveIdentity));
}
log.info?.(`Agent composed ${out.length} operations tool(s) for user '${listingUser?.username ?? 'unknown'}'`);
return out;
}

function adaptRegistryTool(def: RegistryToolDef, resolveIdentity: () => Promise<AuthedUser>): AgentTool {
return {
def: { name: def.name, description: def.description, parameters: def.inputSchema },
// The loop's approval gate keys off `destructive`; the registry expresses the same
// intent as the MCP `destructiveHint` annotation.
destructive: def.annotations?.destructiveHint === true,
handler: async (args: object, ctx: AgentToolContext) => {
// Resolve the enforcement identity fresh so a live role change is honored. If the
// configured user can no longer be resolved, `resolveIdentity` throws (fail closed) —
// the loop turns that into a recoverable tool error rather than running with stale or
// escalated privileges.
const user = await resolveIdentity();
const context: ToolCallContext = {
user,
profile: 'operations',
sessionId: ctx.sessionId,
...(ctx.signal ? { signal: ctx.signal } : {}),
};
const result = await def.handler(args, context);
// The loop records a tool observation from the return value and turns a thrown error
// into a structured `{ ok: false, error }` note for the next model turn. Mapping
// `isError` → throw keeps a Harper operation failure a recoverable observation rather
// than aborting the whole run.
if (result.isError) throw new Error(errorText(result));
return result.structuredContent ?? textOf(result);
Comment on lines +91 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential TypeErrors if a tool handler returns an empty or invalid result, add a defensive check to ensure result is defined before accessing its properties.

			const result = await def.handler(args, context);
			if (!result) {
				throw new Error('Operation returned an empty or invalid result');
			}
			// The loop records a tool observation from the return value and turns a thrown error
			// into a structured '{ ok: false, error }' note for the next model turn. Mapping
			// 'isError' -> throw keeps a Harper operation failure a recoverable observation rather
			// than aborting the whole run.
			if (result.isError) throw new Error(errorText(result));
			return result.structuredContent ?? textOf(result);
References
  1. When accessing properties of a result from a method that might return nullish, guard against nullish values to prevent runtime errors.

},
};
}

function textOf(result: ToolResult): string {
return (result.content ?? [])
.filter((c) => c.type === 'text' && typeof c.text === 'string')
.map((c) => c.text)
.join('\n');
}
Comment on lines +102 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If result.content contains any null or undefined elements, accessing c.type will throw a TypeError. Add a defensive check to ensure c is defined before accessing its properties.

Suggested change
function textOf(result: ToolResult): string {
return (result.content ?? [])
.filter((c) => c.type === 'text' && typeof c.text === 'string')
.map((c) => c.text)
.join('\n');
}
function textOf(result: ToolResult): string {
return (result.content ?? [])
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
.map((c) => c.text)
.join('\n');
}


function errorText(result: ToolResult): string {
const text = textOf(result);
return text || 'operation failed';
}

/** Test-only: reset the one-time registration guard between specs. */
export function _resetRegistryToolsForTests(): void {
operationsRegistered = false;
}
Loading
Loading