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
10 changes: 4 additions & 6 deletions src/bundles/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ import { validateBundleUrl } from "./url-validator.ts";
*
* Absent for in-process platform sources (which don't go through
* `startBundleSource` anyway) and for paths that don't yet plumb the
* deps (boot reload, configureBundle restart, connector eager-start —
* follow-up).
* deps (boot reload, connector eager-start — follow-up).
*/
export interface BundleMcpDeps {
workspaceId: string;
Expand Down Expand Up @@ -500,7 +499,7 @@ export async function startBundleSource(
// pre-#195 installs whose ref doesn't carry the field. Mirrors the
// URL-branch pattern below — without this the registered source
// name would diverge from what install persisted, breaking
// `manage_app uninstall` for every catalog-installed mpak bundle
// uninstall for every catalog-installed mpak bundle
// whose canonical id and package name produce different slugs
// (e.g. `dev.mpak.nimblebraininc/echo` vs `@nimblebraininc/echo`).
const serverName = ref.serverName ?? deriveServerName(ref.name);
Expand Down Expand Up @@ -663,7 +662,7 @@ function buildLocalSource(
/**
* Slugified canonical reverse-DNS form persisted at install time.
* When present, used as the source name so the registered key
* matches what `manage_app uninstall` looks up; falls back to
* matches what uninstall looks up; falls back to
* `deriveServerName(manifest.name)` for legacy installs.
*/
serverName?: string;
Expand Down Expand Up @@ -692,8 +691,7 @@ function buildLocalSource(
// Mirror the named-bundle branch: honor a persisted ref.serverName
// (slugified canonical id from install) before falling back to the
// legacy short slug. Keeps registered source name in lockstep with
// what consumers (manage_app uninstall, lifecycle Map, web routes)
// look up by.
// what consumers (uninstall, lifecycle Map, web routes) look up by.
const serverName = ref.serverName ?? deriveServerName(manifest.name);
validateServerName(serverName);
const mcpConfig = manifest.server.mcp_config;
Expand Down
69 changes: 15 additions & 54 deletions src/bundles/workspace-ops.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/**
* Workspace-scoped bundle install/uninstall operations.
* Workspace-scoped bundle install operation.
*
* These are consumed by system tools for hot bundle management within workspaces.
* Consumed by connector install for hot bundle management within a
* workspace. (Uninstall is owned by `BundleLifecycleManager.uninstall`,
* which resolves the server name, clears credentials, and unregisters
* placements/config/automations in one place.)
*/

import type { EventSink } from "../engine/types.ts";
Expand Down Expand Up @@ -38,21 +41,20 @@ export async function installBundleInWorkspace(
allowInsecureRemotes?: boolean;
workDir?: string;
/**
* Per-workspace host-resources deps. Caller (`system-tools` /
* `manage_app install`, `connector-tools`) pulls from Runtime.
* Passed through to `startBundleSource` so the spawned bundle's
* McpSource registers inbound `ai.nimblebrain/resources/*` handlers.
* Per-workspace host-resources deps. Caller (`connector-tools`,
* catalog/hot install) pulls from Runtime. Passed through to
* `startBundleSource` so the spawned bundle's McpSource registers
* inbound `ai.nimblebrain/resources/*` handlers.
*/
bundleMcp?: BundleMcpDeps;
},
): Promise<ProcessInventoryEntry> {
// workDir default matches the sibling `uninstallBundleFromWorkspace`
// below — previously this function fell through to `""` and emitted
// relative paths from cwd (a latent bug). The new default routes
// through `~/.nimblebrain`, matching every other workspace-scoped
// entry point. A caller that explicitly passes `workDir: ""` now
// hits the `WorkspaceContext` constructor's empty-string rejection
// (deliberate — relative paths in this code path were never correct).
// Default workDir to `~/.nimblebrain` — previously this function fell
// through to `""` and emitted relative paths from cwd (a latent bug),
// out of step with every other workspace-scoped entry point. A caller
// that explicitly passes `workDir: ""` now hits the `WorkspaceContext`
// constructor's empty-string rejection (deliberate — relative paths in
// this code path were never correct).
const workDir = opts?.workDir ?? defaultWorkDir();
const wsContext = new WorkspaceContext({ wsId, workDir });
const serverName = serverNameFromRef(bundleRef);
Expand Down Expand Up @@ -83,44 +85,3 @@ export async function installBundleInWorkspace(
meta: result.meta,
};
}

/**
* Uninstall a bundle from a specific workspace (hot — stops process and deregisters).
*
* Stops the MCP source, removes it from the registry, and clears the
* workspace-scoped credential file for the bundle (best-effort —
* failures are logged but do not fail the uninstall). Data directories
* are intentionally preserved.
*
* `serverName` is the resolved lifecycle key — caller is responsible
* for reading it from the persisted `BundleRef.serverName` (set at
* install time from `slugifyServerName(entry.id)`) with
* `deriveServerName(bundleName)` as a back-compat fallback for legacy
* refs. Passing the canonical name here would skip the slug and miss
* the registered source.
*/
export async function uninstallBundleFromWorkspace(
wsId: string,
bundleName: string,
serverName: string,
registry: ToolRegistry,
opts?: { workDir?: string },
): Promise<void> {
if (!registry.hasSource(serverName)) {
throw new Error(`No bundle "${serverName}" found in workspace "${wsId}"`);
}

await registry.removeSource(serverName);

// Best-effort credential cleanup — don't fail uninstall if it errors.
// Credentials are config, not data: they should not persist across uninstalls.
const workDir = opts?.workDir ?? defaultWorkDir();
try {
await new WorkspaceContext({ wsId, workDir }).getCredentialStore().clearAll(bundleName);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[workspace-ops] Failed to clear credentials for ${bundleName} in ${wsId}: ${msg}\n`,
);
}
}
10 changes: 4 additions & 6 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ export function bundleList(configPath?: string, json?: boolean): void {
/** nb bundle add <name> — deprecated, bundles are now workspace-scoped */
export function bundleAdd(name: string, _configPath?: string): void {
console.error(
`Instance-level bundles have been removed. Use workspace-scoped bundle management instead:\n` +
` nb__manage_app install ${name}\n` +
`Or add the bundle to your workspace definition.`,
`Instance-level bundles have been removed. Install ${name} from the Apps catalog in settings, ` +
`or add the bundle to your workspace definition.`,
);
process.exit(1);
}
Expand All @@ -97,9 +96,8 @@ export function bundleAddRemote(
/** nb bundle remove <name> — deprecated, bundles are now workspace-scoped */
export function bundleRemove(name: string, _configPath?: string): void {
console.error(
`Instance-level bundles have been removed. Use workspace-scoped bundle management instead:\n` +
` nb__manage_app uninstall ${name}\n` +
`Or remove the bundle from your workspace definition.`,
`Instance-level bundles have been removed. Uninstall ${name} from the Apps section in settings, ` +
`or remove the bundle from your workspace definition.`,
);
process.exit(1);
}
Expand Down
9 changes: 7 additions & 2 deletions src/config/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
* Opt-in flags (default false) are documented inline.
*/
export interface FeatureFlags {
/**
* Reserved. Gated conversational bundle install/uninstall/configure via
* `nb__manage_app`, which was removed (install/configure now live in the
* Apps catalog + CLI). Kept as a stable operator config knob for the
* bundle-management tool the delegation model contemplates reintroducing
* (single tool, explicit workspace param, per-call admin auth).
*/
bundleManagement?: boolean;
skillManagement?: boolean;
delegation?: boolean;
Expand Down Expand Up @@ -51,7 +58,6 @@ export function resolveFeatures(config?: FeatureFlags): ResolvedFeatures {
*/
export const FEATURE_TOOL_MAP: Record<string, keyof FeatureFlags> = {
// Prefixed names (as seen by the LLM / MCP clients)
nb__manage_app: "bundleManagement",
nb__delegate: "delegation",
// Identity & workspace tools
nb__manage_users: "userManagement",
Expand All @@ -64,7 +70,6 @@ export const FEATURE_TOOL_MAP: Record<string, keyof FeatureFlags> = {
skills__deactivate: "skillManagement",
skills__move_scope: "skillManagement",
// Unprefixed names (used during system tool registration)
manage_app: "bundleManagement",
delegate: "delegation",
manage_users: "userManagement",
manage_workspaces: "workspaceManagement",
Expand Down
16 changes: 3 additions & 13 deletions src/config/privilege.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,6 @@ interface PrivilegeEntry {
}

const PRIVILEGE_CANDIDATES: PrivilegeEntry[] = [
{
tool: "nb__manage_app",
feature: "bundleManagement",
describe: (input) => `${input.action} ${input.name}?`,
},
{
// Creates land in the prompt as soon as they're written (always-load
// skills) or on the next applicable turn (tool_affined). Gating
Expand Down Expand Up @@ -102,14 +97,9 @@ export function createPrivilegeHook(
const description = entry.describe(call.input);
const approved = await gate.confirm(description, call.input);
if (!approved) {
// Derive `action` from `input.action` when present (legacy shape used
// by `nb__manage_app`); otherwise fall back to the unprefixed tool
// name (`delete`, `move_scope` etc.) so audit consumers always see
// a non-empty action label.
const action =
typeof call.input.action === "string"
? call.input.action
: (call.name.split("__").pop() ?? call.name);
// Audit label: the unprefixed tool name (`create`, `delete`,
// `move_scope`) so consumers always see a non-empty action.
const action = call.name.split("__").pop() ?? call.name;
const target = call.input.name ?? call.input.id ?? null;
eventSink.emit({
type: "audit.permission_denied",
Expand Down
5 changes: 3 additions & 2 deletions src/config/workspace-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,9 @@ export interface ResolveUserConfigInput {
gate?: ConfirmationGate;
/**
* If `true`, prompt for every field via the gate and persist responses to
* the workspace store. Used by the `nb__manage_app configure` TUI action.
* No effect when `gate?.supportsInteraction` is false.
* the workspace store — the interactive "re-enter all credentials" path,
* for updating existing values. No effect when `gate?.supportsInteraction`
* is false.
*/
forcePrompt?: boolean;
}
Expand Down
48 changes: 7 additions & 41 deletions src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,8 @@ export class Runtime {
* Per-workspace host-resources deps factory. Set in `Runtime.start()`
* after the resolver + rate-limit are constructed; consumed by every
* install path that spawns a bundle (lifecycle.installNamed/Local/
* Remote, system-tools manage_app install, connector-tools install,
* workspace-runtime boot reload). Returns `undefined` only when the
* Remote, connector-tools install, workspace-runtime boot reload).
* Returns `undefined` only when the
* runtime is constructed without the host-resources subsystem wired
* — never in production.
*/
Expand Down Expand Up @@ -281,7 +281,6 @@ export class Runtime {
private readonly activeConversations = new Set<string>();

private constructor(
_engine: AgentEngine,
resolveModelFn: (modelString: string) => LanguageModelV3,
store: ConversationStore,
skillMatcher: SkillMatcher,
Expand Down Expand Up @@ -486,9 +485,9 @@ export class Runtime {
},
};

// System tools (search, manage_app, bundle_status, delegate). Skill
// mutation lives in the dedicated `nb__skills` source — registered
// separately via `createPlatformSources`.
// System tools (search, status, delegate). Skill mutation lives in the
// dedicated `nb__skills` source — registered separately via
// `createPlatformSources`.
// Use a late-bound holder so reloadSkills can reference `rt` after construction.
const rtHolder: { rt?: Runtime } = {};
const boundReloadSkills = async () => {
Expand All @@ -514,20 +513,6 @@ export class Runtime {

const store = buildStore(config);
const { contextSkills, skillMatcher } = buildSkills(config);
const defaultModelId = getDefaultModel();
// Workspace-aware ToolRouter proxy: the engine calls availableTools()/execute()
// within runWithRequestContext(), so the proxy reads the current workspace's registry.
const workspaceToolRouter: ToolRouter = {
availableTools: () => {
if (!rtHolder.rt) throw new Error("Runtime not initialized");
return rtHolder.rt.getRegistryForCurrentWorkspace().availableTools();
},
execute: (call) => {
if (!rtHolder.rt) throw new Error("Runtime not initialized");
return rtHolder.rt.getRegistryForCurrentWorkspace().execute(call);
},
};
const engine = new AgentEngine(resolveModelFn(defaultModelId), workspaceToolRouter, events);

// Request-scoped context — all identity/workspace reads go through AsyncLocalStorage.
// Set via runWithRequestContext() in chat(), handleToolCall(), and MCP handler.
Expand All @@ -541,24 +526,6 @@ export class Runtime {
const manageUsersCtx = { getIdentity, userStore, provider: identityProvider };
const manageWorkspacesCtx = { getIdentity, workspaceStore };
const manageMembersCtx = { getIdentity, workspaceStore, userStore };
const manageBundleCtx = {
getWorkspaceId,
workspaceStore,
workDir: resolveWorkDir(config),
configDir: config.configPath ? dirname(config.configPath) : undefined,
allowInsecureRemotes: config.allowInsecureRemotes,
// The runtime sink flows into every McpSource spawned by manage_app
// install/configure. Keeps chat-initiated bundle installs on the same
// live-update pipeline as boot-time bundle startup.
eventSink: events,
// Per-workspace host-resources deps factory. `installBundleInWorkspaceViaCtx`
// calls this for the target workspace and threads the result through
// `installBundleInWorkspace`'s opts so the spawned McpSource registers
// `ai.nimblebrain/resources/*` handlers. Without this, the agent's
// `manage_app install` path silently bypasses the host-resources
// capability — the production path that needs it most.
bundleMcpDepsFactory,
};
const noActiveToolPromotionRun = (toolName: string): ToolPromotionResult => ({
ok: false,
toolName,
Expand Down Expand Up @@ -620,7 +587,6 @@ export class Runtime {

// Create Runtime with empty workspace registries first — needed by system tools
const rt = new Runtime(
engine,
resolveModelFn,
store,
skillMatcher,
Expand Down Expand Up @@ -666,7 +632,7 @@ export class Runtime {
manageUsersCtx,
manageWorkspacesCtx,
manageMembersCtx,
manageBundleCtx,
undefined, // reserved slot — was manageBundleCtx (nb__manage_app, removed)
toolPromotionCtx,
toolEligibilityCtx,
);
Expand Down Expand Up @@ -940,7 +906,7 @@ export class Runtime {
...skill,
body:
skill.body +
`\n\n⚠️ Missing dependencies: ${missing.join(", ")}. Some capabilities may be unavailable. Install with nb__manage_app.`,
`\n\n⚠️ Missing dependencies: ${missing.join(", ")}. Some capabilities may be unavailable. Install the missing apps from the Apps catalog in settings.`,
};
}
}
Expand Down
13 changes: 4 additions & 9 deletions src/skills/core/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ metadata:
- **nb__search** — Unified search tool. Use `scope: "tools"` to search installed tools by keyword (empty query lists everything). Use `scope: "registry"` to search the mpak registry for installable bundles.
- **nb__manage_tools** — Patch your active tool list in one call. Input: `{ add?: ["source__tool", ...], remove?: ["source__tool", ...] }`. Use `add` to promote discovered tools so they become callable on the next turn. Use `remove` to release tools you no longer need (system tools `nb__*` cannot be released). Combine `add` and `remove` in one call when switching domains.
- **nb__status** — Platform status. Default gives an overview (model, app count, skill count). Use `scope: "bundles"` for per-app health/version, `scope: "skills"` for loaded skills, `scope: "config"` for model and limit details.
- **nb__manage_app** — Install, uninstall, or configure apps:
- `install` — Download, prompt for credentials if needed, start.
- `uninstall` — Stop and remove.
- `configure` — Re-prompt for credentials on an existing app.

## Tool Discovery Workflow

Expand All @@ -49,12 +45,11 @@ Never guess tool names. Never skip step 2 — a tool not in your active list is

## Credentials

All credentials are collected by the terminal — never in chat.
App credentials are never collected in chat.

- During install: if the bundle needs credentials, the terminal prompts automatically.
- After success: if the result says "Credentials were configured" — the bundle is ready. Do not suggest further setup.
- To update credentials: use `manage_app("configure", "bundle-name")`.
- If a bundle fails to start: offer to reconfigure. Nothing else.
- Installing apps and setting their credentials happens in the Apps section of settings (or the `nb` CLI), where each bundle's config fields are prompted securely. You cannot install or set credentials from chat.
- If a user asks to install an app or update its credentials, point them to the Apps section of settings.
- If a bundle fails to start for lack of credentials: tell the user and point them to settings. Nothing else.
- **Never mention API keys, tokens, passwords, or connection strings in chat.**

## User Preferences
Expand Down
2 changes: 1 addition & 1 deletion src/skills/core/skill-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ If the skill needs specific tools:
- Set allowed_tools to scope tool visibility: ["policy_search__*"]
- Set requires_bundles to declare dependencies: ["@acme/policy-search"]
- Before creating, use nb__search with scope "tools" to verify tools exist
- If tools are missing, tell the user and offer to install via nb__manage_app
- If tools are missing, tell the user and point them to the Apps section of settings to install the bundle that provides them

## Choosing the Right Scope

Expand Down
14 changes: 6 additions & 8 deletions src/tools/connector-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,9 +1282,8 @@ async function handleInstallRemoteOAuth(
/**
* Mpak (stdio) install. The bundle is fetched from whichever mpak
* registry the SDK is pointed at, spawned as a subprocess, and
* registered in the workspace registry. Same mechanics as the chat
* agent's `bundleManagement.install` so both UI surfaces produce
* identical state.
* registered in the workspace registry via the shared
* `installBundleInWorkspace` primitive.
*
* Workspace-scope only — every stdio bundle is workspace-shared
* today. A future per-user mpak install would need its own
Expand Down Expand Up @@ -2073,9 +2072,8 @@ async function handleSetUserConfig(
// Mode 1 (env_inject) bundles only read user_config at spawn — env
// vars are baked in at fork time. Saving to the credential file is
// necessary but not sufficient; without a respawn the running
// subprocess keeps using whatever it was launched with. Mirror the
// chat agent's `configureBundle` pattern so both the chat path and
// the UI path produce identical post-write state.
// subprocess keeps using whatever it was launched with. Respawn so the
// post-write state reflects the new credentials.
const respawn = await respawnBundleAfterCredentialChange(ctx, wsId, bundleName, serverName);

const populated = await probeUserConfigPopulated(ctx.runtime, wsId, bundleName, schema);
Expand Down Expand Up @@ -2163,8 +2161,8 @@ async function respawnBundleAfterCredentialChange(
}
// Pass `name` (the scoped manifest name) so startBundleSource hits
// the named-bundle path that resolves user_config from the
// workspace credential store. configDir is undefined — same as
// configureBundle's call site; named-bundle path doesn't need it.
// workspace credential store. configDir is undefined — the
// named-bundle path doesn't need it.
await startBundleSource({ name: bundleName }, registry, ctx.runtime.getEventSink(), undefined, {
wsId,
workDir: ctx.runtime.getWorkDir(),
Expand Down
Loading