From 99b20e003fa38ec0201ba8209cce5b9291e12e03 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 13:16:12 +0800 Subject: [PATCH 01/14] refactor(providers): introduce ResourceProvider abstraction (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a resource-sync-mechanism layer above the existing GitProvider host adapters, so a `git` backend and one or more `http` backends can be mounted side by side under one registry. This is phase 1 of #404: purely additive, with zero behavior change — nothing consumes the abstraction yet. - `ResourceProvider` (name/type/priority/capabilities + sync/describe/ teardown) and its capability model in providers/types.ts, alongside the HttpBackendAdapter contract for phase 2. - `ResourceProviderRegistry` with duplicate-name rejection and priority-ordered listing; `syncResourceProviders` runs providers with per-provider failure isolation so one backend cannot abort the others. - `GitResourceProvider`, a thin shell that wraps the existing team-repo pull/teardown as injected closures (no git behavior is reimplemented). Ownership arbitration, primary-write selection and cross-provider failover are deferred to a later phase; `priority` is carried on the interface now so those phases need no type change. --- .../resource-provider-registry.test.ts | 214 ++++++++++++++++++ src/providers/git/resource-provider.ts | 60 +++++ src/providers/index.ts | 15 ++ src/providers/resource-registry.ts | 82 +++++++ src/providers/types.ts | 141 ++++++++++++ 5 files changed, 512 insertions(+) create mode 100644 src/__tests__/resource-provider-registry.test.ts create mode 100644 src/providers/git/resource-provider.ts create mode 100644 src/providers/resource-registry.ts diff --git a/src/__tests__/resource-provider-registry.test.ts b/src/__tests__/resource-provider-registry.test.ts new file mode 100644 index 000000000..1dee80b8a --- /dev/null +++ b/src/__tests__/resource-provider-registry.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + ResourceProviderRegistry, + syncResourceProviders, +} from '../providers/resource-registry.js'; +import { GitResourceProvider } from '../providers/git/resource-provider.js'; +import type { + ResourceProvider, + SyncContext, + ProviderResult, +} from '../providers/types.js'; + +/** A minimal stub ResourceProvider for registry/sync tests. */ +function stubProvider( + name: string, + priority: number, + opts: { + sync?: (ctx: SyncContext) => Promise; + pull?: boolean; + report?: boolean; + } = {}, +): ResourceProvider { + return { + name, + type: 'http', + priority, + capabilities: { + pull: opts.pull ?? true, + push: false, + report: opts.report ?? false, + commands: false, + }, + sync: + opts.sync ?? + (async () => ({ provider: name, ok: true, changed: false })), + describe: async () => ({ + name, + type: 'http', + priority, + capabilities: { pull: true, push: false, report: false, commands: false }, + }), + teardown: async () => {}, + }; +} + +const HOOK: SyncContext = { trigger: 'hook' }; + +describe('ResourceProviderRegistry', () => { + it('rejects a duplicate provider name', () => { + const registry = new ResourceProviderRegistry(); + registry.register(stubProvider('team', 100)); + expect(() => registry.register(stubProvider('team', 50))).toThrow( + /already registered/, + ); + }); + + it('looks up a provider by name', () => { + const registry = new ResourceProviderRegistry(); + const p = stubProvider('team', 100); + registry.register(p); + expect(registry.get('team')).toBe(p); + expect(registry.get('missing')).toBeUndefined(); + expect(registry.size).toBe(1); + }); + + it('lists providers by descending priority, then name', () => { + const registry = new ResourceProviderRegistry(); + registry.register(stubProvider('bbb', 50)); + registry.register(stubProvider('aaa', 100)); + registry.register(stubProvider('ccc', 50)); + expect(registry.list().map((p) => p.name)).toEqual(['aaa', 'bbb', 'ccc']); + }); +}); + +describe('syncResourceProviders', () => { + it('isolates a failing provider from the others', async () => { + const good = stubProvider('good', 10, { + sync: async () => ({ provider: 'good', ok: true, changed: true }), + }); + const bad = stubProvider('bad', 20, { + sync: async () => { + throw new Error('backend down'); + }, + }); + + const results = await syncResourceProviders([good, bad], HOOK); + const byName = Object.fromEntries(results.map((r) => [r.provider, r])); + + expect(byName.good.ok).toBe(true); + expect(byName.good.changed).toBe(true); + expect(byName.bad.ok).toBe(false); + expect(byName.bad.message).toBe('backend down'); + }); + + it('skips providers with neither pull nor report capability', async () => { + const sync = vi.fn(async () => ({ + provider: 'silent', + ok: true, + changed: false, + })); + const silent = stubProvider('silent', 10, { sync, pull: false, report: false }); + + const results = await syncResourceProviders([silent], HOOK); + + expect(sync).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + + it('applies providers lowest-priority first so the winner runs last', async () => { + const order: string[] = []; + const low = stubProvider('low', 10, { + sync: async () => { + order.push('low'); + return { provider: 'low', ok: true, changed: false }; + }, + }); + const high = stubProvider('high', 100, { + sync: async () => { + order.push('high'); + return { provider: 'high', ok: true, changed: false }; + }, + }); + + await syncResourceProviders([high, low], HOOK); + + expect(order).toEqual(['low', 'high']); + }); +}); + +describe('GitResourceProvider', () => { + it('is a git provider whose push capability tracks writability', () => { + const writable = new GitResourceProvider( + 'core', + 100, + 'https://example.com/repo.git', + async () => ({ changed: false }), + async () => {}, + true, + ); + expect(writable.type).toBe('git'); + expect(writable.capabilities).toEqual({ + pull: true, + push: true, + report: false, + commands: false, + }); + + const readOnly = new GitResourceProvider( + 'shared', + 50, + 'https://example.com/shared.git', + async () => ({ changed: false }), + async () => {}, + false, + ); + expect(readOnly.capabilities.push).toBe(false); + }); + + it('delegates sync to the injected operation and reports its result', async () => { + const syncOp = vi.fn(async () => ({ changed: true, message: 'pulled 3 skills' })); + const provider = new GitResourceProvider( + 'core', + 100, + 'https://example.com/repo.git', + syncOp, + async () => {}, + true, + ); + + const result = await provider.sync({ trigger: 'pull', cwd: '/work' }); + + expect(syncOp).toHaveBeenCalledWith({ trigger: 'pull', cwd: '/work' }); + expect(result).toEqual({ + provider: 'core', + ok: true, + changed: true, + message: 'pulled 3 skills', + }); + }); + + it('delegates teardown to the injected operation', async () => { + const teardownOp = vi.fn(async () => {}); + const provider = new GitResourceProvider( + 'core', + 100, + 'https://example.com/repo.git', + async () => ({ changed: false }), + teardownOp, + true, + ); + + await provider.teardown(); + + expect(teardownOp).toHaveBeenCalledOnce(); + }); + + it('describes itself with the repo as endpoint', async () => { + const provider = new GitResourceProvider( + 'core', + 100, + 'https://example.com/repo.git', + async () => ({ changed: false }), + async () => {}, + true, + ); + expect(await provider.describe()).toEqual({ + name: 'core', + type: 'git', + priority: 100, + capabilities: { pull: true, push: true, report: false, commands: false }, + endpoint: 'https://example.com/repo.git', + }); + }); +}); diff --git a/src/providers/git/resource-provider.ts b/src/providers/git/resource-provider.ts new file mode 100644 index 000000000..43221558f --- /dev/null +++ b/src/providers/git/resource-provider.ts @@ -0,0 +1,60 @@ +// ─── Git resource provider ─────────────────────────────── +// +// Wraps the existing team-repo sync (git clone + resource deploy) as a +// ResourceProvider so a git backend sits alongside HTTP backends under one +// registry. This is a thin shell: the actual pull/teardown behavior is injected +// as closures by the caller, so wrapping introduces ZERO behavior change — the +// closures run the same code paths as before the abstraction existed. +// +// See issue #404, phase 1. + +import type { + ResourceProvider, + ProviderCapabilities, + ProviderResult, + ProviderSummary, + SyncContext, +} from '../types.js'; + +/** Sync a git-backed source. Returns whether anything changed on disk. */ +export type GitSyncOperation = (context: SyncContext) => Promise<{ changed: boolean; message?: string }>; + +/** Remove a git-backed source's local state (clone, worktrees, manifests). */ +export type GitTeardownOperation = () => Promise; + +export class GitResourceProvider implements ResourceProvider { + readonly type = 'git' as const; + readonly capabilities: ProviderCapabilities; + + constructor( + readonly name: string, + readonly priority: number, + /** Team repo remote URL, for `describe`. */ + private readonly repo: string, + private readonly syncOperation: GitSyncOperation, + private readonly teardownOperation: GitTeardownOperation, + /** Whether this git source accepts `teamai push` (main repo yes, cross-team source no). */ + writable: boolean, + ) { + this.capabilities = { pull: true, push: writable, report: false, commands: false }; + } + + async sync(context: SyncContext): Promise { + const { changed, message } = await this.syncOperation(context); + return { provider: this.name, ok: true, changed, message }; + } + + async describe(): Promise { + return { + name: this.name, + type: this.type, + priority: this.priority, + capabilities: this.capabilities, + endpoint: this.repo, + }; + } + + async teardown(): Promise { + await this.teardownOperation(); + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 3830fa1c4..e6d103512 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,21 @@ export type { GitProvider, RepoInfo, PrCreateOptions } from './types.js'; export { RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './types.js'; export { getProvider, getProviderFromUrl, detectProvider, detectProviderForInit } from './registry.js'; + +// ─── Resource delivery providers (issue #404) ──────────── +export type { + ResourceProvider, + ResourceProviderType, + ProviderCapabilities, + ProviderSummary, + ProviderResult, + SyncContext, + HttpBackendAdapter, + HttpProviderConfig, + HttpRoutes, +} from './types.js'; +export { ResourceProviderRegistry, syncResourceProviders } from './resource-registry.js'; +export { GitResourceProvider } from './git/resource-provider.js'; export { TGitProvider } from './tgit/index.js'; export { GitHubProvider } from './github/index.js'; export { GitLabProvider } from './gitlab/index.js'; diff --git a/src/providers/resource-registry.ts b/src/providers/resource-registry.ts new file mode 100644 index 000000000..0e88615fe --- /dev/null +++ b/src/providers/resource-registry.ts @@ -0,0 +1,82 @@ +// ─── Resource provider registry ────────────────────────── +// +// Holds the named ResourceProviders mounted for a run and drives a +// failure-isolated sync across them. This is the sync-mechanism layer (git / +// http); it is distinct from the git-host registry in ./registry.ts, which maps +// a repo URL to a GitProvider (github/tgit/…). +// +// See issue #404. Multi-provider ownership arbitration, primary-write selection +// and cross-provider failover are a later phase and intentionally live outside +// this file. + +import type { ResourceProvider, SyncContext, ProviderResult } from './types.js'; +import { log } from '../utils/logger.js'; + +/** + * A set of uniquely-named resource providers. Registration rejects duplicate + * names; listing returns providers by descending priority, then name, so + * ordering is deterministic regardless of registration order. + */ +export class ResourceProviderRegistry { + private readonly providers = new Map(); + + /** Register a provider. Throws if the name is already taken. */ + register(provider: ResourceProvider): void { + if (this.providers.has(provider.name)) { + throw new Error(`Resource provider "${provider.name}" is already registered.`); + } + this.providers.set(provider.name, provider); + } + + /** Look up a provider by name, or undefined if not registered. */ + get(name: string): ResourceProvider | undefined { + return this.providers.get(name); + } + + /** All providers, ordered by descending priority then name. */ + list(): ResourceProvider[] { + return [...this.providers.values()].sort( + (a, b) => b.priority - a.priority || a.name.localeCompare(b.name), + ); + } + + /** Number of registered providers. */ + get size(): number { + return this.providers.size; + } +} + +/** + * Sync every provider that can pull or report, isolating failures: one + * provider throwing or timing out never aborts the others. Each provider's + * outcome — success or a caught error rendered as `{ ok: false }` — is + * returned so the caller can report per-provider status. + * + * Providers run lowest-priority first so that, once same-name arbitration + * lands, a higher-priority provider applied later wins the final on-disk state. + * Until then order only affects log sequencing. + */ +export async function syncResourceProviders( + providers: ResourceProvider[], + context: SyncContext, +): Promise { + const active = providers.filter( + (p) => p.capabilities.pull || p.capabilities.report, + ); + // Ascending priority: apply the winner last (see doc comment). + const ordered = [...active].sort( + (a, b) => a.priority - b.priority || a.name.localeCompare(b.name), + ); + + const results: ProviderResult[] = []; + for (const provider of ordered) { + try { + results.push(await provider.sync(context)); + } catch (e) { + const message = (e as Error).message; + log.debug(`[providers] "${provider.name}" sync failed (isolated): ${message}`); + results.push({ provider: provider.name, ok: false, changed: false, message }); + } + } + return results; +} diff --git a/src/providers/types.ts b/src/providers/types.ts index e67b68adb..c836cab2f 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -206,3 +206,144 @@ export class RepoCreatePermissionError extends Error { this.createUrl = createUrl; } } + +// ─── Resource delivery providers ───────────────────────── +// +// A higher-level abstraction than GitProvider (above). GitProvider adapts a +// *git host* (github/tgit/gitlab/…); a ResourceProvider adapts a *resource +// sync mechanism* — either `git` (clone a team repo, the existing behavior) or +// `http` (talk to an HTTP backend such as ClawPro). Multiple named providers +// can be mounted side by side, each syncing independently and isolated from the +// others' failures. +// +// ResourceProvider +// ├── git (wraps the existing team-repo pull; GitProvider is used inside) +// └── http (HttpResourceProvider → HttpBackendAdapter) +// └── clawpro adapter +// +// See docs/designs/management-backend.md §8 and issue #404. The ownership +// ledger, priority arbitration and cross-provider failover it describes are a +// later phase; `priority` is carried here so those phases need no type change, +// but nothing consumes it for arbitration yet. + +/** Whether a provider syncs via a git repo or an HTTP backend. */ +export type ResourceProviderType = 'git' | 'http'; + +/** + * What a provider can do. Callers gate work on these instead of assuming every + * provider implements git clone / push / command execution. + * - `pull`: delivers resources into the local tool directories. + * - `push`: can be a write target for `teamai push` (git main only; HTTP + * backends and cross-team git sources are read-only → false). + * - `report`: sends usage/telemetry to a backend on hook dispatch. + * - `commands`: executes commands the backend pushes back (install/uninstall/…). + */ +export interface ProviderCapabilities { + pull: boolean; + push: boolean; + report: boolean; + commands: boolean; +} + +/** Why and where a sync was triggered. */ +export interface SyncContext { + /** Working directory the sync runs for (workspace attribution). */ + cwd?: string; + /** Host tool that triggered the sync (e.g. 'claude', 'codebuddy'). */ + tool?: string; + /** What initiated this sync. */ + trigger: 'hook' | 'pull' | 'manual'; + /** Raw hook STDIN payload, when trigger === 'hook'. */ + stdin?: Record; + /** Bypass any pull TTL / freshness cache. */ + force?: boolean; +} + +/** Outcome of a single provider's sync. */ +export interface ProviderResult { + /** Provider name this result is for. */ + provider: string; + /** Whether the sync completed without error. */ + ok: boolean; + /** Whether the sync changed anything on disk. */ + changed: boolean; + /** Human-readable status or error, for logs / CLI output. */ + message?: string; + /** Text a protocol adapter asks the host to emit on its hook stdout. */ + hookOutput?: string; +} + +/** Static description of a provider, for `teamai provider list` and diagnostics. */ +export interface ProviderSummary { + name: string; + type: ResourceProviderType; + priority: number; + capabilities: ProviderCapabilities; + /** HTTP backend endpoint, when type === 'http'. */ + endpoint?: string; + /** HTTP protocol adapter name, when type === 'http'. */ + adapter?: string; +} + +/** + * A mounted resource backend. Named uniquely within a registry. `sync` is the + * one hot-path method; `describe`/`teardown` support listing and removal. + */ +export interface ResourceProvider { + readonly name: string; + readonly type: ResourceProviderType; + /** + * Ordering hint for later multi-provider arbitration (higher wins). Carried + * now so the arbitration phase needs no interface change; not yet consumed + * for same-name resource conflict resolution. + */ + readonly priority: number; + readonly capabilities: ProviderCapabilities; + + /** Deliver resources / report usage for this trigger. */ + sync(context: SyncContext): Promise; + /** Return a static summary for listing and diagnostics. */ + describe(): Promise; + /** Remove this provider's local state (credentials, manifests, caches). */ + teardown(): Promise; +} + +/** Logical route names an HTTP backend exposes, resolved to paths per adapter. */ +export interface HttpRoutes { + projects: string; + report: string; + sync: string; + ack: string; + getConfig: string; +} + +/** Persisted configuration for one named HTTP provider. */ +export interface HttpProviderConfig { + /** Unique provider name (also the state-directory segment). */ + name: string; + /** Protocol adapter that speaks this backend's wire format (e.g. 'clawpro'). */ + adapter: string; + /** Backend base URL. */ + endpoint: string; + /** Arbitration hint (see ResourceProvider.priority). */ + priority: number; +} + +/** + * Translates one HTTP backend's wire format to the common ResourceProvider + * shape. The HTTP provider owns transport concerns generically; the adapter + * owns only protocol differences (routes and payload/command shapes). + */ +export interface HttpBackendAdapter { + readonly name: string; + /** Route table for this backend, given its config (defaults + overrides). */ + routes(config: HttpProviderConfig): HttpRoutes; + /** One-time setup for a newly added provider (e.g. persist a token). */ + initialize?(config: HttpProviderConfig, token?: string): Promise; + /** Run report/sync/command execution for this provider. */ + sync(config: HttpProviderConfig, context: SyncContext): Promise; + /** Static summary for listing. */ + describe(config: HttpProviderConfig): Promise; + /** Remove this provider's local state. */ + teardown(config: HttpProviderConfig): Promise; +} From f98d2613415097272bda0cf5989a9abb16249b51 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 13:18:48 +0800 Subject: [PATCH 02/14] refactor(providers): relocate local-agent to clawpro adapter (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move src/local-agent.ts to src/providers/http/adapters/clawpro/client.ts unchanged (only the relative import prefixes are re-rooted one level deeper), and leave src/local-agent.ts as a 5-line @deprecated re-export so every existing importer — production and tests — keeps working with no edit. This is a pure relocation: no logic changes, and all 140 local-agent regression tests pass from the new location. The HTTP provider wrapper, named state directories and adapter wiring land in the next commit. --- src/local-agent.ts | 3510 +---------------- src/providers/http/adapters/clawpro/client.ts | 3506 ++++++++++++++++ 2 files changed, 3512 insertions(+), 3504 deletions(-) create mode 100644 src/providers/http/adapters/clawpro/client.ts diff --git a/src/local-agent.ts b/src/local-agent.ts index b185090c8..270ba8e91 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -1,3506 +1,8 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import fse from 'fs-extra'; -import { log } from './utils/logger.js'; -import { detachChild } from './utils/exec.js'; -import { parseFrontmatter } from './utils/frontmatter.js'; -import { - ensureDir, - listDirs, - listFilesRecursive, - pathExists, - readFileSafe, - readJson, - remove, - writeFile, - writeJson, - writeJsonAtomic, -} from './utils/fs.js'; -import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { RulesHandler, SkillsHandler } from './resources/index.js'; -import { injectHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, OPENCLAW_TOOLS } from './hooks.js'; -import { parseHookEvent } from './dashboard-collector.js'; -import { resolveHookCwd } from './utils/hook-cwd.js'; -import { isInteractive } from './utils/prompt.js'; -import { getAgentVersion } from './agent-version.js'; -import { getMachineId, deriveLocalAgentId } from './machine-id.js'; -import { EXCLUDED_RULE_NAMES } from './builtin-rules.js'; -import { ruleStemFromFilename } from './resources/rule-format.js'; -import { resolveTeamaiEntryScript } from './builtin-hooks.js'; -import { resolveOpenclawWorkspaceDir } from './openclaw-hooks.js'; -import { assertSafeResourceName } from './utils/path-safety.js'; -import { - detectMcpFormat, - supportsTransport, - renderJsonEntry, - renderCodexBlock, - entryHash, - MCP_SERVER_KEY, -} from './resources/mcp-format.js'; -import { - readJsonDoc, - writeJsonDoc, - writeCodexAtomic, - spliceCodexBlock, - codexServerNames, -} from './mcp-reconcile.js'; -import { normalizeAgentType } from './utils/tool-names.js'; -import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; -import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; -import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; -import { - resolveBaseDir, - resolveToolBaseDir, - scopedToolPaths, - applyToolRoots, - resolveToolRootDir, - CLAUDE_TOOL_ID, - DEFAULT_CLAUDE_ROOT, - COPILOT_TOOL_ID, - getTokenPath, - TEAMAI_CLAUDEMD_START, - TEAMAI_CLAUDEMD_END, - TeamaiConfigSchema, - managedMcpManifestPath, - managedMcpManifestKey, - managedMcpWorkspaceId, - type DashboardEvent, - type LocalConfig, - type ManagedMcpManifest, - type ManagedMcpRecord, - type McpServerDef, - type McpTransport, - type Scope, - type TeamaiConfig, -} from './types.js'; -import { getUserHome } from './utils/home.js'; -import { resolveAnchors } from './utils/git.js'; - -const execFileAsync = promisify(execFile); - -const LOCAL_AGENT_DIR = 'local-agent'; -const CONFIG_FILE = 'config.json'; -const MANIFEST_FILE = 'manifest.json'; -const MODEL_MANIFEST_FILE = 'model-manifest.json'; -const REPORTER_ERROR_LOG = 'reporter/errors.jsonl'; - /** - * Abort timeout for local-agent network calls. - * - * Prevents a fetch from hanging indefinitely when the endpoint is unreachable, - * which would otherwise keep a socket pending on the event loop and stall the - * hook subprocess until the host IDE's default hook timeout fires. + * @deprecated Moved to the ClawPro HTTP provider adapter as part of the + * Git/HTTP ResourceProvider unification (issue #404). The implementation now + * lives in `providers/http/adapters/clawpro/client.ts`; this module re-exports + * it so existing importers keep working. New code should depend on the provider + * abstraction (`providers/http`) rather than importing this path directly. */ -const LOCAL_AGENT_FETCH_TIMEOUT_MS = 15_000; - -/** - * Per-fetch timeout to use while running inside a *foreground* hook. Foreground - * hooks block the host IDE and must finish under its per-event hook timeout - * (UserPromptSubmit/PostToolUse = 10s). Kept under 5s — and safely below the - * foreground handler's dispatch budget (LOCAL_AGENT_FG_TIMEOUT_MS = 4.5s) — so a - * slow/unreachable endpoint fails fast and the whole handler returns before the - * host aborts it. Healthy endpoints answer in well under a second, so this is - * invisible in normal use and never degrades the experience. - */ -const LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS = 3_000; - -/** Active per-fetch timeout; overridden to the hook value inside foreground hooks. */ -let activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; - -type LocalAgentScope = 'instance' | 'user' | 'project'; -type ResourceKind = 'skills' | 'rules' | 'claudemd'; -type CommandResourceKind = 'skill' | 'rule' | 'claudemd'; - -// Command types recognized but not yet implemented by this reporter. Skipped -// silently (see isUnimplementedCommand) so the suffix logic in commandKind() -// cannot misfire (e.g. uninstall_hook_rule ends in _rule and would otherwise be -// treated as a destructive rule uninstall). uninstall_teamai is NOT here — it -// carries a `cmd` and is executed by runCmdCommand (see executeCommand), so the -// local agent actually uninstalls itself and acks. -// install_hook_rule / uninstall_hook_rule are now implemented (see runHookRuleCommand) and are NOT skipped. -const UNIMPLEMENTED_COMMAND_TYPES = new Set([]); - -/** Hook commands this reporter implements (see runHookRuleCommand). Excluded from - * the handle_type==='hook' skip so they dispatch instead of being silently dropped. */ -const IMPLEMENTED_HOOK_COMMAND_TYPES = new Set(['install_hook_rule', 'uninstall_hook_rule']); - -interface WorkspaceBinding { - projectId: number; - projectName?: string; - boundAt: string; - /** Normalized owning tool (via normalizeAgentType). Optional for back-compat with existing config.json; - * absent means "not yet attributed". */ - ideType?: string; -} - -export interface LocalAgentConfig { - endpoint: string; - token?: string; - /** - * @deprecated No longer the id source. local_agent_id is now derived at - * runtime per detected tool via resolveLocalAgentId(). Kept optional so - * older config.json files still load without a rewrite. - */ - localAgentId?: string; - createdAt: string; - userGroupId?: number; - userGroupName?: string; - workspaceBindings: Record; - /** - * Optional per-endpoint path overrides. Maps a logical route name to a custom - * path so a backend that does not use the default `/api/local-agent/*` layout - * can be pointed at its own routes. Unspecified routes fall back to DEFAULT_ROUTES. - * Example: { "getConfig": "/api/plugins/config", "sync": "/v2/agent/sync" } - */ - routes?: Partial>; -} - -/** - * Logical names for every backend endpoint the local agent talks to, mapped to - * their default paths. A deployment can override any of these via config.routes - * (see LocalAgentConfig.routes) without touching call sites. - */ -export const DEFAULT_ROUTES = { - projects: '/api/projects/mine', - report: '/api/local-agent/report', - sync: '/api/local-agent/sync', - ack: '/api/local-agent/commands/ack', - getConfig: '/api/local-agent/get-config', -} as const; - -export type RouteName = keyof typeof DEFAULT_ROUTES; - -interface LocalAgentProject { - id: number; - name: string; - description?: string; -} - -interface ManifestResource { - slug: string; - version?: string; - display_name?: string; - source?: string; - installed_at: string; - /** - * Actual on-disk directory name for skills. Equals the SKILL.md `name:` when - * it differs from the server slug, else the slug. Used at uninstall time to - * locate the directory by slug (the manifest key stays the slug). - */ - dir_name?: string; -} - -interface ManifestScope { - skills: Record; - rules: Record; - claudemd: Record; -} - -interface LocalAgentManifest { - scopes: Record; -} - -interface LocalAgentCommand { - id: number; - type?: string; - scope?: string; - workspace_path?: string; - download_url?: string; - skill_slug?: string; - skill_version?: string; - rule_slug?: string; - rule_version?: string; - rule_type?: string; - handle_type?: string; - claudemd_slug?: string; - claudemd_version?: string; - resource_slug?: string; - resource_version?: string; - slug?: string; - name?: string; - version?: string; - display_name?: string; - cmd?: string; - event?: string; - matcher?: string; - timeout?: number; - mcp_config?: { - transport: string; - url?: string; - headers?: Record; - command?: string; - args?: string[]; - env?: Record; - timeout?: number; - requires?: string[]; - }; -} - -interface DeliveredModel { - provider: string; - model_id: string; - name: string; - base_url: string; - api_key: string; - max_tokens?: number; - context_window?: number; -} - -interface BuddyModelManifest { - codebuddy?: Record; - workbuddy?: Record; - providersByAgent?: Record>; -} - -interface ModelConfigManifest extends BuddyModelManifest { - claudeEnv?: Record; - /** - * model_id → provider for every model this reporter has applied. Claude - * stores its gateway as plain ANTHROPIC_* env vars that carry no provider, - * so this is the only way to report back the provider the server sent. - */ - providers?: Record; - workspaceModels?: Record; -} - -type ModelAgentKind = 'codebuddy' | 'workbuddy' | 'claude'; -type BuddyAgentKind = 'codebuddy' | 'workbuddy'; - -function modelAgentKind(tool: string | undefined): ModelAgentKind | undefined { - const normalized = normalizeAgentType(tool ?? ''); - if (normalized === 'codebuddy' || normalized === 'codebuddy-internal') return 'codebuddy'; - if (normalized === 'workbuddy') return 'workbuddy'; - if (normalized === 'claude') return 'claude'; - return undefined; -} - -/** - * Whether a sync command is recognized-but-unimplemented and must be skipped - * before dispatch. Matches both the known unimplemented type strings and any - * hook command (handle_type === 'hook'), so a future hook `type` outside - * UNIMPLEMENTED_COMMAND_TYPES still skips silently instead of falling through - * to commandKind() and being acked as a failure. - */ -function isUnimplementedCommand(command: LocalAgentCommand): boolean { - const type = command.type ?? ''; - if (IMPLEMENTED_HOOK_COMMAND_TYPES.has(type)) return false; - return UNIMPLEMENTED_COMMAND_TYPES.has(type) || command.handle_type === 'hook'; -} - -interface LocalAgentContext { - cwd?: string; - tool?: string; - status?: string; - event?: DashboardEvent; -} - -function getTeamaiHomePath(): string { - return path.join(getUserHome(), '.teamai'); -} - -function getLocalAgentHome(): string { - return path.join(getTeamaiHomePath(), LOCAL_AGENT_DIR); -} - -function getConfigPath(): string { - return path.join(getLocalAgentHome(), CONFIG_FILE); -} - -function getManifestPath(): string { - return path.join(getLocalAgentHome(), MANIFEST_FILE); -} - -function getModelManifestPath(): string { - return path.join(getLocalAgentHome(), MODEL_MANIFEST_FILE); -} - -function getErrorLogPath(): string { - return path.join(getTeamaiHomePath(), REPORTER_ERROR_LOG); -} - -function compileClaudemdBlock(contents: string[]): string | null { - const parts = contents.map((content) => content.trim()).filter(Boolean); - if (parts.length === 0) return null; - return [ - TEAMAI_CLAUDEMD_START, - '', - '', - parts.join('\n\n'), - '', - TEAMAI_CLAUDEMD_END, - ].join('\n'); -} - -function normalizeEndpoint(endpoint: string): string { - return endpoint.trim().replace(/\/+$/, ''); -} - -/** Normalize a route override so it is a leading-slash path (endpoint has no trailing slash). */ -function normalizeRoute(route: string): string { - const trimmed = route.trim(); - return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; -} - -/** - * Resolve a logical route name to its path, applying config.routes overrides - * over DEFAULT_ROUTES. A blank/whitespace override is ignored (falls back to default). - */ -export function resolveRoute(config: Pick, name: RouteName): string { - const override = config.routes?.[name]; - if (override && override.trim()) return normalizeRoute(override); - return DEFAULT_ROUTES[name]; -} - -/** - * Resolve the per-tool install directory that seeds the local_agent_id hash. - * - * This must match the historical status-report口径 — `~/.` — so that a - * machine upgrading from the status-report era keeps the same id instead of - * drifting. It is derived from the same toolPaths map buildReportPayload uses: - * `~/` (e.g. `.codebuddy/skills` → `~/.codebuddy`). Unknown - * tools fall back to `~/.`, still deterministic and distinct per tool. - * Note: install_path only feeds the local hash — it never leaves the machine. - */ -function resolveAgentInstallPath(agentType: string): string { - const home = getUserHome(); - const skillsRel = createLocalAgentTeamConfig('').toolPaths[agentType]?.skills; - const rel = skillsRel ? path.dirname(skillsRel) : `.${agentType}`; - return path.join(home, rel); -} - -/** - * Resolve the local_agent_id for the current invocation. - * - * Deterministic per (detected tool + machine + install dir) — same tool on the - * same machine always yields the same id, so the backend sees a stable agent - * instead of a fresh random one every hook fire. The tool is auto-detected from - * the hook's --tool flag (context.tool); different tools (claude / codebuddy / - * workbuddy) get different ids because agent_type AND the per-tool install dir - * (~/.) both feed the hash. install_path uses the tool's own dir (not the - * teamai home) to stay byte-for-byte identical to the historical status-report - * derivation, avoiding an id change on upgrade. TEAMAI_LOCAL_AGENT_ID still - * overrides for explicit pinning. - */ -function resolveLocalAgentId(context: LocalAgentContext): string { - const envOverride = process.env.TEAMAI_LOCAL_AGENT_ID; - if (envOverride) return envOverride; - const agentType = context.tool ?? 'workbuddy'; - return deriveLocalAgentId(agentType, getMachineId(), resolveAgentInstallPath(agentType)); -} - -/** - * Detect whether we are running inside a CloudStudio container sandbox. - * - * WorkBuddy can spawn a CloudStudio Linux container that runs its own teamai - * hooks. That container has a different machine_id than the macOS host, so it - * derives a second local_agent_id and reports a duplicate agent card. Both - * signals below are absent on a normal Linux user machine, so this never - * suppresses reporting for legitimate standalone Linux users. - */ -function isCloudStudioSandbox(): boolean { - if (process.env.X_IDE_IS_CLOUDSTUDIO === 'TRUE') return true; - try { - return fs.existsSync('/var/run/cloudstudio'); - } catch { - return false; - } -} - -/** - * Build the unified log tag for local-agent debug output: `[] []` — - * the last 6 chars of the derived agent id plus the agent name (tool), so every - * line (HTTP request/response, report/sync, command ack) reads the same way. - */ -function localAgentTag(context: LocalAgentContext): string { - const tool = context.tool ?? 'workbuddy'; - return `[${resolveLocalAgentId(context).slice(-6)}] [${tool}]`; -} - -function scopeKey(scope: LocalAgentScope, workspacePath?: string): string { - return scope === 'project' ? `project:${workspacePath ?? ''}` : scope; -} - -function emptyManifestScope(): ManifestScope { - return { skills: {}, rules: {}, claudemd: {} }; -} - -async function loadManifest(): Promise { - const manifest = await readJson(getManifestPath()); - return manifest ?? { scopes: {} }; -} - -async function saveManifest(manifest: LocalAgentManifest): Promise { - await writeJson(getManifestPath(), manifest); -} - -/** One HTTP-source agent hook recorded locally so teardown can find & remove it - * across all formats (codex has no in-file marker, so its command is stored). */ -interface AgentHookRecord { - tool: string; - event: string; - command: string; - matcher?: string; - timeout?: number; -} - -/** slug → record. Kept separate from the resource manifest and from the team - * managed-hooks.json so a team pull never treats agent hooks as stale. */ -type AgentHookManifest = Record; - -function getAgentHookManifestPath(): string { - return path.join(getLocalAgentHome(), 'agent-hooks.json'); -} - -async function loadAgentHookManifest(): Promise { - const data = await readJson(getAgentHookManifestPath()); - return data && typeof data === 'object' ? data : {}; -} - -async function saveAgentHookManifest(manifest: AgentHookManifest): Promise { - await writeJsonAtomic(getAgentHookManifestPath(), manifest); -} - -/** - * The member's per-machine tool roots, from the teamai config that governs this - * directory: the project one when there is one, else the user-scope one. - * - * The local agent carries no LocalConfig — it addresses tool roots under $HOME - * directly — but it writes the same files `teamai pull` does, so a root the - * member relocated (CLAUDE_CONFIG_DIR, recorded by `teamai init`) has to reach - * them too. No config, or no entry, leaves the paths exactly as they were. - */ -async function memberToolRoots(workspacePath?: string): Promise | undefined> { - const { resolveMemberToolRoots } = await import('./config.js'); - return resolveMemberToolRoots(workspacePath ?? process.cwd()); -} - -/** Claude Code's user root on this machine, honoring a relocated CLAUDE_CONFIG_DIR. */ -async function claudeUserRoot(): Promise { - return resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await memberToolRoots()); -} - -/** Resolve the current tool's settings file absolute path (user scope, $HOME base). */ -async function resolveToolSettingsPath(config: LocalAgentConfig, tool: string): Promise { - const teamConfig = createLocalAgentTeamConfig(config.endpoint); - const toolPath = applyToolRoots(teamConfig.toolPaths, await memberToolRoots())[tool]; - if (!toolPath?.settings) { - throw new Error(`unsupported tool: ${tool} (no settings path)`); - } - return path.join(getUserHome(), toolPath.settings); -} - -function getPluginStatePath(): string { - return path.join(getLocalAgentHome(), 'plugins.json'); -} - -async function readPluginState(): Promise> { - return (await readJson>(getPluginStatePath())) ?? {}; -} - -/** - * Atomically mutate the plugin-state file under an exclusive lock. If the lock - * cannot be acquired within the timeout, throws (the caller skips this cycle - * rather than writing without the lock — reconcile is throttled, so skipping is safe). - */ -async function withPluginStateLock(mutate: (m: Record) => void): Promise { - const statePath = getPluginStatePath(); - const lockPath = `${statePath}.lock`; - await ensureDir(path.dirname(lockPath)); - const deadline = Date.now() + 5000; - let acquired = false; - while (Date.now() <= deadline) { - try { const fd = await fs.promises.open(lockPath, 'wx'); await fd.close(); acquired = true; break; } - catch (e) { - if ((e as { code?: string }).code !== 'EEXIST') throw e; - try { - const st = await fs.promises.stat(lockPath); - if (Date.now() - st.mtimeMs > 30_000) { await fs.promises.rm(lockPath, { force: true }); continue; } - } catch { /* lock vanished */ } - await new Promise((r) => setTimeout(r, 50)); - } - } - if (!acquired) throw new Error('could not acquire plugin-state lock'); - try { - const m = await readPluginState(); - mutate(m); - await writeJson(statePath, m); - } finally { - await fs.promises.rm(lockPath, { force: true }); - } -} - -function getManifestScope( - manifest: LocalAgentManifest, - scope: LocalAgentScope, - workspacePath?: string, -): ManifestScope { - const key = scopeKey(scope, workspacePath); - manifest.scopes[key] ??= emptyManifestScope(); - return manifest.scopes[key]; -} - -/** - * Canonicalize a workspace path to its physical on-disk form via realpath. - * On case-insensitive filesystems (macOS) this collapses casing variants of the - * same physical directory to one identity; it also resolves symlinks. Falls back - * to the resolved absolute path when the target does not exist (dead binding) or - * realpath fails for any other reason. - */ -async function canonicalizeWorkspacePath(value: string): Promise { - const absolute = path.resolve(value); - try { - return await fs.promises.realpath(absolute); - } catch { - return absolute; - } -} - -function mergeWorkspaceBindings( - existing: WorkspaceBinding | undefined, - incoming: WorkspaceBinding, - canonicalKey: string, -): WorkspaceBinding { - if (!existing) return incoming; - // pick base = whichever has a non-zero projectId; prefer existing on tie - const existingReal = existing.projectId !== 0; - const incomingReal = incoming.projectId !== 0; - if (existingReal && incomingReal && existing.projectId !== incoming.projectId) { - log.warn( - `local-agent: workspace ${canonicalKey} has conflicting project bindings ` + - `(${existing.projectId} vs ${incoming.projectId}); keeping ${existing.projectId}`, - ); - } - const base = existingReal || !incomingReal ? { ...existing } : { ...incoming }; - // A physical workspace tracks one owning tool (same single-owner model as - // stampWorkspaceTool). Only backfill ideType when the base lacks one; if two - // aliases were stamped by different tools, the base's ideType wins — the - // other tool re-stamps itself on its next report from the canonical path. - if (!base.ideType) base.ideType = existing.ideType ?? incoming.ideType; - return base; -} - -export async function loadLocalAgentConfig(): Promise { - const fileConfig = await readJson(getConfigPath()); - if (fileConfig?.endpoint) { - const config = { - ...fileConfig, - endpoint: normalizeEndpoint(fileConfig.endpoint), - workspaceBindings: fileConfig.workspaceBindings ?? {}, - }; - // Migrate: clear legacy group-based bindings (groupId without projectId) - const removedLegacyPaths: string[] = []; - for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { - if ('groupId' in binding && !('projectId' in (binding as Record))) { - delete config.workspaceBindings[wsPath]; - removedLegacyPaths.push(wsPath); - } - } - if (removedLegacyPaths.length > 0) { - log.warn( - `Removed ${removedLegacyPaths.length} legacy group-based workspace binding(s); ` + - `you will be prompted to re-bind on the next session.`, - ); - try { - await saveLocalAgentConfig(config); - } catch (e) { - log.debug(`local-agent: failed to persist binding cleanup: ${(e as Error).message}`); - } - } - // Migrate: canonicalize binding keys to their physical on-disk path so - // case-only / symlink aliases of the same workspace collapse to one entry. - const migrated: Record = {}; - let migrationChanged = false; - for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { - const canonicalKey = await canonicalizeWorkspacePath(wsPath); - if (canonicalKey !== wsPath) migrationChanged = true; - if (migrated[canonicalKey]) migrationChanged = true; - migrated[canonicalKey] = mergeWorkspaceBindings(migrated[canonicalKey], binding, canonicalKey); - } - config.workspaceBindings = migrated; - if (migrationChanged) { - await saveLocalAgentConfig(config); - } - return config; - } - - // Backfill: if config.json is missing but a legacy ~/.teamai/config.yaml has - // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. - const { loadLocalConfig } = await import('./config.js'); - const { resolveApiKey } = await import('./api-key.js'); - const legacy = await loadLocalConfig(); - if (legacy?.repo?.kind === 'http' && legacy.repo.url) { - const endpoint = normalizeEndpoint(legacy.repo.url); - const token = resolveApiKey() ?? undefined; - const backfilled: LocalAgentConfig = { - endpoint, - token, - createdAt: new Date().toISOString(), - workspaceBindings: {}, - }; - try { - await saveLocalAgentConfig(backfilled); - log.debug('local-agent: backfilled config.json from legacy ~/.teamai/config.yaml (http repo)'); - } catch (e) { - log.debug(`local-agent: backfill persist failed, using in-memory config: ${(e as Error).message}`); - } - return backfilled; - } - - const envEndpoint = - process.env.TEAMAI_HTTP_ENDPOINT ?? - process.env.TEAMAI_ENDPOINT ?? - process.env.TEAMAI_API_BASE_URL; - if (!envEndpoint) return null; - - return { - endpoint: normalizeEndpoint(envEndpoint), - token: process.env.TEAMAI_API_TOKEN ?? process.env.TEAMAI_TOKEN, - createdAt: new Date().toISOString(), - workspaceBindings: {}, - }; -} - -async function saveLocalAgentConfig(config: LocalAgentConfig): Promise { - await writeJsonAtomic(getConfigPath(), { - ...config, - endpoint: normalizeEndpoint(config.endpoint), - workspaceBindings: config.workspaceBindings ?? {}, - }); -} - -function createLocalAgentTeamConfig(endpoint: string): TeamaiConfig { - return TeamaiConfigSchema.parse({ - team: 'local-agent', - repo: endpoint, - description: 'HTTP local agent resource cache', - }); -} - -async function createResourceLocalConfig( - config: LocalAgentConfig, - scope: LocalAgentScope, - repoPath: string, - workspacePath?: string, -): Promise { - const projectScope = scope === 'project'; - return { - repo: { localPath: repoPath, remote: config.endpoint }, - username: os.userInfo().username, - scope: projectScope ? 'project' : 'user', - projectRoot: projectScope ? workspacePath : undefined, - additionalRoles: [], - // User-scope paths resolve under $HOME here, so a tool the member relocated - // must be addressed at its recorded root — the same one `teamai pull` uses. - ...(projectScope ? {} : { toolRoots: await memberToolRoots(workspacePath) }), - }; -} - -async function getResourceRepoPath(scope: LocalAgentScope, workspacePath?: string): Promise { - if (scope === 'project' && workspacePath) { - // Project resource cache is A1 (per-project) AND per-worktree: the resource - // cache (claudemd/skills/rules fragments) is what each worktree installs - // independently, and syncClaudemd merges EVERY file in this dir. The partition - // data home is shared by all linked worktrees, so the cache must live in a - // per-worktree subdir — otherwise worktree B's CLAUDE.md would merge in - // worktree A's instructions. Mirror managed-mcp's per-worktree layout. - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope('project', workspacePath); - return path.join(dataHome, 'workspaces', managedMcpWorkspaceId(workspacePath), LOCAL_AGENT_DIR, 'resources'); - } - return path.join(getLocalAgentHome(), 'resources', scope); -} - -async function ensureProjectGitignore(workspacePath: string): Promise { - const teamaiDir = path.join(workspacePath, '.teamai'); - await ensureDir(teamaiDir); - const gitignorePath = path.join(teamaiDir, '.gitignore'); - const existing = await readFileSafe(gitignorePath); - if (!existing) { - await writeFile(gitignorePath, ['# teamai local state', 'local-agent/', ''].join('\n')); - return; - } - if (!existing.split('\n').some((line) => line.trim() === 'local-agent/')) { - await writeFile(gitignorePath, existing.trimEnd() + '\nlocal-agent/\n'); - } -} - -function authHeaders(config: LocalAgentConfig, json = true): Record { - const headers: Record = {}; - if (json) headers['Content-Type'] = 'application/json'; - if (config.token) { - headers.Authorization = `Bearer ${config.token}`; - headers['X-API-Token'] = config.token; - } - return headers; -} - -async function localAgentFetch( - config: LocalAgentConfig, - tag: string, - route: RouteName, - init?: RequestInit, - opts?: { redactResponseLog?: boolean }, -): Promise { - const method = init?.method ?? 'GET'; - const url = `${config.endpoint}${resolveRoute(config, route)}`; - const headers: Record = { - ...authHeaders(config, init?.body !== undefined), - ...((init?.headers as Record | undefined) ?? {}), - }; - logHttpRequest(tag, method, url, headers, init?.body); - - const response = await fetch(url, { - ...init, - headers, - signal: init?.signal ?? AbortSignal.timeout(activeFetchTimeoutMs), - }); - const text = await response.text(); - let body: unknown = null; - if (text.trim()) { - try { - body = JSON.parse(text); - } catch { - body = text; - } - } - logHttpResponse(tag, method, url, response.status, response.statusText, opts?.redactResponseLog ? '' : body); - if (!response.ok) { - const message = typeof body === 'object' && body && 'error' in body - ? String((body as { error: unknown }).error) - : text || `${response.status} ${response.statusText}`; - throw new Error(message); - } - return body as T; -} - -async function appendErrorLog(entry: unknown): Promise { - try { - await ensureDir(path.dirname(getErrorLogPath())); - await fs.promises.appendFile( - getErrorLogPath(), - JSON.stringify({ at: new Date().toISOString(), entry }) + '\n', - 'utf-8', - ); - } catch { - // Best-effort; hook execution must not fail on I/O. - } -} - -export async function fetchUserProjects(config: LocalAgentConfig): Promise { - const response = await localAgentFetch<{ ok?: boolean; projects?: LocalAgentProject[] }>( - config, - localAgentTag({}), - 'projects', - { method: 'GET' }, - ); - return response.projects ?? []; -} - -/** Mask secret values (CLI flags / key=value / bearer tokens) so they don't reach logs. */ -function redactSecrets(s: string): string { - // Secret-bearing identifiers, matched case-insensitively in flag and key=value forms. - const names = 'secret[_-]?(?:key|id)|api[_-]?key|access[_-]?token|token|password|passwd|pwd'; - return s - .replace(new RegExp(`(--(?:${names})[= ]+)\\S+`, 'gi'), '$1***') - .replace(new RegExp(`((?:${names})"?\\s*[:=]\\s*"?)[^"\\s,}]+`, 'gi'), '$1***') - .replace(/(bearer\s+)[\w.\-]+/gi, '$1***'); -} - -/** - * Execute a shell command string with a timeout. - * - * Completion is gated on the process 'exit' event, NOT 'close': a setup command that - * daemonizes and leaves the inherited stderr pipe open in a background process would never - * emit 'close', producing a false timeout even though the command itself finished. - * Rejects on non-zero exit, termination by signal, or timeout. - */ -export async function execPluginCommand(cmd: string, timeoutMs: number): Promise { - const { spawn } = await import('node:child_process'); - await new Promise((resolve, reject) => { - const child = process.platform === 'win32' - ? spawn('cmd', ['/c', cmd], { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) - : spawn('bash', ['-lc', cmd], { stdio: ['ignore', 'ignore', 'pipe'] }); - let stderr = ''; - let settled = false; - let timer: ReturnType; - child.stderr?.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(-8192); }); - const finish = (fn: () => void): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - detachChild(child); - fn(); - }; - timer = setTimeout(() => { - child.kill('SIGKILL'); - finish(() => reject(new Error(`command timed out after ${timeoutMs}ms`))); - }, timeoutMs); - child.on('error', (e) => finish(() => reject(e))); - child.on('exit', (code, signal) => - finish(() => { - if (signal) return reject(new Error(`command killed by ${signal}`)); - if (code === 0) return resolve(); - const tail = stderr ? ' :: ' + redactSecrets(stderr.slice(0, 200).trim()) : ''; - reject(new Error(`command failed (exit ${code})${tail}`)); - }), - ); - }); -} - -/** - * Fetch backend plugin config. - * Route = 'getConfig' (default path /api/local-agent/get-config, overridable via config.routes). - * localAgentFetch builds `url = config.endpoint + resolveRoute(...)`, matching report/sync pattern. - */ -async function fetchPluginConfig(config: LocalAgentConfig, tag: string): Promise { - return localAgentFetch(config, tag, 'getConfig', { method: 'GET' }, { redactResponseLog: true }); -} - -const PLUGIN_PULL_INTERVAL_MS = 12 * 60 * 60 * 1000; -const PLUGIN_FAIL_BACKOFF_MS = 60 * 60 * 1000; - -function getPluginPullStatePath(): string { - return path.join(getLocalAgentHome(), 'plugin-pull.json'); -} - -function buildReconcileDeps(config: LocalAgentConfig, tag: string): ReconcileDeps { - return { - readPlugins: () => readPluginState(), - mutatePlugins: (fn) => withPluginStateLock(fn), - execCommand: (cmd, t) => execPluginCommand(cmd, t), - now: () => Date.now(), - log: { - debug: (msg) => log.debug(`${tag} ${msg}`), - // The reconcile worker runs detached (stdio: 'ignore'), so console-only log.warn - // output is discarded. Mirror warnings to debug.log so failures are traceable. - warn: (msg) => { - log.warn(`${tag} ${msg}`); - log.debug(`${tag} WARN: ${msg}`); - }, - }, - }; -} - -/** On session start, throttle-check and spawn a detached worker for plugin reconcile. Never blocks. */ -async function maybeReconcilePlugins(context: LocalAgentContext): Promise { - try { - const state = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(getPluginPullStatePath())) ?? {}; - const now = Date.now(); - if (state.lastPullAt && now - state.lastPullAt < PLUGIN_PULL_INTERVAL_MS) return; - if (state.lastFailAt && now - state.lastFailAt < PLUGIN_FAIL_BACKOFF_MS) return; - const tool = context.tool ?? 'workbuddy'; - const localAgentId = `${tool}-${resolveLocalAgentId(context)}`; - const { spawn } = await import('node:child_process'); - if (!process.argv[1]) { log.debug('[local-agent] plugin reconcile: no CLI entrypoint (argv[1]), skipping'); return; } - const child = spawn(process.execPath, [process.argv[1], 'source', 'reconcile-plugins'], - { detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env, TEAMAI_PLUGIN_LOCAL_AGENT_ID: localAgentId } }); - child.unref(); - } catch (e) { log.debug(`[local-agent] plugin reconcile spawn skipped: ${(e as Error).message}`); } -} - -/** Detached worker: pull get-config and reconcile plugins once, guarded by a reconcile lock. */ -export async function runPluginReconcileWorker(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return; - const lockPath = path.join(getLocalAgentHome(), 'plugin-reconcile.lock'); - await ensureDir(path.dirname(lockPath)); - let acquired = false; - try { - try { - const fd = await fs.promises.open(lockPath, 'wx'); - await fd.close(); - acquired = true; - } catch (e) { - if ((e as { code?: string }).code !== 'EEXIST') throw e; - try { - const st = await fs.promises.stat(lockPath); - if (Date.now() - st.mtimeMs > 30 * 60 * 1000) { - await fs.promises.rm(lockPath, { force: true }); - const fd = await fs.promises.open(lockPath, 'wx'); - await fd.close(); - acquired = true; - } - } catch { /* ignore */ } - if (!acquired) return; - } - const tag = '[local-agent] [plugin-reconcile]'; - const statePath = getPluginPullStatePath(); - try { - const resp = await fetchPluginConfig(config, tag); - const { vars, plugins } = parseGetConfig(resp); - const declaredSlugs = plugins.length ? ` [${plugins.map((p) => p.slug).join(', ')}]` : ''; - log.debug(`${tag} get-config: ${plugins.length} plugin(s) declared${declaredSlugs}`); - const laid = process.env.TEAMAI_PLUGIN_LOCAL_AGENT_ID; - if (!laid) log.debug(tag + ' no local_agent_id in env; plugins needing it will be skipped'); - const allVars = { ...vars, ...(laid ? { local_agent_id: laid } : {}) }; - const resolved: typeof plugins = []; - for (const p of plugins) { - const rp = { - ...p, - installCmd: substituteVars(p.installCmd, allVars), - updateCmd: p.updateCmd ? substituteVars(p.updateCmd, allVars) : undefined, - uninstallCmd: substituteVars(p.uninstallCmd, allVars), - runCmd: substituteVars(p.runCmd, allVars), - }; - const missing = [...new Set([ - ...unresolvedPlaceholders(rp.installCmd), - ...unresolvedPlaceholders(rp.runCmd), - ...unresolvedPlaceholders(rp.uninstallCmd), - ...(rp.updateCmd ? unresolvedPlaceholders(rp.updateCmd) : []), - ])]; - if (missing.length) { - log.warn(`${tag} plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); - log.debug(`${tag} WARN: plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); - continue; - } - resolved.push(rp); - } - await reconcilePlugins(resolved, buildReconcileDeps(config, tag)); - log.debug(`${tag} reconcile complete (${resolved.length} plugin(s) processed)`); - await writeJson(statePath, { lastPullAt: Date.now() }); - } catch (e) { - const prev = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(statePath)) ?? {}; - await writeJson(statePath, { ...prev, lastFailAt: Date.now() }); - log.debug(`${tag} reconcile failed: ${(e as Error).message}`); - } - } finally { - if (acquired) await fs.promises.rm(lockPath, { force: true }); - } -} - -async function askViaTty(prompt: string): Promise { - // Only prompt on a real interactive terminal (e.g. the user running - // `teamai bind-project` directly). In non-interactive contexts such as an - // IDE-invoked hook, stdin is piped; opening /dev/tty there succeeds when the - // host GUI keeps a controlling terminal, and readline then blocks forever - // waiting for input that never comes — hanging the hook until the host's - // timeout and stalling the IDE. Callers fall back to injecting a stdout - // binding hint when this returns null, so degrade to that instead. - // The decline stays synchronous — this runs on the hook path, where loading - // the prompt module only to say no is work nobody asked for. - if (!isInteractive()) return null; - const { askQuestion } = await import('./utils/prompt.js'); - return askQuestion(prompt, ''); -} - -async function promptForProjectBinding( - workspacePath: string, - projects: LocalAgentProject[], -): Promise { - if (projects.length === 0) return null; - - log.debug(`local-agent: workspace not bound: ${workspacePath}`); - const answer = await askViaTty('是否绑定到一个项目?[y/N] '); - if (!answer || answer.toLowerCase() !== 'y') return null; - - if (projects.length === 1) return projects[0]; - - log.info('可用项目:'); - projects.forEach((project, index) => { - const desc = project.description ? ` - ${project.description}` : ''; - log.info(` ${index + 1}. ${project.name}${desc} [id=${project.id}]`); - }); - - const selection = await askViaTty(`选择项目编号(1-${projects.length},0 跳过): `); - if (selection === null || selection === '0') return null; - const index = selection ? Number.parseInt(selection, 10) : 0; - if (Number.isNaN(index) || index < 1 || index > projects.length) return null; - return projects[index - 1]; -} - -/** - * Persist a ClawPro binding decision for the current checkout. - * - * Binding is a per-project decision, so it is recorded on the `projectAnchor` - * (the main checkout, shared by a repo and all of its git worktrees — issue - * #374 / #387). It is ALSO stamped on the current `workspaceRoot` so this - * checkout is reported with the project_id immediately and its resources land - * in the current worktree (#387's workspaceRoot model — every AI tool discovers - * resources by scanning up from the launch dir, never via git-common-dir). For a - * plain repo the two anchors coincide and this writes a single entry. Falls back - * to `resolvedPath` when `cwd` is not inside a git repo. - * - * Existing fields (e.g. a stamped `ideType`) on any touched entry are preserved. - */ -async function persistWorkspaceBinding( - config: LocalAgentConfig, - cwd: string | undefined, - resolvedPath: string, - projectId: number, - projectName: string, -): Promise { - const anchors = await resolveAnchors(cwd); - const keys = new Set([resolvedPath]); - if (anchors) { - keys.add(anchors.projectAnchor); - keys.add(anchors.workspaceRoot); - } - const boundAt = new Date().toISOString(); - for (const key of keys) { - config.workspaceBindings[key] = { - ...(config.workspaceBindings[key] ?? {}), - projectId, - projectName, - boundAt, - }; - } - await saveLocalAgentConfig(config); -} - -/** - * If the current checkout is an unbound git worktree whose main checkout - * (`projectAnchor`) is already bound or skipped, copy that decision onto the - * current `workspaceRoot` and report success — so a repo is never re-prompted - * for binding once per new worktree (a `--skip` on the main checkout silences - * all of them too). Returns true when the worktree inherited a binding. - */ -async function inheritWorktreeBinding( - config: LocalAgentConfig, - cwd: string | undefined, - resolvedPath: string, -): Promise { - const anchors = await resolveAnchors(cwd); - if (!anchors || anchors.projectAnchor === anchors.workspaceRoot) return false; - const anchorBinding = config.workspaceBindings[anchors.projectAnchor]; - if (!anchorBinding) return false; - config.workspaceBindings[resolvedPath] = { - ...(config.workspaceBindings[resolvedPath] ?? {}), - projectId: anchorBinding.projectId, - projectName: anchorBinding.projectName, - boundAt: new Date().toISOString(), - }; - await saveLocalAgentConfig(config); - return true; -} - -export async function bindWorkspaceToProject( - workspacePath: string, - projectId?: number, -): Promise { - const config = await loadLocalAgentConfig(); - if (!config) { - throw new Error('HTTP local agent is not initialized. Run `teamai init --http --token ` first.'); - } - - const projects = await fetchUserProjects(config); - const project = projectId - ? projects.find((item) => item.id === projectId) - : await promptForProjectBinding(workspacePath, projects); - if (!project) return null; - - const binding: WorkspaceBinding = { - projectId: project.id, - projectName: project.name, - boundAt: new Date().toISOString(), - }; - // Record on the projectAnchor (shared across the repo's worktrees) and the - // current workspaceRoot; workspacePath is already the resolved checkout root. - await persistWorkspaceBinding(config, workspacePath, workspacePath, project.id, project.name); - log.success(`已将工作区绑定到项目:${project.name} [id=${project.id}]`); - return binding; -} - -async function ensureWorkspaceBinding( - config: LocalAgentConfig, - workspacePath: string, - sessionId?: string, - cwd?: string, -): Promise { - if (config.workspaceBindings[workspacePath]) return; - // A worktree inherits its main checkout's binding/skip decision — never prompt. - if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; - - const markerKey = sessionId || `ppid-${process.ppid}`; - const hintMarker = path.join(os.tmpdir(), `teamai-bind-session-${markerKey}`); - if (fs.existsSync(hintMarker)) return; - try { fs.writeFileSync(hintMarker, ''); } catch {} - - let projects: LocalAgentProject[]; - try { - projects = await fetchUserProjects(config); - } catch (e) { - log.debug(`local-agent: failed to load user projects: ${(e as Error).message}`); - return; - } - - if (projects.length === 0) return; - - const project = await promptForProjectBinding(workspacePath, projects); - if (project) { - await persistWorkspaceBinding(config, cwd, workspacePath, project.id, project.name); - return; - } - - // TTY prompt failed (running as hook in background) — output context for AI to prompt user - const projectList = projects.map((p) => `${p.name} (id=${p.id})`).join(', '); - const context = [ - '[teamai] 当前工作区尚未绑定项目,无法接收项目下发的 skills/rules/claudemd 资源。', - `可用项目: ${projectList}`, - `请提示用户运行: teamai bind-project`, - `或指定项目: teamai bind-project --project-id `, - ].join('\n'); - const hookOutput = JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext: context, - }, - }); - process.stdout.write(hookOutput + '\n'); -} - -/** - * The organization-binding prompt (TTY prompt + injected hook context) is - * on by default. Disable it explicitly with `TEAMAI_BIND_PROMPT_ENABLED=0` - * (or `false`). The manual `teamai bind-project` command is always available - * regardless. - */ -function isBindPromptEnabled(): boolean { - const flag = process.env.TEAMAI_BIND_PROMPT_ENABLED; - if (flag === undefined) return true; - const normalized = flag.toLowerCase(); - return normalized !== '0' && normalized !== 'false'; -} - -/** - * ClawPro project binding only backs CodeBuddy/WorkBuddy (the ClawPro-native - * agents); the prompt is noise for every other host (Claude, Cursor, Codex, …), - * which drove the poor UX. Gate the whole prompt — both the SessionStart TTY - * prompt and the UserPromptSubmit hint — on the current tool being a buddy - * agent. Reuses `modelAgentKind` so tool-name variants like `codebuddy-internal` - * still match (a raw Set would miss them). - */ -function isBindPromptTool(tool: string | undefined): boolean { - const kind = modelAgentKind(tool); - return kind === 'codebuddy' || kind === 'workbuddy'; -} - -async function emitBindingHint( - config: LocalAgentConfig, - workspacePath: string, - sessionId?: string, - cwd?: string, -): Promise { - if (config.workspaceBindings[workspacePath]) return; - // A worktree inherits its main checkout's binding/skip decision — never hint. - if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; - - // Only hint once per session — use a temp marker file keyed by sessionId - const markerKey = sessionId || `ppid-${process.ppid}`; - const hintMarker = path.join(os.tmpdir(), `teamai-bind-hint-${markerKey}`); - if (fs.existsSync(hintMarker)) return; - try { fs.writeFileSync(hintMarker, ''); } catch {} - - let projects: LocalAgentProject[]; - try { - projects = await fetchUserProjects(config); - } catch { - return; - } - if (projects.length === 0) return; - - const skipNum = projects.length + 1; - const choices = projects.map((p, i) => `${i + 1}. 绑定到「${p.name}」项目`); - const actions = projects.map((p, i) => `- 用户回复 ${i + 1} → 执行 \`teamai bind-project --project-id ${p.id}\`,完成后回复"绑定完成,请开新会话生效"`); - const context = [ - '[ClawPro项目 绑定提示] 你必须原样展示下面的选项列表,不得修改编号或内容。等用户回复数字后执行对应命令。', - '', - '---', - '当前工作区未绑定ClawPro项目。绑定后可同步团队 skills/rules。请回复数字选择:', - '', - ...choices, - `${skipNum}. 不绑定,以后也不再提示`, - '---', - '', - '执行规则(不要展示给用户):', - ...actions, - `- 用户回复 ${skipNum} → 执行 \`teamai bind-project --skip\`,完成后回复"已跳过,以后不再提示"`, - ].join('\n'); - const hookOutput = JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'UserPromptSubmit', - additionalContext: context, - }, - }); - process.stdout.write(hookOutput + '\n'); -} - -function isEphemeralTaskDir(dir: string): boolean { - const segments = dir.split(path.sep); - const wbIdx = segments.lastIndexOf('WorkBuddy'); - if (wbIdx < 0 || wbIdx >= segments.length - 1) return false; - return /^\d{4}-\d{2}-\d{2}/.test(segments[wbIdx + 1]); -} - -async function resolveWorkspacePath(cwd?: string): Promise { - if (!cwd) return undefined; - const absolute = path.resolve(cwd); - if (isEphemeralTaskDir(absolute)) return undefined; - try { - const { stdout } = await execFileAsync('git', ['-C', absolute, 'rev-parse', '--show-toplevel']); - const root = stdout.trim(); - return await canonicalizeWorkspacePath(root || absolute); - } catch { - return await canonicalizeWorkspacePath(absolute); - } -} - -interface ReportedResource { - slug: string; - version?: string; - display_name?: string; - source: string; -} - -/** - * Resolve a resource's source by looking it up in the local-agent manifest: - * slugs recorded there were installed via HTTP distribution (`enterprise`); - * everything else present only on disk is treated as `local`. - */ -function resolveSource(slug: string, manifestSlugs: Set): string { - return manifestSlugs.has(slug) ? 'enterprise' : 'local'; -} - -/** - * Scan a tool's on-disk skills directory. Each sub-directory containing a - * SKILL.md is one installed skill; slug/version/display_name come from its - * front-matter (falling back to the directory name). - */ -async function scanSkillsFromDisk( - skillsDir: string, - manifestSlugs: Set, -): Promise { - if (!(await pathExists(skillsDir))) return []; - const dirs = (await listDirs(skillsDir)).filter((name) => !name.startsWith('.') && !name.startsWith('_')); - const results: ReportedResource[] = []; - for (const dir of dirs) { - const skillMd = path.join(skillsDir, dir, 'SKILL.md'); - if (!(await pathExists(skillMd))) continue; - const fm = await readFrontmatter(skillMd); - const slug = typeof fm.name === 'string' && fm.name ? fm.name : dir; - const version = fm.version != null ? String(fm.version) : undefined; - results.push({ - slug, - version, - display_name: slug, - source: resolveSource(slug, manifestSlugs), - }); - } - return results.sort((a, b) => a.slug.localeCompare(b.slug)); -} - -/** - * Scan a tool's on-disk rules directory. Every `.md` file (recursively) is one - * installed rule; the slug is its path relative to the rules dir without the - * `.md` extension. - */ -async function scanRulesFromDisk( - rulesDir: string, - manifestSlugs: Set, -): Promise { - if (!(await pathExists(rulesDir))) return []; - // Cursor stores rules as `.mdc`, every other tool as `.md`; match by stem so - // a Cursor agent still reports its installed rules. - const files = await listFilesRecursive(rulesDir); - const results: ReportedResource[] = []; - const seen = new Set(); - for (const file of files) { - const slug = ruleStemFromFilename(file); - if (slug === null) continue; - if (seen.has(slug)) continue; // Same rule under both extensions - seen.add(slug); - // Skip CLI built-in / legacy rules (e.g. teamai-recall) so they are not - // reported as user-installed resources — mirrors the pull/uninstall filter. - if (EXCLUDED_RULE_NAMES.has(path.basename(slug)) || EXCLUDED_RULE_NAMES.has(slug)) continue; - results.push({ - slug, - display_name: slug, - source: resolveSource(slug, manifestSlugs), - }); - } - return results.sort((a, b) => a.slug.localeCompare(b.slug)); -} - -/** Collect every skill/rule slug recorded across all manifest scopes. - * For skills, also includes dir_name (the on-disk SKILL.md name) so that - * scanSkillsFromDisk — which uses SKILL.md name as the reported slug — - * correctly resolves source as 'enterprise' even when dir_name ≠ slug. - */ -function collectManifestSlugs(manifest: LocalAgentManifest): { skills: Set; rules: Set } { - const skills = new Set(); - const rules = new Set(); - for (const scope of Object.values(manifest.scopes)) { - for (const [slug, entry] of Object.entries(scope.skills ?? {})) { - skills.add(slug); - if (entry.dir_name) skills.add(entry.dir_name); - } - for (const slug of Object.keys(scope.rules ?? {})) rules.add(slug); - } - return { skills, rules }; -} - -/** - * Scan the managed-mcp manifest for a given scope and return MCP servers as - * ReportedResource entries. Only servers tracked in managed-mcp.json (i.e. - * installed via HTTP distribution) are reported with source = 'enterprise'. - * - * Results are scoped to the current `tool` so a report never leaks another - * tool's MCP inventory. The manifest is keyed by the same key the installer - * writes under (see `installMcpServer`): `tool` at user scope, `${tool}:project` - * at project scope. `tool` is the raw hook-context value (not run through - * normalizeAgentType), matching how the installer keys the manifest. - */ -async function scanMcpFromManifest( - scope: 'user' | 'project', - tool: string, - projectRoot?: string, -): Promise { - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope(scope, projectRoot); - - // Project scope reads THIS worktree's own manifest file (per-worktree under the - // partition; migrates legacy shared records on first read). User scope reads the - // single global file. Either way every record in the loaded file belongs to this - // scope, so no key filtering is needed. - let manifest: ManagedMcpManifest; - if (scope === 'project' && projectRoot) { - const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); - ({ manifest } = await loadProjectMcpManifest(dataHome, projectRoot)); - } else { - manifest = (await readJson(managedMcpManifestPath(dataHome))) ?? {}; - } - - const manifestKey = `${tool}${scope === 'project' ? ':project' : ''}`; - const records = manifest[manifestKey]; - if (!Array.isArray(records)) return []; - - const seen = new Set(); - const results: ReportedResource[] = []; - for (const rec of records) { - if (!rec.name || seen.has(rec.name)) continue; - seen.add(rec.name); - results.push({ slug: rec.name, source: 'enterprise' }); - } - return results.sort((a, b) => a.slug.localeCompare(b.slug)); -} - -interface ReportedModel { - provider: string; - model_id: string; - name?: string; - source: string; -} - -/** - * Scan the models a tool can currently use, as configured on disk. The server - * requires both `provider` and `model_id`, so entries that cannot supply them - * are dropped rather than reported as incomplete. `source` is derived from the - * model manifest, mirroring how skills/rules classify enterprise vs local. - * - * Only CodeBuddy, WorkBuddy, and Claude keep a discoverable model config; - * every other tool reports nothing. User-owned models are omitted: the - * backend cannot resolve them, so only entries still matching a TeamAI - * delivery are reported. - */ -function buddyModelsPath(agentKind: BuddyAgentKind, workspacePath?: string): string { - return workspacePath - ? path.join(workspacePath, '.codebuddy', 'models.json') - : path.join(getUserHome(), `.${agentKind}`, 'models.json'); -} - -function modelConfigDisplayPath(filePath: string): string { - if (filePath.endsWith(`${path.sep}.codebuddy${path.sep}models.json`)) { - return '.codebuddy/models.json'; - } - if (filePath.endsWith(`${path.sep}.workbuddy${path.sep}models.json`)) { - return '~/.workbuddy/models.json'; - } - return path.basename(filePath); -} - -async function scanModelsFromDisk(tool: string, workspacePath?: string): Promise { - const manifest = (await readJson(getModelManifestPath())) ?? {}; - const agentKind = modelAgentKind(tool); - - if (agentKind === 'codebuddy' || agentKind === 'workbuddy') { - const scopeManifest = workspacePath ? manifest.workspaceModels?.[workspacePath] : manifest; - const providers = scopeManifest?.providersByAgent?.[agentKind] - ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) - ?? {}; - const raw = await readJson(buddyModelsPath(agentKind, workspacePath)); - const entries = Array.isArray(raw) - ? raw - : (Array.isArray((raw as { models?: unknown } | null)?.models) - ? (raw as { models: unknown[] }).models - : []); - const owned = (agentKind === 'codebuddy' - ? scopeManifest?.codebuddy - : scopeManifest?.workbuddy) ?? {}; - const results: ReportedModel[] = []; - for (const entry of entries) { - if (typeof entry !== 'object' || entry === null) continue; - const { id, vendor, name } = entry as Record; - if (typeof id !== 'string' || !id) continue; - if (typeof vendor !== 'string' || !vendor) continue; - // CodeBuddy / WorkBuddy may normalize a model entry by adding capability - // metadata. The manifest's model id is the durable proof that TeamAI - // delivered it; requiring an exact object hash would hide such entries. - if (owned[id] === undefined || providers[id] !== vendor) continue; - results.push({ - provider: vendor, - model_id: id, - ...(typeof name === 'string' && name ? { name } : {}), - source: 'enterprise', - }); - } - return results; - } - - if (agentKind === 'claude' && !workspacePath) { - const providers = manifest.providersByAgent?.claude ?? manifest.providers ?? {}; - const settings = await readJson<{ env?: unknown }>( - path.join(await claudeUserRoot(), 'settings.json'), - ); - const env = settings?.env; - if (typeof env !== 'object' || env === null || Array.isArray(env)) return []; - const { ANTHROPIC_CUSTOM_MODEL_OPTION: modelId, ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: name } = - env as Record; - if (typeof modelId !== 'string' || !modelId) return []; - const managed = manifest.claudeEnv?.ANTHROPIC_CUSTOM_MODEL_OPTION; - if (managed === undefined || entryHash(modelId) !== managed) return []; - const provider = providers[modelId]; - if (!provider) return []; - return [{ - provider, - model_id: modelId, - ...(typeof name === 'string' && name ? { name } : {}), - source: 'enterprise', - }]; - } - - return []; -} - -/** - * Remove workspace bindings whose directory no longer exists on disk. - * - * Workspace bindings are only ever added, never removed, so a deleted - * project directory would otherwise be reported forever and the server - * (full-sync snapshot) could never drop it. This prunes such stale - * entries in place. Applies to skipped ('__skipped__', projectId 0) - * entries too — a deleted directory should not leave a permanent sentinel. - * - * @param config - Loaded local-agent config; its workspaceBindings map is mutated in place. - * @returns True if at least one binding was removed. - */ -export async function pruneDeadWorkspaceBindings(config: LocalAgentConfig): Promise { - let changed = false; - for (const workspacePath of Object.keys(config.workspaceBindings)) { - try { - await fs.promises.stat(workspacePath); - } catch (error) { - // Only prune when the directory is confirmed gone (ENOENT). Transient - // failures — permission errors, unreachable network mounts — must NOT - // delete a still-valid binding, or the server's full-sync snapshot - // would drop that workspace's resources. - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - delete config.workspaceBindings[workspacePath]; - changed = true; - } else { - log.debug( - `local-agent: keeping workspace binding ${workspacePath} despite stat error: ${(error as Error).message}`, - ); - } - } - } - return changed; -} - -/** - * Stamps the workspace binding's owning tool when the hook fires from that tool's own process. - * Because hooks are invoked from within the tool's process, cwd === binding.path is the - * authoritative signal that this binding belongs to the triggering tool. - * - * Returns true if the binding was modified (caller should persist config), false otherwise. - */ -export function stampWorkspaceTool( - config: LocalAgentConfig, - currentPath: string | null | undefined, - tool: string, -): boolean { - if (!currentPath) return false; - const binding = config.workspaceBindings[currentPath]; - if (!binding) return false; - const normalized = normalizeAgentType(tool); - if (binding.ideType === normalized) return false; - binding.ideType = normalized; - return true; -} - -/** - * Select the workspace paths that belong to the current tool for reporting. - * - * A binding belongs to the current tool when its stamped ideType matches, or — - * for the not-yet-attributed current cwd — when it is the workspace the hook - * fired from. An empty/absent ideType on a non-cwd binding is treated as - * unattributed and excluded (it self-heals once its owning tool reports from it). - */ -function selectToolWorkspaces( - config: LocalAgentConfig, - currentPath: string | null | undefined, - currentTool: string, -): string[] { - const paths = new Set(Object.keys(config.workspaceBindings)); - if (currentPath) paths.add(currentPath); - return Array.from(paths).filter((wsPath) => { - const b = config.workspaceBindings[wsPath]; - const wsTool = (b?.ideType || undefined) ?? (wsPath === currentPath ? currentTool : undefined); - return wsTool === currentTool; - }); -} - -export async function buildReportPayload( - config: LocalAgentConfig, - context: LocalAgentContext, -): Promise> { - const manifest = await loadManifest(); - - // Resource discovery scans the tool's on-disk skills/rules directories rather - // than the manifest, so locally-installed resources (not just HTTP-distributed - // ones) are reported. `source` is derived from the manifest: slugs recorded - // there are `enterprise`, the rest `local`. - const tool = context.tool ?? 'workbuddy'; - const teamConfig = createLocalAgentTeamConfig(config.endpoint); - const manifestSlugs = collectManifestSlugs(manifest); - - // Resolve paths through the same user-scope seam the installers use: tools - // that relocate their user customization root (copilot via $COPILOT_HOME) or - // lay user scope out differently from project scope declare a `userScope` - // block. Reading the raw toolPaths map against $HOME would scan the project - // layout under the wrong base — e.g. ~/.github/skills for copilot, a path - // teamai never writes to — and silently report nothing. - const scanScope = async (workspacePath?: string): Promise<{ skills: ReportedResource[]; rules: ReportedResource[] }> => { - const scope: LocalAgentScope = workspacePath ? 'project' : 'user'; - const localConfig = await createResourceLocalConfig(config, scope, workspacePath ?? getUserHome(), workspacePath); - const toolPath = scopedToolPaths(teamConfig, localConfig)[tool]; - if (!toolPath) return { skills: [], rules: [] }; - const baseDir = resolveToolBaseDir(tool, localConfig); - const skills = toolPath.skills - ? await scanSkillsFromDisk(path.join(baseDir, toolPath.skills), manifestSlugs.skills) - : []; - const rules = toolPath.rules - ? await scanRulesFromDisk(path.join(baseDir, toolPath.rules), manifestSlugs.rules) - : []; - return { skills, rules }; - }; - - const userScope = await scanScope(); - - const userLevel: Record = { group_id: config.userGroupId }; - if (userScope.skills.length > 0) userLevel.skills = userScope.skills; - if (userScope.rules.length > 0) userLevel.rules = userScope.rules; - const userMcps = await scanMcpFromManifest('user', tool); - if (userMcps.length > 0) userLevel.mcps = userMcps; - // Omitted when empty for the same full-sync reason as skills/rules: the - // server treats a present array as a snapshot, so [] would wipe the models. - const userModels = await scanModelsFromDisk(tool); - if (userModels.length > 0) userLevel.models = userModels; - - const payload: Record = { - agent_type: normalizeAgentType(tool), - agent_version: await getAgentVersion(tool), - local_agent_id: resolveLocalAgentId(context), - host_name: os.hostname(), - os: os.platform(), - started_at: config.createdAt, - last_status: context.status ?? 'running', - // Instance-level skills/rules are a phase-1 legacy concept. They are - // deliberately omitted (not sent as []): the server treats present arrays - // as a full-sync snapshot ("消失即删"), so an empty array would wipe any - // instance-level resources. Omitting the field leaves them untouched. - user_level: userLevel, - }; - - const currentPath = await resolveWorkspacePath(context.cwd); - const currentTool = normalizeAgentType(tool); - const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); - if (targetPaths.length > 0) { - const workspaceResults = await Promise.all( - targetPaths.map(async (wsPath) => { - const wsScope = await scanScope(wsPath); - const wsBinding = config.workspaceBindings[wsPath]; - const workspace: Record = { - path: wsPath, - name: path.basename(wsPath), - ide_type: currentTool, - project_id: wsBinding?.projectId, - }; - if (wsScope.skills.length > 0) workspace.skills = wsScope.skills; - if (wsScope.rules.length > 0) workspace.rules = wsScope.rules; - const wsMcps = await scanMcpFromManifest('project', tool, wsPath); - if (wsMcps.length > 0) workspace.mcps = wsMcps; - const wsModels = await scanModelsFromDisk(tool, wsPath); - if (wsModels.length > 0) workspace.models = wsModels; - return workspace; - }), - ); - payload.workspaces = workspaceResults; - } - - return payload; -} - -export async function buildSyncPayload( - config: LocalAgentConfig, - context: LocalAgentContext, -): Promise> { - const payload: Record = { - agent_type: normalizeAgentType(context.tool ?? 'workbuddy'), - local_agent_id: resolveLocalAgentId(context), - status: context.status ?? 'running', - }; - const currentPath = await resolveWorkspacePath(context.cwd); - const currentTool = normalizeAgentType(context.tool ?? 'workbuddy'); - const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); - if (targetPaths.length > 0) { - payload.workspaces = targetPaths.map((wsPath) => { - const wsBinding = config.workspaceBindings[wsPath]; - return { - path: wsPath, - name: path.basename(wsPath), - ide_type: currentTool, - project_id: wsBinding?.projectId, - }; - }); - } - return payload; -} - -function commandKind(command: LocalAgentCommand): CommandResourceKind | null { - // Unified cmds[] carries handle_type; legacy commands[] carries rule_type. - // Both map a prompt rule to the claudemd resource kind. - if (command.rule_type === 'prompt' || command.handle_type === 'prompt') return 'claudemd'; - if (command.handle_type === 'rule') return 'rule'; - if (command.handle_type === 'hook') return null; // defensive; skipped before dispatch - const type = command.type ?? ''; - if (type.endsWith('_skill') || type === '') return 'skill'; - if (type.endsWith('_claudemd') || type.endsWith('_claude_md')) return 'claudemd'; - if (type.endsWith('_rule')) return 'rule'; - return null; -} - -function commandAction(command: LocalAgentCommand): 'install' | 'uninstall' | null { - const type = command.type ?? ''; - if (type === '') return 'install'; - if (type.startsWith('install_')) return 'install'; - if (type.startsWith('uninstall_')) return 'uninstall'; - return null; -} - -/** - * Reject slugs that could escape the resource directory. Slugs come from - * backend sync commands and are used directly in filesystem paths, so a value - * like `../../.ssh/authorized_keys` would otherwise write outside the repo. - */ -function validateSlug(slug: string): string { - if ( - !slug || - slug.includes('/') || - slug.includes('\\') || - slug.includes('..') || - path.isAbsolute(slug) - ) { - throw new Error(`Invalid resource slug: ${slug}`); - } - return slug; -} - -function commandSlug(command: LocalAgentCommand, kind: CommandResourceKind): string { - const slug = - kind === 'skill' ? command.skill_slug : - kind === 'rule' ? command.rule_slug : - (command.claudemd_slug ?? command.rule_slug); - const resolved = slug ?? command.resource_slug ?? command.slug ?? command.name; - if (!resolved) { - throw new Error(`Missing ${kind} slug`); - } - return validateSlug(resolved); -} - -function commandVersion(command: LocalAgentCommand, kind: CommandResourceKind): string | undefined { - return ( - kind === 'skill' ? command.skill_version : - kind === 'rule' ? command.rule_version : - (command.claudemd_version ?? command.rule_version) - ) ?? command.resource_version ?? command.version; -} - -/** - * Normalize a backend-sent scope string to an internal LocalAgentScope. - * - * The backend emits `user` / `workspace` (see clawpro local-agent-api.md); - * the deprecated `instance` is no longer sent. Internally project-level - * resources use the `project` scope, so `workspace` maps to `project`. - * Any unrecognized value falls back to `user` (global install). - * - * This only maps the scope; presence of `workspace_path` for project scope - * is validated by the caller (executeCommand throws if it is missing). - */ -function normalizeScope(raw?: string): LocalAgentScope { - if (raw === 'workspace' || raw === 'project') return 'project'; - if (raw !== undefined && raw !== 'user' && raw !== 'instance') { - log.debug(`local-agent: unknown scope "${raw}", defaulting to user`); - } - return 'user'; -} - -function manifestKind(kind: CommandResourceKind): ResourceKind { - return kind === 'skill' ? 'skills' : kind === 'rule' ? 'rules' : 'claudemd'; -} - -/** Only http(s) downloads are allowed — reject file:, ftp:, gopher:, etc. */ -function assertHttpUrl(rawUrl: string): URL { - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - throw new Error(`Invalid download URL: ${rawUrl}`); - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new Error(`Unsupported download URL scheme: ${parsed.protocol}`); - } - return parsed; -} - -/** - * Fetch a resource by URL. download_url comes from backend sync commands, so it - * is treated as untrusted: only http(s) is honoured (no file:// / local-path - * copy, which would be arbitrary local file read), and redirects are followed - * manually so every hop's scheme is re-validated instead of blindly trusting - * whatever Location the server returns. - */ -async function downloadResource(downloadUrl: string): Promise { - const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'teamai-local-agent-')); - const filePath = path.join(tmpDir, 'resource'); - - let current = assertHttpUrl(downloadUrl); - let response: Response; - const maxRedirects = 5; - // One timeout budget for the whole download (all redirect hops combined), so a - // chain of slow redirects cannot exceed the intended bound. - const signal = AbortSignal.timeout(activeFetchTimeoutMs); - for (let hop = 0; ; hop++) { - response = await fetch(current, { redirect: 'manual', signal }); - if (response.status >= 300 && response.status < 400) { - const location = response.headers.get('location'); - if (!location) break; - if (hop >= maxRedirects) { - throw new Error(`Download failed: too many redirects (${downloadUrl})`); - } - current = assertHttpUrl(new URL(location, current).toString()); - continue; - } - break; - } - - if (!response.ok) { - throw new Error(`Download failed: ${response.status} ${response.statusText}`); - } - const buffer = Buffer.from(await response.arrayBuffer()); - await fs.promises.writeFile(filePath, buffer); - return filePath; -} - -const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); - -async function isZipFile(filePath: string): Promise { - const fd = await fs.promises.open(filePath, 'r'); - try { - const buf = Buffer.alloc(4); - await fd.read(buf, 0, 4, 0); - return buf.equals(ZIP_MAGIC); - } finally { - await fd.close(); - } -} - -async function resolveMarkdownFromDownload(downloadedPath: string, slug: string): Promise { - if (await isZipFile(downloadedPath)) { - const extractDir = await extractZip(downloadedPath); - return findMarkdownFile(extractDir, slug); - } - return downloadedPath; -} - -async function extractZip(zipPath: string): Promise { - const extractDir = path.join(path.dirname(zipPath), 'extracted'); - await ensureDir(extractDir); - await execFileAsync('unzip', ['-q', zipPath, '-d', extractDir]); - return extractDir; -} - -async function findFirst( - dir: string, - predicate: (absolutePath: string, name: string) => Promise, -): Promise { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const absolute = path.join(dir, entry.name); - if (await predicate(absolute, entry.name)) return absolute; - if (entry.isDirectory()) { - const nested = await findFirst(absolute, predicate); - if (nested) return nested; - } - } - return null; -} - -async function findSkillRoot(extractDir: string): Promise { - if (await pathExists(path.join(extractDir, 'SKILL.md'))) return extractDir; - const skillMd = await findFirst(extractDir, async (absolute, name) => name === 'SKILL.md' && (await pathExists(absolute))); - if (!skillMd) throw new Error('Downloaded skill package does not contain SKILL.md'); - return path.dirname(skillMd); -} - -async function findMarkdownFile(extractDir: string, preferredName: string): Promise { - const preferred = await findFirst( - extractDir, - async (_absolute, name) => name === `${preferredName}.md` || name === preferredName, - ); - if (preferred) return preferred; - - const firstMd = await findFirst(extractDir, async (_absolute, name) => name.endsWith('.md')); - if (!firstMd) throw new Error('Downloaded package does not contain a markdown file'); - return firstMd; -} - -async function readFrontmatter(filePath: string): Promise> { - const content = await readFileSafe(filePath); - if (!content) return {}; - return parseFrontmatter(content).data; -} - -/** - * Decide the on-disk directory name for a skill. The SKILL.md `name:` field is - * the source of truth for how the skill is identified by the AI tool, so use it - * when it differs from the server-provided slug (matching the git-path behaviour - * in skill-command.ts / #144). Falls back to the slug when the name is missing, - * empty, equal to the slug, or fails path-safety validation. - */ -async function resolveSkillDirName(skillRoot: string, slug: string): Promise { - const fm = await readFrontmatter(path.join(skillRoot, 'SKILL.md')); - const name = typeof fm.name === 'string' ? fm.name.trim() : ''; - if (!name || name === slug) return slug; - try { - assertSafeResourceName(name); - return name; - } catch { - log.debug(`[local-agent] keeping slug "${slug}" as skill dir (SKILL.md name "${name}" failed safety check)`); - return slug; - } -} - -async function installDownloadedResource(input: { - config: LocalAgentConfig; - command: LocalAgentCommand; - kind: CommandResourceKind; - slug: string; - scope: LocalAgentScope; - workspacePath?: string; - tool?: string; -}): Promise { - if (!input.command.download_url) { - throw new Error(`Missing download_url for ${input.command.type ?? 'install_skill'}`); - } - - const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); - if (input.scope === 'project' && input.workspacePath - && repoPath.startsWith(path.join(input.workspacePath, '.teamai') + path.sep)) { - // Only gitignore when the cache actually lands inside the workspace (a legacy, - // un-migrated install). A partitioned install keeps it under ~/.teamai, so - // there is nothing in the workspace to ignore. - await ensureProjectGitignore(input.workspacePath); - } - await ensureDir(repoPath); - - const downloadedPath = await downloadResource(input.command.download_url); - try { - const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); - const tool = input.tool ?? 'workbuddy'; - const toolPath = fullTeamConfig.toolPaths[tool]; - if (!toolPath) { - throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); - } - const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; - const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); - // Ensure the tool root directory exists before dispatch so isToolInstalled - // gate does not skip the resource when the workspace is freshly bound. - // Restricted to project scope: user-scope installs use $HOME as baseDir and - // should continue to rely on isToolInstalled as the gate. - if (localConfig.scope === 'project') { - try { - const baseDir = resolveBaseDir(localConfig); - const resourceToolPath = - input.kind === 'skill' ? toolPath.skills : - input.kind === 'rule' ? toolPath.rules : - // Default branch covers the 'claudemd' kind; if a new CommandResourceKind - // is added, revisit this mapping so it doesn't silently fall through to claudemd. - toolPath.claudemd; - if (resourceToolPath && resourceToolPath.includes('/')) { - const rootSegment = resourceToolPath.split('/')[0]; - await ensureDir(path.join(baseDir, rootSegment)); - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('resolveBaseDir')) { - log.warn(`Cannot resolve base dir to pre-create tool root: ${msg}`); - } else { - log.debug(`Failed to pre-create tool root directory: ${msg}`); - } - } - } - const now = new Date().toISOString(); - let displayName = input.command.display_name ?? input.slug; - // On-disk skill directory name (SKILL.md name when it differs from slug). - // Stays the slug for rules/claudemd. Recorded in the manifest so uninstall - // can find the directory by slug. - let skillDirName = input.slug; - - if (input.kind === 'skill') { - const extractDir = await extractZip(downloadedPath); - const skillRoot = await findSkillRoot(extractDir); - skillDirName = await resolveSkillDirName(skillRoot, input.slug); - const dest = path.join(repoPath, 'skills', skillDirName); - await remove(dest); - await fse.copy(skillRoot, dest, { overwrite: true }); - const fm = await readFrontmatter(path.join(dest, 'SKILL.md')); - displayName = typeof fm.name === 'string' ? fm.name : displayName; - await new SkillsHandler().pullItem({ - name: skillDirName, - type: 'skills', - sourcePath: dest, - relativePath: `skills/${skillDirName}`, - }, teamConfig, localConfig); - } else if (input.kind === 'rule') { - const ruleFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); - const dest = path.join(repoPath, 'rules', `${input.slug}.md`); - await fse.ensureDir(path.dirname(dest)); - await fse.copyFile(ruleFile, dest); - await new RulesHandler().pullAllRules(teamConfig, localConfig); - } else { - const mdFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); - const dest = path.join(repoPath, 'claudemd', `${input.slug}.md`); - await fse.ensureDir(path.dirname(dest)); - await fse.copyFile(mdFile, dest); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); - } - - const version = commandVersion(input.command, input.kind); - const manifest = await loadManifest(); - const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); - scopeManifest[manifestKind(input.kind)][input.slug] = { - slug: input.slug, - version, - display_name: displayName, - source: 'enterprise', - installed_at: now, - ...(input.kind === 'skill' && skillDirName !== input.slug ? { dir_name: skillDirName } : {}), - }; - await saveManifest(manifest); - return version; - } finally { - await remove(path.dirname(downloadedPath)); - } -} - -async function uninstallResource(input: { - config: LocalAgentConfig; - kind: CommandResourceKind; - slug: string; - scope: LocalAgentScope; - workspacePath?: string; - tool?: string; -}): Promise { - const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); - const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); - const tool = input.tool ?? 'workbuddy'; - const toolPath = fullTeamConfig.toolPaths[tool]; - if (!toolPath) { - throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); - } - const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; - const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); - const manifest = await loadManifest(); - const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); - - if (input.kind === 'skill') { - // The directory was created under the SKILL.md name (recorded as dir_name); - // remove by that name, falling back to the slug for older installs. - const dirName = scopeManifest.skills[input.slug]?.dir_name ?? input.slug; - await new SkillsHandler().removeItem(dirName, teamConfig, localConfig); - } else if (input.kind === 'rule') { - await new RulesHandler().removeItem(input.slug, teamConfig, localConfig); - } else { - await remove(path.join(repoPath, 'claudemd', `${input.slug}.md`)); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); - } - - delete scopeManifest[manifestKind(input.kind)][input.slug]; - await saveManifest(manifest); -} - -async function resolveHermesUserBaseDir(): Promise { - try { - const envWs = process.env.TEAMAI_HERMES_WORKSPACE; - if (envWs && path.isAbsolute(envWs)) return envWs; - const cfg = await readJson(getConfigPath()); - const bindings = cfg?.workspaceBindings; - if (bindings && typeof bindings === 'object') { - const entries = Object.entries(bindings) - .filter(([p, v]) => path.isAbsolute(p) && v?.ideType === 'hermes') - .sort((a, b) => (b[1].boundAt ?? '').localeCompare(a[1].boundAt ?? '')); - for (const [p] of entries) { - if (await pathExists(path.join(p, '.hermes'))) return p; - } - } - } catch { /* fall through */ } - return undefined; -} - -async function syncClaudemd( - teamConfig: TeamaiConfig, - localConfig: LocalConfig, - repoPath: string, - workspacePath?: string, -): Promise { - const claudemdDir = path.join(repoPath, 'claudemd'); - const files = (await pathExists(claudemdDir)) - ? (await fse.readdir(claudemdDir)).filter((file) => file.endsWith('.md')).sort() - : []; - const contents: string[] = []; - for (const file of files) { - const content = await readFileSafe(path.join(claudemdDir, file)); - if (content) contents.push(content); - } - const block = compileClaudemdBlock(contents); - let syncedAny = false; - - for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (!toolPath.claudemd) continue; - - let baseDir = resolveToolBaseDir(tool, localConfig); - let resolvedAbsPath: string | null = null; - - if (tool === 'openclaw' && localConfig.scope !== 'project') { - const openclawWs = await resolveOpenclawWorkspaceDir(workspacePath); - if (openclawWs) { - resolvedAbsPath = path.join(openclawWs, path.basename(toolPath.claudemd)); - } - } else if (tool === 'hermes' && localConfig.scope !== 'project') { - const hermesBase = workspacePath ?? await resolveHermesUserBaseDir(); - if (hermesBase) { - baseDir = hermesBase; - log.debug(`local-agent: hermes user-scope baseDir resolved to ${baseDir}`); - } - } - - const toolInstalled = resolvedAbsPath - ? await pathExists(resolvedAbsPath) - : tool === COPILOT_TOOL_ID && localConfig.scope === 'user' - ? await isToolInstalledForConfig(tool, toolPath.claudemd, localConfig) - : toolPath.claudemd.includes('/') - ? await ResourceHandler.isToolInstalled(toolPath.claudemd, baseDir) - : await pathExists(path.join(baseDir, `.${tool}`)); - if (!toolInstalled) { - log.debug(`Skipped CLAUDE.md sync for ${tool}: target not found`); - continue; - } - - const claudeMdPath = resolvedAbsPath ?? path.join(baseDir, toolPath.claudemd); - try { - if (block) { - await injectClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, block); - log.debug(`local-agent: synced CLAUDE.md instructions to ${tool}`); - syncedAny = true; - } else { - await removeClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END); - log.debug(`local-agent: removed CLAUDE.md instructions from ${tool}`); - syncedAny = true; - } - } catch (e) { - log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${(e as Error).message}`); - } - } - - if (files.length > 0 && !syncedAny) { - throw new Error('CLAUDE.md sync landed on no tool: every configured target was skipped'); - } -} - -async function ackCommand( - config: LocalAgentConfig, - tag: string, - command: LocalAgentCommand, - status: 'success' | 'failed', - version?: string, - error?: string, -): Promise { - await localAgentFetch(config, tag, 'ack', { - method: 'POST', - body: JSON.stringify({ - id: command.id, - type: command.type ?? '', - status, - error: error ?? '', - version, - }), - }); -} - -function requireModelString( - value: unknown, - field: keyof Pick, -): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`apply_model_config: ${field} must be a non-empty string`); - } - return value.trim(); -} - -/** CodeBuddy maxOutputTokens when the backend omits max_tokens or sends 0 (Go zero value). */ -const DEFAULT_MAX_TOKENS = 4096; - -function optionalPositiveInteger(value: unknown, field: 'max_tokens' | 'context_window'): number | undefined { - if (value === undefined || value === null || value === '') return undefined; - const normalized = typeof value === 'string' && /^\d+$/.test(value) - ? Number(value) - : value; - if (!Number.isSafeInteger(normalized) || (normalized as number) < 0) { - throw new Error(`apply_model_config: ${field} must be a positive integer`); - } - // 0 is the Go zero value for an unset int, not a real output/context cap. - if ((normalized as number) === 0) return undefined; - return normalized as number; -} - -function parseDeliveredModels(raw: string | undefined): { models: DeliveredModel[]; fullSnapshot: boolean } { - if (!raw) throw new Error('apply_model_config: missing cmd'); - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new Error('apply_model_config: cmd must be valid JSON'); - } - const fullSnapshot = ( - typeof parsed === 'object' && - parsed !== null && - 'models' in parsed - ); - const values = fullSnapshot ? (parsed as { models?: unknown }).models : [parsed]; - if (!Array.isArray(values)) { - throw new Error('apply_model_config: models must be an array'); - } - - const seen = new Set(); - const models = values.map((value) => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('apply_model_config: each model must be an object'); - } - const input = value as Record; - const modelId = requireModelString(input.model_id, 'model_id'); - if (modelId === '__proto__' || modelId === 'prototype' || modelId === 'constructor') { - throw new Error(`apply_model_config: reserved model_id "${modelId}"`); - } - const model: DeliveredModel = { - provider: requireModelString(input.provider, 'provider'), - model_id: modelId, - name: requireModelString(input.name, 'name'), - base_url: requireModelString(input.base_url, 'base_url'), - api_key: requireModelString(input.api_key, 'api_key'), - max_tokens: optionalPositiveInteger(input.max_tokens, 'max_tokens') ?? DEFAULT_MAX_TOKENS, - context_window: optionalPositiveInteger(input.context_window, 'context_window'), - }; - let parsedUrl: URL; - try { - parsedUrl = new URL(model.base_url); - } catch { - throw new Error('apply_model_config: base_url must be a valid URL'); - } - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new Error('apply_model_config: base_url must use http or https'); - } - if (seen.has(model.model_id)) { - throw new Error(`apply_model_config: duplicate model_id "${model.model_id}"`); - } - seen.add(model.model_id); - return model; - }); - return { models, fullSnapshot }; -} - -function buddyModelEntry(model: DeliveredModel): Record { - const baseUrl = model.base_url.replace(/\/+$/, ''); - return { - id: model.model_id, - name: model.name, - vendor: model.provider, - apiKey: model.api_key, - ...(model.context_window === undefined ? {} : { maxInputTokens: model.context_window }), - ...(model.max_tokens === undefined ? {} : { maxOutputTokens: model.max_tokens }), - url: baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`, - supportsToolCall: true, - }; -} - -async function readJsonObject(filePath: string): Promise> { - const source = await readFileSafe(filePath); - if (source === null) return {}; - try { - const parsed = JSON.parse(source); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('root must be an object'); - } - return parsed as Record; - } catch (error) { - throw new Error( - `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, - ); - } -} - -/** Atomically update a model dotfile without replacing a user-managed symlink. */ -async function writeModelJson(filePath: string, data: unknown): Promise { - let targetPath = filePath; - try { - if ((await fs.promises.lstat(filePath)).isSymbolicLink()) { - targetPath = await fs.promises.realpath(filePath); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - // Set the temp file's mode before the atomic rename. A post-rename chmod - // would introduce a symlink-following TOCTOU window. - await writeJsonAtomic(targetPath, data, { mode: 0o600 }); -} - -async function ensureWorkspaceModelGitignore(workspacePath: string): Promise { - const gitignorePath = path.join(workspacePath, '.codebuddy', '.gitignore'); - const existing = await readFileSafe(gitignorePath); - if (existing === null) { - await writeFile(gitignorePath, '# Local model credentials\nmodels.json\n'); - return; - } - if (existing.split(/\r?\n/).some((line) => line.trim() === 'models.json')) return; - await writeFile(gitignorePath, `${existing.trimEnd()}\nmodels.json\n`); -} - -async function readBuddyModelEntries( - filePath: string, -): Promise<{ existing: unknown[]; doc?: Record }> { - const source = await readFileSafe(filePath); - // The current WorkBuddy / CodeBuddy documentation uses an object wrapper. - // Product releases also accept the legacy top-level array, so preserve that - // shape when a user already has one instead of forcing a migration. - if (source === null) return { existing: [], doc: {} }; - try { - const parsed = JSON.parse(source); - if (Array.isArray(parsed)) return { existing: parsed }; - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('root must be an object or array'); - } - const doc = parsed as Record; - const existing = doc.models === undefined ? [] : doc.models; - if (!Array.isArray(existing)) { - throw new Error('models must be an array'); - } - return { existing, doc }; - } catch (error) { - throw new Error( - `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, - ); - } -} - -async function reconcileBuddyModels( - models: DeliveredModel[], - fullSnapshot: boolean, - scopeManifest: BuddyModelManifest, - agentKind: BuddyAgentKind, - workspacePath?: string, -): Promise { - const targetFile = buddyModelsPath(agentKind, workspacePath); - const { existing, doc } = await readBuddyModelEntries(targetFile); - const previouslyManaged = (agentKind === 'codebuddy' - ? scopeManifest.codebuddy - : scopeManifest.workbuddy) ?? {}; - const nextManaged: Record = fullSnapshot ? {} : { ...previouslyManaged }; - const incomingIds = new Set(models.map((model) => model.model_id)); - const removedManaged = new Set(); - const preserved: unknown[] = []; - const occupiedIds = new Set(); - for (const entry of existing) { - const id = typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' - ? (entry as { id: string }).id - : undefined; - if (id && previouslyManaged[id] && entryHash(entry) === previouslyManaged[id]) { - if (fullSnapshot || incomingIds.has(id)) { - removedManaged.add(id); - continue; - } - preserved.push(entry); - occupiedIds.add(id); - continue; - } - preserved.push(entry); - if (id) occupiedIds.add(id); - if (id && previouslyManaged[id]) delete nextManaged[id]; - } - - for (const model of models) { - if (occupiedIds.has(model.model_id)) continue; - const entry = buddyModelEntry(model); - preserved.push(entry); - nextManaged[model.model_id] = entryHash(entry); - } - - if (workspacePath) await ensureWorkspaceModelGitignore(workspacePath); - if (doc) { - doc.models = preserved; - if (Array.isArray(doc.availableModels) && doc.availableModels.length > 0) { - const available = doc.availableModels.filter( - (id): id is string => typeof id === 'string' && !removedManaged.has(id), - ); - for (const id of Object.keys(nextManaged)) { - if (!available.includes(id)) available.push(id); - } - doc.availableModels = available; - } - await writeModelJson(targetFile, doc); - } else { - await writeModelJson(targetFile, preserved); - } - if (agentKind === 'codebuddy') scopeManifest.codebuddy = nextManaged; - else scopeManifest.workbuddy = nextManaged; -} - -function claudeEnvForModel(model: DeliveredModel): Record { - const baseUrl = model.base_url.replace(/\/+$/, '').replace(/\/v1$/, ''); - return { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: model.api_key, - ANTHROPIC_CUSTOM_MODEL_OPTION: model.model_id, - ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: model.name, - }; -} - -/** - * Drop the gateway env and model profile the agent delivered into `claudeRoot`, - * and forget them in the manifest. For `teamai init` moving the Claude root: - * the credentials would otherwise stay in a profile nothing syncs any more. - * No-op when the agent never delivered a model. - */ -export async function releaseClaudeModelConfig(claudeRoot: string): Promise { - const manifest = (await readJson(getModelManifestPath())) ?? {}; - if (Object.keys(manifest.claudeEnv ?? {}).length === 0) return; - await reconcileClaudeModels([], manifest, claudeRoot); - await writeJsonAtomic(getModelManifestPath(), manifest); - log.info(`Removed the delivered Claude model config from ${claudeRoot}`); -} - -async function reconcileClaudeModels( - models: DeliveredModel[], - manifest: ModelConfigManifest, - claudeRoot?: string, -): Promise { - claudeRoot ??= await claudeUserRoot(); - const settingsPath = path.join(claudeRoot, 'settings.json'); - const profilePath = path.join(claudeRoot, 'teamai-models.json'); - const previousHashes = manifest.claudeEnv ?? {}; - const settings = await readJsonObject(settingsPath); - const rawEnv = settings.env === undefined ? {} : settings.env; - if (typeof rawEnv !== 'object' || rawEnv === null || Array.isArray(rawEnv)) { - throw new Error(`apply_model_config: env must be an object in ${settingsPath}`); - } - const env = { ...(rawEnv as Record) }; - - if (models.length === 0) { - const canRemoveGateway = Object.entries(previousHashes).every( - ([key, hash]) => entryHash(env[key]) === hash, - ); - if (canRemoveGateway && Object.keys(previousHashes).length > 0) { - for (const key of Object.keys(previousHashes)) delete env[key]; - settings.env = env; - await writeModelJson(settingsPath, settings); - } - await remove(profilePath); - manifest.claudeEnv = {}; - return; - } - - // Claude supports one active custom gateway in settings. The first model - // seeds that gateway; other candidates remain discoverable from its - // /v1/models endpoint when the gateway implements model discovery. - const desired = claudeEnvForModel(models[0]); - await writeModelJson(profilePath, { env: desired }); - - // Any of these keys, if the user already set them, means they have their own - // Claude gateway/model config we must not silently take over. Beyond the keys - // we write, this also covers auth the gateway swap would break - // (ANTHROPIC_API_KEY, ANTHROPIC_CUSTOM_HEADERS) and the user's model choice - // (ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL). - const conflictKeys = new Set([ - ...Object.keys(desired), - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_CUSTOM_HEADERS', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ]); - // A value is TeamAI-managed if it matches what we recorded last time or the - // value currently in settings.json (settings.json is our own output, so a - // process.env var equal to it is Claude re-injecting settings.json.env into - // the hook, not a user's independent shell config). - const isManagedValue = (key: string, value: unknown): boolean => ( - (previousHashes[key] !== undefined && entryHash(value) === previousHashes[key]) || - (env[key] !== undefined && entryHash(value) === entryHash(env[key])) - ); - // The guard must see config the user set outside settings.json too. Users who - // run Claude via shell `export ANTHROPIC_*` keep no gateway in settings.json, - // so a settings-only check reads env[key] === undefined and wrongly seizes the - // slot — settings.json then outranks the shell env and breaks their setup. - // But Claude injects settings.json.env into the hook's own environment, so we - // must NOT treat our own re-injected managed values as a user conflict — doing - // so would block every follow-up sync and strand the user on stale config. - const userOwnsInShell = (key: string): boolean => { - const value = process.env[key]; - if (typeof value !== 'string' || value.trim() === '') return false; - return !isManagedValue(key, value); - }; - const shellConflicts = [...conflictKeys].filter(userOwnsInShell); - if (shellConflicts.length > 0) { - // The user has their own gateway/model config in the shell. Skip the write, - // but keep manifest.claudeEnv intact: this is not the user editing our - // managed settings.json entry, so we must stay able to reconcile once the - // shell config goes away. - await appendErrorLog({ - apply_model_config: 'skipped claude gateway: user owns conflicting shell env', - conflicts: shellConflicts, - }); - return; - } - - const canManage = [...conflictKeys].every((key) => ( - env[key] === undefined || - (previousHashes[key] !== undefined && entryHash(env[key]) === previousHashes[key]) - )); - if (!canManage) { - manifest.claudeEnv = {}; - return; - } - - for (const [key, hash] of Object.entries(previousHashes)) { - if (entryHash(env[key]) === hash) delete env[key]; - } - Object.assign(env, desired); - settings.env = env; - await writeModelJson(settingsPath, settings); - manifest.claudeEnv = Object.fromEntries( - Object.entries(desired).map(([key, value]) => [key, entryHash(value)]), - ); -} - -async function applyModelConfig( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - const { models, fullSnapshot } = parseDeliveredModels(command.cmd); - const manifest = (await readJson(getModelManifestPath())) ?? {}; - const agentKind = modelAgentKind(context.tool); - if (!agentKind) { - throw new Error(`apply_model_config: unsupported agent "${context.tool ?? ''}"`); - } - - const scope = normalizeScope(command.scope); - const workspacePath = scope === 'project' - ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) - : undefined; - if (scope === 'project' && !workspacePath) { - throw new Error('apply_model_config: workspace command is missing workspace_path'); - } - if (workspacePath && config.workspaceBindings[workspacePath] === undefined) { - throw new Error( - `apply_model_config: workspace "${path.basename(workspacePath)}" is not a registered binding`, - ); - } - if (agentKind === 'claude' && workspacePath) { - throw new Error('apply_model_config: workspace scope is unsupported for claude'); - } - if (!workspacePath) { - // An explicit profile switch takes precedence over server delivery. Keep - // both the Agent config and delivery manifest intact for a later restore. - const { isModelProfileManaged } = await import('./models/switch.js'); - if (await isModelProfileManaged(agentKind)) return; - } - - let scopeManifest: BuddyModelManifest = manifest; - if (workspacePath) { - manifest.workspaceModels ??= {}; - manifest.workspaceModels[workspacePath] ??= {}; - scopeManifest = manifest.workspaceModels[workspacePath]; - } - const previousProviders = scopeManifest.providersByAgent?.[agentKind] - ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) - ?? {}; - const providers = { - ...(fullSnapshot ? {} : previousProviders), - ...Object.fromEntries(models.map((model) => [model.model_id, model.provider])), - }; - scopeManifest.providersByAgent = { - ...scopeManifest.providersByAgent, - [agentKind]: providers, - }; - if (agentKind === 'claude') { - await reconcileClaudeModels(models, manifest); - } else { - await reconcileBuddyModels(models, fullSnapshot, scopeManifest, agentKind, workspacePath); - } - await writeJsonAtomic(getModelManifestPath(), manifest); -} - -/** - * Tokenize a restricted `teamai` command string into an argv array. - * - * Supports single and double quotes so arguments containing spaces survive - * (e.g. `--name "a b"`). No variable expansion, no globbing; shell - * metacharacters like `;`, `|`, `&`, `$`, `(`, `)` are treated as literals. - * Throws when the string is empty, has an unterminated quote, or its first - * token is not exactly `teamai` — so a backend can never launch anything but - * a teamai subcommand. - */ -export function parseTeamaiCmd(raw: string): string[] { - const argv: string[] = []; - let current = ''; - let quote: '"' | "'" | null = null; - let hasToken = false; - for (const char of raw) { - if (quote) { - if (char === quote) { - quote = null; - } else { - current += char; - } - continue; - } - if (char === '"' || char === "'") { - quote = char; - hasToken = true; - continue; - } - if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { - if (hasToken) { - argv.push(current); - current = ''; - hasToken = false; - } - continue; - } - current += char; - hasToken = true; - } - if (quote) { - throw new Error('Unterminated quote in cmd'); - } - if (hasToken) { - argv.push(current); - } - if (argv.length === 0) { - throw new Error('Empty cmd'); - } - if (argv[0] !== 'teamai') { - throw new Error(`Rejected cmd: only "teamai" subcommands are allowed, got "${argv[0]}"`); - } - return argv; -} - -/** - * Resolve the teamai entry script to run a pushed cmd. Prefers the current - * process entry (`process.argv[1]`) so the running teamai is reused, and - * falls back to resolving `dist/index.js` from this bundle when argv[1] is - * unavailable (some sandboxed hook launchers). Returns null when neither - * resolves. - */ -function resolveCmdEntry(): string | null { - const argvEntry = process.argv[1]; - if (argvEntry) { - return argvEntry; - } - return resolveTeamaiEntryScript(); -} - -/** - * Execute an `uninstall_teamai` command's `cmd` string pushed via sync. Runs a - * teamai subcommand once with the current Node binary (`process.execPath`) and - * the resolved entry script — no shell, so there is no metacharacter injection - * and no PATH dependency (works inside sandboxes with a bundled Node). The - * whole `process.env` is forwarded so bundled-node runtime variables survive. - * - * Throws (which the caller acks as `failed`) when remote cmd is disabled, the - * cmd is missing/rejected, the entry cannot be resolved, or the subprocess - * exits non-zero or times out. Returns undefined on success (no version to - * report for a cmd). - */ -async function runCmdCommand( - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { - throw new Error('remote cmd disabled by client'); - } - if (!command.cmd) { - throw new Error('cmd command is missing the "cmd" field'); - } - const argv = parseTeamaiCmd(command.cmd); - const entry = resolveCmdEntry(); - if (!entry) { - throw new Error('Cannot resolve teamai entry script to run cmd'); - } - const tag = localAgentTag(context); - try { - const { stdout } = await execFileAsync( - process.execPath, - [entry, ...argv.slice(1)], - { timeout: 120_000, env: process.env, maxBuffer: 4 * 1024 * 1024 }, - ); - const summary = stdout.trim().split('\n').slice(0, 3).join(' | '); - log.debug(`${tag} cmd OK: ${command.cmd}${summary ? ` — ${summary}` : ''}`); - return undefined; - } catch (e) { - const err = e as { stderr?: string; message?: string; killed?: boolean; code?: string }; - const detail = (err.stderr?.trim() || err.message || 'unknown error') - .split('\n') - .slice(0, 3) - .join(' | ') - .slice(0, 200); - // `killed` is also set on maxBuffer overflow, so disambiguate before labeling. - const prefix = err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' - ? 'cmd output too large' - : err.killed - ? 'cmd timed out' - : 'cmd failed'; - throw new Error(`${prefix}: ${detail}`); - } -} - -/** - * Execute an install_hook_rule / uninstall_hook_rule sync command (issue #238): - * write or remove a single HTTP-source agent hook in the CURRENT tool's settings, - * tracked in the agent-hook manifest. Each tool family has its own hook format: - * claude/codex use settings.json, hermes uses config.yaml, openclaw-family uses - * HOOK.md + handler.ts. cursor is rejected. Throws on validation failure so - * the caller acks 'failed' with the message. - * - * Gated by the same TEAMAI_DISABLE_REMOTE_CMD kill-switch as runCmdCommand: an - * agent hook writes a backend-supplied command that the tool auto-runs on session - * events, so the client's single remote-command opt-out disables this surface too. - */ -async function runHookRuleCommand( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { - throw new Error('remote cmd disabled by client'); - } - const tool = context.tool; - if (!tool) { - throw new Error('install_hook_rule: missing current tool in context'); - } - if (!isAgentHookSupportedTool(tool)) { - throw new Error(`unsupported tool: ${tool}`); - } - const slug = command.slug; - if (!slug) { - throw new Error(`${command.type}: missing slug`); - } - const manifest = await loadAgentHookManifest(); - - if (command.type === 'uninstall_hook_rule') { - const rec = manifest[slug]; - if (rec) { - if (rec.tool === 'hermes') { - const { removeHermesAgentHook } = await import('./hermes-hooks.js'); - await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); - } else if (OPENCLAW_TOOLS.has(rec.tool)) { - const { removeOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await removeOpenClawAgentHook({ slug, tool: rec.tool }); - } else if (rec.tool === 'opencode') { - const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); - await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); - } else if (rec.tool === 'pi') { - const { removePiAgentHook } = await import('./pi-hooks.js'); - await removePiAgentHook(slug); - } else { - const settingsPath = await resolveToolSettingsPath(config, rec.tool); - await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); - } - delete manifest[slug]; - await saveAgentHookManifest(manifest); - } - return undefined; - } - - // install_hook_rule - const event = command.event; - const cmd = command.cmd; - if (!event || !cmd) { - throw new Error('install_hook_rule: missing event or cmd'); - } - if (!isAgentHookEvent(event)) { - throw new Error(`unsupported event: ${event}`); - } - const timeout = command.timeout ?? 10; - const matcher = command.matcher; // may be undefined → applyAgentHook defaults to '*' - - // If this slug was previously installed, remove the old entry first so re-install - // never leaves a stale hook behind. This must run even when the tool is unchanged: - // applyAgentHook only replaces within the new event (claude) or by the new command - // (codex), so a same-tool re-install that changes the event or command would - // otherwise orphan the old entry. removeAgentHook scans all events by slug (claude) - // and matches prior.command (codex), covering both cases. - const prior = manifest[slug]; - if (prior) { - try { - if (prior.tool === 'hermes') { - const { removeHermesAgentHook } = await import('./hermes-hooks.js'); - await removeHermesAgentHook({ slug, event: prior.event, command: prior.command }); - } else if (OPENCLAW_TOOLS.has(prior.tool)) { - const { removeOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await removeOpenClawAgentHook({ slug, tool: prior.tool }); - } else if (prior.tool === 'opencode') { - const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); - await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); - } else if (prior.tool === 'pi') { - const { removePiAgentHook } = await import('./pi-hooks.js'); - await removePiAgentHook(slug); - } else { - const priorPath = await resolveToolSettingsPath(config, prior.tool); - await removeAgentHook(priorPath, prior.tool, { slug, command: prior.command }); - } - } catch (e) { - log.debug(`agent hook [${slug}] prior cleanup failed: ${(e as Error).message}`); - } - } - - if (tool === 'hermes') { - const { applyHermesAgentHook } = await import('./hermes-hooks.js'); - await applyHermesAgentHook({ slug, event, command: cmd, matcher, timeout }); - } else if (OPENCLAW_TOOLS.has(tool)) { - const { applyOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await applyOpenClawAgentHook({ slug, event, command: cmd, tool, matcher, timeout }); - } else if (tool === 'opencode') { - // OpenCode loads plugins from ~/.config/opencode/plugin (user scope). - const { applyOpencodeAgentHook } = await import('./opencode-hooks.js'); - await applyOpencodeAgentHook({ slug, event, command: cmd, baseDir: getUserHome(), scope: 'user', matcher }); - } else if (tool === 'pi') { - const { applyPiAgentHook } = await import('./pi-hooks.js'); - await applyPiAgentHook({ slug, event, command: cmd, matcher, timeout }); - } else { - const settingsPath = await resolveToolSettingsPath(config, tool); - await applyAgentHook(settingsPath, tool, { slug, event, command: cmd, matcher, timeout }); - } - manifest[slug] = { tool, event, command: cmd, matcher, timeout }; - await saveAgentHookManifest(manifest); - return undefined; -} - -// ─── MCP server install / uninstall (HTTP distribution) ───── - -const VALID_MCP_TRANSPORTS = new Set(['stdio', 'http', 'sse']); - -function mcpConfigToDef(slug: string, cfg: NonNullable): McpServerDef { - if (!VALID_MCP_TRANSPORTS.has(cfg.transport)) { - throw new Error(`install_mcp: unsupported transport "${cfg.transport}" for server "${slug}"`); - } - return { - name: slug, - transport: cfg.transport as McpTransport, - command: cfg.command, - args: cfg.args, - url: cfg.url, - headers: cfg.headers, - env: cfg.env, - timeout: cfg.timeout, - requires: cfg.requires, - }; -} - -function updateManifestRecord( - manifest: ManagedMcpManifest, - key: string, - name: string, - hash: string, -): void { - const records = manifest[key] ?? []; - const idx = records.findIndex((r: ManagedMcpRecord) => r.name === name); - if (idx >= 0) { - records[idx] = { name, hash }; - } else { - records.push({ name, hash }); - } - manifest[key] = records; -} - -async function installMcpServer( - config: LocalAgentConfig, - command: LocalAgentCommand, - tool: string, - slug: string, - scope: LocalAgentScope, - workspacePath?: string, -): Promise { - if (!command.mcp_config) { - throw new Error('install_mcp: missing mcp_config'); - } - - const def = mcpConfigToDef(slug, command.mcp_config); - const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); - // Resolved through the scope seam, so the user-scope MCP file follows a root - // the member relocated (`toolRoots`) the way `teamai pull` writes it. Project - // scope returns `mcpProject` unchanged — it belongs to the workspace. - const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); - const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; - if (!toolPath) { - throw new Error(`install_mcp: unknown tool "${tool}"`); - } - - const projectScope = scope === 'project'; - const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; - if (!mcpRel) { - throw new Error(`install_mcp: tool "${tool}" has no MCP config path for scope "${scope}"`); - } - - const format = detectMcpFormat(tool); - if (!format) { - throw new Error(`install_mcp: tool "${tool}" has no known MCP format`); - } - if (!supportsTransport(format, def.transport)) { - throw new Error(`install_mcp: tool "${tool}" does not support ${def.transport} transport`); - } - - const baseDir = resolveToolBaseDir(tool, localConfig); - const targetFile = path.join(baseDir, mcpRel); - - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); - // Project scope uses THIS worktree's own manifest file (per-worktree under the - // partition; migrates legacy shared records on first read). User scope uses the - // single global file. The ownership key needs no workspace segment. - let manifestPath: string; - let manifest: ManagedMcpManifest; - if (projectScope && workspacePath) { - const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); - ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); - } else { - manifestPath = managedMcpManifestPath(dataHome); - manifest = (await readJson(manifestPath)) ?? {}; - } - const manifestKey = managedMcpManifestKey(tool, projectScope); - const owned = manifest[manifestKey] ?? []; - const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); - - if (format === 'codex') { - const block = renderCodexBlock(def); - const hash = entryHash(block); - let source = (await readFileSafe(targetFile)) ?? ''; - const present = new Set(codexServerNames(source)); - if (present.has(slug) && !ownedNames.has(slug)) { - throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); - } - updateManifestRecord(manifest, manifestKey, slug, hash); - await writeJsonAtomic(manifestPath, manifest); - source = spliceCodexBlock(source, slug, block); - await writeCodexAtomic(targetFile, source); - } else { - const entry = renderJsonEntry(format, def); - const serverKey = MCP_SERVER_KEY[format]; - const hash = entryHash(entry); - const allowBare = format === 'copilot' && projectScope; - const doc = await readJsonDoc(targetFile, serverKey, allowBare); - if (!doc) { - throw new Error(`install_mcp: cannot parse ${targetFile}`); - } - if (doc.servers[slug] !== undefined && !ownedNames.has(slug)) { - throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); - } - updateManifestRecord(manifest, manifestKey, slug, hash); - await writeJsonAtomic(manifestPath, manifest); - doc.servers[slug] = entry; - await writeJsonDoc(targetFile, serverKey, doc); - } - log.debug(`local-agent: installed MCP server "${slug}" for ${tool} (scope=${scope})`); - return command.version; -} - -async function uninstallMcpServer( - config: LocalAgentConfig, - tool: string, - slug: string, - scope: LocalAgentScope, - workspacePath?: string, -): Promise { - const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); - // Removal has to look where the install wrote: same scope seam, same root. - const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); - const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; - if (!toolPath) return; - - const projectScope = scope === 'project'; - const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; - if (!mcpRel) return; - - const format = detectMcpFormat(tool); - if (!format) return; - - const baseDir = resolveToolBaseDir(tool, localConfig); - const targetFile = path.join(baseDir, mcpRel); - - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); - // Project scope uses THIS worktree's own manifest file (per-worktree under the - // partition; migrates legacy shared records on first read). User scope uses the - // single global file. The ownership key needs no workspace segment. - let manifestPath: string; - let manifest: ManagedMcpManifest; - if (projectScope && workspacePath) { - const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); - ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); - } else { - manifestPath = managedMcpManifestPath(dataHome); - manifest = (await readJson(manifestPath)) ?? {}; - } - const manifestKey = managedMcpManifestKey(tool, projectScope); - const owned = manifest[manifestKey] ?? []; - const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); - - if (!ownedNames.has(slug)) return; - - manifest[manifestKey] = owned.filter((r: ManagedMcpRecord) => r.name !== slug); - if ((manifest[manifestKey] as ManagedMcpRecord[]).length === 0) delete manifest[manifestKey]; - await writeJsonAtomic(manifestPath, manifest); - - if (format === 'codex') { - let source = (await readFileSafe(targetFile)) ?? ''; - source = spliceCodexBlock(source, slug, null); - await writeCodexAtomic(targetFile, source); - } else { - const serverKey = MCP_SERVER_KEY[format]; - const allowBare = format === 'copilot' && projectScope; - const doc = await readJsonDoc(targetFile, serverKey, allowBare); - if (doc && doc.servers[slug] !== undefined) { - delete doc.servers[slug]; - await writeJsonDoc(targetFile, serverKey, doc); - } - } - log.debug(`local-agent: uninstalled MCP server "${slug}" from ${tool} (scope=${scope})`); -} - -async function runMcpCommand( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - const tool = context.tool; - if (!tool) { - throw new Error(`${command.type}: cannot determine current tool`); - } - const slug = command.slug; - if (!slug) { - throw new Error(`${command.type}: missing slug`); - } - assertSafeResourceName(slug); - - const scope = normalizeScope(command.scope); - const workspacePath = scope === 'project' - ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) - : undefined; - if (scope === 'project' && !workspacePath) { - throw new Error(`${command.type}: workspace command is missing workspace_path`); - } - - if (command.type === 'install_mcp') { - return installMcpServer(config, command, tool, slug, scope, workspacePath); - } - - await uninstallMcpServer(config, tool, slug, scope, workspacePath); - return command.version; -} - -async function executeCommand( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - if (command.type === 'apply_model_config') { - await applyModelConfig(config, command, context); - return; - } - // uninstall_teamai (clawpro three-phase: cmd = "teamai uninstall --force - // --agent ") executes its `cmd` string as a restricted teamai subcommand. - if (command.type === 'uninstall_teamai') { - return runCmdCommand(command, context); - } - if (command.type === 'install_hook_rule' || command.type === 'uninstall_hook_rule') { - return runHookRuleCommand(config, command, context); - } - if (command.type === 'install_mcp' || command.type === 'uninstall_mcp') { - return runMcpCommand(config, command, context); - } - const kind = commandKind(command); - const action = commandAction(command); - if (!kind || !action) { - throw new Error(`Unsupported command type: ${command.type ?? ''}`); - } - - const scope = normalizeScope(command.scope); - const workspacePath = scope === 'project' - ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) - : undefined; - if (scope === 'project' && !workspacePath) { - throw new Error('Project command is missing workspace_path'); - } - - const slug = commandSlug(command, kind); - const tool = context.tool; - if (action === 'install') { - return installDownloadedResource({ config, command, kind, slug, scope, workspacePath, tool }); - } - - await uninstallResource({ config, kind, slug, scope, workspacePath, tool }); - return commandVersion(command, kind); -} - -async function processCommands( - config: LocalAgentConfig, - commands: LocalAgentCommand[], - context: LocalAgentContext, -): Promise { - const tag = localAgentTag(context); - let modelConfigApplied = false; - for (const command of commands) { - // Keep these special types aligned with executeCommand's direct branches. - // Resource commands are recognized generically by commandKind/action; - // everything else is a future protocol extension and must be skipped. - if (isUnimplementedCommand(command) || ( - command.type !== 'apply_model_config' && - command.type !== 'uninstall_teamai' && - command.type !== 'install_hook_rule' && - command.type !== 'uninstall_hook_rule' && - command.type !== 'install_mcp' && - command.type !== 'uninstall_mcp' && - (!commandKind(command) || !commandAction(command)) - )) { - log.debug(`${tag} skipping unimplemented command ${command.id} (${command.type})`); - continue; - } - try { - const version = await executeCommand(config, command, context); - await ackCommand(config, tag, command, 'success', version); - if (command.type === 'apply_model_config') modelConfigApplied = true; - log.debug(`${tag} command ${command.id} (${command.type ?? ''}) succeeded`); - // Uninstall succeeded — skip remaining commands; the hook process exits naturally. - if (command.type === 'uninstall_teamai') { - log.debug(`${tag} uninstall_teamai completed — remaining commands skipped`); - return modelConfigApplied; - } - } catch (e) { - const error = (e as Error).message; - log.error(`${tag} command ${command.id} failed: ${error}`); - try { - await ackCommand(config, tag, command, 'failed', undefined, error); - } catch (ackError) { - log.debug(`${tag} failed to ack command ${command.id}: ${(ackError as Error).message}`); - } - } - } - return modelConfigApplied; -} - -export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return false; - - // Binding prompt is injected via stdout hook context (not HTTP), so it must run - // even inside the CloudStudio sandbox — the sandbox guard below only skips the - // HTTP report/sync that would produce a duplicate card. Resolve the workspace - // only when the prompt is enabled AND the host is a buddy agent, so every other - // path (disabled flag, or a non-buddy tool like Claude/Cursor/Codex) forks no - // git process. - if (isBindPromptEnabled() && isBindPromptTool(context.tool)) { - const workspacePath = await resolveWorkspacePath(context.cwd); - if (workspacePath) { - const sid = context.event?.sessionId; - if (context.event?.type === 'session_start') { - await ensureWorkspaceBinding(config, workspacePath, sid, context.cwd); - } - if (context.event?.type === 'prompt_submit') { - await emitBindingHint(config, workspacePath, sid, context.cwd); - } - } - } - - // CloudStudio sandbox reports a duplicate agent card (different machine id - // than the host), so we skip the report POST here. Sync + command execution - // must still run so sandboxed agents can receive pushed cmds (e.g. uninstall); - // sync produces no card, so there is no duplicate risk. - // TEAMAI_ALLOW_SANDBOX_REPORT=1 restores the report too (backward compatible). - const skipReport = isCloudStudioSandbox() && process.env.TEAMAI_ALLOW_SANDBOX_REPORT !== '1'; - if (skipReport) { - log.debug( - '[local-agent] CloudStudio sandbox detected; skipping HTTP report ' + - '(sync still runs; set TEAMAI_ALLOW_SANDBOX_REPORT=1 to report too)', - ); - } - - const tag = localAgentTag(context); - log.debug(`${tag} run: endpoint=${config.endpoint}`); - - // Report-side bookkeeping (plugin reconcile + binding prune + tool stamp) is - // tied to the report path and must stay skipped inside the CloudStudio sandbox, - // exactly as before this branch stopped returning early. In particular, - // pruneDeadWorkspaceBindings would wrongly drop host bindings whose paths are - // not mounted in the container. Only sync + command execution run when - // skipReport is set. - if (!skipReport) { - if (context.event?.type === 'session_start') { - await maybeReconcilePlugins(context); - } - - const pruned = await pruneDeadWorkspaceBindings(config); - // Resolve the current workspace independently here rather than reusing an - // earlier local, so tool attribution does not depend on the binding-prompt - // block above keeping a `workspacePath` in scope. - const currentPath = await resolveWorkspacePath(context.cwd); - const stamped = stampWorkspaceTool(config, currentPath, context.tool ?? 'workbuddy'); - if (pruned || stamped) { - await saveLocalAgentConfig(config); - } - } - - try { - if (!skipReport) { - const reportPayload = await buildReportPayload(config, context); - await localAgentFetch(config, tag, 'report', { - method: 'POST', - body: JSON.stringify(reportPayload), - }); - log.debug(`${tag} report OK`); - } - - const syncPayload = await buildSyncPayload(config, context); - const syncResponse = await localAgentFetch<{ - ok?: boolean; - cmds?: LocalAgentCommand[]; - commands?: LocalAgentCommand[]; - }>( - config, - tag, - 'sync', - { method: 'POST', body: JSON.stringify(syncPayload) }, - { redactResponseLog: true }, - ); - // Prefer the unified cmds[] (source of truth). Fall back to the legacy - // commands[] for older backends that do not yet emit cmds. An empty cmds[] - // is treated as "cmds not available" and falls back too — the backend sends - // identical data in both arrays, so this only affects old backends where - // cmds is genuinely absent/empty while commands still carries the work. - // TODO(jiahe, cmds-migration): drop the `commands` fallback once the backend - // guarantees `cmds` on all sync responses (clawpro iwiki ch.7). - const cmds = syncResponse.cmds; - const commands = cmds && cmds.length > 0 ? cmds : (syncResponse.commands ?? []); - if (commands.length > 0) { - log.debug(`${tag} sync returned ${commands.length} command(s): ${commands.map((c) => `${c.type}#${c.id}`).join(', ')}`); - const modelConfigApplied = await processCommands(config, commands, context); - if (modelConfigApplied && !skipReport) { - const reportPayload = await buildReportPayload(config, context); - await localAgentFetch(config, tag, 'report', { - method: 'POST', - body: JSON.stringify(reportPayload), - }); - log.debug(`${tag} model config report OK`); - } - } - log.debug(`${tag} sync OK (${commands.length} command(s))`); - } catch (e) { - const error = (e as Error).message; - log.error(`${tag} sync FAILED: ${error}`); - await appendErrorLog({ error, context }); - } - - return true; -} - -function statusFromEvent(event?: DashboardEvent): string { - if (!event) return 'running'; - if (event.type === 'stop' || event.type === 'process_exit') return 'stopped'; - return 'running'; -} - -/** - * Hook-handler adapter: run local-agent report/sync (incl. workspace binding - * prompts) from within the unified hook dispatcher. Accepts pre-parsed STDIN - * data so the dispatcher reads STDIN only once. - */ -export async function reportAndSyncFromHook( - stdin: Record, - tool: string, -): Promise { - const raw = JSON.stringify(stdin); - const event = await parseHookEvent(raw, tool); - // parseHookEvent resolves cwd via resolveHookCwd too, so event?.cwd would be - // identical here — resolve once and fall back to process.cwd(). - const cwd = resolveHookCwd(stdin) ?? process.cwd(); - - // SessionStart and UserPromptSubmit run this handler in the *foreground*, where - // it blocks the host IDE's hook (UserPromptSubmit cap = 10s). Narrow the - // per-fetch timeout so a slow/unreachable endpoint fails fast and the handler - // returns before the host aborts the hook. Stop / PostToolUse run detached in - // the background, so they keep the full interactive timeout to complete real - // resource syncs/downloads. - const isForegroundEvent = event?.type === 'session_start' || event?.type === 'prompt_submit'; - activeFetchTimeoutMs = isForegroundEvent - ? LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS - : LOCAL_AGENT_FETCH_TIMEOUT_MS; - try { - await reportAndSyncLocalAgent({ - cwd, - tool, - status: statusFromEvent(event ?? undefined), - event: event ?? undefined, - }); - return null; - } finally { - activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; - } -} - -/** - * Persist the API token as a credential file with owner-only (0o600) - * permissions. chmod after write so an already-existing token file (whose perms - * mode-on-create would not touch) is also tightened. - */ -export async function writeTokenFile(tokenPath: string, token: string): Promise { - await fs.promises.writeFile(tokenPath, token + '\n', { mode: 0o600 }); - await fs.promises.chmod(tokenPath, 0o600); -} - -export async function initLocalAgentHttp(options: { - endpoint: string; - token?: string; - force?: boolean; - filterAgents?: string[]; -}): Promise { - const endpoint = normalizeEndpoint(options.endpoint); - if (!endpoint) { - throw new Error('HTTP endpoint is required.'); - } - - const existing = await loadLocalAgentConfig(); - if (existing && !options.force) { - throw new Error('HTTP local agent is already initialized. Re-run with --force to overwrite.'); - } - - const config: LocalAgentConfig = { - endpoint, - token: options.token, - createdAt: existing?.createdAt ?? new Date().toISOString(), - workspaceBindings: existing?.workspaceBindings ?? {}, - userGroupId: existing?.userGroupId, - userGroupName: existing?.userGroupName, - }; - - await ensureDir(getLocalAgentHome()); - await saveLocalAgentConfig(config); - if (options.token) { - await writeTokenFile(getTokenPath(), options.token); - } - - const teamConfig = createLocalAgentTeamConfig(endpoint); - // The local agent is always user-scope and always rooted at HOME, so resolve - // the user-scope paths (Qoder CN's user config lives under ~/.qoder-cn). - await injectHooksToAllTools( - scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), - getUserHome(), - options.filterAgents, - ); - log.success(`HTTP local agent initialized at ${getConfigPath()}`); -} - -export async function pullLocalAgentForCwd(context?: LocalAgentContext): Promise { - return reportAndSyncLocalAgent({ - cwd: context?.cwd ?? process.cwd(), - tool: context?.tool ?? 'workbuddy', - status: context?.status ?? 'running', - event: context?.event, - }); -} - -/** Summary of the configured HTTP local-agent bypass, for `teamai source list`. */ -export interface LocalAgentSummary { - endpoint: string; - boundProjects: Array<{ path: string; projectName?: string; projectId: number }>; - resourceCounts: { skills: number; rules: number; claudemd: number }; -} - -/** - * Describe the configured HTTP local-agent bypass (report/sync/ack), or null when - * none is configured. Used by `teamai source list` to show the HTTP side channel - * alongside git cross-team sources. - */ -export async function describeLocalAgent(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return null; - - const boundProjects = Object.entries(config.workspaceBindings) - .filter(([, b]) => b.projectId !== 0) - .map(([workspacePath, b]) => ({ path: workspacePath, projectName: b.projectName, projectId: b.projectId })); - - const manifest = await loadManifest(); - const counts = { skills: 0, rules: 0, claudemd: 0 }; - for (const scope of Object.values(manifest.scopes)) { - counts.skills += Object.keys(scope.skills ?? {}).length; - counts.rules += Object.keys(scope.rules ?? {}).length; - counts.claudemd += Object.keys(scope.claudemd ?? {}).length; - } - - return { endpoint: config.endpoint, boundProjects, resourceCounts: counts }; -} - -/** Parse a manifest scope key back into (scope, workspacePath). */ -function parseScopeKey(key: string): { scope: LocalAgentScope; workspacePath?: string } { - if (key.startsWith('project:')) { - return { scope: 'project', workspacePath: key.slice('project:'.length) || undefined }; - } - return { scope: key === 'instance' ? 'instance' : 'user' }; -} - -/** - * Run each installed plugin's uninstall_cmd (stop daemons, deregister autostart, - * remove packages) using the persisted plugin manifest. No-op when no HTTP source - * is configured. - * - * Best-effort: failures are logged, never thrown, so teardown of the rest of teamai - * is never blocked. Must run BEFORE ~/.teamai is deleted — it reads the plugin - * manifest and endpoint config from ~/.teamai/local-agent/. - */ -export async function teardownLocalAgentPlugins(): Promise { - try { - const config = await loadLocalAgentConfig(); - if (!config) return; - await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); - } catch (e) { - log.warn(`[local-agent] plugin teardown failed: ${e instanceof Error ? e.message : String(e)}`); - } -} - -/** - * Remove every HTTP-source agent hook recorded in the agent-hook manifest from - * each tool's settings, then clear the manifest. Best-effort; used by - * `source remove-http` and `teamai uninstall` teardown (issue #238). Safe to call - * when no config / no manifest exists. - */ -export async function removeAllAgentHooks(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return; - const manifest = await loadAgentHookManifest(); - const slugs = Object.keys(manifest); - if (slugs.length === 0) return; - for (const slug of slugs) { - const rec = manifest[slug]; - try { - if (rec.tool === 'hermes') { - const { removeHermesAgentHook } = await import('./hermes-hooks.js'); - await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); - } else if (OPENCLAW_TOOLS.has(rec.tool)) { - const { removeOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await removeOpenClawAgentHook({ slug, tool: rec.tool }); - } else if (rec.tool === 'opencode') { - const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); - await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); - } else if (rec.tool === 'pi') { - const { removePiAgentHook } = await import('./pi-hooks.js'); - await removePiAgentHook(slug); - } else { - const settingsPath = await resolveToolSettingsPath(config, rec.tool); - await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); - } - } catch (e) { - log.debug(`agent hook [${slug}] teardown failed: ${(e as Error).message}`); - } - } - await saveAgentHookManifest({}); -} - -/** - * Tear down the HTTP local-agent bypass: uninstall every resource recorded in the - * manifest (skills/rules/claudemd, across all scopes) from the AI tool dirs, then - * remove the whole ~/.teamai/local-agent/ directory (config + manifest). - * - * Best-effort per resource: a single failed uninstall is logged and skipped so a - * stale entry cannot block the teardown. - */ -export async function removeLocalAgentHttp(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) { - log.info('No HTTP source configured — nothing to remove.'); - return; - } - - // Tear down installed plugins before removing teamai's local-agent state. - try { - await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); - } catch (e) { log.warn(`[local-agent] plugin teardown failed: ${(e as Error).message}`); } - - const kinds: CommandResourceKind[] = ['skill', 'rule', 'claudemd']; - const manifest = await loadManifest(); - for (const [key, scopeManifest] of Object.entries(manifest.scopes)) { - const { scope, workspacePath } = parseScopeKey(key); - for (const kind of kinds) { - for (const slug of Object.keys(scopeManifest[manifestKind(kind)] ?? {})) { - try { - await uninstallResource({ config, kind, slug, scope, workspacePath }); - } catch (e) { - log.debug(`local-agent: failed to uninstall ${kind} "${slug}": ${(e as Error).message}`); - } - } - } - } - - await removeAllAgentHooks(); - await remove(getLocalAgentHome()); - log.success('HTTP source removed (resources uninstalled, config cleared).'); -} - -export async function bindCurrentProject(options?: { projectId?: number; skip?: boolean; cwd?: string }): Promise { - const workspacePath = await resolveWorkspacePath(options?.cwd ?? process.cwd()); - if (!workspacePath) { - throw new Error('Cannot resolve current workspace path.'); - } - if (options?.skip) { - const config = await loadLocalAgentConfig(); - if (!config) { - throw new Error('Local agent not initialized. Run `teamai init --http` first.'); - } - // Skip the whole project (main checkout + all its worktrees), not just this - // one checkout, so sibling worktrees are not re-prompted. - await persistWorkspaceBinding(config, options?.cwd ?? process.cwd(), workspacePath, 0, '__skipped__'); - log.info(`已跳过绑定,以后不再提示此工作区。`); - return; - } - const binding = await bindWorkspaceToProject(workspacePath, options?.projectId); - if (!binding) { - log.info('未绑定项目。'); - } -} +export * from './providers/http/adapters/clawpro/client.js'; diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts new file mode 100644 index 000000000..d9671fe99 --- /dev/null +++ b/src/providers/http/adapters/clawpro/client.ts @@ -0,0 +1,3506 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fse from 'fs-extra'; +import { log } from '../../../../utils/logger.js'; +import { detachChild } from '../../../../utils/exec.js'; +import { parseFrontmatter } from '../../../../utils/frontmatter.js'; +import { + ensureDir, + listDirs, + listFilesRecursive, + pathExists, + readFileSafe, + readJson, + remove, + writeFile, + writeJson, + writeJsonAtomic, +} from '../../../../utils/fs.js'; +import { isToolInstalledForConfig, ResourceHandler } from '../../../../resources/base.js'; +import { RulesHandler, SkillsHandler } from '../../../../resources/index.js'; +import { injectHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, OPENCLAW_TOOLS } from '../../../../hooks.js'; +import { parseHookEvent } from '../../../../dashboard-collector.js'; +import { resolveHookCwd } from '../../../../utils/hook-cwd.js'; +import { isInteractive } from '../../../../utils/prompt.js'; +import { getAgentVersion } from '../../../../agent-version.js'; +import { getMachineId, deriveLocalAgentId } from '../../../../machine-id.js'; +import { EXCLUDED_RULE_NAMES } from '../../../../builtin-rules.js'; +import { ruleStemFromFilename } from '../../../../resources/rule-format.js'; +import { resolveTeamaiEntryScript } from '../../../../builtin-hooks.js'; +import { resolveOpenclawWorkspaceDir } from '../../../../openclaw-hooks.js'; +import { assertSafeResourceName } from '../../../../utils/path-safety.js'; +import { + detectMcpFormat, + supportsTransport, + renderJsonEntry, + renderCodexBlock, + entryHash, + MCP_SERVER_KEY, +} from '../../../../resources/mcp-format.js'; +import { + readJsonDoc, + writeJsonDoc, + writeCodexAtomic, + spliceCodexBlock, + codexServerNames, +} from '../../../../mcp-reconcile.js'; +import { normalizeAgentType } from '../../../../utils/tool-names.js'; +import { logHttpRequest, logHttpResponse } from '../../../../utils/http-log.js'; +import { injectClaudeMdSection, removeClaudeMdSection } from '../../../../utils/claudemd.js'; +import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from '../../../../plugin-lifecycle.js'; +import { + resolveBaseDir, + resolveToolBaseDir, + scopedToolPaths, + applyToolRoots, + resolveToolRootDir, + CLAUDE_TOOL_ID, + DEFAULT_CLAUDE_ROOT, + COPILOT_TOOL_ID, + getTokenPath, + TEAMAI_CLAUDEMD_START, + TEAMAI_CLAUDEMD_END, + TeamaiConfigSchema, + managedMcpManifestPath, + managedMcpManifestKey, + managedMcpWorkspaceId, + type DashboardEvent, + type LocalConfig, + type ManagedMcpManifest, + type ManagedMcpRecord, + type McpServerDef, + type McpTransport, + type Scope, + type TeamaiConfig, +} from '../../../../types.js'; +import { getUserHome } from '../../../../utils/home.js'; +import { resolveAnchors } from '../../../../utils/git.js'; + +const execFileAsync = promisify(execFile); + +const LOCAL_AGENT_DIR = 'local-agent'; +const CONFIG_FILE = 'config.json'; +const MANIFEST_FILE = 'manifest.json'; +const MODEL_MANIFEST_FILE = 'model-manifest.json'; +const REPORTER_ERROR_LOG = 'reporter/errors.jsonl'; + +/** + * Abort timeout for local-agent network calls. + * + * Prevents a fetch from hanging indefinitely when the endpoint is unreachable, + * which would otherwise keep a socket pending on the event loop and stall the + * hook subprocess until the host IDE's default hook timeout fires. + */ +const LOCAL_AGENT_FETCH_TIMEOUT_MS = 15_000; + +/** + * Per-fetch timeout to use while running inside a *foreground* hook. Foreground + * hooks block the host IDE and must finish under its per-event hook timeout + * (UserPromptSubmit/PostToolUse = 10s). Kept under 5s — and safely below the + * foreground handler's dispatch budget (LOCAL_AGENT_FG_TIMEOUT_MS = 4.5s) — so a + * slow/unreachable endpoint fails fast and the whole handler returns before the + * host aborts it. Healthy endpoints answer in well under a second, so this is + * invisible in normal use and never degrades the experience. + */ +const LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS = 3_000; + +/** Active per-fetch timeout; overridden to the hook value inside foreground hooks. */ +let activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; + +type LocalAgentScope = 'instance' | 'user' | 'project'; +type ResourceKind = 'skills' | 'rules' | 'claudemd'; +type CommandResourceKind = 'skill' | 'rule' | 'claudemd'; + +// Command types recognized but not yet implemented by this reporter. Skipped +// silently (see isUnimplementedCommand) so the suffix logic in commandKind() +// cannot misfire (e.g. uninstall_hook_rule ends in _rule and would otherwise be +// treated as a destructive rule uninstall). uninstall_teamai is NOT here — it +// carries a `cmd` and is executed by runCmdCommand (see executeCommand), so the +// local agent actually uninstalls itself and acks. +// install_hook_rule / uninstall_hook_rule are now implemented (see runHookRuleCommand) and are NOT skipped. +const UNIMPLEMENTED_COMMAND_TYPES = new Set([]); + +/** Hook commands this reporter implements (see runHookRuleCommand). Excluded from + * the handle_type==='hook' skip so they dispatch instead of being silently dropped. */ +const IMPLEMENTED_HOOK_COMMAND_TYPES = new Set(['install_hook_rule', 'uninstall_hook_rule']); + +interface WorkspaceBinding { + projectId: number; + projectName?: string; + boundAt: string; + /** Normalized owning tool (via normalizeAgentType). Optional for back-compat with existing config.json; + * absent means "not yet attributed". */ + ideType?: string; +} + +export interface LocalAgentConfig { + endpoint: string; + token?: string; + /** + * @deprecated No longer the id source. local_agent_id is now derived at + * runtime per detected tool via resolveLocalAgentId(). Kept optional so + * older config.json files still load without a rewrite. + */ + localAgentId?: string; + createdAt: string; + userGroupId?: number; + userGroupName?: string; + workspaceBindings: Record; + /** + * Optional per-endpoint path overrides. Maps a logical route name to a custom + * path so a backend that does not use the default `/api/local-agent/*` layout + * can be pointed at its own routes. Unspecified routes fall back to DEFAULT_ROUTES. + * Example: { "getConfig": "/api/plugins/config", "sync": "/v2/agent/sync" } + */ + routes?: Partial>; +} + +/** + * Logical names for every backend endpoint the local agent talks to, mapped to + * their default paths. A deployment can override any of these via config.routes + * (see LocalAgentConfig.routes) without touching call sites. + */ +export const DEFAULT_ROUTES = { + projects: '/api/projects/mine', + report: '/api/local-agent/report', + sync: '/api/local-agent/sync', + ack: '/api/local-agent/commands/ack', + getConfig: '/api/local-agent/get-config', +} as const; + +export type RouteName = keyof typeof DEFAULT_ROUTES; + +interface LocalAgentProject { + id: number; + name: string; + description?: string; +} + +interface ManifestResource { + slug: string; + version?: string; + display_name?: string; + source?: string; + installed_at: string; + /** + * Actual on-disk directory name for skills. Equals the SKILL.md `name:` when + * it differs from the server slug, else the slug. Used at uninstall time to + * locate the directory by slug (the manifest key stays the slug). + */ + dir_name?: string; +} + +interface ManifestScope { + skills: Record; + rules: Record; + claudemd: Record; +} + +interface LocalAgentManifest { + scopes: Record; +} + +interface LocalAgentCommand { + id: number; + type?: string; + scope?: string; + workspace_path?: string; + download_url?: string; + skill_slug?: string; + skill_version?: string; + rule_slug?: string; + rule_version?: string; + rule_type?: string; + handle_type?: string; + claudemd_slug?: string; + claudemd_version?: string; + resource_slug?: string; + resource_version?: string; + slug?: string; + name?: string; + version?: string; + display_name?: string; + cmd?: string; + event?: string; + matcher?: string; + timeout?: number; + mcp_config?: { + transport: string; + url?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; + timeout?: number; + requires?: string[]; + }; +} + +interface DeliveredModel { + provider: string; + model_id: string; + name: string; + base_url: string; + api_key: string; + max_tokens?: number; + context_window?: number; +} + +interface BuddyModelManifest { + codebuddy?: Record; + workbuddy?: Record; + providersByAgent?: Record>; +} + +interface ModelConfigManifest extends BuddyModelManifest { + claudeEnv?: Record; + /** + * model_id → provider for every model this reporter has applied. Claude + * stores its gateway as plain ANTHROPIC_* env vars that carry no provider, + * so this is the only way to report back the provider the server sent. + */ + providers?: Record; + workspaceModels?: Record; +} + +type ModelAgentKind = 'codebuddy' | 'workbuddy' | 'claude'; +type BuddyAgentKind = 'codebuddy' | 'workbuddy'; + +function modelAgentKind(tool: string | undefined): ModelAgentKind | undefined { + const normalized = normalizeAgentType(tool ?? ''); + if (normalized === 'codebuddy' || normalized === 'codebuddy-internal') return 'codebuddy'; + if (normalized === 'workbuddy') return 'workbuddy'; + if (normalized === 'claude') return 'claude'; + return undefined; +} + +/** + * Whether a sync command is recognized-but-unimplemented and must be skipped + * before dispatch. Matches both the known unimplemented type strings and any + * hook command (handle_type === 'hook'), so a future hook `type` outside + * UNIMPLEMENTED_COMMAND_TYPES still skips silently instead of falling through + * to commandKind() and being acked as a failure. + */ +function isUnimplementedCommand(command: LocalAgentCommand): boolean { + const type = command.type ?? ''; + if (IMPLEMENTED_HOOK_COMMAND_TYPES.has(type)) return false; + return UNIMPLEMENTED_COMMAND_TYPES.has(type) || command.handle_type === 'hook'; +} + +interface LocalAgentContext { + cwd?: string; + tool?: string; + status?: string; + event?: DashboardEvent; +} + +function getTeamaiHomePath(): string { + return path.join(getUserHome(), '.teamai'); +} + +function getLocalAgentHome(): string { + return path.join(getTeamaiHomePath(), LOCAL_AGENT_DIR); +} + +function getConfigPath(): string { + return path.join(getLocalAgentHome(), CONFIG_FILE); +} + +function getManifestPath(): string { + return path.join(getLocalAgentHome(), MANIFEST_FILE); +} + +function getModelManifestPath(): string { + return path.join(getLocalAgentHome(), MODEL_MANIFEST_FILE); +} + +function getErrorLogPath(): string { + return path.join(getTeamaiHomePath(), REPORTER_ERROR_LOG); +} + +function compileClaudemdBlock(contents: string[]): string | null { + const parts = contents.map((content) => content.trim()).filter(Boolean); + if (parts.length === 0) return null; + return [ + TEAMAI_CLAUDEMD_START, + '', + '', + parts.join('\n\n'), + '', + TEAMAI_CLAUDEMD_END, + ].join('\n'); +} + +function normalizeEndpoint(endpoint: string): string { + return endpoint.trim().replace(/\/+$/, ''); +} + +/** Normalize a route override so it is a leading-slash path (endpoint has no trailing slash). */ +function normalizeRoute(route: string): string { + const trimmed = route.trim(); + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} + +/** + * Resolve a logical route name to its path, applying config.routes overrides + * over DEFAULT_ROUTES. A blank/whitespace override is ignored (falls back to default). + */ +export function resolveRoute(config: Pick, name: RouteName): string { + const override = config.routes?.[name]; + if (override && override.trim()) return normalizeRoute(override); + return DEFAULT_ROUTES[name]; +} + +/** + * Resolve the per-tool install directory that seeds the local_agent_id hash. + * + * This must match the historical status-report口径 — `~/.` — so that a + * machine upgrading from the status-report era keeps the same id instead of + * drifting. It is derived from the same toolPaths map buildReportPayload uses: + * `~/` (e.g. `.codebuddy/skills` → `~/.codebuddy`). Unknown + * tools fall back to `~/.`, still deterministic and distinct per tool. + * Note: install_path only feeds the local hash — it never leaves the machine. + */ +function resolveAgentInstallPath(agentType: string): string { + const home = getUserHome(); + const skillsRel = createLocalAgentTeamConfig('').toolPaths[agentType]?.skills; + const rel = skillsRel ? path.dirname(skillsRel) : `.${agentType}`; + return path.join(home, rel); +} + +/** + * Resolve the local_agent_id for the current invocation. + * + * Deterministic per (detected tool + machine + install dir) — same tool on the + * same machine always yields the same id, so the backend sees a stable agent + * instead of a fresh random one every hook fire. The tool is auto-detected from + * the hook's --tool flag (context.tool); different tools (claude / codebuddy / + * workbuddy) get different ids because agent_type AND the per-tool install dir + * (~/.) both feed the hash. install_path uses the tool's own dir (not the + * teamai home) to stay byte-for-byte identical to the historical status-report + * derivation, avoiding an id change on upgrade. TEAMAI_LOCAL_AGENT_ID still + * overrides for explicit pinning. + */ +function resolveLocalAgentId(context: LocalAgentContext): string { + const envOverride = process.env.TEAMAI_LOCAL_AGENT_ID; + if (envOverride) return envOverride; + const agentType = context.tool ?? 'workbuddy'; + return deriveLocalAgentId(agentType, getMachineId(), resolveAgentInstallPath(agentType)); +} + +/** + * Detect whether we are running inside a CloudStudio container sandbox. + * + * WorkBuddy can spawn a CloudStudio Linux container that runs its own teamai + * hooks. That container has a different machine_id than the macOS host, so it + * derives a second local_agent_id and reports a duplicate agent card. Both + * signals below are absent on a normal Linux user machine, so this never + * suppresses reporting for legitimate standalone Linux users. + */ +function isCloudStudioSandbox(): boolean { + if (process.env.X_IDE_IS_CLOUDSTUDIO === 'TRUE') return true; + try { + return fs.existsSync('/var/run/cloudstudio'); + } catch { + return false; + } +} + +/** + * Build the unified log tag for local-agent debug output: `[] []` — + * the last 6 chars of the derived agent id plus the agent name (tool), so every + * line (HTTP request/response, report/sync, command ack) reads the same way. + */ +function localAgentTag(context: LocalAgentContext): string { + const tool = context.tool ?? 'workbuddy'; + return `[${resolveLocalAgentId(context).slice(-6)}] [${tool}]`; +} + +function scopeKey(scope: LocalAgentScope, workspacePath?: string): string { + return scope === 'project' ? `project:${workspacePath ?? ''}` : scope; +} + +function emptyManifestScope(): ManifestScope { + return { skills: {}, rules: {}, claudemd: {} }; +} + +async function loadManifest(): Promise { + const manifest = await readJson(getManifestPath()); + return manifest ?? { scopes: {} }; +} + +async function saveManifest(manifest: LocalAgentManifest): Promise { + await writeJson(getManifestPath(), manifest); +} + +/** One HTTP-source agent hook recorded locally so teardown can find & remove it + * across all formats (codex has no in-file marker, so its command is stored). */ +interface AgentHookRecord { + tool: string; + event: string; + command: string; + matcher?: string; + timeout?: number; +} + +/** slug → record. Kept separate from the resource manifest and from the team + * managed-hooks.json so a team pull never treats agent hooks as stale. */ +type AgentHookManifest = Record; + +function getAgentHookManifestPath(): string { + return path.join(getLocalAgentHome(), 'agent-hooks.json'); +} + +async function loadAgentHookManifest(): Promise { + const data = await readJson(getAgentHookManifestPath()); + return data && typeof data === 'object' ? data : {}; +} + +async function saveAgentHookManifest(manifest: AgentHookManifest): Promise { + await writeJsonAtomic(getAgentHookManifestPath(), manifest); +} + +/** + * The member's per-machine tool roots, from the teamai config that governs this + * directory: the project one when there is one, else the user-scope one. + * + * The local agent carries no LocalConfig — it addresses tool roots under $HOME + * directly — but it writes the same files `teamai pull` does, so a root the + * member relocated (CLAUDE_CONFIG_DIR, recorded by `teamai init`) has to reach + * them too. No config, or no entry, leaves the paths exactly as they were. + */ +async function memberToolRoots(workspacePath?: string): Promise | undefined> { + const { resolveMemberToolRoots } = await import('../../../../config.js'); + return resolveMemberToolRoots(workspacePath ?? process.cwd()); +} + +/** Claude Code's user root on this machine, honoring a relocated CLAUDE_CONFIG_DIR. */ +async function claudeUserRoot(): Promise { + return resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await memberToolRoots()); +} + +/** Resolve the current tool's settings file absolute path (user scope, $HOME base). */ +async function resolveToolSettingsPath(config: LocalAgentConfig, tool: string): Promise { + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + const toolPath = applyToolRoots(teamConfig.toolPaths, await memberToolRoots())[tool]; + if (!toolPath?.settings) { + throw new Error(`unsupported tool: ${tool} (no settings path)`); + } + return path.join(getUserHome(), toolPath.settings); +} + +function getPluginStatePath(): string { + return path.join(getLocalAgentHome(), 'plugins.json'); +} + +async function readPluginState(): Promise> { + return (await readJson>(getPluginStatePath())) ?? {}; +} + +/** + * Atomically mutate the plugin-state file under an exclusive lock. If the lock + * cannot be acquired within the timeout, throws (the caller skips this cycle + * rather than writing without the lock — reconcile is throttled, so skipping is safe). + */ +async function withPluginStateLock(mutate: (m: Record) => void): Promise { + const statePath = getPluginStatePath(); + const lockPath = `${statePath}.lock`; + await ensureDir(path.dirname(lockPath)); + const deadline = Date.now() + 5000; + let acquired = false; + while (Date.now() <= deadline) { + try { const fd = await fs.promises.open(lockPath, 'wx'); await fd.close(); acquired = true; break; } + catch (e) { + if ((e as { code?: string }).code !== 'EEXIST') throw e; + try { + const st = await fs.promises.stat(lockPath); + if (Date.now() - st.mtimeMs > 30_000) { await fs.promises.rm(lockPath, { force: true }); continue; } + } catch { /* lock vanished */ } + await new Promise((r) => setTimeout(r, 50)); + } + } + if (!acquired) throw new Error('could not acquire plugin-state lock'); + try { + const m = await readPluginState(); + mutate(m); + await writeJson(statePath, m); + } finally { + await fs.promises.rm(lockPath, { force: true }); + } +} + +function getManifestScope( + manifest: LocalAgentManifest, + scope: LocalAgentScope, + workspacePath?: string, +): ManifestScope { + const key = scopeKey(scope, workspacePath); + manifest.scopes[key] ??= emptyManifestScope(); + return manifest.scopes[key]; +} + +/** + * Canonicalize a workspace path to its physical on-disk form via realpath. + * On case-insensitive filesystems (macOS) this collapses casing variants of the + * same physical directory to one identity; it also resolves symlinks. Falls back + * to the resolved absolute path when the target does not exist (dead binding) or + * realpath fails for any other reason. + */ +async function canonicalizeWorkspacePath(value: string): Promise { + const absolute = path.resolve(value); + try { + return await fs.promises.realpath(absolute); + } catch { + return absolute; + } +} + +function mergeWorkspaceBindings( + existing: WorkspaceBinding | undefined, + incoming: WorkspaceBinding, + canonicalKey: string, +): WorkspaceBinding { + if (!existing) return incoming; + // pick base = whichever has a non-zero projectId; prefer existing on tie + const existingReal = existing.projectId !== 0; + const incomingReal = incoming.projectId !== 0; + if (existingReal && incomingReal && existing.projectId !== incoming.projectId) { + log.warn( + `local-agent: workspace ${canonicalKey} has conflicting project bindings ` + + `(${existing.projectId} vs ${incoming.projectId}); keeping ${existing.projectId}`, + ); + } + const base = existingReal || !incomingReal ? { ...existing } : { ...incoming }; + // A physical workspace tracks one owning tool (same single-owner model as + // stampWorkspaceTool). Only backfill ideType when the base lacks one; if two + // aliases were stamped by different tools, the base's ideType wins — the + // other tool re-stamps itself on its next report from the canonical path. + if (!base.ideType) base.ideType = existing.ideType ?? incoming.ideType; + return base; +} + +export async function loadLocalAgentConfig(): Promise { + const fileConfig = await readJson(getConfigPath()); + if (fileConfig?.endpoint) { + const config = { + ...fileConfig, + endpoint: normalizeEndpoint(fileConfig.endpoint), + workspaceBindings: fileConfig.workspaceBindings ?? {}, + }; + // Migrate: clear legacy group-based bindings (groupId without projectId) + const removedLegacyPaths: string[] = []; + for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { + if ('groupId' in binding && !('projectId' in (binding as Record))) { + delete config.workspaceBindings[wsPath]; + removedLegacyPaths.push(wsPath); + } + } + if (removedLegacyPaths.length > 0) { + log.warn( + `Removed ${removedLegacyPaths.length} legacy group-based workspace binding(s); ` + + `you will be prompted to re-bind on the next session.`, + ); + try { + await saveLocalAgentConfig(config); + } catch (e) { + log.debug(`local-agent: failed to persist binding cleanup: ${(e as Error).message}`); + } + } + // Migrate: canonicalize binding keys to their physical on-disk path so + // case-only / symlink aliases of the same workspace collapse to one entry. + const migrated: Record = {}; + let migrationChanged = false; + for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { + const canonicalKey = await canonicalizeWorkspacePath(wsPath); + if (canonicalKey !== wsPath) migrationChanged = true; + if (migrated[canonicalKey]) migrationChanged = true; + migrated[canonicalKey] = mergeWorkspaceBindings(migrated[canonicalKey], binding, canonicalKey); + } + config.workspaceBindings = migrated; + if (migrationChanged) { + await saveLocalAgentConfig(config); + } + return config; + } + + // Backfill: if config.json is missing but a legacy ~/.teamai/config.yaml has + // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. + const { loadLocalConfig } = await import('../../../../config.js'); + const { resolveApiKey } = await import('../../../../api-key.js'); + const legacy = await loadLocalConfig(); + if (legacy?.repo?.kind === 'http' && legacy.repo.url) { + const endpoint = normalizeEndpoint(legacy.repo.url); + const token = resolveApiKey() ?? undefined; + const backfilled: LocalAgentConfig = { + endpoint, + token, + createdAt: new Date().toISOString(), + workspaceBindings: {}, + }; + try { + await saveLocalAgentConfig(backfilled); + log.debug('local-agent: backfilled config.json from legacy ~/.teamai/config.yaml (http repo)'); + } catch (e) { + log.debug(`local-agent: backfill persist failed, using in-memory config: ${(e as Error).message}`); + } + return backfilled; + } + + const envEndpoint = + process.env.TEAMAI_HTTP_ENDPOINT ?? + process.env.TEAMAI_ENDPOINT ?? + process.env.TEAMAI_API_BASE_URL; + if (!envEndpoint) return null; + + return { + endpoint: normalizeEndpoint(envEndpoint), + token: process.env.TEAMAI_API_TOKEN ?? process.env.TEAMAI_TOKEN, + createdAt: new Date().toISOString(), + workspaceBindings: {}, + }; +} + +async function saveLocalAgentConfig(config: LocalAgentConfig): Promise { + await writeJsonAtomic(getConfigPath(), { + ...config, + endpoint: normalizeEndpoint(config.endpoint), + workspaceBindings: config.workspaceBindings ?? {}, + }); +} + +function createLocalAgentTeamConfig(endpoint: string): TeamaiConfig { + return TeamaiConfigSchema.parse({ + team: 'local-agent', + repo: endpoint, + description: 'HTTP local agent resource cache', + }); +} + +async function createResourceLocalConfig( + config: LocalAgentConfig, + scope: LocalAgentScope, + repoPath: string, + workspacePath?: string, +): Promise { + const projectScope = scope === 'project'; + return { + repo: { localPath: repoPath, remote: config.endpoint }, + username: os.userInfo().username, + scope: projectScope ? 'project' : 'user', + projectRoot: projectScope ? workspacePath : undefined, + additionalRoles: [], + // User-scope paths resolve under $HOME here, so a tool the member relocated + // must be addressed at its recorded root — the same one `teamai pull` uses. + ...(projectScope ? {} : { toolRoots: await memberToolRoots(workspacePath) }), + }; +} + +async function getResourceRepoPath(scope: LocalAgentScope, workspacePath?: string): Promise { + if (scope === 'project' && workspacePath) { + // Project resource cache is A1 (per-project) AND per-worktree: the resource + // cache (claudemd/skills/rules fragments) is what each worktree installs + // independently, and syncClaudemd merges EVERY file in this dir. The partition + // data home is shared by all linked worktrees, so the cache must live in a + // per-worktree subdir — otherwise worktree B's CLAUDE.md would merge in + // worktree A's instructions. Mirror managed-mcp's per-worktree layout. + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope('project', workspacePath); + return path.join(dataHome, 'workspaces', managedMcpWorkspaceId(workspacePath), LOCAL_AGENT_DIR, 'resources'); + } + return path.join(getLocalAgentHome(), 'resources', scope); +} + +async function ensureProjectGitignore(workspacePath: string): Promise { + const teamaiDir = path.join(workspacePath, '.teamai'); + await ensureDir(teamaiDir); + const gitignorePath = path.join(teamaiDir, '.gitignore'); + const existing = await readFileSafe(gitignorePath); + if (!existing) { + await writeFile(gitignorePath, ['# teamai local state', 'local-agent/', ''].join('\n')); + return; + } + if (!existing.split('\n').some((line) => line.trim() === 'local-agent/')) { + await writeFile(gitignorePath, existing.trimEnd() + '\nlocal-agent/\n'); + } +} + +function authHeaders(config: LocalAgentConfig, json = true): Record { + const headers: Record = {}; + if (json) headers['Content-Type'] = 'application/json'; + if (config.token) { + headers.Authorization = `Bearer ${config.token}`; + headers['X-API-Token'] = config.token; + } + return headers; +} + +async function localAgentFetch( + config: LocalAgentConfig, + tag: string, + route: RouteName, + init?: RequestInit, + opts?: { redactResponseLog?: boolean }, +): Promise { + const method = init?.method ?? 'GET'; + const url = `${config.endpoint}${resolveRoute(config, route)}`; + const headers: Record = { + ...authHeaders(config, init?.body !== undefined), + ...((init?.headers as Record | undefined) ?? {}), + }; + logHttpRequest(tag, method, url, headers, init?.body); + + const response = await fetch(url, { + ...init, + headers, + signal: init?.signal ?? AbortSignal.timeout(activeFetchTimeoutMs), + }); + const text = await response.text(); + let body: unknown = null; + if (text.trim()) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + logHttpResponse(tag, method, url, response.status, response.statusText, opts?.redactResponseLog ? '' : body); + if (!response.ok) { + const message = typeof body === 'object' && body && 'error' in body + ? String((body as { error: unknown }).error) + : text || `${response.status} ${response.statusText}`; + throw new Error(message); + } + return body as T; +} + +async function appendErrorLog(entry: unknown): Promise { + try { + await ensureDir(path.dirname(getErrorLogPath())); + await fs.promises.appendFile( + getErrorLogPath(), + JSON.stringify({ at: new Date().toISOString(), entry }) + '\n', + 'utf-8', + ); + } catch { + // Best-effort; hook execution must not fail on I/O. + } +} + +export async function fetchUserProjects(config: LocalAgentConfig): Promise { + const response = await localAgentFetch<{ ok?: boolean; projects?: LocalAgentProject[] }>( + config, + localAgentTag({}), + 'projects', + { method: 'GET' }, + ); + return response.projects ?? []; +} + +/** Mask secret values (CLI flags / key=value / bearer tokens) so they don't reach logs. */ +function redactSecrets(s: string): string { + // Secret-bearing identifiers, matched case-insensitively in flag and key=value forms. + const names = 'secret[_-]?(?:key|id)|api[_-]?key|access[_-]?token|token|password|passwd|pwd'; + return s + .replace(new RegExp(`(--(?:${names})[= ]+)\\S+`, 'gi'), '$1***') + .replace(new RegExp(`((?:${names})"?\\s*[:=]\\s*"?)[^"\\s,}]+`, 'gi'), '$1***') + .replace(/(bearer\s+)[\w.\-]+/gi, '$1***'); +} + +/** + * Execute a shell command string with a timeout. + * + * Completion is gated on the process 'exit' event, NOT 'close': a setup command that + * daemonizes and leaves the inherited stderr pipe open in a background process would never + * emit 'close', producing a false timeout even though the command itself finished. + * Rejects on non-zero exit, termination by signal, or timeout. + */ +export async function execPluginCommand(cmd: string, timeoutMs: number): Promise { + const { spawn } = await import('node:child_process'); + await new Promise((resolve, reject) => { + const child = process.platform === 'win32' + ? spawn('cmd', ['/c', cmd], { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) + : spawn('bash', ['-lc', cmd], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + let settled = false; + let timer: ReturnType; + child.stderr?.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(-8192); }); + const finish = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + detachChild(child); + fn(); + }; + timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(() => reject(new Error(`command timed out after ${timeoutMs}ms`))); + }, timeoutMs); + child.on('error', (e) => finish(() => reject(e))); + child.on('exit', (code, signal) => + finish(() => { + if (signal) return reject(new Error(`command killed by ${signal}`)); + if (code === 0) return resolve(); + const tail = stderr ? ' :: ' + redactSecrets(stderr.slice(0, 200).trim()) : ''; + reject(new Error(`command failed (exit ${code})${tail}`)); + }), + ); + }); +} + +/** + * Fetch backend plugin config. + * Route = 'getConfig' (default path /api/local-agent/get-config, overridable via config.routes). + * localAgentFetch builds `url = config.endpoint + resolveRoute(...)`, matching report/sync pattern. + */ +async function fetchPluginConfig(config: LocalAgentConfig, tag: string): Promise { + return localAgentFetch(config, tag, 'getConfig', { method: 'GET' }, { redactResponseLog: true }); +} + +const PLUGIN_PULL_INTERVAL_MS = 12 * 60 * 60 * 1000; +const PLUGIN_FAIL_BACKOFF_MS = 60 * 60 * 1000; + +function getPluginPullStatePath(): string { + return path.join(getLocalAgentHome(), 'plugin-pull.json'); +} + +function buildReconcileDeps(config: LocalAgentConfig, tag: string): ReconcileDeps { + return { + readPlugins: () => readPluginState(), + mutatePlugins: (fn) => withPluginStateLock(fn), + execCommand: (cmd, t) => execPluginCommand(cmd, t), + now: () => Date.now(), + log: { + debug: (msg) => log.debug(`${tag} ${msg}`), + // The reconcile worker runs detached (stdio: 'ignore'), so console-only log.warn + // output is discarded. Mirror warnings to debug.log so failures are traceable. + warn: (msg) => { + log.warn(`${tag} ${msg}`); + log.debug(`${tag} WARN: ${msg}`); + }, + }, + }; +} + +/** On session start, throttle-check and spawn a detached worker for plugin reconcile. Never blocks. */ +async function maybeReconcilePlugins(context: LocalAgentContext): Promise { + try { + const state = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(getPluginPullStatePath())) ?? {}; + const now = Date.now(); + if (state.lastPullAt && now - state.lastPullAt < PLUGIN_PULL_INTERVAL_MS) return; + if (state.lastFailAt && now - state.lastFailAt < PLUGIN_FAIL_BACKOFF_MS) return; + const tool = context.tool ?? 'workbuddy'; + const localAgentId = `${tool}-${resolveLocalAgentId(context)}`; + const { spawn } = await import('node:child_process'); + if (!process.argv[1]) { log.debug('[local-agent] plugin reconcile: no CLI entrypoint (argv[1]), skipping'); return; } + const child = spawn(process.execPath, [process.argv[1], 'source', 'reconcile-plugins'], + { detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env, TEAMAI_PLUGIN_LOCAL_AGENT_ID: localAgentId } }); + child.unref(); + } catch (e) { log.debug(`[local-agent] plugin reconcile spawn skipped: ${(e as Error).message}`); } +} + +/** Detached worker: pull get-config and reconcile plugins once, guarded by a reconcile lock. */ +export async function runPluginReconcileWorker(): Promise { + const config = await loadLocalAgentConfig(); + if (!config) return; + const lockPath = path.join(getLocalAgentHome(), 'plugin-reconcile.lock'); + await ensureDir(path.dirname(lockPath)); + let acquired = false; + try { + try { + const fd = await fs.promises.open(lockPath, 'wx'); + await fd.close(); + acquired = true; + } catch (e) { + if ((e as { code?: string }).code !== 'EEXIST') throw e; + try { + const st = await fs.promises.stat(lockPath); + if (Date.now() - st.mtimeMs > 30 * 60 * 1000) { + await fs.promises.rm(lockPath, { force: true }); + const fd = await fs.promises.open(lockPath, 'wx'); + await fd.close(); + acquired = true; + } + } catch { /* ignore */ } + if (!acquired) return; + } + const tag = '[local-agent] [plugin-reconcile]'; + const statePath = getPluginPullStatePath(); + try { + const resp = await fetchPluginConfig(config, tag); + const { vars, plugins } = parseGetConfig(resp); + const declaredSlugs = plugins.length ? ` [${plugins.map((p) => p.slug).join(', ')}]` : ''; + log.debug(`${tag} get-config: ${plugins.length} plugin(s) declared${declaredSlugs}`); + const laid = process.env.TEAMAI_PLUGIN_LOCAL_AGENT_ID; + if (!laid) log.debug(tag + ' no local_agent_id in env; plugins needing it will be skipped'); + const allVars = { ...vars, ...(laid ? { local_agent_id: laid } : {}) }; + const resolved: typeof plugins = []; + for (const p of plugins) { + const rp = { + ...p, + installCmd: substituteVars(p.installCmd, allVars), + updateCmd: p.updateCmd ? substituteVars(p.updateCmd, allVars) : undefined, + uninstallCmd: substituteVars(p.uninstallCmd, allVars), + runCmd: substituteVars(p.runCmd, allVars), + }; + const missing = [...new Set([ + ...unresolvedPlaceholders(rp.installCmd), + ...unresolvedPlaceholders(rp.runCmd), + ...unresolvedPlaceholders(rp.uninstallCmd), + ...(rp.updateCmd ? unresolvedPlaceholders(rp.updateCmd) : []), + ])]; + if (missing.length) { + log.warn(`${tag} plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); + log.debug(`${tag} WARN: plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); + continue; + } + resolved.push(rp); + } + await reconcilePlugins(resolved, buildReconcileDeps(config, tag)); + log.debug(`${tag} reconcile complete (${resolved.length} plugin(s) processed)`); + await writeJson(statePath, { lastPullAt: Date.now() }); + } catch (e) { + const prev = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(statePath)) ?? {}; + await writeJson(statePath, { ...prev, lastFailAt: Date.now() }); + log.debug(`${tag} reconcile failed: ${(e as Error).message}`); + } + } finally { + if (acquired) await fs.promises.rm(lockPath, { force: true }); + } +} + +async function askViaTty(prompt: string): Promise { + // Only prompt on a real interactive terminal (e.g. the user running + // `teamai bind-project` directly). In non-interactive contexts such as an + // IDE-invoked hook, stdin is piped; opening /dev/tty there succeeds when the + // host GUI keeps a controlling terminal, and readline then blocks forever + // waiting for input that never comes — hanging the hook until the host's + // timeout and stalling the IDE. Callers fall back to injecting a stdout + // binding hint when this returns null, so degrade to that instead. + // The decline stays synchronous — this runs on the hook path, where loading + // the prompt module only to say no is work nobody asked for. + if (!isInteractive()) return null; + const { askQuestion } = await import('../../../../utils/prompt.js'); + return askQuestion(prompt, ''); +} + +async function promptForProjectBinding( + workspacePath: string, + projects: LocalAgentProject[], +): Promise { + if (projects.length === 0) return null; + + log.debug(`local-agent: workspace not bound: ${workspacePath}`); + const answer = await askViaTty('是否绑定到一个项目?[y/N] '); + if (!answer || answer.toLowerCase() !== 'y') return null; + + if (projects.length === 1) return projects[0]; + + log.info('可用项目:'); + projects.forEach((project, index) => { + const desc = project.description ? ` - ${project.description}` : ''; + log.info(` ${index + 1}. ${project.name}${desc} [id=${project.id}]`); + }); + + const selection = await askViaTty(`选择项目编号(1-${projects.length},0 跳过): `); + if (selection === null || selection === '0') return null; + const index = selection ? Number.parseInt(selection, 10) : 0; + if (Number.isNaN(index) || index < 1 || index > projects.length) return null; + return projects[index - 1]; +} + +/** + * Persist a ClawPro binding decision for the current checkout. + * + * Binding is a per-project decision, so it is recorded on the `projectAnchor` + * (the main checkout, shared by a repo and all of its git worktrees — issue + * #374 / #387). It is ALSO stamped on the current `workspaceRoot` so this + * checkout is reported with the project_id immediately and its resources land + * in the current worktree (#387's workspaceRoot model — every AI tool discovers + * resources by scanning up from the launch dir, never via git-common-dir). For a + * plain repo the two anchors coincide and this writes a single entry. Falls back + * to `resolvedPath` when `cwd` is not inside a git repo. + * + * Existing fields (e.g. a stamped `ideType`) on any touched entry are preserved. + */ +async function persistWorkspaceBinding( + config: LocalAgentConfig, + cwd: string | undefined, + resolvedPath: string, + projectId: number, + projectName: string, +): Promise { + const anchors = await resolveAnchors(cwd); + const keys = new Set([resolvedPath]); + if (anchors) { + keys.add(anchors.projectAnchor); + keys.add(anchors.workspaceRoot); + } + const boundAt = new Date().toISOString(); + for (const key of keys) { + config.workspaceBindings[key] = { + ...(config.workspaceBindings[key] ?? {}), + projectId, + projectName, + boundAt, + }; + } + await saveLocalAgentConfig(config); +} + +/** + * If the current checkout is an unbound git worktree whose main checkout + * (`projectAnchor`) is already bound or skipped, copy that decision onto the + * current `workspaceRoot` and report success — so a repo is never re-prompted + * for binding once per new worktree (a `--skip` on the main checkout silences + * all of them too). Returns true when the worktree inherited a binding. + */ +async function inheritWorktreeBinding( + config: LocalAgentConfig, + cwd: string | undefined, + resolvedPath: string, +): Promise { + const anchors = await resolveAnchors(cwd); + if (!anchors || anchors.projectAnchor === anchors.workspaceRoot) return false; + const anchorBinding = config.workspaceBindings[anchors.projectAnchor]; + if (!anchorBinding) return false; + config.workspaceBindings[resolvedPath] = { + ...(config.workspaceBindings[resolvedPath] ?? {}), + projectId: anchorBinding.projectId, + projectName: anchorBinding.projectName, + boundAt: new Date().toISOString(), + }; + await saveLocalAgentConfig(config); + return true; +} + +export async function bindWorkspaceToProject( + workspacePath: string, + projectId?: number, +): Promise { + const config = await loadLocalAgentConfig(); + if (!config) { + throw new Error('HTTP local agent is not initialized. Run `teamai init --http --token ` first.'); + } + + const projects = await fetchUserProjects(config); + const project = projectId + ? projects.find((item) => item.id === projectId) + : await promptForProjectBinding(workspacePath, projects); + if (!project) return null; + + const binding: WorkspaceBinding = { + projectId: project.id, + projectName: project.name, + boundAt: new Date().toISOString(), + }; + // Record on the projectAnchor (shared across the repo's worktrees) and the + // current workspaceRoot; workspacePath is already the resolved checkout root. + await persistWorkspaceBinding(config, workspacePath, workspacePath, project.id, project.name); + log.success(`已将工作区绑定到项目:${project.name} [id=${project.id}]`); + return binding; +} + +async function ensureWorkspaceBinding( + config: LocalAgentConfig, + workspacePath: string, + sessionId?: string, + cwd?: string, +): Promise { + if (config.workspaceBindings[workspacePath]) return; + // A worktree inherits its main checkout's binding/skip decision — never prompt. + if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; + + const markerKey = sessionId || `ppid-${process.ppid}`; + const hintMarker = path.join(os.tmpdir(), `teamai-bind-session-${markerKey}`); + if (fs.existsSync(hintMarker)) return; + try { fs.writeFileSync(hintMarker, ''); } catch {} + + let projects: LocalAgentProject[]; + try { + projects = await fetchUserProjects(config); + } catch (e) { + log.debug(`local-agent: failed to load user projects: ${(e as Error).message}`); + return; + } + + if (projects.length === 0) return; + + const project = await promptForProjectBinding(workspacePath, projects); + if (project) { + await persistWorkspaceBinding(config, cwd, workspacePath, project.id, project.name); + return; + } + + // TTY prompt failed (running as hook in background) — output context for AI to prompt user + const projectList = projects.map((p) => `${p.name} (id=${p.id})`).join(', '); + const context = [ + '[teamai] 当前工作区尚未绑定项目,无法接收项目下发的 skills/rules/claudemd 资源。', + `可用项目: ${projectList}`, + `请提示用户运行: teamai bind-project`, + `或指定项目: teamai bind-project --project-id `, + ].join('\n'); + const hookOutput = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: context, + }, + }); + process.stdout.write(hookOutput + '\n'); +} + +/** + * The organization-binding prompt (TTY prompt + injected hook context) is + * on by default. Disable it explicitly with `TEAMAI_BIND_PROMPT_ENABLED=0` + * (or `false`). The manual `teamai bind-project` command is always available + * regardless. + */ +function isBindPromptEnabled(): boolean { + const flag = process.env.TEAMAI_BIND_PROMPT_ENABLED; + if (flag === undefined) return true; + const normalized = flag.toLowerCase(); + return normalized !== '0' && normalized !== 'false'; +} + +/** + * ClawPro project binding only backs CodeBuddy/WorkBuddy (the ClawPro-native + * agents); the prompt is noise for every other host (Claude, Cursor, Codex, …), + * which drove the poor UX. Gate the whole prompt — both the SessionStart TTY + * prompt and the UserPromptSubmit hint — on the current tool being a buddy + * agent. Reuses `modelAgentKind` so tool-name variants like `codebuddy-internal` + * still match (a raw Set would miss them). + */ +function isBindPromptTool(tool: string | undefined): boolean { + const kind = modelAgentKind(tool); + return kind === 'codebuddy' || kind === 'workbuddy'; +} + +async function emitBindingHint( + config: LocalAgentConfig, + workspacePath: string, + sessionId?: string, + cwd?: string, +): Promise { + if (config.workspaceBindings[workspacePath]) return; + // A worktree inherits its main checkout's binding/skip decision — never hint. + if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; + + // Only hint once per session — use a temp marker file keyed by sessionId + const markerKey = sessionId || `ppid-${process.ppid}`; + const hintMarker = path.join(os.tmpdir(), `teamai-bind-hint-${markerKey}`); + if (fs.existsSync(hintMarker)) return; + try { fs.writeFileSync(hintMarker, ''); } catch {} + + let projects: LocalAgentProject[]; + try { + projects = await fetchUserProjects(config); + } catch { + return; + } + if (projects.length === 0) return; + + const skipNum = projects.length + 1; + const choices = projects.map((p, i) => `${i + 1}. 绑定到「${p.name}」项目`); + const actions = projects.map((p, i) => `- 用户回复 ${i + 1} → 执行 \`teamai bind-project --project-id ${p.id}\`,完成后回复"绑定完成,请开新会话生效"`); + const context = [ + '[ClawPro项目 绑定提示] 你必须原样展示下面的选项列表,不得修改编号或内容。等用户回复数字后执行对应命令。', + '', + '---', + '当前工作区未绑定ClawPro项目。绑定后可同步团队 skills/rules。请回复数字选择:', + '', + ...choices, + `${skipNum}. 不绑定,以后也不再提示`, + '---', + '', + '执行规则(不要展示给用户):', + ...actions, + `- 用户回复 ${skipNum} → 执行 \`teamai bind-project --skip\`,完成后回复"已跳过,以后不再提示"`, + ].join('\n'); + const hookOutput = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: context, + }, + }); + process.stdout.write(hookOutput + '\n'); +} + +function isEphemeralTaskDir(dir: string): boolean { + const segments = dir.split(path.sep); + const wbIdx = segments.lastIndexOf('WorkBuddy'); + if (wbIdx < 0 || wbIdx >= segments.length - 1) return false; + return /^\d{4}-\d{2}-\d{2}/.test(segments[wbIdx + 1]); +} + +async function resolveWorkspacePath(cwd?: string): Promise { + if (!cwd) return undefined; + const absolute = path.resolve(cwd); + if (isEphemeralTaskDir(absolute)) return undefined; + try { + const { stdout } = await execFileAsync('git', ['-C', absolute, 'rev-parse', '--show-toplevel']); + const root = stdout.trim(); + return await canonicalizeWorkspacePath(root || absolute); + } catch { + return await canonicalizeWorkspacePath(absolute); + } +} + +interface ReportedResource { + slug: string; + version?: string; + display_name?: string; + source: string; +} + +/** + * Resolve a resource's source by looking it up in the local-agent manifest: + * slugs recorded there were installed via HTTP distribution (`enterprise`); + * everything else present only on disk is treated as `local`. + */ +function resolveSource(slug: string, manifestSlugs: Set): string { + return manifestSlugs.has(slug) ? 'enterprise' : 'local'; +} + +/** + * Scan a tool's on-disk skills directory. Each sub-directory containing a + * SKILL.md is one installed skill; slug/version/display_name come from its + * front-matter (falling back to the directory name). + */ +async function scanSkillsFromDisk( + skillsDir: string, + manifestSlugs: Set, +): Promise { + if (!(await pathExists(skillsDir))) return []; + const dirs = (await listDirs(skillsDir)).filter((name) => !name.startsWith('.') && !name.startsWith('_')); + const results: ReportedResource[] = []; + for (const dir of dirs) { + const skillMd = path.join(skillsDir, dir, 'SKILL.md'); + if (!(await pathExists(skillMd))) continue; + const fm = await readFrontmatter(skillMd); + const slug = typeof fm.name === 'string' && fm.name ? fm.name : dir; + const version = fm.version != null ? String(fm.version) : undefined; + results.push({ + slug, + version, + display_name: slug, + source: resolveSource(slug, manifestSlugs), + }); + } + return results.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +/** + * Scan a tool's on-disk rules directory. Every `.md` file (recursively) is one + * installed rule; the slug is its path relative to the rules dir without the + * `.md` extension. + */ +async function scanRulesFromDisk( + rulesDir: string, + manifestSlugs: Set, +): Promise { + if (!(await pathExists(rulesDir))) return []; + // Cursor stores rules as `.mdc`, every other tool as `.md`; match by stem so + // a Cursor agent still reports its installed rules. + const files = await listFilesRecursive(rulesDir); + const results: ReportedResource[] = []; + const seen = new Set(); + for (const file of files) { + const slug = ruleStemFromFilename(file); + if (slug === null) continue; + if (seen.has(slug)) continue; // Same rule under both extensions + seen.add(slug); + // Skip CLI built-in / legacy rules (e.g. teamai-recall) so they are not + // reported as user-installed resources — mirrors the pull/uninstall filter. + if (EXCLUDED_RULE_NAMES.has(path.basename(slug)) || EXCLUDED_RULE_NAMES.has(slug)) continue; + results.push({ + slug, + display_name: slug, + source: resolveSource(slug, manifestSlugs), + }); + } + return results.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +/** Collect every skill/rule slug recorded across all manifest scopes. + * For skills, also includes dir_name (the on-disk SKILL.md name) so that + * scanSkillsFromDisk — which uses SKILL.md name as the reported slug — + * correctly resolves source as 'enterprise' even when dir_name ≠ slug. + */ +function collectManifestSlugs(manifest: LocalAgentManifest): { skills: Set; rules: Set } { + const skills = new Set(); + const rules = new Set(); + for (const scope of Object.values(manifest.scopes)) { + for (const [slug, entry] of Object.entries(scope.skills ?? {})) { + skills.add(slug); + if (entry.dir_name) skills.add(entry.dir_name); + } + for (const slug of Object.keys(scope.rules ?? {})) rules.add(slug); + } + return { skills, rules }; +} + +/** + * Scan the managed-mcp manifest for a given scope and return MCP servers as + * ReportedResource entries. Only servers tracked in managed-mcp.json (i.e. + * installed via HTTP distribution) are reported with source = 'enterprise'. + * + * Results are scoped to the current `tool` so a report never leaks another + * tool's MCP inventory. The manifest is keyed by the same key the installer + * writes under (see `installMcpServer`): `tool` at user scope, `${tool}:project` + * at project scope. `tool` is the raw hook-context value (not run through + * normalizeAgentType), matching how the installer keys the manifest. + */ +async function scanMcpFromManifest( + scope: 'user' | 'project', + tool: string, + projectRoot?: string, +): Promise { + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope(scope, projectRoot); + + // Project scope reads THIS worktree's own manifest file (per-worktree under the + // partition; migrates legacy shared records on first read). User scope reads the + // single global file. Either way every record in the loaded file belongs to this + // scope, so no key filtering is needed. + let manifest: ManagedMcpManifest; + if (scope === 'project' && projectRoot) { + const { loadProjectMcpManifest } = await import('../../../../utils/mcp-manifest.js'); + ({ manifest } = await loadProjectMcpManifest(dataHome, projectRoot)); + } else { + manifest = (await readJson(managedMcpManifestPath(dataHome))) ?? {}; + } + + const manifestKey = `${tool}${scope === 'project' ? ':project' : ''}`; + const records = manifest[manifestKey]; + if (!Array.isArray(records)) return []; + + const seen = new Set(); + const results: ReportedResource[] = []; + for (const rec of records) { + if (!rec.name || seen.has(rec.name)) continue; + seen.add(rec.name); + results.push({ slug: rec.name, source: 'enterprise' }); + } + return results.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +interface ReportedModel { + provider: string; + model_id: string; + name?: string; + source: string; +} + +/** + * Scan the models a tool can currently use, as configured on disk. The server + * requires both `provider` and `model_id`, so entries that cannot supply them + * are dropped rather than reported as incomplete. `source` is derived from the + * model manifest, mirroring how skills/rules classify enterprise vs local. + * + * Only CodeBuddy, WorkBuddy, and Claude keep a discoverable model config; + * every other tool reports nothing. User-owned models are omitted: the + * backend cannot resolve them, so only entries still matching a TeamAI + * delivery are reported. + */ +function buddyModelsPath(agentKind: BuddyAgentKind, workspacePath?: string): string { + return workspacePath + ? path.join(workspacePath, '.codebuddy', 'models.json') + : path.join(getUserHome(), `.${agentKind}`, 'models.json'); +} + +function modelConfigDisplayPath(filePath: string): string { + if (filePath.endsWith(`${path.sep}.codebuddy${path.sep}models.json`)) { + return '.codebuddy/models.json'; + } + if (filePath.endsWith(`${path.sep}.workbuddy${path.sep}models.json`)) { + return '~/.workbuddy/models.json'; + } + return path.basename(filePath); +} + +async function scanModelsFromDisk(tool: string, workspacePath?: string): Promise { + const manifest = (await readJson(getModelManifestPath())) ?? {}; + const agentKind = modelAgentKind(tool); + + if (agentKind === 'codebuddy' || agentKind === 'workbuddy') { + const scopeManifest = workspacePath ? manifest.workspaceModels?.[workspacePath] : manifest; + const providers = scopeManifest?.providersByAgent?.[agentKind] + ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) + ?? {}; + const raw = await readJson(buddyModelsPath(agentKind, workspacePath)); + const entries = Array.isArray(raw) + ? raw + : (Array.isArray((raw as { models?: unknown } | null)?.models) + ? (raw as { models: unknown[] }).models + : []); + const owned = (agentKind === 'codebuddy' + ? scopeManifest?.codebuddy + : scopeManifest?.workbuddy) ?? {}; + const results: ReportedModel[] = []; + for (const entry of entries) { + if (typeof entry !== 'object' || entry === null) continue; + const { id, vendor, name } = entry as Record; + if (typeof id !== 'string' || !id) continue; + if (typeof vendor !== 'string' || !vendor) continue; + // CodeBuddy / WorkBuddy may normalize a model entry by adding capability + // metadata. The manifest's model id is the durable proof that TeamAI + // delivered it; requiring an exact object hash would hide such entries. + if (owned[id] === undefined || providers[id] !== vendor) continue; + results.push({ + provider: vendor, + model_id: id, + ...(typeof name === 'string' && name ? { name } : {}), + source: 'enterprise', + }); + } + return results; + } + + if (agentKind === 'claude' && !workspacePath) { + const providers = manifest.providersByAgent?.claude ?? manifest.providers ?? {}; + const settings = await readJson<{ env?: unknown }>( + path.join(await claudeUserRoot(), 'settings.json'), + ); + const env = settings?.env; + if (typeof env !== 'object' || env === null || Array.isArray(env)) return []; + const { ANTHROPIC_CUSTOM_MODEL_OPTION: modelId, ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: name } = + env as Record; + if (typeof modelId !== 'string' || !modelId) return []; + const managed = manifest.claudeEnv?.ANTHROPIC_CUSTOM_MODEL_OPTION; + if (managed === undefined || entryHash(modelId) !== managed) return []; + const provider = providers[modelId]; + if (!provider) return []; + return [{ + provider, + model_id: modelId, + ...(typeof name === 'string' && name ? { name } : {}), + source: 'enterprise', + }]; + } + + return []; +} + +/** + * Remove workspace bindings whose directory no longer exists on disk. + * + * Workspace bindings are only ever added, never removed, so a deleted + * project directory would otherwise be reported forever and the server + * (full-sync snapshot) could never drop it. This prunes such stale + * entries in place. Applies to skipped ('__skipped__', projectId 0) + * entries too — a deleted directory should not leave a permanent sentinel. + * + * @param config - Loaded local-agent config; its workspaceBindings map is mutated in place. + * @returns True if at least one binding was removed. + */ +export async function pruneDeadWorkspaceBindings(config: LocalAgentConfig): Promise { + let changed = false; + for (const workspacePath of Object.keys(config.workspaceBindings)) { + try { + await fs.promises.stat(workspacePath); + } catch (error) { + // Only prune when the directory is confirmed gone (ENOENT). Transient + // failures — permission errors, unreachable network mounts — must NOT + // delete a still-valid binding, or the server's full-sync snapshot + // would drop that workspace's resources. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + delete config.workspaceBindings[workspacePath]; + changed = true; + } else { + log.debug( + `local-agent: keeping workspace binding ${workspacePath} despite stat error: ${(error as Error).message}`, + ); + } + } + } + return changed; +} + +/** + * Stamps the workspace binding's owning tool when the hook fires from that tool's own process. + * Because hooks are invoked from within the tool's process, cwd === binding.path is the + * authoritative signal that this binding belongs to the triggering tool. + * + * Returns true if the binding was modified (caller should persist config), false otherwise. + */ +export function stampWorkspaceTool( + config: LocalAgentConfig, + currentPath: string | null | undefined, + tool: string, +): boolean { + if (!currentPath) return false; + const binding = config.workspaceBindings[currentPath]; + if (!binding) return false; + const normalized = normalizeAgentType(tool); + if (binding.ideType === normalized) return false; + binding.ideType = normalized; + return true; +} + +/** + * Select the workspace paths that belong to the current tool for reporting. + * + * A binding belongs to the current tool when its stamped ideType matches, or — + * for the not-yet-attributed current cwd — when it is the workspace the hook + * fired from. An empty/absent ideType on a non-cwd binding is treated as + * unattributed and excluded (it self-heals once its owning tool reports from it). + */ +function selectToolWorkspaces( + config: LocalAgentConfig, + currentPath: string | null | undefined, + currentTool: string, +): string[] { + const paths = new Set(Object.keys(config.workspaceBindings)); + if (currentPath) paths.add(currentPath); + return Array.from(paths).filter((wsPath) => { + const b = config.workspaceBindings[wsPath]; + const wsTool = (b?.ideType || undefined) ?? (wsPath === currentPath ? currentTool : undefined); + return wsTool === currentTool; + }); +} + +export async function buildReportPayload( + config: LocalAgentConfig, + context: LocalAgentContext, +): Promise> { + const manifest = await loadManifest(); + + // Resource discovery scans the tool's on-disk skills/rules directories rather + // than the manifest, so locally-installed resources (not just HTTP-distributed + // ones) are reported. `source` is derived from the manifest: slugs recorded + // there are `enterprise`, the rest `local`. + const tool = context.tool ?? 'workbuddy'; + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + const manifestSlugs = collectManifestSlugs(manifest); + + // Resolve paths through the same user-scope seam the installers use: tools + // that relocate their user customization root (copilot via $COPILOT_HOME) or + // lay user scope out differently from project scope declare a `userScope` + // block. Reading the raw toolPaths map against $HOME would scan the project + // layout under the wrong base — e.g. ~/.github/skills for copilot, a path + // teamai never writes to — and silently report nothing. + const scanScope = async (workspacePath?: string): Promise<{ skills: ReportedResource[]; rules: ReportedResource[] }> => { + const scope: LocalAgentScope = workspacePath ? 'project' : 'user'; + const localConfig = await createResourceLocalConfig(config, scope, workspacePath ?? getUserHome(), workspacePath); + const toolPath = scopedToolPaths(teamConfig, localConfig)[tool]; + if (!toolPath) return { skills: [], rules: [] }; + const baseDir = resolveToolBaseDir(tool, localConfig); + const skills = toolPath.skills + ? await scanSkillsFromDisk(path.join(baseDir, toolPath.skills), manifestSlugs.skills) + : []; + const rules = toolPath.rules + ? await scanRulesFromDisk(path.join(baseDir, toolPath.rules), manifestSlugs.rules) + : []; + return { skills, rules }; + }; + + const userScope = await scanScope(); + + const userLevel: Record = { group_id: config.userGroupId }; + if (userScope.skills.length > 0) userLevel.skills = userScope.skills; + if (userScope.rules.length > 0) userLevel.rules = userScope.rules; + const userMcps = await scanMcpFromManifest('user', tool); + if (userMcps.length > 0) userLevel.mcps = userMcps; + // Omitted when empty for the same full-sync reason as skills/rules: the + // server treats a present array as a snapshot, so [] would wipe the models. + const userModels = await scanModelsFromDisk(tool); + if (userModels.length > 0) userLevel.models = userModels; + + const payload: Record = { + agent_type: normalizeAgentType(tool), + agent_version: await getAgentVersion(tool), + local_agent_id: resolveLocalAgentId(context), + host_name: os.hostname(), + os: os.platform(), + started_at: config.createdAt, + last_status: context.status ?? 'running', + // Instance-level skills/rules are a phase-1 legacy concept. They are + // deliberately omitted (not sent as []): the server treats present arrays + // as a full-sync snapshot ("消失即删"), so an empty array would wipe any + // instance-level resources. Omitting the field leaves them untouched. + user_level: userLevel, + }; + + const currentPath = await resolveWorkspacePath(context.cwd); + const currentTool = normalizeAgentType(tool); + const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); + if (targetPaths.length > 0) { + const workspaceResults = await Promise.all( + targetPaths.map(async (wsPath) => { + const wsScope = await scanScope(wsPath); + const wsBinding = config.workspaceBindings[wsPath]; + const workspace: Record = { + path: wsPath, + name: path.basename(wsPath), + ide_type: currentTool, + project_id: wsBinding?.projectId, + }; + if (wsScope.skills.length > 0) workspace.skills = wsScope.skills; + if (wsScope.rules.length > 0) workspace.rules = wsScope.rules; + const wsMcps = await scanMcpFromManifest('project', tool, wsPath); + if (wsMcps.length > 0) workspace.mcps = wsMcps; + const wsModels = await scanModelsFromDisk(tool, wsPath); + if (wsModels.length > 0) workspace.models = wsModels; + return workspace; + }), + ); + payload.workspaces = workspaceResults; + } + + return payload; +} + +export async function buildSyncPayload( + config: LocalAgentConfig, + context: LocalAgentContext, +): Promise> { + const payload: Record = { + agent_type: normalizeAgentType(context.tool ?? 'workbuddy'), + local_agent_id: resolveLocalAgentId(context), + status: context.status ?? 'running', + }; + const currentPath = await resolveWorkspacePath(context.cwd); + const currentTool = normalizeAgentType(context.tool ?? 'workbuddy'); + const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); + if (targetPaths.length > 0) { + payload.workspaces = targetPaths.map((wsPath) => { + const wsBinding = config.workspaceBindings[wsPath]; + return { + path: wsPath, + name: path.basename(wsPath), + ide_type: currentTool, + project_id: wsBinding?.projectId, + }; + }); + } + return payload; +} + +function commandKind(command: LocalAgentCommand): CommandResourceKind | null { + // Unified cmds[] carries handle_type; legacy commands[] carries rule_type. + // Both map a prompt rule to the claudemd resource kind. + if (command.rule_type === 'prompt' || command.handle_type === 'prompt') return 'claudemd'; + if (command.handle_type === 'rule') return 'rule'; + if (command.handle_type === 'hook') return null; // defensive; skipped before dispatch + const type = command.type ?? ''; + if (type.endsWith('_skill') || type === '') return 'skill'; + if (type.endsWith('_claudemd') || type.endsWith('_claude_md')) return 'claudemd'; + if (type.endsWith('_rule')) return 'rule'; + return null; +} + +function commandAction(command: LocalAgentCommand): 'install' | 'uninstall' | null { + const type = command.type ?? ''; + if (type === '') return 'install'; + if (type.startsWith('install_')) return 'install'; + if (type.startsWith('uninstall_')) return 'uninstall'; + return null; +} + +/** + * Reject slugs that could escape the resource directory. Slugs come from + * backend sync commands and are used directly in filesystem paths, so a value + * like `../../.ssh/authorized_keys` would otherwise write outside the repo. + */ +function validateSlug(slug: string): string { + if ( + !slug || + slug.includes('/') || + slug.includes('\\') || + slug.includes('..') || + path.isAbsolute(slug) + ) { + throw new Error(`Invalid resource slug: ${slug}`); + } + return slug; +} + +function commandSlug(command: LocalAgentCommand, kind: CommandResourceKind): string { + const slug = + kind === 'skill' ? command.skill_slug : + kind === 'rule' ? command.rule_slug : + (command.claudemd_slug ?? command.rule_slug); + const resolved = slug ?? command.resource_slug ?? command.slug ?? command.name; + if (!resolved) { + throw new Error(`Missing ${kind} slug`); + } + return validateSlug(resolved); +} + +function commandVersion(command: LocalAgentCommand, kind: CommandResourceKind): string | undefined { + return ( + kind === 'skill' ? command.skill_version : + kind === 'rule' ? command.rule_version : + (command.claudemd_version ?? command.rule_version) + ) ?? command.resource_version ?? command.version; +} + +/** + * Normalize a backend-sent scope string to an internal LocalAgentScope. + * + * The backend emits `user` / `workspace` (see clawpro local-agent-api.md); + * the deprecated `instance` is no longer sent. Internally project-level + * resources use the `project` scope, so `workspace` maps to `project`. + * Any unrecognized value falls back to `user` (global install). + * + * This only maps the scope; presence of `workspace_path` for project scope + * is validated by the caller (executeCommand throws if it is missing). + */ +function normalizeScope(raw?: string): LocalAgentScope { + if (raw === 'workspace' || raw === 'project') return 'project'; + if (raw !== undefined && raw !== 'user' && raw !== 'instance') { + log.debug(`local-agent: unknown scope "${raw}", defaulting to user`); + } + return 'user'; +} + +function manifestKind(kind: CommandResourceKind): ResourceKind { + return kind === 'skill' ? 'skills' : kind === 'rule' ? 'rules' : 'claudemd'; +} + +/** Only http(s) downloads are allowed — reject file:, ftp:, gopher:, etc. */ +function assertHttpUrl(rawUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`Invalid download URL: ${rawUrl}`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported download URL scheme: ${parsed.protocol}`); + } + return parsed; +} + +/** + * Fetch a resource by URL. download_url comes from backend sync commands, so it + * is treated as untrusted: only http(s) is honoured (no file:// / local-path + * copy, which would be arbitrary local file read), and redirects are followed + * manually so every hop's scheme is re-validated instead of blindly trusting + * whatever Location the server returns. + */ +async function downloadResource(downloadUrl: string): Promise { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'teamai-local-agent-')); + const filePath = path.join(tmpDir, 'resource'); + + let current = assertHttpUrl(downloadUrl); + let response: Response; + const maxRedirects = 5; + // One timeout budget for the whole download (all redirect hops combined), so a + // chain of slow redirects cannot exceed the intended bound. + const signal = AbortSignal.timeout(activeFetchTimeoutMs); + for (let hop = 0; ; hop++) { + response = await fetch(current, { redirect: 'manual', signal }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) break; + if (hop >= maxRedirects) { + throw new Error(`Download failed: too many redirects (${downloadUrl})`); + } + current = assertHttpUrl(new URL(location, current).toString()); + continue; + } + break; + } + + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + await fs.promises.writeFile(filePath, buffer); + return filePath; +} + +const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); + +async function isZipFile(filePath: string): Promise { + const fd = await fs.promises.open(filePath, 'r'); + try { + const buf = Buffer.alloc(4); + await fd.read(buf, 0, 4, 0); + return buf.equals(ZIP_MAGIC); + } finally { + await fd.close(); + } +} + +async function resolveMarkdownFromDownload(downloadedPath: string, slug: string): Promise { + if (await isZipFile(downloadedPath)) { + const extractDir = await extractZip(downloadedPath); + return findMarkdownFile(extractDir, slug); + } + return downloadedPath; +} + +async function extractZip(zipPath: string): Promise { + const extractDir = path.join(path.dirname(zipPath), 'extracted'); + await ensureDir(extractDir); + await execFileAsync('unzip', ['-q', zipPath, '-d', extractDir]); + return extractDir; +} + +async function findFirst( + dir: string, + predicate: (absolutePath: string, name: string) => Promise, +): Promise { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + if (await predicate(absolute, entry.name)) return absolute; + if (entry.isDirectory()) { + const nested = await findFirst(absolute, predicate); + if (nested) return nested; + } + } + return null; +} + +async function findSkillRoot(extractDir: string): Promise { + if (await pathExists(path.join(extractDir, 'SKILL.md'))) return extractDir; + const skillMd = await findFirst(extractDir, async (absolute, name) => name === 'SKILL.md' && (await pathExists(absolute))); + if (!skillMd) throw new Error('Downloaded skill package does not contain SKILL.md'); + return path.dirname(skillMd); +} + +async function findMarkdownFile(extractDir: string, preferredName: string): Promise { + const preferred = await findFirst( + extractDir, + async (_absolute, name) => name === `${preferredName}.md` || name === preferredName, + ); + if (preferred) return preferred; + + const firstMd = await findFirst(extractDir, async (_absolute, name) => name.endsWith('.md')); + if (!firstMd) throw new Error('Downloaded package does not contain a markdown file'); + return firstMd; +} + +async function readFrontmatter(filePath: string): Promise> { + const content = await readFileSafe(filePath); + if (!content) return {}; + return parseFrontmatter(content).data; +} + +/** + * Decide the on-disk directory name for a skill. The SKILL.md `name:` field is + * the source of truth for how the skill is identified by the AI tool, so use it + * when it differs from the server-provided slug (matching the git-path behaviour + * in skill-command.ts / #144). Falls back to the slug when the name is missing, + * empty, equal to the slug, or fails path-safety validation. + */ +async function resolveSkillDirName(skillRoot: string, slug: string): Promise { + const fm = await readFrontmatter(path.join(skillRoot, 'SKILL.md')); + const name = typeof fm.name === 'string' ? fm.name.trim() : ''; + if (!name || name === slug) return slug; + try { + assertSafeResourceName(name); + return name; + } catch { + log.debug(`[local-agent] keeping slug "${slug}" as skill dir (SKILL.md name "${name}" failed safety check)`); + return slug; + } +} + +async function installDownloadedResource(input: { + config: LocalAgentConfig; + command: LocalAgentCommand; + kind: CommandResourceKind; + slug: string; + scope: LocalAgentScope; + workspacePath?: string; + tool?: string; +}): Promise { + if (!input.command.download_url) { + throw new Error(`Missing download_url for ${input.command.type ?? 'install_skill'}`); + } + + const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); + if (input.scope === 'project' && input.workspacePath + && repoPath.startsWith(path.join(input.workspacePath, '.teamai') + path.sep)) { + // Only gitignore when the cache actually lands inside the workspace (a legacy, + // un-migrated install). A partitioned install keeps it under ~/.teamai, so + // there is nothing in the workspace to ignore. + await ensureProjectGitignore(input.workspacePath); + } + await ensureDir(repoPath); + + const downloadedPath = await downloadResource(input.command.download_url); + try { + const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); + const tool = input.tool ?? 'workbuddy'; + const toolPath = fullTeamConfig.toolPaths[tool]; + if (!toolPath) { + throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); + } + const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; + const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); + // Ensure the tool root directory exists before dispatch so isToolInstalled + // gate does not skip the resource when the workspace is freshly bound. + // Restricted to project scope: user-scope installs use $HOME as baseDir and + // should continue to rely on isToolInstalled as the gate. + if (localConfig.scope === 'project') { + try { + const baseDir = resolveBaseDir(localConfig); + const resourceToolPath = + input.kind === 'skill' ? toolPath.skills : + input.kind === 'rule' ? toolPath.rules : + // Default branch covers the 'claudemd' kind; if a new CommandResourceKind + // is added, revisit this mapping so it doesn't silently fall through to claudemd. + toolPath.claudemd; + if (resourceToolPath && resourceToolPath.includes('/')) { + const rootSegment = resourceToolPath.split('/')[0]; + await ensureDir(path.join(baseDir, rootSegment)); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('resolveBaseDir')) { + log.warn(`Cannot resolve base dir to pre-create tool root: ${msg}`); + } else { + log.debug(`Failed to pre-create tool root directory: ${msg}`); + } + } + } + const now = new Date().toISOString(); + let displayName = input.command.display_name ?? input.slug; + // On-disk skill directory name (SKILL.md name when it differs from slug). + // Stays the slug for rules/claudemd. Recorded in the manifest so uninstall + // can find the directory by slug. + let skillDirName = input.slug; + + if (input.kind === 'skill') { + const extractDir = await extractZip(downloadedPath); + const skillRoot = await findSkillRoot(extractDir); + skillDirName = await resolveSkillDirName(skillRoot, input.slug); + const dest = path.join(repoPath, 'skills', skillDirName); + await remove(dest); + await fse.copy(skillRoot, dest, { overwrite: true }); + const fm = await readFrontmatter(path.join(dest, 'SKILL.md')); + displayName = typeof fm.name === 'string' ? fm.name : displayName; + await new SkillsHandler().pullItem({ + name: skillDirName, + type: 'skills', + sourcePath: dest, + relativePath: `skills/${skillDirName}`, + }, teamConfig, localConfig); + } else if (input.kind === 'rule') { + const ruleFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); + const dest = path.join(repoPath, 'rules', `${input.slug}.md`); + await fse.ensureDir(path.dirname(dest)); + await fse.copyFile(ruleFile, dest); + await new RulesHandler().pullAllRules(teamConfig, localConfig); + } else { + const mdFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); + const dest = path.join(repoPath, 'claudemd', `${input.slug}.md`); + await fse.ensureDir(path.dirname(dest)); + await fse.copyFile(mdFile, dest); + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); + } + + const version = commandVersion(input.command, input.kind); + const manifest = await loadManifest(); + const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); + scopeManifest[manifestKind(input.kind)][input.slug] = { + slug: input.slug, + version, + display_name: displayName, + source: 'enterprise', + installed_at: now, + ...(input.kind === 'skill' && skillDirName !== input.slug ? { dir_name: skillDirName } : {}), + }; + await saveManifest(manifest); + return version; + } finally { + await remove(path.dirname(downloadedPath)); + } +} + +async function uninstallResource(input: { + config: LocalAgentConfig; + kind: CommandResourceKind; + slug: string; + scope: LocalAgentScope; + workspacePath?: string; + tool?: string; +}): Promise { + const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); + const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); + const tool = input.tool ?? 'workbuddy'; + const toolPath = fullTeamConfig.toolPaths[tool]; + if (!toolPath) { + throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); + } + const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; + const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); + const manifest = await loadManifest(); + const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); + + if (input.kind === 'skill') { + // The directory was created under the SKILL.md name (recorded as dir_name); + // remove by that name, falling back to the slug for older installs. + const dirName = scopeManifest.skills[input.slug]?.dir_name ?? input.slug; + await new SkillsHandler().removeItem(dirName, teamConfig, localConfig); + } else if (input.kind === 'rule') { + await new RulesHandler().removeItem(input.slug, teamConfig, localConfig); + } else { + await remove(path.join(repoPath, 'claudemd', `${input.slug}.md`)); + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); + } + + delete scopeManifest[manifestKind(input.kind)][input.slug]; + await saveManifest(manifest); +} + +async function resolveHermesUserBaseDir(): Promise { + try { + const envWs = process.env.TEAMAI_HERMES_WORKSPACE; + if (envWs && path.isAbsolute(envWs)) return envWs; + const cfg = await readJson(getConfigPath()); + const bindings = cfg?.workspaceBindings; + if (bindings && typeof bindings === 'object') { + const entries = Object.entries(bindings) + .filter(([p, v]) => path.isAbsolute(p) && v?.ideType === 'hermes') + .sort((a, b) => (b[1].boundAt ?? '').localeCompare(a[1].boundAt ?? '')); + for (const [p] of entries) { + if (await pathExists(path.join(p, '.hermes'))) return p; + } + } + } catch { /* fall through */ } + return undefined; +} + +async function syncClaudemd( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + repoPath: string, + workspacePath?: string, +): Promise { + const claudemdDir = path.join(repoPath, 'claudemd'); + const files = (await pathExists(claudemdDir)) + ? (await fse.readdir(claudemdDir)).filter((file) => file.endsWith('.md')).sort() + : []; + const contents: string[] = []; + for (const file of files) { + const content = await readFileSafe(path.join(claudemdDir, file)); + if (content) contents.push(content); + } + const block = compileClaudemdBlock(contents); + let syncedAny = false; + + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!toolPath.claudemd) continue; + + let baseDir = resolveToolBaseDir(tool, localConfig); + let resolvedAbsPath: string | null = null; + + if (tool === 'openclaw' && localConfig.scope !== 'project') { + const openclawWs = await resolveOpenclawWorkspaceDir(workspacePath); + if (openclawWs) { + resolvedAbsPath = path.join(openclawWs, path.basename(toolPath.claudemd)); + } + } else if (tool === 'hermes' && localConfig.scope !== 'project') { + const hermesBase = workspacePath ?? await resolveHermesUserBaseDir(); + if (hermesBase) { + baseDir = hermesBase; + log.debug(`local-agent: hermes user-scope baseDir resolved to ${baseDir}`); + } + } + + const toolInstalled = resolvedAbsPath + ? await pathExists(resolvedAbsPath) + : tool === COPILOT_TOOL_ID && localConfig.scope === 'user' + ? await isToolInstalledForConfig(tool, toolPath.claudemd, localConfig) + : toolPath.claudemd.includes('/') + ? await ResourceHandler.isToolInstalled(toolPath.claudemd, baseDir) + : await pathExists(path.join(baseDir, `.${tool}`)); + if (!toolInstalled) { + log.debug(`Skipped CLAUDE.md sync for ${tool}: target not found`); + continue; + } + + const claudeMdPath = resolvedAbsPath ?? path.join(baseDir, toolPath.claudemd); + try { + if (block) { + await injectClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, block); + log.debug(`local-agent: synced CLAUDE.md instructions to ${tool}`); + syncedAny = true; + } else { + await removeClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END); + log.debug(`local-agent: removed CLAUDE.md instructions from ${tool}`); + syncedAny = true; + } + } catch (e) { + log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${(e as Error).message}`); + } + } + + if (files.length > 0 && !syncedAny) { + throw new Error('CLAUDE.md sync landed on no tool: every configured target was skipped'); + } +} + +async function ackCommand( + config: LocalAgentConfig, + tag: string, + command: LocalAgentCommand, + status: 'success' | 'failed', + version?: string, + error?: string, +): Promise { + await localAgentFetch(config, tag, 'ack', { + method: 'POST', + body: JSON.stringify({ + id: command.id, + type: command.type ?? '', + status, + error: error ?? '', + version, + }), + }); +} + +function requireModelString( + value: unknown, + field: keyof Pick, +): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`apply_model_config: ${field} must be a non-empty string`); + } + return value.trim(); +} + +/** CodeBuddy maxOutputTokens when the backend omits max_tokens or sends 0 (Go zero value). */ +const DEFAULT_MAX_TOKENS = 4096; + +function optionalPositiveInteger(value: unknown, field: 'max_tokens' | 'context_window'): number | undefined { + if (value === undefined || value === null || value === '') return undefined; + const normalized = typeof value === 'string' && /^\d+$/.test(value) + ? Number(value) + : value; + if (!Number.isSafeInteger(normalized) || (normalized as number) < 0) { + throw new Error(`apply_model_config: ${field} must be a positive integer`); + } + // 0 is the Go zero value for an unset int, not a real output/context cap. + if ((normalized as number) === 0) return undefined; + return normalized as number; +} + +function parseDeliveredModels(raw: string | undefined): { models: DeliveredModel[]; fullSnapshot: boolean } { + if (!raw) throw new Error('apply_model_config: missing cmd'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('apply_model_config: cmd must be valid JSON'); + } + const fullSnapshot = ( + typeof parsed === 'object' && + parsed !== null && + 'models' in parsed + ); + const values = fullSnapshot ? (parsed as { models?: unknown }).models : [parsed]; + if (!Array.isArray(values)) { + throw new Error('apply_model_config: models must be an array'); + } + + const seen = new Set(); + const models = values.map((value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('apply_model_config: each model must be an object'); + } + const input = value as Record; + const modelId = requireModelString(input.model_id, 'model_id'); + if (modelId === '__proto__' || modelId === 'prototype' || modelId === 'constructor') { + throw new Error(`apply_model_config: reserved model_id "${modelId}"`); + } + const model: DeliveredModel = { + provider: requireModelString(input.provider, 'provider'), + model_id: modelId, + name: requireModelString(input.name, 'name'), + base_url: requireModelString(input.base_url, 'base_url'), + api_key: requireModelString(input.api_key, 'api_key'), + max_tokens: optionalPositiveInteger(input.max_tokens, 'max_tokens') ?? DEFAULT_MAX_TOKENS, + context_window: optionalPositiveInteger(input.context_window, 'context_window'), + }; + let parsedUrl: URL; + try { + parsedUrl = new URL(model.base_url); + } catch { + throw new Error('apply_model_config: base_url must be a valid URL'); + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('apply_model_config: base_url must use http or https'); + } + if (seen.has(model.model_id)) { + throw new Error(`apply_model_config: duplicate model_id "${model.model_id}"`); + } + seen.add(model.model_id); + return model; + }); + return { models, fullSnapshot }; +} + +function buddyModelEntry(model: DeliveredModel): Record { + const baseUrl = model.base_url.replace(/\/+$/, ''); + return { + id: model.model_id, + name: model.name, + vendor: model.provider, + apiKey: model.api_key, + ...(model.context_window === undefined ? {} : { maxInputTokens: model.context_window }), + ...(model.max_tokens === undefined ? {} : { maxOutputTokens: model.max_tokens }), + url: baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`, + supportsToolCall: true, + }; +} + +async function readJsonObject(filePath: string): Promise> { + const source = await readFileSafe(filePath); + if (source === null) return {}; + try { + const parsed = JSON.parse(source); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('root must be an object'); + } + return parsed as Record; + } catch (error) { + throw new Error( + `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, + ); + } +} + +/** Atomically update a model dotfile without replacing a user-managed symlink. */ +async function writeModelJson(filePath: string, data: unknown): Promise { + let targetPath = filePath; + try { + if ((await fs.promises.lstat(filePath)).isSymbolicLink()) { + targetPath = await fs.promises.realpath(filePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + // Set the temp file's mode before the atomic rename. A post-rename chmod + // would introduce a symlink-following TOCTOU window. + await writeJsonAtomic(targetPath, data, { mode: 0o600 }); +} + +async function ensureWorkspaceModelGitignore(workspacePath: string): Promise { + const gitignorePath = path.join(workspacePath, '.codebuddy', '.gitignore'); + const existing = await readFileSafe(gitignorePath); + if (existing === null) { + await writeFile(gitignorePath, '# Local model credentials\nmodels.json\n'); + return; + } + if (existing.split(/\r?\n/).some((line) => line.trim() === 'models.json')) return; + await writeFile(gitignorePath, `${existing.trimEnd()}\nmodels.json\n`); +} + +async function readBuddyModelEntries( + filePath: string, +): Promise<{ existing: unknown[]; doc?: Record }> { + const source = await readFileSafe(filePath); + // The current WorkBuddy / CodeBuddy documentation uses an object wrapper. + // Product releases also accept the legacy top-level array, so preserve that + // shape when a user already has one instead of forcing a migration. + if (source === null) return { existing: [], doc: {} }; + try { + const parsed = JSON.parse(source); + if (Array.isArray(parsed)) return { existing: parsed }; + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('root must be an object or array'); + } + const doc = parsed as Record; + const existing = doc.models === undefined ? [] : doc.models; + if (!Array.isArray(existing)) { + throw new Error('models must be an array'); + } + return { existing, doc }; + } catch (error) { + throw new Error( + `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, + ); + } +} + +async function reconcileBuddyModels( + models: DeliveredModel[], + fullSnapshot: boolean, + scopeManifest: BuddyModelManifest, + agentKind: BuddyAgentKind, + workspacePath?: string, +): Promise { + const targetFile = buddyModelsPath(agentKind, workspacePath); + const { existing, doc } = await readBuddyModelEntries(targetFile); + const previouslyManaged = (agentKind === 'codebuddy' + ? scopeManifest.codebuddy + : scopeManifest.workbuddy) ?? {}; + const nextManaged: Record = fullSnapshot ? {} : { ...previouslyManaged }; + const incomingIds = new Set(models.map((model) => model.model_id)); + const removedManaged = new Set(); + const preserved: unknown[] = []; + const occupiedIds = new Set(); + for (const entry of existing) { + const id = typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' + ? (entry as { id: string }).id + : undefined; + if (id && previouslyManaged[id] && entryHash(entry) === previouslyManaged[id]) { + if (fullSnapshot || incomingIds.has(id)) { + removedManaged.add(id); + continue; + } + preserved.push(entry); + occupiedIds.add(id); + continue; + } + preserved.push(entry); + if (id) occupiedIds.add(id); + if (id && previouslyManaged[id]) delete nextManaged[id]; + } + + for (const model of models) { + if (occupiedIds.has(model.model_id)) continue; + const entry = buddyModelEntry(model); + preserved.push(entry); + nextManaged[model.model_id] = entryHash(entry); + } + + if (workspacePath) await ensureWorkspaceModelGitignore(workspacePath); + if (doc) { + doc.models = preserved; + if (Array.isArray(doc.availableModels) && doc.availableModels.length > 0) { + const available = doc.availableModels.filter( + (id): id is string => typeof id === 'string' && !removedManaged.has(id), + ); + for (const id of Object.keys(nextManaged)) { + if (!available.includes(id)) available.push(id); + } + doc.availableModels = available; + } + await writeModelJson(targetFile, doc); + } else { + await writeModelJson(targetFile, preserved); + } + if (agentKind === 'codebuddy') scopeManifest.codebuddy = nextManaged; + else scopeManifest.workbuddy = nextManaged; +} + +function claudeEnvForModel(model: DeliveredModel): Record { + const baseUrl = model.base_url.replace(/\/+$/, '').replace(/\/v1$/, ''); + return { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: model.api_key, + ANTHROPIC_CUSTOM_MODEL_OPTION: model.model_id, + ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: model.name, + }; +} + +/** + * Drop the gateway env and model profile the agent delivered into `claudeRoot`, + * and forget them in the manifest. For `teamai init` moving the Claude root: + * the credentials would otherwise stay in a profile nothing syncs any more. + * No-op when the agent never delivered a model. + */ +export async function releaseClaudeModelConfig(claudeRoot: string): Promise { + const manifest = (await readJson(getModelManifestPath())) ?? {}; + if (Object.keys(manifest.claudeEnv ?? {}).length === 0) return; + await reconcileClaudeModels([], manifest, claudeRoot); + await writeJsonAtomic(getModelManifestPath(), manifest); + log.info(`Removed the delivered Claude model config from ${claudeRoot}`); +} + +async function reconcileClaudeModels( + models: DeliveredModel[], + manifest: ModelConfigManifest, + claudeRoot?: string, +): Promise { + claudeRoot ??= await claudeUserRoot(); + const settingsPath = path.join(claudeRoot, 'settings.json'); + const profilePath = path.join(claudeRoot, 'teamai-models.json'); + const previousHashes = manifest.claudeEnv ?? {}; + const settings = await readJsonObject(settingsPath); + const rawEnv = settings.env === undefined ? {} : settings.env; + if (typeof rawEnv !== 'object' || rawEnv === null || Array.isArray(rawEnv)) { + throw new Error(`apply_model_config: env must be an object in ${settingsPath}`); + } + const env = { ...(rawEnv as Record) }; + + if (models.length === 0) { + const canRemoveGateway = Object.entries(previousHashes).every( + ([key, hash]) => entryHash(env[key]) === hash, + ); + if (canRemoveGateway && Object.keys(previousHashes).length > 0) { + for (const key of Object.keys(previousHashes)) delete env[key]; + settings.env = env; + await writeModelJson(settingsPath, settings); + } + await remove(profilePath); + manifest.claudeEnv = {}; + return; + } + + // Claude supports one active custom gateway in settings. The first model + // seeds that gateway; other candidates remain discoverable from its + // /v1/models endpoint when the gateway implements model discovery. + const desired = claudeEnvForModel(models[0]); + await writeModelJson(profilePath, { env: desired }); + + // Any of these keys, if the user already set them, means they have their own + // Claude gateway/model config we must not silently take over. Beyond the keys + // we write, this also covers auth the gateway swap would break + // (ANTHROPIC_API_KEY, ANTHROPIC_CUSTOM_HEADERS) and the user's model choice + // (ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL). + const conflictKeys = new Set([ + ...Object.keys(desired), + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_CUSTOM_HEADERS', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ]); + // A value is TeamAI-managed if it matches what we recorded last time or the + // value currently in settings.json (settings.json is our own output, so a + // process.env var equal to it is Claude re-injecting settings.json.env into + // the hook, not a user's independent shell config). + const isManagedValue = (key: string, value: unknown): boolean => ( + (previousHashes[key] !== undefined && entryHash(value) === previousHashes[key]) || + (env[key] !== undefined && entryHash(value) === entryHash(env[key])) + ); + // The guard must see config the user set outside settings.json too. Users who + // run Claude via shell `export ANTHROPIC_*` keep no gateway in settings.json, + // so a settings-only check reads env[key] === undefined and wrongly seizes the + // slot — settings.json then outranks the shell env and breaks their setup. + // But Claude injects settings.json.env into the hook's own environment, so we + // must NOT treat our own re-injected managed values as a user conflict — doing + // so would block every follow-up sync and strand the user on stale config. + const userOwnsInShell = (key: string): boolean => { + const value = process.env[key]; + if (typeof value !== 'string' || value.trim() === '') return false; + return !isManagedValue(key, value); + }; + const shellConflicts = [...conflictKeys].filter(userOwnsInShell); + if (shellConflicts.length > 0) { + // The user has their own gateway/model config in the shell. Skip the write, + // but keep manifest.claudeEnv intact: this is not the user editing our + // managed settings.json entry, so we must stay able to reconcile once the + // shell config goes away. + await appendErrorLog({ + apply_model_config: 'skipped claude gateway: user owns conflicting shell env', + conflicts: shellConflicts, + }); + return; + } + + const canManage = [...conflictKeys].every((key) => ( + env[key] === undefined || + (previousHashes[key] !== undefined && entryHash(env[key]) === previousHashes[key]) + )); + if (!canManage) { + manifest.claudeEnv = {}; + return; + } + + for (const [key, hash] of Object.entries(previousHashes)) { + if (entryHash(env[key]) === hash) delete env[key]; + } + Object.assign(env, desired); + settings.env = env; + await writeModelJson(settingsPath, settings); + manifest.claudeEnv = Object.fromEntries( + Object.entries(desired).map(([key, value]) => [key, entryHash(value)]), + ); +} + +async function applyModelConfig( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + const { models, fullSnapshot } = parseDeliveredModels(command.cmd); + const manifest = (await readJson(getModelManifestPath())) ?? {}; + const agentKind = modelAgentKind(context.tool); + if (!agentKind) { + throw new Error(`apply_model_config: unsupported agent "${context.tool ?? ''}"`); + } + + const scope = normalizeScope(command.scope); + const workspacePath = scope === 'project' + ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) + : undefined; + if (scope === 'project' && !workspacePath) { + throw new Error('apply_model_config: workspace command is missing workspace_path'); + } + if (workspacePath && config.workspaceBindings[workspacePath] === undefined) { + throw new Error( + `apply_model_config: workspace "${path.basename(workspacePath)}" is not a registered binding`, + ); + } + if (agentKind === 'claude' && workspacePath) { + throw new Error('apply_model_config: workspace scope is unsupported for claude'); + } + if (!workspacePath) { + // An explicit profile switch takes precedence over server delivery. Keep + // both the Agent config and delivery manifest intact for a later restore. + const { isModelProfileManaged } = await import('../../../../models/switch.js'); + if (await isModelProfileManaged(agentKind)) return; + } + + let scopeManifest: BuddyModelManifest = manifest; + if (workspacePath) { + manifest.workspaceModels ??= {}; + manifest.workspaceModels[workspacePath] ??= {}; + scopeManifest = manifest.workspaceModels[workspacePath]; + } + const previousProviders = scopeManifest.providersByAgent?.[agentKind] + ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) + ?? {}; + const providers = { + ...(fullSnapshot ? {} : previousProviders), + ...Object.fromEntries(models.map((model) => [model.model_id, model.provider])), + }; + scopeManifest.providersByAgent = { + ...scopeManifest.providersByAgent, + [agentKind]: providers, + }; + if (agentKind === 'claude') { + await reconcileClaudeModels(models, manifest); + } else { + await reconcileBuddyModels(models, fullSnapshot, scopeManifest, agentKind, workspacePath); + } + await writeJsonAtomic(getModelManifestPath(), manifest); +} + +/** + * Tokenize a restricted `teamai` command string into an argv array. + * + * Supports single and double quotes so arguments containing spaces survive + * (e.g. `--name "a b"`). No variable expansion, no globbing; shell + * metacharacters like `;`, `|`, `&`, `$`, `(`, `)` are treated as literals. + * Throws when the string is empty, has an unterminated quote, or its first + * token is not exactly `teamai` — so a backend can never launch anything but + * a teamai subcommand. + */ +export function parseTeamaiCmd(raw: string): string[] { + const argv: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let hasToken = false; + for (const char of raw) { + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + hasToken = true; + continue; + } + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { + if (hasToken) { + argv.push(current); + current = ''; + hasToken = false; + } + continue; + } + current += char; + hasToken = true; + } + if (quote) { + throw new Error('Unterminated quote in cmd'); + } + if (hasToken) { + argv.push(current); + } + if (argv.length === 0) { + throw new Error('Empty cmd'); + } + if (argv[0] !== 'teamai') { + throw new Error(`Rejected cmd: only "teamai" subcommands are allowed, got "${argv[0]}"`); + } + return argv; +} + +/** + * Resolve the teamai entry script to run a pushed cmd. Prefers the current + * process entry (`process.argv[1]`) so the running teamai is reused, and + * falls back to resolving `dist/index.js` from this bundle when argv[1] is + * unavailable (some sandboxed hook launchers). Returns null when neither + * resolves. + */ +function resolveCmdEntry(): string | null { + const argvEntry = process.argv[1]; + if (argvEntry) { + return argvEntry; + } + return resolveTeamaiEntryScript(); +} + +/** + * Execute an `uninstall_teamai` command's `cmd` string pushed via sync. Runs a + * teamai subcommand once with the current Node binary (`process.execPath`) and + * the resolved entry script — no shell, so there is no metacharacter injection + * and no PATH dependency (works inside sandboxes with a bundled Node). The + * whole `process.env` is forwarded so bundled-node runtime variables survive. + * + * Throws (which the caller acks as `failed`) when remote cmd is disabled, the + * cmd is missing/rejected, the entry cannot be resolved, or the subprocess + * exits non-zero or times out. Returns undefined on success (no version to + * report for a cmd). + */ +async function runCmdCommand( + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { + throw new Error('remote cmd disabled by client'); + } + if (!command.cmd) { + throw new Error('cmd command is missing the "cmd" field'); + } + const argv = parseTeamaiCmd(command.cmd); + const entry = resolveCmdEntry(); + if (!entry) { + throw new Error('Cannot resolve teamai entry script to run cmd'); + } + const tag = localAgentTag(context); + try { + const { stdout } = await execFileAsync( + process.execPath, + [entry, ...argv.slice(1)], + { timeout: 120_000, env: process.env, maxBuffer: 4 * 1024 * 1024 }, + ); + const summary = stdout.trim().split('\n').slice(0, 3).join(' | '); + log.debug(`${tag} cmd OK: ${command.cmd}${summary ? ` — ${summary}` : ''}`); + return undefined; + } catch (e) { + const err = e as { stderr?: string; message?: string; killed?: boolean; code?: string }; + const detail = (err.stderr?.trim() || err.message || 'unknown error') + .split('\n') + .slice(0, 3) + .join(' | ') + .slice(0, 200); + // `killed` is also set on maxBuffer overflow, so disambiguate before labeling. + const prefix = err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + ? 'cmd output too large' + : err.killed + ? 'cmd timed out' + : 'cmd failed'; + throw new Error(`${prefix}: ${detail}`); + } +} + +/** + * Execute an install_hook_rule / uninstall_hook_rule sync command (issue #238): + * write or remove a single HTTP-source agent hook in the CURRENT tool's settings, + * tracked in the agent-hook manifest. Each tool family has its own hook format: + * claude/codex use settings.json, hermes uses config.yaml, openclaw-family uses + * HOOK.md + handler.ts. cursor is rejected. Throws on validation failure so + * the caller acks 'failed' with the message. + * + * Gated by the same TEAMAI_DISABLE_REMOTE_CMD kill-switch as runCmdCommand: an + * agent hook writes a backend-supplied command that the tool auto-runs on session + * events, so the client's single remote-command opt-out disables this surface too. + */ +async function runHookRuleCommand( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { + throw new Error('remote cmd disabled by client'); + } + const tool = context.tool; + if (!tool) { + throw new Error('install_hook_rule: missing current tool in context'); + } + if (!isAgentHookSupportedTool(tool)) { + throw new Error(`unsupported tool: ${tool}`); + } + const slug = command.slug; + if (!slug) { + throw new Error(`${command.type}: missing slug`); + } + const manifest = await loadAgentHookManifest(); + + if (command.type === 'uninstall_hook_rule') { + const rec = manifest[slug]; + if (rec) { + if (rec.tool === 'hermes') { + const { removeHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); + } else if (OPENCLAW_TOOLS.has(rec.tool)) { + const { removeOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await removeOpenClawAgentHook({ slug, tool: rec.tool }); + } else if (rec.tool === 'opencode') { + const { removeOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (rec.tool === 'pi') { + const { removePiAgentHook } = await import('../../../../pi-hooks.js'); + await removePiAgentHook(slug); + } else { + const settingsPath = await resolveToolSettingsPath(config, rec.tool); + await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); + } + delete manifest[slug]; + await saveAgentHookManifest(manifest); + } + return undefined; + } + + // install_hook_rule + const event = command.event; + const cmd = command.cmd; + if (!event || !cmd) { + throw new Error('install_hook_rule: missing event or cmd'); + } + if (!isAgentHookEvent(event)) { + throw new Error(`unsupported event: ${event}`); + } + const timeout = command.timeout ?? 10; + const matcher = command.matcher; // may be undefined → applyAgentHook defaults to '*' + + // If this slug was previously installed, remove the old entry first so re-install + // never leaves a stale hook behind. This must run even when the tool is unchanged: + // applyAgentHook only replaces within the new event (claude) or by the new command + // (codex), so a same-tool re-install that changes the event or command would + // otherwise orphan the old entry. removeAgentHook scans all events by slug (claude) + // and matches prior.command (codex), covering both cases. + const prior = manifest[slug]; + if (prior) { + try { + if (prior.tool === 'hermes') { + const { removeHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await removeHermesAgentHook({ slug, event: prior.event, command: prior.command }); + } else if (OPENCLAW_TOOLS.has(prior.tool)) { + const { removeOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await removeOpenClawAgentHook({ slug, tool: prior.tool }); + } else if (prior.tool === 'opencode') { + const { removeOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (prior.tool === 'pi') { + const { removePiAgentHook } = await import('../../../../pi-hooks.js'); + await removePiAgentHook(slug); + } else { + const priorPath = await resolveToolSettingsPath(config, prior.tool); + await removeAgentHook(priorPath, prior.tool, { slug, command: prior.command }); + } + } catch (e) { + log.debug(`agent hook [${slug}] prior cleanup failed: ${(e as Error).message}`); + } + } + + if (tool === 'hermes') { + const { applyHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await applyHermesAgentHook({ slug, event, command: cmd, matcher, timeout }); + } else if (OPENCLAW_TOOLS.has(tool)) { + const { applyOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await applyOpenClawAgentHook({ slug, event, command: cmd, tool, matcher, timeout }); + } else if (tool === 'opencode') { + // OpenCode loads plugins from ~/.config/opencode/plugin (user scope). + const { applyOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await applyOpencodeAgentHook({ slug, event, command: cmd, baseDir: getUserHome(), scope: 'user', matcher }); + } else if (tool === 'pi') { + const { applyPiAgentHook } = await import('../../../../pi-hooks.js'); + await applyPiAgentHook({ slug, event, command: cmd, matcher, timeout }); + } else { + const settingsPath = await resolveToolSettingsPath(config, tool); + await applyAgentHook(settingsPath, tool, { slug, event, command: cmd, matcher, timeout }); + } + manifest[slug] = { tool, event, command: cmd, matcher, timeout }; + await saveAgentHookManifest(manifest); + return undefined; +} + +// ─── MCP server install / uninstall (HTTP distribution) ───── + +const VALID_MCP_TRANSPORTS = new Set(['stdio', 'http', 'sse']); + +function mcpConfigToDef(slug: string, cfg: NonNullable): McpServerDef { + if (!VALID_MCP_TRANSPORTS.has(cfg.transport)) { + throw new Error(`install_mcp: unsupported transport "${cfg.transport}" for server "${slug}"`); + } + return { + name: slug, + transport: cfg.transport as McpTransport, + command: cfg.command, + args: cfg.args, + url: cfg.url, + headers: cfg.headers, + env: cfg.env, + timeout: cfg.timeout, + requires: cfg.requires, + }; +} + +function updateManifestRecord( + manifest: ManagedMcpManifest, + key: string, + name: string, + hash: string, +): void { + const records = manifest[key] ?? []; + const idx = records.findIndex((r: ManagedMcpRecord) => r.name === name); + if (idx >= 0) { + records[idx] = { name, hash }; + } else { + records.push({ name, hash }); + } + manifest[key] = records; +} + +async function installMcpServer( + config: LocalAgentConfig, + command: LocalAgentCommand, + tool: string, + slug: string, + scope: LocalAgentScope, + workspacePath?: string, +): Promise { + if (!command.mcp_config) { + throw new Error('install_mcp: missing mcp_config'); + } + + const def = mcpConfigToDef(slug, command.mcp_config); + const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); + // Resolved through the scope seam, so the user-scope MCP file follows a root + // the member relocated (`toolRoots`) the way `teamai pull` writes it. Project + // scope returns `mcpProject` unchanged — it belongs to the workspace. + const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); + const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; + if (!toolPath) { + throw new Error(`install_mcp: unknown tool "${tool}"`); + } + + const projectScope = scope === 'project'; + const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; + if (!mcpRel) { + throw new Error(`install_mcp: tool "${tool}" has no MCP config path for scope "${scope}"`); + } + + const format = detectMcpFormat(tool); + if (!format) { + throw new Error(`install_mcp: tool "${tool}" has no known MCP format`); + } + if (!supportsTransport(format, def.transport)) { + throw new Error(`install_mcp: tool "${tool}" does not support ${def.transport} transport`); + } + + const baseDir = resolveToolBaseDir(tool, localConfig); + const targetFile = path.join(baseDir, mcpRel); + + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); + // Project scope uses THIS worktree's own manifest file (per-worktree under the + // partition; migrates legacy shared records on first read). User scope uses the + // single global file. The ownership key needs no workspace segment. + let manifestPath: string; + let manifest: ManagedMcpManifest; + if (projectScope && workspacePath) { + const { loadProjectMcpManifest } = await import('../../../../utils/mcp-manifest.js'); + ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); + } else { + manifestPath = managedMcpManifestPath(dataHome); + manifest = (await readJson(manifestPath)) ?? {}; + } + const manifestKey = managedMcpManifestKey(tool, projectScope); + const owned = manifest[manifestKey] ?? []; + const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); + + if (format === 'codex') { + const block = renderCodexBlock(def); + const hash = entryHash(block); + let source = (await readFileSafe(targetFile)) ?? ''; + const present = new Set(codexServerNames(source)); + if (present.has(slug) && !ownedNames.has(slug)) { + throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); + } + updateManifestRecord(manifest, manifestKey, slug, hash); + await writeJsonAtomic(manifestPath, manifest); + source = spliceCodexBlock(source, slug, block); + await writeCodexAtomic(targetFile, source); + } else { + const entry = renderJsonEntry(format, def); + const serverKey = MCP_SERVER_KEY[format]; + const hash = entryHash(entry); + const allowBare = format === 'copilot' && projectScope; + const doc = await readJsonDoc(targetFile, serverKey, allowBare); + if (!doc) { + throw new Error(`install_mcp: cannot parse ${targetFile}`); + } + if (doc.servers[slug] !== undefined && !ownedNames.has(slug)) { + throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); + } + updateManifestRecord(manifest, manifestKey, slug, hash); + await writeJsonAtomic(manifestPath, manifest); + doc.servers[slug] = entry; + await writeJsonDoc(targetFile, serverKey, doc); + } + log.debug(`local-agent: installed MCP server "${slug}" for ${tool} (scope=${scope})`); + return command.version; +} + +async function uninstallMcpServer( + config: LocalAgentConfig, + tool: string, + slug: string, + scope: LocalAgentScope, + workspacePath?: string, +): Promise { + const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); + // Removal has to look where the install wrote: same scope seam, same root. + const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); + const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; + if (!toolPath) return; + + const projectScope = scope === 'project'; + const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; + if (!mcpRel) return; + + const format = detectMcpFormat(tool); + if (!format) return; + + const baseDir = resolveToolBaseDir(tool, localConfig); + const targetFile = path.join(baseDir, mcpRel); + + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); + // Project scope uses THIS worktree's own manifest file (per-worktree under the + // partition; migrates legacy shared records on first read). User scope uses the + // single global file. The ownership key needs no workspace segment. + let manifestPath: string; + let manifest: ManagedMcpManifest; + if (projectScope && workspacePath) { + const { loadProjectMcpManifest } = await import('../../../../utils/mcp-manifest.js'); + ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); + } else { + manifestPath = managedMcpManifestPath(dataHome); + manifest = (await readJson(manifestPath)) ?? {}; + } + const manifestKey = managedMcpManifestKey(tool, projectScope); + const owned = manifest[manifestKey] ?? []; + const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); + + if (!ownedNames.has(slug)) return; + + manifest[manifestKey] = owned.filter((r: ManagedMcpRecord) => r.name !== slug); + if ((manifest[manifestKey] as ManagedMcpRecord[]).length === 0) delete manifest[manifestKey]; + await writeJsonAtomic(manifestPath, manifest); + + if (format === 'codex') { + let source = (await readFileSafe(targetFile)) ?? ''; + source = spliceCodexBlock(source, slug, null); + await writeCodexAtomic(targetFile, source); + } else { + const serverKey = MCP_SERVER_KEY[format]; + const allowBare = format === 'copilot' && projectScope; + const doc = await readJsonDoc(targetFile, serverKey, allowBare); + if (doc && doc.servers[slug] !== undefined) { + delete doc.servers[slug]; + await writeJsonDoc(targetFile, serverKey, doc); + } + } + log.debug(`local-agent: uninstalled MCP server "${slug}" from ${tool} (scope=${scope})`); +} + +async function runMcpCommand( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + const tool = context.tool; + if (!tool) { + throw new Error(`${command.type}: cannot determine current tool`); + } + const slug = command.slug; + if (!slug) { + throw new Error(`${command.type}: missing slug`); + } + assertSafeResourceName(slug); + + const scope = normalizeScope(command.scope); + const workspacePath = scope === 'project' + ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) + : undefined; + if (scope === 'project' && !workspacePath) { + throw new Error(`${command.type}: workspace command is missing workspace_path`); + } + + if (command.type === 'install_mcp') { + return installMcpServer(config, command, tool, slug, scope, workspacePath); + } + + await uninstallMcpServer(config, tool, slug, scope, workspacePath); + return command.version; +} + +async function executeCommand( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (command.type === 'apply_model_config') { + await applyModelConfig(config, command, context); + return; + } + // uninstall_teamai (clawpro three-phase: cmd = "teamai uninstall --force + // --agent ") executes its `cmd` string as a restricted teamai subcommand. + if (command.type === 'uninstall_teamai') { + return runCmdCommand(command, context); + } + if (command.type === 'install_hook_rule' || command.type === 'uninstall_hook_rule') { + return runHookRuleCommand(config, command, context); + } + if (command.type === 'install_mcp' || command.type === 'uninstall_mcp') { + return runMcpCommand(config, command, context); + } + const kind = commandKind(command); + const action = commandAction(command); + if (!kind || !action) { + throw new Error(`Unsupported command type: ${command.type ?? ''}`); + } + + const scope = normalizeScope(command.scope); + const workspacePath = scope === 'project' + ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) + : undefined; + if (scope === 'project' && !workspacePath) { + throw new Error('Project command is missing workspace_path'); + } + + const slug = commandSlug(command, kind); + const tool = context.tool; + if (action === 'install') { + return installDownloadedResource({ config, command, kind, slug, scope, workspacePath, tool }); + } + + await uninstallResource({ config, kind, slug, scope, workspacePath, tool }); + return commandVersion(command, kind); +} + +async function processCommands( + config: LocalAgentConfig, + commands: LocalAgentCommand[], + context: LocalAgentContext, +): Promise { + const tag = localAgentTag(context); + let modelConfigApplied = false; + for (const command of commands) { + // Keep these special types aligned with executeCommand's direct branches. + // Resource commands are recognized generically by commandKind/action; + // everything else is a future protocol extension and must be skipped. + if (isUnimplementedCommand(command) || ( + command.type !== 'apply_model_config' && + command.type !== 'uninstall_teamai' && + command.type !== 'install_hook_rule' && + command.type !== 'uninstall_hook_rule' && + command.type !== 'install_mcp' && + command.type !== 'uninstall_mcp' && + (!commandKind(command) || !commandAction(command)) + )) { + log.debug(`${tag} skipping unimplemented command ${command.id} (${command.type})`); + continue; + } + try { + const version = await executeCommand(config, command, context); + await ackCommand(config, tag, command, 'success', version); + if (command.type === 'apply_model_config') modelConfigApplied = true; + log.debug(`${tag} command ${command.id} (${command.type ?? ''}) succeeded`); + // Uninstall succeeded — skip remaining commands; the hook process exits naturally. + if (command.type === 'uninstall_teamai') { + log.debug(`${tag} uninstall_teamai completed — remaining commands skipped`); + return modelConfigApplied; + } + } catch (e) { + const error = (e as Error).message; + log.error(`${tag} command ${command.id} failed: ${error}`); + try { + await ackCommand(config, tag, command, 'failed', undefined, error); + } catch (ackError) { + log.debug(`${tag} failed to ack command ${command.id}: ${(ackError as Error).message}`); + } + } + } + return modelConfigApplied; +} + +export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promise { + const config = await loadLocalAgentConfig(); + if (!config) return false; + + // Binding prompt is injected via stdout hook context (not HTTP), so it must run + // even inside the CloudStudio sandbox — the sandbox guard below only skips the + // HTTP report/sync that would produce a duplicate card. Resolve the workspace + // only when the prompt is enabled AND the host is a buddy agent, so every other + // path (disabled flag, or a non-buddy tool like Claude/Cursor/Codex) forks no + // git process. + if (isBindPromptEnabled() && isBindPromptTool(context.tool)) { + const workspacePath = await resolveWorkspacePath(context.cwd); + if (workspacePath) { + const sid = context.event?.sessionId; + if (context.event?.type === 'session_start') { + await ensureWorkspaceBinding(config, workspacePath, sid, context.cwd); + } + if (context.event?.type === 'prompt_submit') { + await emitBindingHint(config, workspacePath, sid, context.cwd); + } + } + } + + // CloudStudio sandbox reports a duplicate agent card (different machine id + // than the host), so we skip the report POST here. Sync + command execution + // must still run so sandboxed agents can receive pushed cmds (e.g. uninstall); + // sync produces no card, so there is no duplicate risk. + // TEAMAI_ALLOW_SANDBOX_REPORT=1 restores the report too (backward compatible). + const skipReport = isCloudStudioSandbox() && process.env.TEAMAI_ALLOW_SANDBOX_REPORT !== '1'; + if (skipReport) { + log.debug( + '[local-agent] CloudStudio sandbox detected; skipping HTTP report ' + + '(sync still runs; set TEAMAI_ALLOW_SANDBOX_REPORT=1 to report too)', + ); + } + + const tag = localAgentTag(context); + log.debug(`${tag} run: endpoint=${config.endpoint}`); + + // Report-side bookkeeping (plugin reconcile + binding prune + tool stamp) is + // tied to the report path and must stay skipped inside the CloudStudio sandbox, + // exactly as before this branch stopped returning early. In particular, + // pruneDeadWorkspaceBindings would wrongly drop host bindings whose paths are + // not mounted in the container. Only sync + command execution run when + // skipReport is set. + if (!skipReport) { + if (context.event?.type === 'session_start') { + await maybeReconcilePlugins(context); + } + + const pruned = await pruneDeadWorkspaceBindings(config); + // Resolve the current workspace independently here rather than reusing an + // earlier local, so tool attribution does not depend on the binding-prompt + // block above keeping a `workspacePath` in scope. + const currentPath = await resolveWorkspacePath(context.cwd); + const stamped = stampWorkspaceTool(config, currentPath, context.tool ?? 'workbuddy'); + if (pruned || stamped) { + await saveLocalAgentConfig(config); + } + } + + try { + if (!skipReport) { + const reportPayload = await buildReportPayload(config, context); + await localAgentFetch(config, tag, 'report', { + method: 'POST', + body: JSON.stringify(reportPayload), + }); + log.debug(`${tag} report OK`); + } + + const syncPayload = await buildSyncPayload(config, context); + const syncResponse = await localAgentFetch<{ + ok?: boolean; + cmds?: LocalAgentCommand[]; + commands?: LocalAgentCommand[]; + }>( + config, + tag, + 'sync', + { method: 'POST', body: JSON.stringify(syncPayload) }, + { redactResponseLog: true }, + ); + // Prefer the unified cmds[] (source of truth). Fall back to the legacy + // commands[] for older backends that do not yet emit cmds. An empty cmds[] + // is treated as "cmds not available" and falls back too — the backend sends + // identical data in both arrays, so this only affects old backends where + // cmds is genuinely absent/empty while commands still carries the work. + // TODO(jiahe, cmds-migration): drop the `commands` fallback once the backend + // guarantees `cmds` on all sync responses (clawpro iwiki ch.7). + const cmds = syncResponse.cmds; + const commands = cmds && cmds.length > 0 ? cmds : (syncResponse.commands ?? []); + if (commands.length > 0) { + log.debug(`${tag} sync returned ${commands.length} command(s): ${commands.map((c) => `${c.type}#${c.id}`).join(', ')}`); + const modelConfigApplied = await processCommands(config, commands, context); + if (modelConfigApplied && !skipReport) { + const reportPayload = await buildReportPayload(config, context); + await localAgentFetch(config, tag, 'report', { + method: 'POST', + body: JSON.stringify(reportPayload), + }); + log.debug(`${tag} model config report OK`); + } + } + log.debug(`${tag} sync OK (${commands.length} command(s))`); + } catch (e) { + const error = (e as Error).message; + log.error(`${tag} sync FAILED: ${error}`); + await appendErrorLog({ error, context }); + } + + return true; +} + +function statusFromEvent(event?: DashboardEvent): string { + if (!event) return 'running'; + if (event.type === 'stop' || event.type === 'process_exit') return 'stopped'; + return 'running'; +} + +/** + * Hook-handler adapter: run local-agent report/sync (incl. workspace binding + * prompts) from within the unified hook dispatcher. Accepts pre-parsed STDIN + * data so the dispatcher reads STDIN only once. + */ +export async function reportAndSyncFromHook( + stdin: Record, + tool: string, +): Promise { + const raw = JSON.stringify(stdin); + const event = await parseHookEvent(raw, tool); + // parseHookEvent resolves cwd via resolveHookCwd too, so event?.cwd would be + // identical here — resolve once and fall back to process.cwd(). + const cwd = resolveHookCwd(stdin) ?? process.cwd(); + + // SessionStart and UserPromptSubmit run this handler in the *foreground*, where + // it blocks the host IDE's hook (UserPromptSubmit cap = 10s). Narrow the + // per-fetch timeout so a slow/unreachable endpoint fails fast and the handler + // returns before the host aborts the hook. Stop / PostToolUse run detached in + // the background, so they keep the full interactive timeout to complete real + // resource syncs/downloads. + const isForegroundEvent = event?.type === 'session_start' || event?.type === 'prompt_submit'; + activeFetchTimeoutMs = isForegroundEvent + ? LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS + : LOCAL_AGENT_FETCH_TIMEOUT_MS; + try { + await reportAndSyncLocalAgent({ + cwd, + tool, + status: statusFromEvent(event ?? undefined), + event: event ?? undefined, + }); + return null; + } finally { + activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; + } +} + +/** + * Persist the API token as a credential file with owner-only (0o600) + * permissions. chmod after write so an already-existing token file (whose perms + * mode-on-create would not touch) is also tightened. + */ +export async function writeTokenFile(tokenPath: string, token: string): Promise { + await fs.promises.writeFile(tokenPath, token + '\n', { mode: 0o600 }); + await fs.promises.chmod(tokenPath, 0o600); +} + +export async function initLocalAgentHttp(options: { + endpoint: string; + token?: string; + force?: boolean; + filterAgents?: string[]; +}): Promise { + const endpoint = normalizeEndpoint(options.endpoint); + if (!endpoint) { + throw new Error('HTTP endpoint is required.'); + } + + const existing = await loadLocalAgentConfig(); + if (existing && !options.force) { + throw new Error('HTTP local agent is already initialized. Re-run with --force to overwrite.'); + } + + const config: LocalAgentConfig = { + endpoint, + token: options.token, + createdAt: existing?.createdAt ?? new Date().toISOString(), + workspaceBindings: existing?.workspaceBindings ?? {}, + userGroupId: existing?.userGroupId, + userGroupName: existing?.userGroupName, + }; + + await ensureDir(getLocalAgentHome()); + await saveLocalAgentConfig(config); + if (options.token) { + await writeTokenFile(getTokenPath(), options.token); + } + + const teamConfig = createLocalAgentTeamConfig(endpoint); + // The local agent is always user-scope and always rooted at HOME, so resolve + // the user-scope paths (Qoder CN's user config lives under ~/.qoder-cn). + await injectHooksToAllTools( + scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), + getUserHome(), + options.filterAgents, + ); + log.success(`HTTP local agent initialized at ${getConfigPath()}`); +} + +export async function pullLocalAgentForCwd(context?: LocalAgentContext): Promise { + return reportAndSyncLocalAgent({ + cwd: context?.cwd ?? process.cwd(), + tool: context?.tool ?? 'workbuddy', + status: context?.status ?? 'running', + event: context?.event, + }); +} + +/** Summary of the configured HTTP local-agent bypass, for `teamai source list`. */ +export interface LocalAgentSummary { + endpoint: string; + boundProjects: Array<{ path: string; projectName?: string; projectId: number }>; + resourceCounts: { skills: number; rules: number; claudemd: number }; +} + +/** + * Describe the configured HTTP local-agent bypass (report/sync/ack), or null when + * none is configured. Used by `teamai source list` to show the HTTP side channel + * alongside git cross-team sources. + */ +export async function describeLocalAgent(): Promise { + const config = await loadLocalAgentConfig(); + if (!config) return null; + + const boundProjects = Object.entries(config.workspaceBindings) + .filter(([, b]) => b.projectId !== 0) + .map(([workspacePath, b]) => ({ path: workspacePath, projectName: b.projectName, projectId: b.projectId })); + + const manifest = await loadManifest(); + const counts = { skills: 0, rules: 0, claudemd: 0 }; + for (const scope of Object.values(manifest.scopes)) { + counts.skills += Object.keys(scope.skills ?? {}).length; + counts.rules += Object.keys(scope.rules ?? {}).length; + counts.claudemd += Object.keys(scope.claudemd ?? {}).length; + } + + return { endpoint: config.endpoint, boundProjects, resourceCounts: counts }; +} + +/** Parse a manifest scope key back into (scope, workspacePath). */ +function parseScopeKey(key: string): { scope: LocalAgentScope; workspacePath?: string } { + if (key.startsWith('project:')) { + return { scope: 'project', workspacePath: key.slice('project:'.length) || undefined }; + } + return { scope: key === 'instance' ? 'instance' : 'user' }; +} + +/** + * Run each installed plugin's uninstall_cmd (stop daemons, deregister autostart, + * remove packages) using the persisted plugin manifest. No-op when no HTTP source + * is configured. + * + * Best-effort: failures are logged, never thrown, so teardown of the rest of teamai + * is never blocked. Must run BEFORE ~/.teamai is deleted — it reads the plugin + * manifest and endpoint config from ~/.teamai/local-agent/. + */ +export async function teardownLocalAgentPlugins(): Promise { + try { + const config = await loadLocalAgentConfig(); + if (!config) return; + await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); + } catch (e) { + log.warn(`[local-agent] plugin teardown failed: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** + * Remove every HTTP-source agent hook recorded in the agent-hook manifest from + * each tool's settings, then clear the manifest. Best-effort; used by + * `source remove-http` and `teamai uninstall` teardown (issue #238). Safe to call + * when no config / no manifest exists. + */ +export async function removeAllAgentHooks(): Promise { + const config = await loadLocalAgentConfig(); + if (!config) return; + const manifest = await loadAgentHookManifest(); + const slugs = Object.keys(manifest); + if (slugs.length === 0) return; + for (const slug of slugs) { + const rec = manifest[slug]; + try { + if (rec.tool === 'hermes') { + const { removeHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); + } else if (OPENCLAW_TOOLS.has(rec.tool)) { + const { removeOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await removeOpenClawAgentHook({ slug, tool: rec.tool }); + } else if (rec.tool === 'opencode') { + const { removeOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (rec.tool === 'pi') { + const { removePiAgentHook } = await import('../../../../pi-hooks.js'); + await removePiAgentHook(slug); + } else { + const settingsPath = await resolveToolSettingsPath(config, rec.tool); + await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); + } + } catch (e) { + log.debug(`agent hook [${slug}] teardown failed: ${(e as Error).message}`); + } + } + await saveAgentHookManifest({}); +} + +/** + * Tear down the HTTP local-agent bypass: uninstall every resource recorded in the + * manifest (skills/rules/claudemd, across all scopes) from the AI tool dirs, then + * remove the whole ~/.teamai/local-agent/ directory (config + manifest). + * + * Best-effort per resource: a single failed uninstall is logged and skipped so a + * stale entry cannot block the teardown. + */ +export async function removeLocalAgentHttp(): Promise { + const config = await loadLocalAgentConfig(); + if (!config) { + log.info('No HTTP source configured — nothing to remove.'); + return; + } + + // Tear down installed plugins before removing teamai's local-agent state. + try { + await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); + } catch (e) { log.warn(`[local-agent] plugin teardown failed: ${(e as Error).message}`); } + + const kinds: CommandResourceKind[] = ['skill', 'rule', 'claudemd']; + const manifest = await loadManifest(); + for (const [key, scopeManifest] of Object.entries(manifest.scopes)) { + const { scope, workspacePath } = parseScopeKey(key); + for (const kind of kinds) { + for (const slug of Object.keys(scopeManifest[manifestKind(kind)] ?? {})) { + try { + await uninstallResource({ config, kind, slug, scope, workspacePath }); + } catch (e) { + log.debug(`local-agent: failed to uninstall ${kind} "${slug}": ${(e as Error).message}`); + } + } + } + } + + await removeAllAgentHooks(); + await remove(getLocalAgentHome()); + log.success('HTTP source removed (resources uninstalled, config cleared).'); +} + +export async function bindCurrentProject(options?: { projectId?: number; skip?: boolean; cwd?: string }): Promise { + const workspacePath = await resolveWorkspacePath(options?.cwd ?? process.cwd()); + if (!workspacePath) { + throw new Error('Cannot resolve current workspace path.'); + } + if (options?.skip) { + const config = await loadLocalAgentConfig(); + if (!config) { + throw new Error('Local agent not initialized. Run `teamai init --http` first.'); + } + // Skip the whole project (main checkout + all its worktrees), not just this + // one checkout, so sibling worktrees are not re-prompted. + await persistWorkspaceBinding(config, options?.cwd ?? process.cwd(), workspacePath, 0, '__skipped__'); + log.info(`已跳过绑定,以后不再提示此工作区。`); + return; + } + const binding = await bindWorkspaceToProject(workspacePath, options?.projectId); + if (!binding) { + log.info('未绑定项目。'); + } +} From a28b3342b0b27c484f342094a1b3e70758e924d9 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 13:33:30 +0800 Subject: [PATCH 03/14] feat(providers): add named HTTP providers with the ClawPro adapter (#404) Build the HTTP side of the ResourceProvider abstraction on top of the relocated ClawPro client, so multiple HTTP backends can be mounted side by side, each fully isolated, without the old global local-agent singleton. Phase 2 of #404; backward compatible. - HttpResourceProvider delegates to a protocol HttpBackendAdapter; ClawProAdapter maps the report/sync/ack wire format onto the client, running each call inside the provider's own execution context. The wire format (routes, local_agent_id) is unchanged. - An AsyncLocalStorage seam in the client redirects state to ~/.teamai/providers/http// for a named provider and keeps the legacy ~/.teamai/local-agent/ location when no context is active, so every pre-#404 path behaves exactly as before. - Per-provider store: config in the provider home + a registry in ~/.teamai/providers/settings.json; the credential lives in a separate 0600 ~/.teamai/credentials/ file and is never written into config.json. - `migrateLegacyHttpProvider` promotes the legacy singleton with a staged atomic copy, extracts the credential to the isolated file, and keeps the old dir as a rollback snapshot behind a migrated-to marker (idempotent). - `teamai provider add http | list | sync | remove | migrate-legacy` CLI; the hook dispatcher syncs every named provider once with failure isolation, then falls back to the legacy singleton only while it is still active. `teamai uninstall` tears down every named provider too. Git provider management, cross-provider ownership arbitration, priority failover and primary-write selection remain later phases. --- skill-data/core/references/commands.md | 17 ++ src/__tests__/hook-handlers.test.ts | 10 + src/__tests__/http-provider-multi.test.ts | 87 +++++++ src/__tests__/http-provider-store.test.ts | 203 ++++++++++++++++ src/hook-handlers.ts | 37 ++- src/index.ts | 61 +++++ src/provider-command.ts | 155 ++++++++++++ src/providers/http/adapters/clawpro/client.ts | 81 ++++++- src/providers/http/adapters/clawpro/index.ts | 79 ++++++ src/providers/http/provider.ts | 53 ++++ src/providers/http/registry.ts | 43 ++++ src/providers/http/store.ts | 226 ++++++++++++++++++ src/uninstall.ts | 20 +- 13 files changed, 1063 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/http-provider-multi.test.ts create mode 100644 src/__tests__/http-provider-store.test.ts create mode 100644 src/provider-command.ts create mode 100644 src/providers/http/adapters/clawpro/index.ts create mode 100644 src/providers/http/provider.ts create mode 100644 src/providers/http/registry.ts create mode 100644 src/providers/http/store.ts diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 1bfbeed3b..9e72df665 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -162,6 +162,23 @@ Generated: do not edit by hand. Regenerate with - `teamai source list` — List all configured sources - `teamai source browse ` — Browse public skills from a source +## provider + +- `teamai provider` — Manage named HTTP resource providers + - `teamai provider add` — Add a resource provider + - `teamai provider add http ` — Add a named HTTP provider (e.g. a ClawPro backend) + - `--name ` — Unique name for this provider + - `--adapter ` — Protocol adapter (default: clawpro) + - `--token ` — API token (stored 0600 outside config, never committed) + - `--priority ` — Arbitration priority; higher wins (default: 50) + - `teamai provider list` — List configured HTTP providers + - `teamai provider sync` — Sync all configured HTTP providers now + - `--force` — Bypass any freshness cache + - `teamai provider remove ` — Remove an HTTP provider and clean up its resources + - `teamai provider migrate-legacy` — Promote the legacy ~/.teamai/local-agent/ singleton to a named provider + - `--name ` — Name for the migrated provider + - `--priority ` — Arbitration priority (default: 50) + ## update - `teamai update` — Check for updates and upgrade teamai CLI diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 3e074fd3c..c47c68c18 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -108,6 +108,16 @@ vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: mockReportAndSyncFromHook, })); +// local-agent-sync now dispatches named HTTP providers first, then falls back +// to the legacy singleton (issue #404). These tests exercise the legacy path: +// no named providers configured, singleton active. +vi.mock('../providers/http/registry.js', () => ({ + loadHttpResourceProviders: vi.fn().mockResolvedValue([]), +})); +vi.mock('../providers/http/store.js', () => ({ + legacySingletonActive: vi.fn().mockResolvedValue(true), +})); + vi.mock('../pkg/pkg-hint.js', () => ({ packageManifestHashForCwd: mockPackageManifestHash, stashPackageHintAfterPull: mockStashPackageHint, diff --git a/src/__tests__/http-provider-multi.test.ts b/src/__tests__/http-provider-multi.test.ts new file mode 100644 index 000000000..dcb951099 --- /dev/null +++ b/src/__tests__/http-provider-multi.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +let tmpDir: string; +let origHome: string | undefined; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-http-multi-')); + origHome = process.env.HOME; + process.env.HOME = tmpDir; +}); + +afterEach(async () => { + process.env.HOME = origHome; + await fse.remove(tmpDir); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('multiple HTTP providers: hook dispatch', () => { + it('dispatches every configured provider once, isolating one failure', async () => { + const seen: Array<{ name: string; endpoint: string; home: string }> = []; + + // Intercept the ClawPro client entry the adapter calls, capturing the + // active provider context so we can assert per-provider isolation without a + // real backend. + vi.doMock('../providers/http/adapters/clawpro/client.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reportAndSyncFromHook: vi.fn(async () => { + const ctx = actual.currentHttpProvider(); + const cfg = await actual.loadLocalAgentConfig(); + seen.push({ name: ctx?.name ?? '?', endpoint: cfg?.endpoint ?? '?', home: ctx?.home ?? '?' }); + if (ctx?.name === 'flaky') throw new Error('backend down'); + return `hint:${ctx?.name}`; + }), + }; + }); + + const { upsertHttpProviderConfig, httpProviderExecutionContext } = await import('../providers/http/store.js'); + const { withHttpProvider, initLocalAgentHttp } = await import('../providers/http/adapters/clawpro/client.js'); + for (const p of [ + { name: 'good', endpoint: 'https://good/api', priority: 40 }, + { name: 'flaky', endpoint: 'https://flaky/api', priority: 80 }, + ]) { + await upsertHttpProviderConfig({ name: p.name, adapter: 'clawpro', endpoint: p.endpoint, priority: p.priority }); + // Seed each provider's isolated config.json so loadLocalAgentConfig + // returns its endpoint (initLocalAgentHttp injects no hooks without tools). + await withHttpProvider(httpProviderExecutionContext(p.name), () => + initLocalAgentHttp({ endpoint: p.endpoint, force: true }), + ); + } + + const { loadHttpResourceProviders } = await import('../providers/http/registry.js'); + const { syncResourceProviders } = await import('../providers/resource-registry.js'); + const providers = await loadHttpResourceProviders(); + const results = await syncResourceProviders(providers, { + trigger: 'hook', + tool: 'claude', + stdin: { hook_event_name: 'SessionStart' }, + cwd: tmpDir, + }); + + // Each provider dispatched exactly once. + expect(seen.map((s) => s.name).sort()).toEqual(['flaky', 'good']); + // Each ran against its own endpoint / state home (isolation). + const good = seen.find((s) => s.name === 'good')!; + expect(good.endpoint).toBe('https://good/api'); + expect(good.home).toContain(path.join('providers', 'http', 'good')); + const flaky = seen.find((s) => s.name === 'flaky')!; + expect(flaky.home).toContain(path.join('providers', 'http', 'flaky')); + + // Failure isolation: one down backend does not sink the other. + const byName = Object.fromEntries(results.map((r) => [r.provider, r])); + expect(byName.good.ok).toBe(true); + expect(byName.good.hookOutput).toBe('hint:good'); + expect(byName.flaky.ok).toBe(false); + expect(byName.flaky.message).toBe('backend down'); + }); +}); diff --git a/src/__tests__/http-provider-store.test.ts b/src/__tests__/http-provider-store.test.ts new file mode 100644 index 000000000..27ef2edd3 --- /dev/null +++ b/src/__tests__/http-provider-store.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +let tmpDir: string; +let origHome: string | undefined; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-http-store-')); + origHome = process.env.HOME; + process.env.HOME = tmpDir; +}); + +afterEach(async () => { + process.env.HOME = origHome; + await fse.remove(tmpDir); + vi.restoreAllMocks(); +}); + +const teamai = () => path.join(tmpDir, '.teamai'); + +describe('http provider store: config registry', () => { + it('rejects an unsafe provider name', async () => { + const { assertValidProviderName } = await import('../providers/http/store.js'); + expect(() => assertValidProviderName('../evil')).toThrow(/Invalid provider name/); + expect(() => assertValidProviderName('bad/name')).toThrow(/Invalid provider name/); + expect(() => assertValidProviderName('good-name.1')).not.toThrow(); + }); + + it('isolates two providers by name in registry and per-provider home', async () => { + const { upsertHttpProviderConfig, listHttpProviderConfigs, getHttpProviderConfig, httpProviderHome } = + await import('../providers/http/store.js'); + + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 80 }); + await upsertHttpProviderConfig({ name: 'community', adapter: 'clawpro', endpoint: 'https://b/api', priority: 40 }); + + const all = await listHttpProviderConfigs(); + expect(all.map((c) => c.name).sort()).toEqual(['community', 'company']); + expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://a/api'); + + // Each provider gets a self-describing config in its own home. + const companyConfig = await fse.readJson(path.join(httpProviderHome('company'), 'provider.json')); + expect(companyConfig.endpoint).toBe('https://a/api'); + expect(fs.existsSync(httpProviderHome('community'))).toBe(true); + }); + + it('removes only the selected provider from the registry', async () => { + const { upsertHttpProviderConfig, removeHttpProviderConfig, listHttpProviderConfigs } = + await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'a', adapter: 'clawpro', endpoint: 'https://a/api', priority: 10 }); + await upsertHttpProviderConfig({ name: 'b', adapter: 'clawpro', endpoint: 'https://b/api', priority: 20 }); + + expect(await removeHttpProviderConfig('a')).toBe(true); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['b']); + expect(await removeHttpProviderConfig('missing')).toBe(false); + }); + + it('removes a provider state home and credential file', async () => { + const { upsertHttpProviderConfig, removeHttpProviderState, httpProviderHome, httpProviderCredentialPath } = + await import('../providers/http/store.js'); + const { writeTokenFile } = await import('../local-agent.js'); + + await upsertHttpProviderConfig({ name: 'a', adapter: 'clawpro', endpoint: 'https://a/api', priority: 10 }); + await fse.ensureDir(path.dirname(httpProviderCredentialPath('a'))); + await writeTokenFile(httpProviderCredentialPath('a'), 'secret'); + expect(fs.existsSync(httpProviderHome('a'))).toBe(true); + expect(fs.existsSync(httpProviderCredentialPath('a'))).toBe(true); + + await removeHttpProviderState('a'); + expect(fs.existsSync(httpProviderHome('a'))).toBe(false); + expect(fs.existsSync(httpProviderCredentialPath('a'))).toBe(false); + }); +}); + +describe('http provider: named-context credential isolation', () => { + it('keeps the token in a 0600 file, never in config.json', async () => { + const { withHttpProvider, initLocalAgentHttp, loadLocalAgentConfig } = await import('../local-agent.js'); + const { httpProviderExecutionContext, httpProviderCredentialPath, httpProviderHome } = await import( + '../providers/http/store.js' + ); + + await withHttpProvider(httpProviderExecutionContext('company'), () => + initLocalAgentHttp({ endpoint: 'https://a/api', token: 'super-secret', force: true }), + ); + + // config.json under the provider home carries NO token. + const cfg = await fse.readJson(path.join(httpProviderHome('company'), 'config.json')); + expect(cfg.endpoint).toBe('https://a/api'); + expect(cfg.token).toBeUndefined(); + + // The credential lives in an isolated 0600 file. + const credPath = httpProviderCredentialPath('company'); + expect(fs.existsSync(credPath)).toBe(true); + if (process.platform !== 'win32') { + expect(fs.statSync(credPath).mode & 0o777).toBe(0o600); + } + + // Loading inside the context re-reads the token from the credential file. + const loaded = await withHttpProvider(httpProviderExecutionContext('company'), () => loadLocalAgentConfig()); + expect(loaded?.token).toBe('super-secret'); + }); + + it('routes two providers to separate state homes', async () => { + const { withHttpProvider, initLocalAgentHttp } = await import('../local-agent.js'); + const { httpProviderExecutionContext, httpProviderHome } = await import('../providers/http/store.js'); + + await withHttpProvider(httpProviderExecutionContext('a'), () => + initLocalAgentHttp({ endpoint: 'https://a/api', token: 'ta', force: true }), + ); + await withHttpProvider(httpProviderExecutionContext('b'), () => + initLocalAgentHttp({ endpoint: 'https://b/api', token: 'tb', force: true }), + ); + + expect((await fse.readJson(path.join(httpProviderHome('a'), 'config.json'))).endpoint).toBe('https://a/api'); + expect((await fse.readJson(path.join(httpProviderHome('b'), 'config.json'))).endpoint).toBe('https://b/api'); + // Neither run wrote to the legacy singleton location. + expect(fs.existsSync(path.join(teamai(), 'local-agent', 'config.json'))).toBe(false); + }); +}); + +describe('http provider: legacy singleton migration', () => { + async function seedLegacy(token?: string) { + const legacyDir = path.join(teamai(), 'local-agent'); + await fse.ensureDir(legacyDir); + await fse.writeJson(path.join(legacyDir, 'config.json'), { + endpoint: 'https://legacy/api', + ...(token ? { token } : {}), + createdAt: '2026-01-01T00:00:00.000Z', + workspaceBindings: {}, + }); + // A manifest file, to prove the whole state home is copied. + await fse.writeJson(path.join(legacyDir, 'manifest.json'), { scopes: {} }); + return legacyDir; + } + + it('reports legacy singleton active until migrated', async () => { + const { legacySingletonActive } = await import('../providers/http/store.js'); + expect(await legacySingletonActive()).toBe(false); + await seedLegacy('t'); + expect(await legacySingletonActive()).toBe(true); + }); + + it('promotes the legacy singleton to a named provider with an isolated credential', async () => { + const legacyDir = await seedLegacy('legacy-token'); + const { + migrateLegacyHttpProvider, + legacySingletonActive, + httpProviderHome, + httpProviderCredentialPath, + getHttpProviderConfig, + } = await import('../providers/http/store.js'); + + const config = await migrateLegacyHttpProvider({ name: 'company', priority: 70 }); + expect(config).toMatchObject({ name: 'company', adapter: 'clawpro', endpoint: 'https://legacy/api', priority: 70 }); + + // Registered and self-describing. + expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://legacy/api'); + // State copied over. + expect(fs.existsSync(path.join(httpProviderHome('company'), 'manifest.json'))).toBe(true); + // Token extracted to the isolated 0600 file, stripped from migrated config.json. + expect(fs.readFileSync(httpProviderCredentialPath('company'), 'utf-8').trim()).toBe('legacy-token'); + expect((await fse.readJson(path.join(httpProviderHome('company'), 'config.json'))).token).toBeUndefined(); + + // The legacy dir is kept as a rollback snapshot, but marked migrated. + expect(fs.existsSync(legacyDir)).toBe(true); + expect(fs.existsSync(path.join(legacyDir, 'migrated-to'))).toBe(true); + expect(await legacySingletonActive()).toBe(false); + }); + + it('extracts the token from the legacy ~/.teamai/token file when config has none', async () => { + await seedLegacy(); + await fse.writeFile(path.join(teamai(), 'token'), 'file-token\n'); + const { migrateLegacyHttpProvider, httpProviderCredentialPath } = await import('../providers/http/store.js'); + + await migrateLegacyHttpProvider({ name: 'company' }); + expect(fs.readFileSync(httpProviderCredentialPath('company'), 'utf-8').trim()).toBe('file-token'); + }); + + it('is idempotent: a second migration is a no-op', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider } = await import('../providers/http/store.js'); + expect(await migrateLegacyHttpProvider({ name: 'company' })).not.toBeNull(); + expect(await migrateLegacyHttpProvider({ name: 'company2' })).toBeNull(); + }); + + it('returns null when there is no legacy singleton', async () => { + const { migrateLegacyHttpProvider } = await import('../providers/http/store.js'); + expect(await migrateLegacyHttpProvider({ name: 'company' })).toBeNull(); + }); + + it('refuses to overwrite an existing provider home', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider, httpProviderHome } = await import('../providers/http/store.js'); + await fse.ensureDir(httpProviderHome('company')); + await expect(migrateLegacyHttpProvider({ name: 'company' })).rejects.toThrow(/already has state/); + }); +}); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 30fe12116..94fefe169 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -499,12 +499,43 @@ const packageHintHandler: HookHandler = { }, }; -/** HTTP local-agent report/sync + workspace binding prompts. */ +/** + * HTTP provider report/sync + workspace binding prompts (issue #404). + * + * Dispatches every configured named HTTP provider once, isolating failures so a + * slow/unreachable backend cannot block the others or the hook itself. Falls + * back to the legacy ~/.teamai/local-agent/ singleton only while it is still + * active (not yet migrated to a named provider), so upgraders keep working + * until they run `teamai provider migrate-legacy`. + */ const localAgentHandler: HookHandler = { name: 'local-agent-sync', async execute(stdin, tool) { - const { reportAndSyncFromHook } = await import('./local-agent.js'); - return reportAndSyncFromHook(stdin, tool); + const outputs: string[] = []; + + const { loadHttpResourceProviders } = await import('./providers/http/registry.js'); + const { syncResourceProviders } = await import('./providers/resource-registry.js'); + const providers = await loadHttpResourceProviders(); + if (providers.length > 0) { + const results = await syncResourceProviders(providers, { + trigger: 'hook', + tool, + stdin, + cwd: resolveHookCwd(stdin) ?? process.cwd(), + }); + for (const r of results) { + if (r.hookOutput) outputs.push(r.hookOutput); + } + } + + const { legacySingletonActive } = await import('./providers/http/store.js'); + if (await legacySingletonActive()) { + const { reportAndSyncFromHook } = await import('./local-agent.js'); + const legacyOutput = await reportAndSyncFromHook(stdin, tool); + if (legacyOutput) outputs.push(legacyOutput); + } + + return outputs.length > 0 ? outputs.join('\n') : null; }, }; diff --git a/src/index.ts b/src/index.ts index ce8606439..719ef671e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -546,6 +546,67 @@ sourceCmd await sourceBrowse(name, globalOpts); }); +// ─── Provider subcommands (Git/HTTP resource backends, #404) ── + +const providerCmd = program + .command('provider') + .description('Manage named HTTP resource providers') + .action(async () => { + const { providerList } = await import('./provider-command.js'); + await providerList(); + }); + +const providerAddCmd = providerCmd + .command('add') + .description('Add a resource provider'); + +providerAddCmd + .command('http ') + .description('Add a named HTTP provider (e.g. a ClawPro backend)') + .requiredOption('--name ', 'Unique name for this provider') + .option('--adapter ', 'Protocol adapter (default: clawpro)') + .option('--token ', 'API token (stored 0600 outside config, never committed)') + .option('--priority ', 'Arbitration priority; higher wins (default: 50)') + .action(async (endpoint: string, cmdOpts) => { + const { providerAddHttp } = await import('./provider-command.js'); + await providerAddHttp(endpoint, cmdOpts); + }); + +providerCmd + .command('list') + .description('List configured HTTP providers') + .action(async () => { + const { providerList } = await import('./provider-command.js'); + await providerList(); + }); + +providerCmd + .command('sync') + .description('Sync all configured HTTP providers now') + .option('--force', 'Bypass any freshness cache') + .action(async (cmdOpts) => { + const { providerSync } = await import('./provider-command.js'); + await providerSync(cmdOpts); + }); + +providerCmd + .command('remove ') + .description('Remove an HTTP provider and clean up its resources') + .action(async (name: string) => { + const { providerRemove } = await import('./provider-command.js'); + await providerRemove(name); + }); + +providerCmd + .command('migrate-legacy') + .description('Promote the legacy ~/.teamai/local-agent/ singleton to a named provider') + .requiredOption('--name ', 'Name for the migrated provider') + .option('--priority ', 'Arbitration priority (default: 50)') + .action(async (cmdOpts) => { + const { providerMigrateLegacy } = await import('./provider-command.js'); + await providerMigrateLegacy(cmdOpts); + }); + // ─── Other subcommands ──────────────────────────────────── program diff --git a/src/provider-command.ts b/src/provider-command.ts new file mode 100644 index 000000000..479b6e4e4 --- /dev/null +++ b/src/provider-command.ts @@ -0,0 +1,155 @@ +// ─── `teamai provider` commands ────────────────────────── +// +// Manage named HTTP resource providers (issue #404, phase 2). Git provider +// management (`provider add git`, `set-primary`) and cross-provider write-target +// selection belong to a later phase and are intentionally not exposed here. + +import { log } from './utils/logger.js'; +import { + listHttpProviderConfigs, + getHttpProviderConfig, + upsertHttpProviderConfig, + removeHttpProviderConfig, + removeHttpProviderState, + migrateLegacyHttpProvider, + assertValidProviderName, +} from './providers/http/store.js'; +import { + availableHttpAdapters, + getHttpAdapter, + createHttpResourceProvider, +} from './providers/http/registry.js'; +import { syncResourceProviders } from './providers/resource-registry.js'; +import type { HttpProviderConfig } from './providers/types.js'; + +interface AddHttpOptions { + name: string; + adapter?: string; + token?: string; + priority?: string; +} + +/** `teamai provider add http --name --adapter --token --priority` */ +export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): Promise { + if (!opts.name) { + log.error('A provider name is required: --name '); + process.exit(1); + } + assertValidProviderName(opts.name); + + const adapter = opts.adapter ?? 'clawpro'; + // Fail early on an unknown adapter rather than after persisting config. + if (!availableHttpAdapters().includes(adapter)) { + log.error(`Unknown HTTP adapter "${adapter}". Available: ${availableHttpAdapters().join(', ')}`); + process.exit(1); + } + + if (await getHttpProviderConfig(opts.name)) { + log.error(`Provider "${opts.name}" already exists. Remove it first or choose another name.`); + process.exit(1); + } + + const priority = parsePriority(opts.priority); + const config: HttpProviderConfig = { + name: opts.name, + adapter, + endpoint: endpoint.trim().replace(/\/+$/, ''), + priority, + }; + + await upsertHttpProviderConfig(config); + // initialize is optional on the adapter interface; call it when present so the + // credential and initial state land in the provider's isolated home. + const backend = getHttpAdapter(adapter); + if (backend.initialize) { + await backend.initialize(config, opts.token); + } + + log.success(`Added HTTP provider "${config.name}" (${config.adapter}) → ${config.endpoint}`); +} + +/** `teamai provider list` */ +export async function providerList(): Promise { + const configs = await listHttpProviderConfigs(); + if (configs.length === 0) { + log.info('No HTTP providers configured. Add one with `teamai provider add http --name `.'); + return; + } + log.info('HTTP providers:'); + for (const c of [...configs].sort((a, b) => b.priority - a.priority || a.name.localeCompare(b.name))) { + log.info(` ${c.name} [${c.adapter}] priority=${c.priority}`); + log.info(` ${c.endpoint}`); + } +} + +/** `teamai provider sync [--force]` */ +export async function providerSync(opts: { force?: boolean }): Promise { + const configs = await listHttpProviderConfigs(); + if (configs.length === 0) { + log.info('No HTTP providers configured.'); + return; + } + const providers = configs.map(createHttpResourceProvider); + const results = await syncResourceProviders(providers, { trigger: 'manual', force: opts.force }); + for (const r of results) { + if (r.ok) log.success(` ${r.provider}: ok${r.changed ? ' (changed)' : ''}`); + else log.error(` ${r.provider}: ${r.message ?? 'failed'}`); + } +} + +/** `teamai provider remove ` */ +export async function providerRemove(name: string): Promise { + const config = await getHttpProviderConfig(name); + if (!config) { + log.error(`No HTTP provider named "${name}".`); + process.exit(1); + } + // Tear down the provider's resources first, then drop its config and state so + // a failed teardown does not orphan installed resources. + const provider = createHttpResourceProvider(config); + await provider.teardown(); + await removeHttpProviderConfig(name); + await removeHttpProviderState(name); + log.success(`Removed HTTP provider "${name}".`); +} + +interface MigrateLegacyOptions { + name: string; + priority?: string; +} + +/** `teamai provider migrate-legacy --name --priority` */ +export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise { + if (!opts.name) { + log.error('A provider name is required: --name '); + process.exit(1); + } + const config = await migrateLegacyHttpProvider({ + name: opts.name, + priority: parsePriority(opts.priority), + }); + if (!config) { + log.info('No legacy HTTP local agent to migrate (or it was already migrated).'); + return; + } + log.success( + `Migrated legacy HTTP local agent to provider "${config.name}" ` + + `(the old ~/.teamai/local-agent/ is kept as a rollback snapshot).`, + ); +} + +/** Parse a --priority value, defaulting to 50 and rejecting non-integers. */ +function parsePriority(raw?: string): number { + if (raw === undefined) return 50; + const n = Number(raw); + if (!Number.isInteger(n)) { + log.error(`--priority must be an integer, got "${raw}".`); + process.exit(1); + } + return n; +} + +/** Adapter names this build supports, for CLI help. */ +export function providerAdapters(): string[] { + return availableHttpAdapters(); +} diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index d9671fe99..bed512755 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { AsyncLocalStorage } from 'node:async_hooks'; import fse from 'fs-extra'; import { log } from '../../../../utils/logger.js'; import { detachChild } from '../../../../utils/exec.js'; @@ -297,12 +298,46 @@ interface LocalAgentContext { event?: DashboardEvent; } +/** + * Execution context for a *named* HTTP provider (issue #404). When a provider + * runs inside `withHttpProvider`, its state (config, manifests, credentials, + * caches) is isolated to the provider's own directory instead of the legacy + * global `~/.teamai/local-agent/` singleton. Absent context = legacy singleton, + * so every pre-#404 code path (and unmigrated installs) behaves exactly as + * before. + */ +export interface HttpProviderExecutionContext { + /** Provider name (state-directory segment). */ + name: string; + /** Absolute state home for this provider, e.g. ~/.teamai/providers/http/. */ + home: string; + /** Absolute credential-file path, kept outside `home` and out of config. */ + credentialPath: string; +} + +const httpProviderContext = new AsyncLocalStorage(); + +/** Run `op` with a named HTTP provider's state isolated to its own directory. */ +export function withHttpProvider( + ctx: HttpProviderExecutionContext, + op: () => Promise, +): Promise { + return httpProviderContext.run(ctx, op); +} + +/** The active named-provider context, or undefined for the legacy singleton. */ +export function currentHttpProvider(): HttpProviderExecutionContext | undefined { + return httpProviderContext.getStore(); +} + function getTeamaiHomePath(): string { return path.join(getUserHome(), '.teamai'); } function getLocalAgentHome(): string { - return path.join(getTeamaiHomePath(), LOCAL_AGENT_DIR); + // A named provider redirects all state to its own directory; without a + // context we fall back to the legacy global singleton location. + return httpProviderContext.getStore()?.home ?? path.join(getTeamaiHomePath(), LOCAL_AGENT_DIR); } function getConfigPath(): string { @@ -318,6 +353,10 @@ function getModelManifestPath(): string { } function getErrorLogPath(): string { + // Error log stays under the provider home when named, so a provider's error + // stream is isolated too; legacy singleton keeps its historical HOME location. + const ctx = httpProviderContext.getStore(); + if (ctx) return path.join(ctx.home, REPORTER_ERROR_LOG); return path.join(getTeamaiHomePath(), REPORTER_ERROR_LOG); } @@ -583,10 +622,17 @@ function mergeWorkspaceBindings( } export async function loadLocalAgentConfig(): Promise { + const providerCtx = httpProviderContext.getStore(); const fileConfig = await readJson(getConfigPath()); if (fileConfig?.endpoint) { + // Named providers keep the credential in a separate file outside config + // (issue #404); fall back to any inline token for the legacy singleton. + const token = providerCtx + ? (await readCredentialFile(providerCtx.credentialPath)) ?? fileConfig.token + : fileConfig.token; const config = { ...fileConfig, + token, endpoint: normalizeEndpoint(fileConfig.endpoint), workspaceBindings: fileConfig.workspaceBindings ?? {}, }; @@ -663,12 +709,25 @@ export async function loadLocalAgentConfig(): Promise { }; } +/** Read a 0600 credential file's token, trimming the trailing newline. Missing → undefined. */ +async function readCredentialFile(credentialPath: string): Promise { + const raw = await readFileSafe(credentialPath); + const token = raw?.trim(); + return token ? token : undefined; +} + async function saveLocalAgentConfig(config: LocalAgentConfig): Promise { - await writeJsonAtomic(getConfigPath(), { + const providerCtx = httpProviderContext.getStore(); + const persisted = { ...config, endpoint: normalizeEndpoint(config.endpoint), workspaceBindings: config.workspaceBindings ?? {}, - }); + }; + // A named provider stores its credential in a separate 0600 file, never in + // config.json — strip any in-memory token before persisting so migration + // saves (binding cleanup / key canonicalization) cannot leak it. + if (providerCtx) delete persisted.token; + await writeJsonAtomic(getConfigPath(), persisted); } function createLocalAgentTeamConfig(endpoint: string): TeamaiConfig { @@ -3324,7 +3383,12 @@ export async function initLocalAgentHttp(options: { await ensureDir(getLocalAgentHome()); await saveLocalAgentConfig(config); if (options.token) { - await writeTokenFile(getTokenPath(), options.token); + // Named providers keep the credential in their own 0600 file; the legacy + // singleton keeps its historical ~/.teamai/token location. + const providerCtx = httpProviderContext.getStore(); + const credentialPath = providerCtx?.credentialPath ?? getTokenPath(); + await ensureDir(path.dirname(credentialPath)); + await writeTokenFile(credentialPath, options.token); } const teamConfig = createLocalAgentTeamConfig(endpoint); @@ -3335,7 +3399,11 @@ export async function initLocalAgentHttp(options: { getUserHome(), options.filterAgents, ); - log.success(`HTTP local agent initialized at ${getConfigPath()}`); + // A named provider prints its own "Added HTTP provider" line; only the legacy + // singleton path announces initialization here. + if (!httpProviderContext.getStore()) { + log.success(`HTTP provider initialized at ${getConfigPath()}`); + } } export async function pullLocalAgentForCwd(context?: LocalAgentContext): Promise { @@ -3480,6 +3548,9 @@ export async function removeLocalAgentHttp(): Promise { await removeAllAgentHooks(); await remove(getLocalAgentHome()); + // A named provider's credential lives outside its state home; remove it too. + const providerCtx = httpProviderContext.getStore(); + if (providerCtx) await remove(providerCtx.credentialPath); log.success('HTTP source removed (resources uninstalled, config cleared).'); } diff --git a/src/providers/http/adapters/clawpro/index.ts b/src/providers/http/adapters/clawpro/index.ts new file mode 100644 index 000000000..511e62166 --- /dev/null +++ b/src/providers/http/adapters/clawpro/index.ts @@ -0,0 +1,79 @@ +// ─── ClawPro HTTP backend adapter ──────────────────────── +// +// Speaks the ClawPro report/sync/ack wire format. It is a thin translation +// layer over ./client.ts (relocated from the former src/local-agent.ts): each +// HttpBackendAdapter method runs the corresponding client entry point inside the +// provider's isolated execution context, so a named provider's state lands in +// its own directory. The wire format — routes, `local_agent_id`, payload shapes +// — is unchanged; see issue #404. + +import type { + HttpBackendAdapter, + HttpProviderConfig, + HttpRoutes, + ProviderResult, + ProviderSummary, + SyncContext, +} from '../../../types.js'; +import { + DEFAULT_ROUTES, + withHttpProvider, + initLocalAgentHttp, + reportAndSyncFromHook, + pullLocalAgentForCwd, + describeLocalAgent, + removeLocalAgentHttp, +} from './client.js'; +import { httpProviderExecutionContext } from '../../store.js'; + +export class ClawProAdapter implements HttpBackendAdapter { + readonly name = 'clawpro'; + + routes(_config: HttpProviderConfig): HttpRoutes { + // ClawPro uses the default /api/local-agent/* layout. + return { ...DEFAULT_ROUTES }; + } + + async initialize(config: HttpProviderConfig, token?: string): Promise { + await withHttpProvider(httpProviderExecutionContext(config.name), () => + initLocalAgentHttp({ endpoint: config.endpoint, token, force: true }), + ); + } + + async sync(config: HttpProviderConfig, context: SyncContext): Promise { + return withHttpProvider(httpProviderExecutionContext(config.name), async () => { + let hookOutput: string | null = null; + if (context.trigger === 'hook' && context.stdin) { + hookOutput = await reportAndSyncFromHook(context.stdin, context.tool ?? 'workbuddy'); + } else { + await pullLocalAgentForCwd({ cwd: context.cwd, tool: context.tool }); + } + return { + provider: config.name, + ok: true, + changed: false, + ...(hookOutput ? { hookOutput } : {}), + }; + }); + } + + async describe(config: HttpProviderConfig): Promise { + const summary = await withHttpProvider(httpProviderExecutionContext(config.name), () => + describeLocalAgent(), + ); + return { + name: config.name, + type: 'http', + priority: config.priority, + capabilities: { pull: true, push: false, report: true, commands: true }, + endpoint: summary?.endpoint ?? config.endpoint, + adapter: this.name, + }; + } + + async teardown(config: HttpProviderConfig): Promise { + await withHttpProvider(httpProviderExecutionContext(config.name), () => + removeLocalAgentHttp(), + ); + } +} diff --git a/src/providers/http/provider.ts b/src/providers/http/provider.ts new file mode 100644 index 000000000..a92a4dbdd --- /dev/null +++ b/src/providers/http/provider.ts @@ -0,0 +1,53 @@ +// ─── HTTP resource provider ────────────────────────────── +// +// A ResourceProvider backed by an HTTP backend. It owns the common shape +// (capabilities, name, type) and delegates every operation to a protocol +// HttpBackendAdapter, which handles that backend's wire format. HTTP backends +// deliver and report but are never a push target. See issue #404. + +import type { + ResourceProvider, + ProviderCapabilities, + ProviderResult, + ProviderSummary, + SyncContext, + HttpBackendAdapter, + HttpProviderConfig, +} from '../types.js'; + +const HTTP_CAPABILITIES: ProviderCapabilities = { + pull: true, + push: false, + report: true, + commands: true, +}; + +export class HttpResourceProvider implements ResourceProvider { + readonly type = 'http' as const; + readonly capabilities = HTTP_CAPABILITIES; + + constructor( + private readonly config: HttpProviderConfig, + private readonly adapter: HttpBackendAdapter, + ) {} + + get name(): string { + return this.config.name; + } + + get priority(): number { + return this.config.priority; + } + + sync(context: SyncContext): Promise { + return this.adapter.sync(this.config, context); + } + + describe(): Promise { + return this.adapter.describe(this.config); + } + + teardown(): Promise { + return this.adapter.teardown(this.config); + } +} diff --git a/src/providers/http/registry.ts b/src/providers/http/registry.ts new file mode 100644 index 000000000..917b54ee3 --- /dev/null +++ b/src/providers/http/registry.ts @@ -0,0 +1,43 @@ +// ─── HTTP adapter registry ─────────────────────────────── +// +// Maps a protocol adapter name (e.g. 'clawpro') to its implementation and +// builds HttpResourceProviders from persisted config. Deleting an adapter +// directory removes it here without touching the generic HTTP provider — the +// point of the ClawPro extraction in issue #404. + +import type { HttpBackendAdapter, HttpProviderConfig } from '../types.js'; +import { HttpResourceProvider } from './provider.js'; +import { ClawProAdapter } from './adapters/clawpro/index.js'; +import { listHttpProviderConfigs } from './store.js'; + +/** Adapter factories, keyed by adapter name. */ +const ADAPTERS: Record HttpBackendAdapter> = { + clawpro: () => new ClawProAdapter(), +}; + +/** Names of the HTTP protocol adapters this build supports. */ +export function availableHttpAdapters(): string[] { + return Object.keys(ADAPTERS); +} + +/** Instantiate an HTTP backend adapter by name. Throws for an unknown adapter. */ +export function getHttpAdapter(name: string): HttpBackendAdapter { + const factory = ADAPTERS[name]; + if (!factory) { + throw new Error( + `Unknown HTTP adapter "${name}". Available: ${availableHttpAdapters().join(', ')}`, + ); + } + return factory(); +} + +/** Build a provider from one config. */ +export function createHttpResourceProvider(config: HttpProviderConfig): HttpResourceProvider { + return new HttpResourceProvider(config, getHttpAdapter(config.adapter)); +} + +/** Load every configured HTTP provider from the store. */ +export async function loadHttpResourceProviders(): Promise { + const configs = await listHttpProviderConfigs(); + return configs.map(createHttpResourceProvider); +} diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts new file mode 100644 index 000000000..d83fdadab --- /dev/null +++ b/src/providers/http/store.ts @@ -0,0 +1,226 @@ +// ─── HTTP provider store ───────────────────────────────── +// +// On-disk layout for named HTTP providers (issue #404). Each provider's state +// is isolated so credentials, manifests, bindings, plugin state and caches of +// one backend never touch another's: +// +// ~/.teamai/providers/http// state home (config.json, manifest, …) +// ~/.teamai/credentials/ credential (0600), outside config +// ~/.teamai/providers/settings.json registry (which providers exist) +// +// The legacy single HTTP backend at ~/.teamai/local-agent/ keeps working +// untouched; `migrateLegacyHttpProvider` promotes it to a named provider with +// an atomic copy + rollback snapshot, and leaves a marker so the old singleton +// stops claiming the backend. + +import path from 'node:path'; +import fse from 'fs-extra'; +import { getUserHome } from '../../utils/home.js'; +import { + ensureDir, + pathExists, + readJson, + remove, + writeJsonAtomic, +} from '../../utils/fs.js'; +import { readFileSafe } from '../../utils/fs.js'; +import { writeTokenFile } from './adapters/clawpro/client.js'; +import type { HttpProviderExecutionContext } from './adapters/clawpro/client.js'; +import type { HttpProviderConfig } from '../types.js'; + +const PROVIDERS_DIR = 'providers'; +const HTTP_DIR = 'http'; +const CREDENTIALS_DIR = 'credentials'; +const REGISTRY_FILE = 'settings.json'; +const PROVIDER_CONFIG_FILE = 'provider.json'; +const LEGACY_DIR = 'local-agent'; +/** Marker written into the legacy dir once it has been migrated to a named provider. */ +const MIGRATED_MARKER = 'migrated-to'; + +function teamaiHome(): string { + return path.join(getUserHome(), '.teamai'); +} + +function httpProvidersRoot(): string { + return path.join(teamaiHome(), PROVIDERS_DIR, HTTP_DIR); +} + +/** State home for one named HTTP provider. */ +export function httpProviderHome(name: string): string { + return path.join(httpProvidersRoot(), name); +} + +/** Credential-file path for one named HTTP provider (0600, outside config). */ +export function httpProviderCredentialPath(name: string): string { + return path.join(teamaiHome(), CREDENTIALS_DIR, name); +} + +/** Build the execution context a named provider run needs (see withHttpProvider). */ +export function httpProviderExecutionContext(name: string): HttpProviderExecutionContext { + return { + name, + home: httpProviderHome(name), + credentialPath: httpProviderCredentialPath(name), + }; +} + +function registryPath(): string { + return path.join(teamaiHome(), PROVIDERS_DIR, REGISTRY_FILE); +} + +interface ProviderRegistryFile { + http?: HttpProviderConfig[]; +} + +/** Provider names must be a safe single path segment (state-dir + credential-file). */ +export function assertValidProviderName(name: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) { + throw new Error( + `Invalid provider name "${name}". Use letters, digits, '.', '_' or '-', starting alphanumeric.`, + ); + } +} + +/** Read the registry of configured HTTP providers (empty when none). */ +export async function listHttpProviderConfigs(): Promise { + const file = await readJson(registryPath()); + return file?.http ?? []; +} + +/** Look up one HTTP provider's config by name. */ +export async function getHttpProviderConfig(name: string): Promise { + return (await listHttpProviderConfigs()).find((p) => p.name === name); +} + +/** Add or replace an HTTP provider in the registry, persisting its config. */ +export async function upsertHttpProviderConfig(config: HttpProviderConfig): Promise { + assertValidProviderName(config.name); + const existing = await listHttpProviderConfigs(); + const next = existing.filter((p) => p.name !== config.name); + next.push(config); + next.sort((a, b) => a.name.localeCompare(b.name)); + await ensureDir(path.dirname(registryPath())); + await writeJsonAtomic(registryPath(), { http: next }); + + // Also persist the per-provider config into its own home, so the state + // directory is self-describing and survives a registry rebuild. + await ensureDir(httpProviderHome(config.name)); + await writeJsonAtomic(path.join(httpProviderHome(config.name), PROVIDER_CONFIG_FILE), config); +} + +/** Remove an HTTP provider from the registry. Does not delete its state (teardown does). */ +export async function removeHttpProviderConfig(name: string): Promise { + const existing = await listHttpProviderConfigs(); + const next = existing.filter((p) => p.name !== name); + if (next.length === existing.length) return false; + await ensureDir(path.dirname(registryPath())); + await writeJsonAtomic(registryPath(), { http: next }); + return true; +} + +/** Delete a named provider's state home and credential file. */ +export async function removeHttpProviderState(name: string): Promise { + await remove(httpProviderHome(name)); + await remove(httpProviderCredentialPath(name)); +} + +// ─── Legacy singleton migration ────────────────────────── + +function legacyHome(): string { + return path.join(teamaiHome(), LEGACY_DIR); +} + +function legacyMarkerPath(): string { + return path.join(legacyHome(), MIGRATED_MARKER); +} + +/** + * True when a legacy ~/.teamai/local-agent/ singleton still owns the backend — + * i.e. it exists and has NOT been migrated to a named provider. The hook + * dispatcher uses this to keep running the legacy path until migration. + */ +export async function legacySingletonActive(): Promise { + if (!(await pathExists(path.join(legacyHome(), 'config.json')))) return false; + return !(await pathExists(legacyMarkerPath())); +} + +interface LegacyConfigShape { + endpoint?: string; + token?: string; + priority?: number; +} + +/** + * Promote the legacy singleton to a named HTTP provider. Copies the legacy + * state home into the provider's home via a staging directory + atomic rename, + * moves the credential to the isolated 0600 file, and only then writes the + * migration marker into the legacy dir — which is kept as a rollback snapshot, + * not deleted. Idempotent: a second call after a successful migration is a + * no-op. + * + * @returns the provider config it registered, or null when there was no legacy + * singleton to migrate. + */ +export async function migrateLegacyHttpProvider(options: { + name: string; + adapter?: string; + priority?: number; +}): Promise { + assertValidProviderName(options.name); + const legacyConfigPath = path.join(legacyHome(), 'config.json'); + const legacy = await readJson(legacyConfigPath); + if (!legacy?.endpoint) return null; + if (await pathExists(legacyMarkerPath())) return null; // already migrated + + const config: HttpProviderConfig = { + name: options.name, + adapter: options.adapter ?? 'clawpro', + endpoint: legacy.endpoint, + priority: options.priority ?? legacy.priority ?? 50, + }; + + const home = httpProviderHome(options.name); + if (await pathExists(home)) { + throw new Error(`Provider "${options.name}" already has state at ${home}; choose another name.`); + } + + // Stage a full copy, then atomically move it into place so an interrupted + // migration never leaves a half-populated provider home. + const staging = `${home}.migrating`; + await remove(staging); + await ensureDir(path.dirname(home)); + await fse.copy(legacyHome(), staging); + // Never carry the migration marker (not present yet) or a raw token into the + // named home — the token moves to the isolated credential file below. + await remove(path.join(staging, MIGRATED_MARKER)); + await fse.move(staging, home); + + // Extract the credential to the isolated 0600 file. Prefer the legacy inline + // token, then the legacy ~/.teamai/token file. + const token = + legacy.token ?? + (await readFileSafe(path.join(teamaiHome(), 'token')))?.trim() ?? + undefined; + if (token) { + await ensureDir(path.dirname(httpProviderCredentialPath(options.name))); + await writeTokenFile(httpProviderCredentialPath(options.name), token); + // Strip the inline token from the migrated config.json so the credential + // lives only in the isolated file. + const migratedConfig = await readJson>( + path.join(home, 'config.json'), + ); + if (migratedConfig && 'token' in migratedConfig) { + delete migratedConfig.token; + await writeJsonAtomic(path.join(home, 'config.json'), migratedConfig); + } + } + + await upsertHttpProviderConfig(config); + + // Mark the legacy dir migrated LAST, so a crash before this point re-runs the + // migration rather than orphaning the backend. The legacy dir stays on disk + // as a rollback snapshot. + await writeJsonAtomic(legacyMarkerPath(), { name: options.name, migratedAt: new Date().toISOString() }); + + return config; +} diff --git a/src/uninstall.ts b/src/uninstall.ts index c5dfcdc64..19d1fd0b2 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -855,7 +855,8 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { /** * Stop and uninstall local-agent plugins (best-effort) before ~/.teamai is deleted. * Dynamic import mirrors source.ts — keeps local-agent's heavy dependency graph out - * of uninstall's static import chain. + * of uninstall's static import chain. Covers the legacy singleton and every named + * HTTP provider (issue #404), each torn down in its own isolated context. */ async function teardownPlugins(): Promise { try { @@ -864,6 +865,23 @@ async function teardownPlugins(): Promise { } catch (e) { log.warn(`plugin teardown failed: ${e instanceof Error ? e.message : String(e)}`); } + try { + const { listHttpProviderConfigs, httpProviderExecutionContext } = await import( + './providers/http/store.js' + ); + const { withHttpProvider, teardownLocalAgentPlugins } = await import('./local-agent.js'); + for (const config of await listHttpProviderConfigs()) { + try { + await withHttpProvider(httpProviderExecutionContext(config.name), () => + teardownLocalAgentPlugins(), + ); + } catch (e) { + log.warn(`plugin teardown for provider "${config.name}" failed: ${(e as Error).message}`); + } + } + } catch (e) { + log.warn(`HTTP provider teardown failed: ${e instanceof Error ? e.message : String(e)}`); + } } async function executeRemoval(plan: RemovalPlan): Promise { From 9427794ce7a576ab25217e07f1752a6cf591dbf6 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 13:35:12 +0800 Subject: [PATCH 04/14] docs(providers): document multiple HTTP providers and legacy migration (#404) Add a "Multiple HTTP providers" section to the bilingual usage guide covering `teamai provider add http | list | sync | remove | migrate-legacy`, per-provider isolation and the 0600 credential layout. --- docs/usage-guide.md | 27 +++++++++++++++++++++++++++ docs/usage-guide.zh-CN.md | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cfd76b34d..3c05ffbf3 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1854,6 +1854,33 @@ teamai source remove-http An HTTP source reports status and pulls skill commands via hook dispatch on every session. Only one HTTP source is supported per install. If the main repo is already in HTTP mode (`init --http`), `add-http` is unavailable (the main repo already occupies the HTTP config). +#### Multiple HTTP providers + +`source add-http` configures a single global HTTP backend. To mount **several** HTTP backends side by side — each with its own credential, resource manifest and cache, fully isolated — use `teamai provider`: + +```bash +# Add named HTTP providers (each is one backend behind a protocol adapter) +teamai provider add http https://company-host/api --name company --adapter clawpro --token --priority 80 +teamai provider add http https://community-host/api --name community --adapter clawpro --priority 40 + +# List and sync them +teamai provider list +teamai provider sync + +# Remove one — the others (and their resources) are untouched +teamai provider remove company +``` + +Each provider's state lives under `~/.teamai/providers/http//`; its token is stored `0600` at `~/.teamai/credentials/`, never in a config file. On every session, hook dispatch syncs each provider once, and one slow or failing backend never blocks the others. A provider's `--priority` is recorded for a later resource-arbitration phase; it does not yet decide same-name conflicts. + +To move an existing single HTTP backend (`init --http` / `source add-http`) onto the named-provider model, run: + +```bash +teamai provider migrate-legacy --name company +``` + +This promotes `~/.teamai/local-agent/` to a named provider (copying its state and extracting its credential to the isolated `0600` file) and keeps the old directory as a rollback snapshot. It is idempotent. + --- ## Command Reference diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 462475e0b..fe3ea5b81 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1786,6 +1786,33 @@ teamai source remove-http HTTP 源通过 hook dispatch 在每次 session 中上报状态并拉取 skill 指令。每个安装仅支持一个 HTTP 源。若主仓本身已是 HTTP 模式(`init --http`),则 `add-http` 不可用(主仓已占用 HTTP 配置)。 +#### 多个 HTTP provider + +`source add-http` 只能配置一个全局 HTTP 后端。若要**同时**挂载多个 HTTP 后端——每个都有独立的凭据、资源清单和缓存,彼此完全隔离——请使用 `teamai provider`: + +```bash +# 添加具名 HTTP provider(每个 provider 是一个协议 adapter 后的后端) +teamai provider add http https://company-host/api --name company --adapter clawpro --token --priority 80 +teamai provider add http https://community-host/api --name community --adapter clawpro --priority 40 + +# 列出并同步 +teamai provider list +teamai provider sync + +# 删除其中一个——其余 provider 及其资源不受影响 +teamai provider remove company +``` + +每个 provider 的状态存放在 `~/.teamai/providers/http//`;其 token 以 `0600` 权限单独存于 `~/.teamai/credentials/`,绝不写入任何配置文件。每次 session 中,hook dispatch 会对每个 provider 各同步一次,单个后端缓慢或失败都不会阻塞其他 provider。provider 的 `--priority` 为后续的资源仲裁阶段预留,目前尚不参与同名资源的冲突决策。 + +要把已有的单个 HTTP 后端(`init --http` / `source add-http`)迁移到具名 provider 模型,运行: + +```bash +teamai provider migrate-legacy --name company +``` + +这会把 `~/.teamai/local-agent/` 提升为具名 provider(复制其状态,并把凭据抽取到隔离的 `0600` 文件),并保留旧目录作为回滚快照。该操作幂等。 + --- ## 命令参考 From a3bd495686a631e2514450c5919c058cd6d84bdb Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 17:38:28 +0800 Subject: [PATCH 05/14] fix(providers): address code-review P1 findings (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Respond to the review on PR #780. Findings #1/#2 (config.ts / usage-tracker.ts "regressions") were false positives from a pre-rebase diff — this branch does not touch those files. The rest are fixed: - Gate to a single HTTP provider (review #9/#10). Running multiple HTTP providers concurrently is unsafe until the ownership ledger arbitrates same-name resources (issue #404 phase 4) and can blow the foreground hook budget. `provider add http` now refuses a second provider (and one while the legacy singleton is active); migrate-legacy still works. - `provider sync` reports honest results (review #5): the ClawPro client swallows network/command errors, so it now fills a SyncOutcome the adapter reads — a backend failure surfaces as `ok:false` and a non-zero exit, never a false success. - Publish the registry record only after adapter init succeeds, rolling back partial state on failure (review #3). - Make legacy migration retriable (review #4): conflict is decided by the registry, not the home dir, so a home left by a crashed run is discarded and rebuilt (legacy stays authoritative until the marker). - `uninstall` runs each named provider's full teardown() — resources, agent hooks and plugins — not just plugin cleanup, so a provider's skills/rules/CLAUDE.md/hooks are not orphaned (review #7). - bind-project runs inside the named provider's context instead of the legacy singleton (review #6). - Reject provider names that collide case-insensitively or hit Windows path hazards (trailing dot, reserved device names) (review #8). - Drop the no-op `provider sync --force` flag (review #12). Docs: document the single-provider limit and migration in the bilingual usage guide and join-member skill-data (review #11). Tests: add provider-command gate/rollback coverage, case-collision + retriable migration + honest-failure cases. --- docs/usage-guide.md | 17 ++-- docs/usage-guide.zh-CN.md | 17 ++-- skill-data/core/references/commands.md | 1 - skill-data/setup/references/join-member.md | 17 ++++ src/__tests__/http-provider-multi.test.ts | 40 ++++++++ src/__tests__/http-provider-store.test.ts | 42 +++++++- src/__tests__/provider-command.test.ts | 99 +++++++++++++++++++ src/index.ts | 25 +++-- src/provider-command.ts | 54 ++++++++-- src/providers/http/adapters/clawpro/client.ts | 32 +++++- src/providers/http/adapters/clawpro/index.ts | 12 ++- src/providers/http/store.ts | 50 +++++++++- src/uninstall.ts | 22 +++-- 13 files changed, 375 insertions(+), 53 deletions(-) create mode 100644 src/__tests__/provider-command.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 3c05ffbf3..c8639ad49 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1854,24 +1854,23 @@ teamai source remove-http An HTTP source reports status and pulls skill commands via hook dispatch on every session. Only one HTTP source is supported per install. If the main repo is already in HTTP mode (`init --http`), `add-http` is unavailable (the main repo already occupies the HTTP config). -#### Multiple HTTP providers +#### Named HTTP provider -`source add-http` configures a single global HTTP backend. To mount **several** HTTP backends side by side — each with its own credential, resource manifest and cache, fully isolated — use `teamai provider`: +`source add-http` / `init --http` configure a single global HTTP backend whose state sits in `~/.teamai/local-agent/`. `teamai provider` mounts an HTTP backend as a **named** provider instead, with its credential, resource manifest and cache isolated under its own directory: ```bash -# Add named HTTP providers (each is one backend behind a protocol adapter) -teamai provider add http https://company-host/api --name company --adapter clawpro --token --priority 80 -teamai provider add http https://community-host/api --name community --adapter clawpro --priority 40 +# Add a named HTTP provider (a backend behind a protocol adapter, e.g. clawpro) +teamai provider add http https://company-host/api --name company --adapter clawpro --token -# List and sync them +# List, sync, remove teamai provider list teamai provider sync - -# Remove one — the others (and their resources) are untouched teamai provider remove company ``` -Each provider's state lives under `~/.teamai/providers/http//`; its token is stored `0600` at `~/.teamai/credentials/`, never in a config file. On every session, hook dispatch syncs each provider once, and one slow or failing backend never blocks the others. A provider's `--priority` is recorded for a later resource-arbitration phase; it does not yet decide same-name conflicts. +The provider's state lives under `~/.teamai/providers/http//`; its token is stored `0600` at `~/.teamai/credentials/`, never in a config file. On every session, hook dispatch syncs it, reporting a per-provider result so a failing backend surfaces as a failure rather than a false success. + +> **One HTTP provider at a time.** Mounting several HTTP backends concurrently needs cross-provider ownership arbitration (so same-name resources don't overwrite or delete each other) — that is a later phase (issue #404). Until then `provider add http` refuses a second provider. The `--priority` flag is recorded for that future phase and does not yet affect behavior. To move an existing single HTTP backend (`init --http` / `source add-http`) onto the named-provider model, run: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index fe3ea5b81..4cb6f7fea 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1786,24 +1786,23 @@ teamai source remove-http HTTP 源通过 hook dispatch 在每次 session 中上报状态并拉取 skill 指令。每个安装仅支持一个 HTTP 源。若主仓本身已是 HTTP 模式(`init --http`),则 `add-http` 不可用(主仓已占用 HTTP 配置)。 -#### 多个 HTTP provider +#### 具名 HTTP provider -`source add-http` 只能配置一个全局 HTTP 后端。若要**同时**挂载多个 HTTP 后端——每个都有独立的凭据、资源清单和缓存,彼此完全隔离——请使用 `teamai provider`: +`source add-http` / `init --http` 只能配置一个全局 HTTP 后端,状态落在 `~/.teamai/local-agent/`。`teamai provider` 则把 HTTP 后端挂成一个**具名 provider**,其凭据、资源清单和缓存隔离在各自目录下: ```bash -# 添加具名 HTTP provider(每个 provider 是一个协议 adapter 后的后端) -teamai provider add http https://company-host/api --name company --adapter clawpro --token --priority 80 -teamai provider add http https://community-host/api --name community --adapter clawpro --priority 40 +# 添加具名 HTTP provider(一个协议 adapter 后的后端,如 clawpro) +teamai provider add http https://company-host/api --name company --adapter clawpro --token -# 列出并同步 +# 列出、同步、删除 teamai provider list teamai provider sync - -# 删除其中一个——其余 provider 及其资源不受影响 teamai provider remove company ``` -每个 provider 的状态存放在 `~/.teamai/providers/http//`;其 token 以 `0600` 权限单独存于 `~/.teamai/credentials/`,绝不写入任何配置文件。每次 session 中,hook dispatch 会对每个 provider 各同步一次,单个后端缓慢或失败都不会阻塞其他 provider。provider 的 `--priority` 为后续的资源仲裁阶段预留,目前尚不参与同名资源的冲突决策。 +该 provider 的状态存放在 `~/.teamai/providers/http//`;其 token 以 `0600` 权限单独存于 `~/.teamai/credentials/`,绝不写入任何配置文件。每次 session 中,hook dispatch 会同步它,并返回每个 provider 的结果——后端失败会如实报为失败,而非假成功。 + +> **目前每台机器只支持一个 HTTP provider。** 同时挂载多个 HTTP 后端需要跨 provider 的归属仲裁(避免同名资源互相覆盖或删除),这是后续阶段(issue #404)。在此之前,`provider add http` 会拒绝添加第二个 provider。`--priority` 参数为该未来阶段预留,目前不影响行为。 要把已有的单个 HTTP 后端(`init --http` / `source add-http`)迁移到具名 provider 模型,运行: diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 9e72df665..2aabd054b 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -173,7 +173,6 @@ Generated: do not edit by hand. Regenerate with - `--priority ` — Arbitration priority; higher wins (default: 50) - `teamai provider list` — List configured HTTP providers - `teamai provider sync` — Sync all configured HTTP providers now - - `--force` — Bypass any freshness cache - `teamai provider remove ` — Remove an HTTP provider and clean up its resources - `teamai provider migrate-legacy` — Promote the legacy ~/.teamai/local-agent/ singleton to a named provider - `--name ` — Name for the migrated provider diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 83be86a23..24787b03d 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -88,6 +88,23 @@ teamai init --http https://your-team-host/api --token This is a read-only consumer mode — `push` / `contribute` are not available, but skills and rules still sync. +**Named HTTP provider (isolated state):** instead of the global `init --http` +singleton, an HTTP backend can be mounted as a *named* provider whose config, +credential (0600, outside config) and manifest are isolated under +`~/.teamai/providers/http//`: + +```bash +teamai provider add http https://your-team-host/api --name --token +teamai provider list +teamai provider remove +``` + +To move an existing `init --http` singleton onto this model without losing state, +run `teamai provider migrate-legacy --name ` (it keeps the old directory as +a rollback snapshot; idempotent). Only one HTTP provider is supported per install +for now — mounting several concurrently needs cross-provider ownership +arbitration, which is a later phase (issue #404). + **Claude Code kept in a different directory (`CLAUDE_CONFIG_DIR`):** `init` records that directory (as `toolRoots.claude` in the local config) and syncs every Claude path there, so run `init` from a shell that has the variable exported. Re-running diff --git a/src/__tests__/http-provider-multi.test.ts b/src/__tests__/http-provider-multi.test.ts index dcb951099..a4139eeaa 100644 --- a/src/__tests__/http-provider-multi.test.ts +++ b/src/__tests__/http-provider-multi.test.ts @@ -84,4 +84,44 @@ describe('multiple HTTP providers: hook dispatch', () => { expect(byName.flaky.ok).toBe(false); expect(byName.flaky.message).toBe('backend down'); }); + + it('reports ok:false when the client swallows a report/sync error (outcome signal)', async () => { + // reportAndSyncFromHook does NOT throw here — it returns normally but fills + // the outcome out-param with a failure, exactly as the real client does when + // its internal try/catch swallows a network error. The adapter must still + // surface ok:false, not a false success. + vi.doMock('../providers/http/adapters/clawpro/client.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reportAndSyncFromHook: vi.fn(async (_stdin, _tool, outcome) => { + if (outcome) { + outcome.failed = true; + outcome.error = 'sync FAILED: network down'; + } + return null; + }), + }; + }); + + const { upsertHttpProviderConfig, httpProviderExecutionContext } = await import('../providers/http/store.js'); + const { withHttpProvider, initLocalAgentHttp } = await import('../providers/http/adapters/clawpro/client.js'); + await upsertHttpProviderConfig({ name: 'solo', adapter: 'clawpro', endpoint: 'https://solo/api', priority: 50 }); + await withHttpProvider(httpProviderExecutionContext('solo'), () => + initLocalAgentHttp({ endpoint: 'https://solo/api', force: true }), + ); + + const { loadHttpResourceProviders } = await import('../providers/http/registry.js'); + const { syncResourceProviders } = await import('../providers/resource-registry.js'); + const results = await syncResourceProviders(await loadHttpResourceProviders(), { + trigger: 'hook', + tool: 'claude', + stdin: { hook_event_name: 'SessionStart' }, + cwd: tmpDir, + }); + + expect(results).toHaveLength(1); + expect(results[0].ok).toBe(false); + expect(results[0].message).toBe('sync FAILED: network down'); + }); }); diff --git a/src/__tests__/http-provider-store.test.ts b/src/__tests__/http-provider-store.test.ts index 27ef2edd3..2f8bbed3d 100644 --- a/src/__tests__/http-provider-store.test.ts +++ b/src/__tests__/http-provider-store.test.ts @@ -31,6 +31,23 @@ describe('http provider store: config registry', () => { expect(() => assertValidProviderName('../evil')).toThrow(/Invalid provider name/); expect(() => assertValidProviderName('bad/name')).toThrow(/Invalid provider name/); expect(() => assertValidProviderName('good-name.1')).not.toThrow(); + // Cross-platform path-segment hazards: trailing dot and Windows reserved names. + expect(() => assertValidProviderName('name.')).toThrow(/must not end with/); + expect(() => assertValidProviderName('CON')).toThrow(/reserved device name/); + expect(() => assertValidProviderName('com1')).toThrow(/reserved device name/); + }); + + it('rejects a name that collides case-insensitively with an existing provider', async () => { + const { upsertHttpProviderConfig } = await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'Company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 10 }); + // Same lowercase form, different spelling → collides on a case-insensitive FS. + await expect( + upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://b/api', priority: 20 }), + ).rejects.toThrow(/collides with existing/); + // Exact-name replacement is still allowed (intentional upsert). + await expect( + upsertHttpProviderConfig({ name: 'Company', adapter: 'clawpro', endpoint: 'https://c/api', priority: 30 }), + ).resolves.toBeUndefined(); }); it('isolates two providers by name in registry and per-provider home', async () => { @@ -194,10 +211,29 @@ describe('http provider: legacy singleton migration', () => { expect(await migrateLegacyHttpProvider({ name: 'company' })).toBeNull(); }); - it('refuses to overwrite an existing provider home', async () => { + it('refuses to migrate onto a name a real provider already occupies', async () => { await seedLegacy('t'); - const { migrateLegacyHttpProvider, httpProviderHome } = await import('../providers/http/store.js'); + const { migrateLegacyHttpProvider, upsertHttpProviderConfig } = await import('../providers/http/store.js'); + // A registered provider is the real conflict. + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://x/api', priority: 10 }); + await expect(migrateLegacyHttpProvider({ name: 'company' })).rejects.toThrow(/already exists/); + }); + + it('is retriable: a home left by a crashed migration is discarded and rebuilt', async () => { + await seedLegacy('legacy-token'); + const { migrateLegacyHttpProvider, httpProviderHome, getHttpProviderConfig } = await import( + '../providers/http/store.js' + ); + // Simulate a crash after the state-dir move but before the registry/marker + // write: a home dir exists but no registry entry and no marker. await fse.ensureDir(httpProviderHome('company')); - await expect(migrateLegacyHttpProvider({ name: 'company' })).rejects.toThrow(/already has state/); + await fse.writeFile(path.join(httpProviderHome('company'), 'stale.txt'), 'leftover'); + + const config = await migrateLegacyHttpProvider({ name: 'company' }); + expect(config).not.toBeNull(); + // The leftover was discarded and the home rebuilt from legacy (manifest copied). + expect(fs.existsSync(path.join(httpProviderHome('company'), 'stale.txt'))).toBe(false); + expect(fs.existsSync(path.join(httpProviderHome('company'), 'manifest.json'))).toBe(true); + expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://legacy/api'); }); }); diff --git a/src/__tests__/provider-command.test.ts b/src/__tests__/provider-command.test.ts new file mode 100644 index 000000000..44ac57e43 --- /dev/null +++ b/src/__tests__/provider-command.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +let tmpDir: string; +let origHome: string | undefined; +let exitSpy: ReturnType; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-provider-cmd-')); + origHome = process.env.HOME; + process.env.HOME = tmpDir; + // process.exit(1) marks a CLI failure; throw so the test can assert it and + // the function stops (as it would in the real CLI). + exitSpy = vi.fn((code?: number) => { + throw new Error(`process.exit(${code})`); + }); + vi.spyOn(process, 'exit').mockImplementation(exitSpy as never); +}); + +afterEach(async () => { + process.env.HOME = origHome; + await fse.remove(tmpDir); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('provider add http: single-provider gate (issue #404 phase 2)', () => { + it('refuses a second HTTP provider while one is configured', async () => { + const { upsertHttpProviderConfig } = await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'first', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://b/api', { name: 'second' }), + ).rejects.toThrow(/process.exit\(1\)/); + expect(exitSpy).toHaveBeenCalledWith(1); + + // The second provider was never registered. + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['first']); + }); + + it('refuses to add a provider while the legacy singleton is active', async () => { + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://b/api', { name: 'company' }), + ).rejects.toThrow(/process.exit\(1\)/); + + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + }); + + it('rejects an unknown adapter before writing anything', async () => { + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://a/api', { name: 'x', adapter: 'nope' }), + ).rejects.toThrow(/process.exit\(1\)/); + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + }); +}); + +describe('provider add http: registry published only after init succeeds (issue #404)', () => { + it('rolls back and leaves no registry entry when adapter init fails', async () => { + // Force the ClawPro adapter's initialize to fail. + vi.doMock('../providers/http/registry.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getHttpAdapter: (name: string) => { + const backend = actual.getHttpAdapter(name); + return { ...backend, initialize: async () => { throw new Error('bad token'); } }; + }, + }; + }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://a/api', { name: 'company', token: 'x' }), + ).rejects.toThrow(/process.exit\(1\)/); + + // No registered provider, no leftover state home. + const { listHttpProviderConfigs, httpProviderHome } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + expect(fse.existsSync(httpProviderHome('company'))).toBe(false); + }); +}); diff --git a/src/index.ts b/src/index.ts index 719ef671e..c20e7132f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -583,10 +583,9 @@ providerCmd providerCmd .command('sync') .description('Sync all configured HTTP providers now') - .option('--force', 'Bypass any freshness cache') - .action(async (cmdOpts) => { + .action(async () => { const { providerSync } = await import('./provider-command.js'); - await providerSync(cmdOpts); + await providerSync(); }); providerCmd @@ -992,11 +991,25 @@ program .option('--project-id ', 'Project ID from /projects/mine') .option('--skip', 'Mark current workspace as skipped (never prompt again)') .action(async (cmdOpts) => { - const { bindCurrentProject } = await import('./local-agent.js'); - await bindCurrentProject({ + const { bindCurrentProject, withHttpProvider } = await import('./local-agent.js'); + const args = { projectId: cmdOpts.projectId ? Number.parseInt(cmdOpts.projectId, 10) : undefined, skip: !!cmdOpts.skip, - }); + }; + // Bind inside the named HTTP provider's context so it reads/writes that + // provider's own bindings, not the legacy ~/.teamai/local-agent/ singleton. + // Falls back to the legacy path when no named provider is configured. + const { listHttpProviderConfigs, httpProviderExecutionContext } = await import( + './providers/http/store.js' + ); + const [provider] = await listHttpProviderConfigs(); + if (provider) { + await withHttpProvider(httpProviderExecutionContext(provider.name), () => + bindCurrentProject(args), + ); + } else { + await bindCurrentProject(args); + } }); // ─── Contribute commands ────────────────────────────────── diff --git a/src/provider-command.ts b/src/provider-command.ts index 479b6e4e4..0186167e2 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -12,6 +12,7 @@ import { removeHttpProviderConfig, removeHttpProviderState, migrateLegacyHttpProvider, + legacySingletonActive, assertValidProviderName, } from './providers/http/store.js'; import { @@ -49,6 +50,31 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P process.exit(1); } + // Single-provider gate (issue #404, phase 2). Running two HTTP providers + // concurrently is unsafe until the ownership ledger (phase 4) arbitrates + // same-name resources across providers — otherwise one provider's uninstall + // deletes files another provider installed, and serial hook sync can exceed + // the foreground budget. Until then, allow exactly one HTTP provider (plus + // the legacy singleton, which double-track dispatch already handles). + const existing = await listHttpProviderConfigs(); + if (existing.length > 0) { + log.error( + `An HTTP provider ("${existing[0].name}") is already configured. Multiple HTTP ` + + 'providers need cross-provider ownership arbitration (issue #404 phase 4) and ' + + 'are not supported yet. Remove the existing one with `teamai provider remove ' + + `${existing[0].name}\` first.`, + ); + process.exit(1); + } + if (await legacySingletonActive()) { + log.error( + 'A legacy HTTP local agent is already configured. Migrate it with ' + + '`teamai provider migrate-legacy --name ` instead of adding a second ' + + 'HTTP provider (multiple providers need issue #404 phase 4).', + ); + process.exit(1); + } + const priority = parsePriority(opts.priority); const config: HttpProviderConfig = { name: opts.name, @@ -57,12 +83,22 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P priority, }; - await upsertHttpProviderConfig(config); - // initialize is optional on the adapter interface; call it when present so the - // credential and initial state land in the provider's isolated home. + // Initialize the backend BEFORE publishing the registry record, so a failed + // init (bad token, unwritable dir, hook injection failure) never leaves a + // registered-but-broken provider that later hook dispatches keep loading. + // Publish the registry record only after init succeeds; on any failure roll + // back the partial state so a retry starts clean. const backend = getHttpAdapter(adapter); - if (backend.initialize) { - await backend.initialize(config, opts.token); + try { + if (backend.initialize) { + await backend.initialize(config, opts.token); + } + await upsertHttpProviderConfig(config); + } catch (e) { + await removeHttpProviderState(config.name); + await removeHttpProviderConfig(config.name); + log.error(`Failed to add provider "${config.name}": ${(e as Error).message}`); + process.exit(1); } log.success(`Added HTTP provider "${config.name}" (${config.adapter}) → ${config.endpoint}`); @@ -82,19 +118,21 @@ export async function providerList(): Promise { } } -/** `teamai provider sync [--force]` */ -export async function providerSync(opts: { force?: boolean }): Promise { +/** `teamai provider sync` */ +export async function providerSync(): Promise { const configs = await listHttpProviderConfigs(); if (configs.length === 0) { log.info('No HTTP providers configured.'); return; } const providers = configs.map(createHttpResourceProvider); - const results = await syncResourceProviders(providers, { trigger: 'manual', force: opts.force }); + const results = await syncResourceProviders(providers, { trigger: 'manual' }); for (const r of results) { if (r.ok) log.success(` ${r.provider}: ok${r.changed ? ' (changed)' : ''}`); else log.error(` ${r.provider}: ${r.message ?? 'failed'}`); } + // Exit non-zero if any provider failed, so scripts/CI can detect it. + if (results.some((r) => !r.ok)) process.exit(1); } /** `teamai provider remove ` */ diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index bed512755..1938559f3 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -3187,7 +3187,23 @@ async function processCommands( return modelConfigApplied; } -export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promise { +/** + * Out-parameter for report/sync outcome. The boolean return of + * `reportAndSyncLocalAgent` means "config present and ran", not "succeeded" + * (many callers/tests rely on that), so a failed report/sync is reported here + * instead: `failed` is set true with the error when the network/command phase + * throws. Legacy callers omit it and are unaffected; the HTTP provider adapter + * passes one so `provider sync` never prints a backend failure as success. + */ +export interface SyncOutcome { + failed?: boolean; + error?: string; +} + +export async function reportAndSyncLocalAgent( + context: LocalAgentContext, + outcome?: SyncOutcome, +): Promise { const config = await loadLocalAgentConfig(); if (!config) return false; @@ -3296,6 +3312,10 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi const error = (e as Error).message; log.error(`${tag} sync FAILED: ${error}`); await appendErrorLog({ error, context }); + if (outcome) { + outcome.failed = true; + outcome.error = error; + } } return true; @@ -3315,6 +3335,7 @@ function statusFromEvent(event?: DashboardEvent): string { export async function reportAndSyncFromHook( stdin: Record, tool: string, + outcome?: SyncOutcome, ): Promise { const raw = JSON.stringify(stdin); const event = await parseHookEvent(raw, tool); @@ -3338,7 +3359,7 @@ export async function reportAndSyncFromHook( tool, status: statusFromEvent(event ?? undefined), event: event ?? undefined, - }); + }, outcome); return null; } finally { activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; @@ -3406,13 +3427,16 @@ export async function initLocalAgentHttp(options: { } } -export async function pullLocalAgentForCwd(context?: LocalAgentContext): Promise { +export async function pullLocalAgentForCwd( + context?: LocalAgentContext, + outcome?: SyncOutcome, +): Promise { return reportAndSyncLocalAgent({ cwd: context?.cwd ?? process.cwd(), tool: context?.tool ?? 'workbuddy', status: context?.status ?? 'running', event: context?.event, - }); + }, outcome); } /** Summary of the configured HTTP local-agent bypass, for `teamai source list`. */ diff --git a/src/providers/http/adapters/clawpro/index.ts b/src/providers/http/adapters/clawpro/index.ts index 511e62166..e0512e981 100644 --- a/src/providers/http/adapters/clawpro/index.ts +++ b/src/providers/http/adapters/clawpro/index.ts @@ -23,6 +23,7 @@ import { pullLocalAgentForCwd, describeLocalAgent, removeLocalAgentHttp, + type SyncOutcome, } from './client.js'; import { httpProviderExecutionContext } from '../../store.js'; @@ -42,16 +43,21 @@ export class ClawProAdapter implements HttpBackendAdapter { async sync(config: HttpProviderConfig, context: SyncContext): Promise { return withHttpProvider(httpProviderExecutionContext(config.name), async () => { + // reportAndSyncLocalAgent swallows network/command errors (so one bad + // backend never crashes a hook); it signals them through this outcome + // instead, so we report an honest ok rather than always true. + const outcome: SyncOutcome = {}; let hookOutput: string | null = null; if (context.trigger === 'hook' && context.stdin) { - hookOutput = await reportAndSyncFromHook(context.stdin, context.tool ?? 'workbuddy'); + hookOutput = await reportAndSyncFromHook(context.stdin, context.tool ?? 'workbuddy', outcome); } else { - await pullLocalAgentForCwd({ cwd: context.cwd, tool: context.tool }); + await pullLocalAgentForCwd({ cwd: context.cwd, tool: context.tool }, outcome); } return { provider: config.name, - ok: true, + ok: !outcome.failed, changed: false, + ...(outcome.error ? { message: outcome.error } : {}), ...(hookOutput ? { hookOutput } : {}), }; }); diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts index d83fdadab..0a621d3bf 100644 --- a/src/providers/http/store.ts +++ b/src/providers/http/store.ts @@ -72,13 +72,48 @@ interface ProviderRegistryFile { http?: HttpProviderConfig[]; } -/** Provider names must be a safe single path segment (state-dir + credential-file). */ +/** Windows reserved device names (case-insensitive), which cannot be a path segment. */ +const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +/** + * A provider name becomes a single path segment for its state dir and + * credential file, so it must be safe on every platform. Beyond the character + * set, reject anything that could collide with another name on a + * case-insensitive filesystem (`Foo` vs `foo`), a name ending in `.` or a + * space (Windows strips them, so `name.` and `name` would share a path), and + * Windows reserved device names (`CON`, `COM1`, …). + */ export function assertValidProviderName(name: string): void { if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) { throw new Error( `Invalid provider name "${name}". Use letters, digits, '.', '_' or '-', starting alphanumeric.`, ); } + if (name.endsWith('.')) { + throw new Error(`Invalid provider name "${name}": must not end with '.'.`); + } + if (WINDOWS_RESERVED_NAME.test(name)) { + throw new Error(`Invalid provider name "${name}": reserved device name.`); + } +} + +/** + * Reject a name that collides with an existing provider on a case-insensitive + * filesystem (`Foo` when `foo` exists). Same-name replacement is allowed (that + * is an intentional upsert), so only a *different* name with the same lowercase + * form is a conflict. Call before writing a NEW provider. + */ +async function assertNameNotCaseColliding(name: string): Promise { + const lower = name.toLowerCase(); + const clash = (await listHttpProviderConfigs()).find( + (p) => p.name !== name && p.name.toLowerCase() === lower, + ); + if (clash) { + throw new Error( + `Provider name "${name}" collides with existing "${clash.name}" on a ` + + 'case-insensitive filesystem. Choose a distinct name.', + ); + } } /** Read the registry of configured HTTP providers (empty when none). */ @@ -95,6 +130,7 @@ export async function getHttpProviderConfig(name: string): Promise { assertValidProviderName(config.name); + await assertNameNotCaseColliding(config.name); const existing = await listHttpProviderConfigs(); const next = existing.filter((p) => p.name !== config.name); next.push(config); @@ -179,10 +215,18 @@ export async function migrateLegacyHttpProvider(options: { priority: options.priority ?? legacy.priority ?? 50, }; + // Conflict is decided by the registry, not the home directory: a real + // `provider add` records the name in settings.json. A home dir that exists + // WITHOUT a registry entry (and with no marker — checked above) is leftover + // from a migration that crashed before it finished. Because the legacy dir + // stays authoritative until the marker is written, that partial home carries + // no unique data and is safe to discard and rebuild — which is what makes + // this operation retriable rather than permanently stuck. const home = httpProviderHome(options.name); - if (await pathExists(home)) { - throw new Error(`Provider "${options.name}" already has state at ${home}; choose another name.`); + if (await getHttpProviderConfig(options.name)) { + throw new Error(`Provider "${options.name}" already exists; choose another name.`); } + await remove(home); // Stage a full copy, then atomically move it into place so an interrupted // migration never leaves a half-populated provider home. diff --git a/src/uninstall.ts b/src/uninstall.ts index 19d1fd0b2..9c5a17ba1 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -853,10 +853,18 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { // ─── Execution ───────────────────────────────────────── /** - * Stop and uninstall local-agent plugins (best-effort) before ~/.teamai is deleted. - * Dynamic import mirrors source.ts — keeps local-agent's heavy dependency graph out - * of uninstall's static import chain. Covers the legacy singleton and every named - * HTTP provider (issue #404), each torn down in its own isolated context. + * Tear down HTTP resource backends before ~/.teamai is deleted (best-effort). + * Dynamic import mirrors source.ts — keeps local-agent's heavy dependency graph + * out of uninstall's static import chain. + * + * The legacy singleton only needs its plugins stopped: uninstall's own removal + * plan already reads the legacy manifest and strips its resources/hooks. But a + * named HTTP provider's resources are recorded in ITS OWN manifest under + * ~/.teamai/providers/http//, which the removal plan never reads — so its + * installed skills/rules/CLAUDE.md/agent-hooks would be orphaned in the tool + * dirs when ~/.teamai is deleted. Each named provider therefore gets a full + * teardown() (uninstall all manifest resources + remove agent hooks + stop + * plugins) inside its own isolated context (issue #404). */ async function teardownPlugins(): Promise { try { @@ -869,14 +877,14 @@ async function teardownPlugins(): Promise { const { listHttpProviderConfigs, httpProviderExecutionContext } = await import( './providers/http/store.js' ); - const { withHttpProvider, teardownLocalAgentPlugins } = await import('./local-agent.js'); + const { withHttpProvider, removeLocalAgentHttp } = await import('./local-agent.js'); for (const config of await listHttpProviderConfigs()) { try { await withHttpProvider(httpProviderExecutionContext(config.name), () => - teardownLocalAgentPlugins(), + removeLocalAgentHttp(), ); } catch (e) { - log.warn(`plugin teardown for provider "${config.name}" failed: ${(e as Error).message}`); + log.warn(`teardown for provider "${config.name}" failed: ${(e as Error).message}`); } } } catch (e) { From f431ce9bd0652b01f09e2326782dc3dfd43815e7 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 18:47:53 +0800 Subject: [PATCH 06/14] fix(providers): address second-round code-review P1 findings (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on PR #780. Findings on config.ts / usage-tracker.ts remain false positives — this branch's diff does not touch either file (git blame attributes those lines to upstream #775). The rest are fixed: - Close the single-provider gate on the compat entry points too (review #5): `source add-http` and `init --http` now refuse to stand up the legacy singleton when a named HTTP provider exists, and `provider migrate-legacy` refuses when one already exists — so a named provider and a legacy singleton can never be dispatched together. - `provider sync` reports a failed remote command as a failure (review #5-cmd): processCommands now records a per-command failure into the SyncOutcome (still isolated — it neither throws nor aborts the loop), so the adapter returns ok:false instead of a false success. - Roll back injected hooks on a failed `provider add` (review #3): the rollback now runs the adapter's full teardown(), not just state + registry removal, so hooks init already wrote are not orphaned. - Make legacy migration genuinely resumable (review #4): a registry entry whose endpoint matches the legacy one is treated as this same interrupted migration and resumed (legacy stays authoritative until the marker); only a different-endpoint entry is a foreign conflict. - Reject Windows reserved names with an extension too — CON.txt, LPT1.foo (review #8) — and surface every invalid name as a clean error + exit, never an uncaught throw. Tests: reserved-name-with-extension, resumable migration after a registry write, migrate-legacy gate, and clean-exit on invalid name. --- src/__tests__/http-provider-store.test.ts | 24 +++++++++++++ src/__tests__/provider-command.test.ts | 33 +++++++++++++++++ src/init.ts | 15 ++++++++ src/provider-command.ts | 36 +++++++++++++++++-- src/providers/http/adapters/clawpro/client.ts | 10 +++++- src/providers/http/store.ts | 27 ++++++++------ src/source.ts | 16 +++++++++ 7 files changed, 147 insertions(+), 14 deletions(-) diff --git a/src/__tests__/http-provider-store.test.ts b/src/__tests__/http-provider-store.test.ts index 2f8bbed3d..a29748606 100644 --- a/src/__tests__/http-provider-store.test.ts +++ b/src/__tests__/http-provider-store.test.ts @@ -35,6 +35,11 @@ describe('http provider store: config registry', () => { expect(() => assertValidProviderName('name.')).toThrow(/must not end with/); expect(() => assertValidProviderName('CON')).toThrow(/reserved device name/); expect(() => assertValidProviderName('com1')).toThrow(/reserved device name/); + // Windows also forbids a reserved name with any extension (CON.txt → device). + expect(() => assertValidProviderName('CON.txt')).toThrow(/reserved device name/); + expect(() => assertValidProviderName('LPT1.foo')).toThrow(/reserved device name/); + // A name that merely starts with those letters is fine. + expect(() => assertValidProviderName('console')).not.toThrow(); }); it('rejects a name that collides case-insensitively with an existing provider', async () => { @@ -236,4 +241,23 @@ describe('http provider: legacy singleton migration', () => { expect(fs.existsSync(path.join(httpProviderHome('company'), 'manifest.json'))).toBe(true); expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://legacy/api'); }); + + it('resumes when a prior attempt already wrote the registry entry but no marker', async () => { + await seedLegacy('legacy-token'); + const { migrateLegacyHttpProvider, upsertHttpProviderConfig, legacySingletonActive } = await import( + '../providers/http/store.js' + ); + // Simulate a crash AFTER upsert (registry entry with the legacy endpoint) + // but BEFORE the marker: legacy is still active. A retry must resume, not + // fail with "already exists". + await upsertHttpProviderConfig({ + name: 'company', adapter: 'clawpro', endpoint: 'https://legacy/api', priority: 50, + }); + expect(await legacySingletonActive()).toBe(true); + + const config = await migrateLegacyHttpProvider({ name: 'company' }); + expect(config).not.toBeNull(); + // Migration now completed: legacy marked migrated. + expect(await legacySingletonActive()).toBe(false); + }); }); diff --git a/src/__tests__/provider-command.test.ts b/src/__tests__/provider-command.test.ts index 44ac57e43..09abdc1f7 100644 --- a/src/__tests__/provider-command.test.ts +++ b/src/__tests__/provider-command.test.ts @@ -70,6 +70,39 @@ describe('provider add http: single-provider gate (issue #404 phase 2)', () => { const { listHttpProviderConfigs } = await import('../providers/http/store.js'); expect(await listHttpProviderConfigs()).toEqual([]); }); + + it('rejects an invalid name with a clean exit, not an uncaught throw', async () => { + const { log } = await import('../utils/logger.js'); + const { providerAddHttp } = await import('../provider-command.js'); + // A Windows reserved name with an extension must be a clean error + exit(1). + await expect( + providerAddHttp('https://a/api', { name: 'CON.txt', token: 't' }), + ).rejects.toThrow(/process.exit\(1\)/); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('reserved device name')); + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + }); +}); + +describe('provider migrate-legacy: single-provider gate (issue #404 phase 2)', () => { + it('refuses to migrate while a named provider already exists', async () => { + // Seed a legacy singleton AND a named provider. + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + const { upsertHttpProviderConfig, listHttpProviderConfigs } = await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'existing', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + + const { providerMigrateLegacy } = await import('../provider-command.js'); + await expect( + providerMigrateLegacy({ name: 'migrated' }), + ).rejects.toThrow(/process.exit\(1\)/); + + // Still only the original named provider; no second one was created. + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['existing']); + }); }); describe('provider add http: registry published only after init succeeds (issue #404)', () => { diff --git a/src/init.ts b/src/init.ts index 0431f195a..2e79f75a4 100644 --- a/src/init.ts +++ b/src/init.ts @@ -442,6 +442,21 @@ export async function initHttp( ): Promise { const { resolveApiKey, saveApiKey, getApiKeyPath } = await import('./api-key.js'); + // Single-provider gate (issue #404 phase 2): refuse to stand up the legacy + // HTTP singleton when a named HTTP provider already exists, so the two are + // never dispatched together (cross-provider ownership arbitration is phase 4). + const { listHttpProviderConfigs } = await import('./providers/http/store.js'); + const namedProviders = await listHttpProviderConfigs(); + if (namedProviders.length > 0) { + log.error( + `A named HTTP provider ("${namedProviders[0].name}") is already configured; ` + + 'running it alongside a legacy HTTP init is not supported yet (issue #404 phase 4).', + ); + log.info(`Remove it first with \`teamai provider remove ${namedProviders[0].name}\`, or manage this endpoint via \`teamai provider add http\`.`); + process.exit(1); + return; + } + log.info('Initializing teamai (HTTP read-only consumer)...'); // Step 0: scope (same rules as git init — default project) diff --git a/src/provider-command.ts b/src/provider-command.ts index 0186167e2..f3ef37b03 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -36,7 +36,14 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P log.error('A provider name is required: --name '); process.exit(1); } - assertValidProviderName(opts.name); + // Validate the name here (not just deep in the store) so an invalid name is a + // clean error + exit, never an uncaught stack trace. + try { + assertValidProviderName(opts.name); + } catch (e) { + log.error((e as Error).message); + process.exit(1); + } const adapter = opts.adapter ?? 'clawpro'; // Fail early on an unknown adapter rather than after persisting config. @@ -86,8 +93,9 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P // Initialize the backend BEFORE publishing the registry record, so a failed // init (bad token, unwritable dir, hook injection failure) never leaves a // registered-but-broken provider that later hook dispatches keep loading. - // Publish the registry record only after init succeeds; on any failure roll - // back the partial state so a retry starts clean. + // Publish the registry record only after init succeeds; on any failure run a + // FULL teardown so nothing init already did — including the hooks it injected + // into the tools' settings — is left behind, then start clean on retry. const backend = getHttpAdapter(adapter); try { if (backend.initialize) { @@ -95,6 +103,11 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P } await upsertHttpProviderConfig(config); } catch (e) { + try { + await backend.teardown(config); + } catch (teardownErr) { + log.warn(`Rollback teardown for "${config.name}" hit an error: ${(teardownErr as Error).message}`); + } await removeHttpProviderState(config.name); await removeHttpProviderConfig(config.name); log.error(`Failed to add provider "${config.name}": ${(e as Error).message}`); @@ -162,6 +175,23 @@ export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise log.error('A provider name is required: --name '); process.exit(1); } + try { + assertValidProviderName(opts.name); + } catch (e) { + log.error((e as Error).message); + process.exit(1); + } + // Single-provider gate (issue #404 phase 2): migrating a legacy singleton + // while a named provider already exists would leave two named HTTP providers, + // bypassing the one-provider limit. Refuse until phase 4's arbitration lands. + const existing = await listHttpProviderConfigs(); + if (existing.length > 0) { + log.error( + `A named HTTP provider ("${existing[0].name}") already exists; migrating the legacy ` + + 'singleton would create a second one, which is not supported yet (issue #404 phase 4).', + ); + process.exit(1); + } const config = await migrateLegacyHttpProvider({ name: opts.name, priority: parsePriority(opts.priority), diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index 1938559f3..df8f3adcc 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -3145,6 +3145,7 @@ async function processCommands( config: LocalAgentConfig, commands: LocalAgentCommand[], context: LocalAgentContext, + outcome?: SyncOutcome, ): Promise { const tag = localAgentTag(context); let modelConfigApplied = false; @@ -3177,6 +3178,13 @@ async function processCommands( } catch (e) { const error = (e as Error).message; log.error(`${tag} command ${command.id} failed: ${error}`); + // A failed command is isolated (the session continues), but the sync as a + // whole did not fully succeed — surface it so `provider sync` reports the + // failure instead of a false success (does not throw / abort the loop). + if (outcome && !outcome.failed) { + outcome.failed = true; + outcome.error = `command ${command.id} (${command.type ?? ''}) failed: ${error}`; + } try { await ackCommand(config, tag, command, 'failed', undefined, error); } catch (ackError) { @@ -3297,7 +3305,7 @@ export async function reportAndSyncLocalAgent( const commands = cmds && cmds.length > 0 ? cmds : (syncResponse.commands ?? []); if (commands.length > 0) { log.debug(`${tag} sync returned ${commands.length} command(s): ${commands.map((c) => `${c.type}#${c.id}`).join(', ')}`); - const modelConfigApplied = await processCommands(config, commands, context); + const modelConfigApplied = await processCommands(config, commands, context, outcome); if (modelConfigApplied && !skipReport) { const reportPayload = await buildReportPayload(config, context); await localAgentFetch(config, tag, 'report', { diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts index 0a621d3bf..9152941ba 100644 --- a/src/providers/http/store.ts +++ b/src/providers/http/store.ts @@ -72,8 +72,12 @@ interface ProviderRegistryFile { http?: HttpProviderConfig[]; } -/** Windows reserved device names (case-insensitive), which cannot be a path segment. */ -const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; +/** + * Windows reserved device names (case-insensitive). Windows forbids these both + * bare and with any extension (`CON`, `CON.txt`, `LPT1.foo` all resolve to the + * device), so match an optional `.` suffix too. + */ +const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; /** * A provider name becomes a single path segment for its state dir and @@ -215,15 +219,18 @@ export async function migrateLegacyHttpProvider(options: { priority: options.priority ?? legacy.priority ?? 50, }; - // Conflict is decided by the registry, not the home directory: a real - // `provider add` records the name in settings.json. A home dir that exists - // WITHOUT a registry entry (and with no marker — checked above) is leftover - // from a migration that crashed before it finished. Because the legacy dir - // stays authoritative until the marker is written, that partial home carries - // no unique data and is safe to discard and rebuild — which is what makes - // this operation retriable rather than permanently stuck. + // Retriability: because the legacy marker is written LAST, reaching this + // point means the previous attempt (if any) was interrupted before it + // finished — the legacy backend is still authoritative. Any partial state the + // previous attempt left (a registry entry, a half-copied home) therefore + // carries no unique data and belongs to THIS same migration, so we resume by + // discarding and rebuilding it rather than failing with "already exists". + // + // A registry entry for this name whose endpoint DIFFERS from the legacy one is + // a genuine foreign `provider add` conflict, not our leftover — reject that. const home = httpProviderHome(options.name); - if (await getHttpProviderConfig(options.name)) { + const existingEntry = await getHttpProviderConfig(options.name); + if (existingEntry && existingEntry.endpoint !== config.endpoint) { throw new Error(`Provider "${options.name}" already exists; choose another name.`); } await remove(home); diff --git a/src/source.ts b/src/source.ts index ddf083d3b..e3edae048 100644 --- a/src/source.ts +++ b/src/source.ts @@ -302,6 +302,22 @@ export async function sourceAddHttp( return; } + // Single-provider gate (issue #404 phase 2): a named HTTP provider and this + // legacy singleton would both be dispatched each session, reintroducing the + // cross-provider resource-ownership and timeout problems the phase-2 gate + // exists to avoid. Refuse rather than create a second active HTTP backend. + const { listHttpProviderConfigs } = await import('./providers/http/store.js'); + const namedProviders = await listHttpProviderConfigs(); + if (namedProviders.length > 0) { + log.error( + `A named HTTP provider ("${namedProviders[0].name}") is already configured. ` + + 'Running it alongside a legacy HTTP source needs cross-provider ownership ' + + 'arbitration (issue #404 phase 4) and is not supported yet.', + ); + log.info(`Remove it first with \`teamai provider remove ${namedProviders[0].name}\`, or manage this endpoint via \`teamai provider add http\`.`); + return; + } + if (options.dryRun) { log.info(`[dry-run] Would add HTTP source ${trimmed}`); return; From 3de4713b180bd89e9eb588eab563497edbe40e0a Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 19:08:25 +0800 Subject: [PATCH 07/14] fix(providers): address third-round code-review P1 findings (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round on PR #780. config.ts / usage-tracker.ts findings remain false positives (git blame attributes those lines to upstream #775; this branch's diff for both files is empty). The rest are fixed: - Restore migrate-legacy idempotency (review #3, a regression from the round-2 gate): the single-provider gate now rejects only a DIFFERENTLY-named provider, so re-running after a completed migration is a clean no-op and a crash-interrupted migration can still resume through the store's recovery path. - Fail `provider add` when init injected zero hooks (review #4): injectHooksToAllTools now returns an attempted/succeeded tally, and a named-provider init throws when hooks were attempted but none landed, so the add rolls back instead of reporting a false success. - teardown removes the built-in teamai hooks too (review #5): a named provider's removeLocalAgentHttp now clears the dispatch hooks its own init injected (via reconcileHooksToAllTools removeAll), gated on no other teamai install still needing them — so `provider remove` and failed-add rollback leave no orphaned hooks. - Serialize `provider add` under a machine lock (review #6): the single-provider check-and-write now holds ~/.teamai/providers/.add.lock so two concurrent adds cannot both pass the empty-registry gate. - Sync the bilingual management-backend design doc (review #7): it now points at providers/http/adapters/clawpro/client.ts and states the #404 phase 1-2 abstraction has landed. P2 (unused GitResourceProvider / registry class / adapter routes) is kept as the phase-1 abstraction contract by maintainer preference. Tests: migrate idempotency-after-success, init-injected-no-hooks rollback. --- docs/designs/management-backend.md | 21 +-- docs/designs/management-backend.zh-CN.md | 10 +- src/__tests__/provider-command.test.ts | 42 ++++++ src/hooks.ts | 84 ++++++----- src/provider-command.ts | 130 ++++++++++-------- src/providers/http/adapters/clawpro/client.ts | 49 ++++++- 6 files changed, 232 insertions(+), 104 deletions(-) diff --git a/docs/designs/management-backend.md b/docs/designs/management-backend.md index 2f6409b84..803e7e9ed 100644 --- a/docs/designs/management-backend.md +++ b/docs/designs/management-backend.md @@ -24,14 +24,19 @@ reconciles hooks/MCP, maintains recall indexes and reports activity. The backend's wider resource model must not falsely imply every current CLI handler already supports every write operation. -The current [local agent](../../src/local-agent.ts) uses -`/api/projects/mine`, `/api/local-agent/report`, `/api/local-agent/sync`, -`/api/local-agent/commands/ack` and `/api/local-agent/get-config`. It delivers -commands/resources and manages workspace bindings, rather than materializing a -complete versioned team repository. These routes remain a separate compatibility -adapter; they are not aliases for the new API. The provider abstraction proposed -in [#469](https://github.com/Tencent/teamai-cli/pull/469) can host a future management -adapter if accepted; this design does not assume that PR has landed. +The ClawPro HTTP client (now at +[providers/http/adapters/clawpro/client.ts](../../src/providers/http/adapters/clawpro/client.ts), +with [src/local-agent.ts](../../src/local-agent.ts) kept as a deprecated +re-export) uses `/api/projects/mine`, `/api/local-agent/report`, +`/api/local-agent/sync`, `/api/local-agent/commands/ack` and +`/api/local-agent/get-config`. It delivers commands/resources and manages +workspace bindings, rather than materializing a complete versioned team +repository. These routes remain a separate compatibility adapter; they are not +aliases for the new API. The Git/HTTP `ResourceProvider` abstraction (issue +[#404](https://github.com/Tencent/teamai-cli/issues/404), phases 1–2 landed: +`ResourceProvider`/`HttpBackendAdapter` with ClawPro as an HTTP adapter) is the +seam a future management adapter would plug into as another HTTP adapter; the +ownership-ledger and multi-provider arbitration it needs are later phases. The [data-directory design](data-directory-layout.md) distinguishes a local workspace partition from a logical project. The [multi-project design](multi-project-management.md) diff --git a/docs/designs/management-backend.zh-CN.md b/docs/designs/management-backend.zh-CN.md index 4a2ae9b19..dec105864 100644 --- a/docs/designs/management-backend.zh-CN.md +++ b/docs/designs/management-backend.zh-CN.md @@ -20,13 +20,17 @@ [remove](../../src/remove.ts) 当前只暴露 skills、rules、agents 和 MCP 的删除。 后端覆盖更多资源,不代表当前每个 CLI 处理器已经支持所有写操作。 -现有 [local-agent](../../src/local-agent.ts) 使用 +ClawPro HTTP 客户端(现位于 +[providers/http/adapters/clawpro/client.ts](../../src/providers/http/adapters/clawpro/client.ts), +[src/local-agent.ts](../../src/local-agent.ts) 保留为已弃用的 re-export)使用 `/api/projects/mine`、`/api/local-agent/report`、`/api/local-agent/sync`、 `/api/local-agent/commands/ack` 和 `/api/local-agent/get-config`, 负责命令与资源下发及工作区绑定,并不生成完整的版本化团队仓快照。 这些路由保留为独立兼容适配器,不作为新 API 的别名。 -[#469](https://github.com/Tencent/teamai-cli/pull/469) 提议的 Provider 抽象若获合入, -可以承载未来的管理后端适配器;本文不假设该 PR 已经落地。 +Git/HTTP `ResourceProvider` 抽象(issue +[#404](https://github.com/Tencent/teamai-cli/issues/404),阶段 1–2 已落地: +`ResourceProvider`/`HttpBackendAdapter`,ClawPro 作为一个 HTTP adapter)即未来管理后端 +适配器可作为又一个 HTTP adapter 接入的接缝;其所需的 ownership ledger 与多 provider 仲裁属后续阶段。 [数据目录设计](data-directory-layout.md) 区分机器上的工作区分区与逻辑项目。 [多项目设计](multi-project-management.md) 通过项目和角色选择器决定资源命名空间。 diff --git a/src/__tests__/provider-command.test.ts b/src/__tests__/provider-command.test.ts index 09abdc1f7..e82677e51 100644 --- a/src/__tests__/provider-command.test.ts +++ b/src/__tests__/provider-command.test.ts @@ -103,6 +103,27 @@ describe('provider migrate-legacy: single-provider gate (issue #404 phase 2)', ( // Still only the original named provider; no second one was created. expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['existing']); }); + + it('stays idempotent: re-running after a completed migration is a no-op, not an error', async () => { + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + await fse.writeJson(path.join(legacy, 'manifest.json'), { scopes: {} }); + + const { providerMigrateLegacy } = await import('../provider-command.js'); + // First migration succeeds and registers "company". + await providerMigrateLegacy({ name: 'company' }); + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['company']); + + // Re-running with the SAME name must NOT error on the single-provider gate + // (regression: the gate previously rejected any existing entry, including + // this migration's own). It is a clean no-op. + await expect(providerMigrateLegacy({ name: 'company' })).resolves.toBeUndefined(); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['company']); + }); }); describe('provider add http: registry published only after init succeeds (issue #404)', () => { @@ -129,4 +150,25 @@ describe('provider add http: registry published only after init succeeds (issue expect(await listHttpProviderConfigs()).toEqual([]); expect(fse.existsSync(httpProviderHome('company'))).toBe(false); }); + + it('fails the add when init injected no hooks at all (delivers nothing)', async () => { + // Simulate every tool's hook injection failing: attempted > 0, succeeded 0. + vi.doMock('../hooks.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + injectHooksToAllTools: vi.fn(async () => ({ attempted: 2, succeeded: 0 })), + }; + }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://a/api', { name: 'company', token: 'x' }), + ).rejects.toThrow(/process.exit\(1\)/); + + // Rolled back: no registry entry, no leftover home. + const { listHttpProviderConfigs, httpProviderHome } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + expect(fse.existsSync(httpProviderHome('company'))).toBe(false); + }); }); diff --git a/src/hooks.ts b/src/hooks.ts index dceccf9fd..a15050bc0 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1415,57 +1415,75 @@ async function reconcilePiExtension( * Only writes to tools whose root directory already exists on disk, * preventing creation of config dirs for tools the user hasn't installed. */ -export async function injectHooksToAllTools(toolPaths: Record, baseDir?: string, filterAgents?: string[]): Promise { +/** Per-run tally of hook injection: how many tools were attempted vs. succeeded. */ +export interface HookInjectionResult { + /** Tools whose hook injection was attempted (installed, not skipped). */ + attempted: number; + /** Tools whose hook injection succeeded. */ + succeeded: number; +} + +export async function injectHooksToAllTools(toolPaths: Record, baseDir?: string, filterAgents?: string[]): Promise { const resolvedBaseDir = baseDir ?? getUserHome(); const skipped = skipToolsWithoutShell( Object.keys(toolPaths).filter(t => !filterAgents || filterAgents.includes(t)), ); + let attempted = 0; + let succeeded = 0; + const attempt = async (fn: () => Promise, onError: (e: Error) => void): Promise => { + attempted += 1; + try { + await fn(); + succeeded += 1; + } catch (e) { + onError(e as Error); + } + }; for (const [tool, paths] of Object.entries(toolPaths)) { if (filterAgents && !filterAgents.includes(tool)) continue; if (skipped.has(tool)) continue; if (tool === 'pi') { - try { - await reconcilePiExtension(resolvedBaseDir); - } catch (e) { - log.warn(`Failed to inject Pi hook: ${(e as Error).message}`); - } + await attempt( + () => reconcilePiExtension(resolvedBaseDir), + (e) => log.warn(`Failed to inject Pi hook: ${e.message}`), + ); } else if (paths.settings) { const toolRoot = path.join(resolvedBaseDir, toolInstallRoot(paths.settings)); if (!await pathExists(toolRoot)) continue; const settingsPath = path.join(resolvedBaseDir, paths.settings); - try { - await injectHooks(settingsPath, tool); - } catch (e) { - log.warn(`Failed to inject hook into ${tool}: ${(e as Error).message}`); - } + await attempt( + () => injectHooks(settingsPath, tool), + (e) => log.warn(`Failed to inject hook into ${tool}: ${e.message}`), + ); } else if (OPENCLAW_TOOLS.has(tool)) { - try { - const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); - await injectOpenClawHooks(undefined, tool); - } catch (e) { - log.warn(`Failed to inject OpenClaw hook into ${tool}: ${(e as Error).message}`); - } + await attempt( + async () => { + const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); + await injectOpenClawHooks(undefined, tool); + }, + (e) => log.warn(`Failed to inject OpenClaw hook into ${tool}: ${e.message}`), + ); } else if (tool === 'hermes') { - try { - const { injectHermesHooks } = await import('./hermes-hooks.js'); - await injectHermesHooks(); - } catch (e) { - log.warn(`Failed to inject Hermes hook: ${(e as Error).message}`); - } + await attempt( + async () => { + const { injectHermesHooks } = await import('./hermes-hooks.js'); + await injectHermesHooks(); + }, + (e) => log.warn(`Failed to inject Hermes hook: ${e.message}`), + ); } else if (tool === 'opencode') { - try { - await reconcileOpencodePlugin(resolvedBaseDir); - } catch (e) { - log.warn(`Failed to inject OpenCode hook into ${tool}: ${(e as Error).message}`); - } + await attempt( + () => reconcileOpencodePlugin(resolvedBaseDir), + (e) => log.warn(`Failed to inject OpenCode hook into ${tool}: ${e.message}`), + ); } else if (tool === 'omp') { - try { - await reconcileOmpExtension(); - } catch (e) { - log.warn(`Failed to inject OMP hook into ${tool}: ${(e as Error).message}`); - } + await attempt( + () => reconcileOmpExtension(), + (e) => log.warn(`Failed to inject OMP hook into ${tool}: ${e.message}`), + ); } } + return { attempted, succeeded }; } /** diff --git a/src/provider-command.ts b/src/provider-command.ts index f3ef37b03..0a321bcde 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -4,7 +4,9 @@ // management (`provider add git`, `set-primary`) and cross-provider write-target // selection belong to a later phase and are intentionally not exposed here. +import path from 'node:path'; import { log } from './utils/logger.js'; +import { getUserHome } from './utils/home.js'; import { listHttpProviderConfigs, getHttpProviderConfig, @@ -52,36 +54,6 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P process.exit(1); } - if (await getHttpProviderConfig(opts.name)) { - log.error(`Provider "${opts.name}" already exists. Remove it first or choose another name.`); - process.exit(1); - } - - // Single-provider gate (issue #404, phase 2). Running two HTTP providers - // concurrently is unsafe until the ownership ledger (phase 4) arbitrates - // same-name resources across providers — otherwise one provider's uninstall - // deletes files another provider installed, and serial hook sync can exceed - // the foreground budget. Until then, allow exactly one HTTP provider (plus - // the legacy singleton, which double-track dispatch already handles). - const existing = await listHttpProviderConfigs(); - if (existing.length > 0) { - log.error( - `An HTTP provider ("${existing[0].name}") is already configured. Multiple HTTP ` - + 'providers need cross-provider ownership arbitration (issue #404 phase 4) and ' - + 'are not supported yet. Remove the existing one with `teamai provider remove ' - + `${existing[0].name}\` first.`, - ); - process.exit(1); - } - if (await legacySingletonActive()) { - log.error( - 'A legacy HTTP local agent is already configured. Migrate it with ' - + '`teamai provider migrate-legacy --name ` instead of adding a second ' - + 'HTTP provider (multiple providers need issue #404 phase 4).', - ); - process.exit(1); - } - const priority = parsePriority(opts.priority); const config: HttpProviderConfig = { name: opts.name, @@ -90,28 +62,73 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P priority, }; - // Initialize the backend BEFORE publishing the registry record, so a failed - // init (bad token, unwritable dir, hook injection failure) never leaves a - // registered-but-broken provider that later hook dispatches keep loading. - // Publish the registry record only after init succeeds; on any failure run a - // FULL teardown so nothing init already did — including the hooks it injected - // into the tools' settings — is left behind, then start clean on retry. - const backend = getHttpAdapter(adapter); + // Serialize the whole check-and-write under a machine-level lock so two + // concurrent `provider add` runs cannot both see an empty registry and each + // create a provider (the single-provider gate below is otherwise a racy + // check-then-act). The lock also covers init + publish so a rollback cannot + // interleave with another add. + const { acquireLock, releaseLock } = await import('./update.js'); + const lockPath = path.join(getUserHome(), '.teamai', 'providers', '.add.lock'); + if (!(await acquireLock(lockPath))) { + log.error('Another `teamai provider` operation is in progress. Try again in a moment.'); + process.exit(1); + } try { - if (backend.initialize) { - await backend.initialize(config, opts.token); + if (await getHttpProviderConfig(opts.name)) { + log.error(`Provider "${opts.name}" already exists. Remove it first or choose another name.`); + process.exit(1); } - await upsertHttpProviderConfig(config); - } catch (e) { + + // Single-provider gate (issue #404, phase 2). Running two HTTP providers + // concurrently is unsafe until the ownership ledger (phase 4) arbitrates + // same-name resources across providers — otherwise one provider's uninstall + // deletes files another provider installed, and serial hook sync can exceed + // the foreground budget. Until then, allow exactly one HTTP provider (plus + // the legacy singleton, which double-track dispatch already handles). + const existing = await listHttpProviderConfigs(); + if (existing.length > 0) { + log.error( + `An HTTP provider ("${existing[0].name}") is already configured. Multiple HTTP ` + + 'providers need cross-provider ownership arbitration (issue #404 phase 4) and ' + + 'are not supported yet. Remove the existing one with `teamai provider remove ' + + `${existing[0].name}\` first.`, + ); + process.exit(1); + } + if (await legacySingletonActive()) { + log.error( + 'A legacy HTTP local agent is already configured. Migrate it with ' + + '`teamai provider migrate-legacy --name ` instead of adding a second ' + + 'HTTP provider (multiple providers need issue #404 phase 4).', + ); + process.exit(1); + } + + // Initialize the backend BEFORE publishing the registry record, so a failed + // init (bad token, unwritable dir, hook injection failure) never leaves a + // registered-but-broken provider that later hook dispatches keep loading. + // Publish the registry record only after init succeeds; on any failure run a + // FULL teardown so nothing init already did — including the hooks it injected + // into the tools' settings — is left behind, then start clean on retry. + const backend = getHttpAdapter(adapter); try { - await backend.teardown(config); - } catch (teardownErr) { - log.warn(`Rollback teardown for "${config.name}" hit an error: ${(teardownErr as Error).message}`); + if (backend.initialize) { + await backend.initialize(config, opts.token); + } + await upsertHttpProviderConfig(config); + } catch (e) { + try { + await backend.teardown(config); + } catch (teardownErr) { + log.warn(`Rollback teardown for "${config.name}" hit an error: ${(teardownErr as Error).message}`); + } + await removeHttpProviderState(config.name); + await removeHttpProviderConfig(config.name); + log.error(`Failed to add provider "${config.name}": ${(e as Error).message}`); + process.exit(1); } - await removeHttpProviderState(config.name); - await removeHttpProviderConfig(config.name); - log.error(`Failed to add provider "${config.name}": ${(e as Error).message}`); - process.exit(1); + } finally { + await releaseLock(lockPath); } log.success(`Added HTTP provider "${config.name}" (${config.adapter}) → ${config.endpoint}`); @@ -181,13 +198,18 @@ export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise log.error((e as Error).message); process.exit(1); } - // Single-provider gate (issue #404 phase 2): migrating a legacy singleton - // while a named provider already exists would leave two named HTTP providers, - // bypassing the one-provider limit. Refuse until phase 4's arbitration lands. - const existing = await listHttpProviderConfigs(); - if (existing.length > 0) { + // Single-provider gate (issue #404 phase 2): a legacy migration must not + // create a SECOND provider alongside an existing one. Only a provider with a + // DIFFERENT name is a foreign second provider — an entry under this same + // target name is either an already-finished migration (idempotent re-run) or + // one interrupted after the registry write but before the marker, both of + // which the store function resolves. Gating on "any entry" here would break + // that idempotency/resume (a re-run would error instead of no-op), so only + // reject a differently-named provider. + const foreign = (await listHttpProviderConfigs()).filter((p) => p.name !== opts.name); + if (foreign.length > 0) { log.error( - `A named HTTP provider ("${existing[0].name}") already exists; migrating the legacy ` + `A named HTTP provider ("${foreign[0].name}") already exists; migrating the legacy ` + 'singleton would create a second one, which is not supported yet (issue #404 phase 4).', ); process.exit(1); diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index df8f3adcc..6fdbc6a86 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -22,7 +22,7 @@ import { } from '../../../../utils/fs.js'; import { isToolInstalledForConfig, ResourceHandler } from '../../../../resources/base.js'; import { RulesHandler, SkillsHandler } from '../../../../resources/index.js'; -import { injectHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, OPENCLAW_TOOLS } from '../../../../hooks.js'; +import { injectHooksToAllTools, reconcileHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, OPENCLAW_TOOLS } from '../../../../hooks.js'; import { parseHookEvent } from '../../../../dashboard-collector.js'; import { resolveHookCwd } from '../../../../utils/hook-cwd.js'; import { isInteractive } from '../../../../utils/prompt.js'; @@ -62,6 +62,7 @@ import { DEFAULT_CLAUDE_ROOT, COPILOT_TOOL_ID, getTokenPath, + getManagedHooksPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, TeamaiConfigSchema, @@ -3423,14 +3424,23 @@ export async function initLocalAgentHttp(options: { const teamConfig = createLocalAgentTeamConfig(endpoint); // The local agent is always user-scope and always rooted at HOME, so resolve // the user-scope paths (Qoder CN's user config lives under ~/.qoder-cn). - await injectHooksToAllTools( + const hookResult = await injectHooksToAllTools( scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), getUserHome(), options.filterAgents, ); - // A named provider prints its own "Added HTTP provider" line; only the legacy - // singleton path announces initialization here. - if (!httpProviderContext.getStore()) { + // injectHooksToAllTools is best-effort (one tool failing warns and continues). + // For a named provider that is a fresh, explicit `provider add`, a run where + // hooks were attempted but NONE succeeded means the provider would deliver + // nothing — surface that as a failure so the caller rolls back instead of + // reporting a false success. The legacy singleton keeps its lenient behavior. + if (httpProviderContext.getStore()) { + if (hookResult.attempted > 0 && hookResult.succeeded === 0) { + throw new Error( + 'Failed to inject the teamai hook into any detected tool; the provider would deliver nothing.', + ); + } + } else { log.success(`HTTP provider initialized at ${getConfigPath()}`); } } @@ -3579,9 +3589,36 @@ export async function removeLocalAgentHttp(): Promise { } await removeAllAgentHooks(); + + // Remove the built-in teamai hooks this provider's initLocalAgentHttp injected + // via injectHooksToAllTools — removeAllAgentHooks only clears backend-delivered + // agent hooks, so without this a standalone `provider add` → `provider remove` + // (or a failed-add rollback) would leave the dispatch hooks running in every + // tool. Only do this for a named provider AND only when no OTHER teamai install + // (git/self/legacy user config) still relies on those shared built-in hooks — + // otherwise removing them would break a coexisting install. + const providerCtx = httpProviderContext.getStore(); + if (providerCtx) { + const { loadLocalConfig } = await import('../../../../config.js'); + const otherInstall = await loadLocalConfig(); + if (!otherInstall) { + try { + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + await reconcileHooksToAllTools( + scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), + getUserHome(), + [], + getManagedHooksPath('user'), + { removeAll: true }, + ); + } catch (e) { + log.warn(`[local-agent] built-in hook removal failed: ${(e as Error).message}`); + } + } + } + await remove(getLocalAgentHome()); // A named provider's credential lives outside its state home; remove it too. - const providerCtx = httpProviderContext.getStore(); if (providerCtx) await remove(providerCtx.credentialPath); log.success('HTTP source removed (resources uninstalled, config cleared).'); } From 41aa3b829853cd8b83677255ee9bda0734ac668e Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 19:45:29 +0800 Subject: [PATCH 08/14] fix(providers): address fourth-round code-review P1 findings (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review round on PR #780. config.ts / usage-tracker.ts findings stay false positives (empty diff vs origin/main; upstream #775 owns those lines). The rest are fixed: - Don't count an uninstalled adapter tool as a successful hook injection (review #3): injectHooksToAllTools now gates the pi/openclaw/hermes/ opencode/omp branches on the tool actually being installed, so a named-provider init that lands zero real hooks is detected and fails (was masked by adapter no-op "successes"). - teardown's built-in-hook removal now checks for ANY other teamai install — user scope AND every project-scope partition — not just the user config (review #4), so removing a named provider never strips hooks a project-only Git install still needs. - A re-written legacy singleton clears the stale migrated-to marker (review #5): initLocalAgentHttp (legacy path) calls clearLegacyMigrationMarker, so migrate → remove → source add-http / init --http reactivates the singleton instead of leaving it dormant. - teardown keeps the provider home + manifest when any resource uninstall fails (review #6), throwing so cleanup can be retried rather than orphaning resources with a destroyed ownership manifest. - Serialize migrate-legacy under the same machine lock as provider add via a shared withProviderLock helper (review P3). - source list no longer shows a migrated legacy snapshot as an active HTTP source (review P2). P2 (unused GitResourceProvider / registry class) kept as the phase-1 abstraction contract by maintainer preference. Tests: injection tally excludes uninstalled tools, marker reactivation. --- src/__tests__/hooks.test.ts | 25 ++++++ src/__tests__/http-provider-store.test.ts | 16 ++++ src/hooks.ts | 13 +++ src/provider-command.ts | 82 +++++++++++------- src/providers/http/adapters/clawpro/client.ts | 86 +++++++++++++++---- src/providers/http/store.ts | 11 +++ 6 files changed, 182 insertions(+), 51 deletions(-) diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 9076077a2..a48e2189e 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -401,6 +401,31 @@ describe('hooks', () => { } }); + it('returns an attempted/succeeded tally that excludes uninstalled tools (issue #404)', async () => { + const originalHome = process.env.HOME; + process.env.HOME = '/test-home'; + + const { pathExists: mockedPathExists } = await import('../utils/fs.js'); + // Only .claude is installed; .tclaude's root is absent. + (mockedPathExists as ReturnType).mockImplementation(async (p: string) => + (p as string).includes('.claude') && !(p as string).includes('.tclaude'), + ); + + try { + const result = await injectHooksToAllTools({ + claude: { settings: '.claude/settings.json' }, + tclaude: { settings: '.tclaude/settings.json' }, + }); + // Only the installed tool is attempted and counted — an uninstalled tool + // is neither attempted nor a "success", so a single real injection is + // distinguishable from "nothing landed". + expect(result).toEqual({ attempted: 1, succeeded: 1 }); + } finally { + (mockedPathExists as ReturnType).mockImplementation(async () => true); + process.env.HOME = originalHome; + } + }); + it('filterAgents limits injection to specified tools only', async () => { const originalHome = process.env.HOME; process.env.HOME = '/test-home'; diff --git a/src/__tests__/http-provider-store.test.ts b/src/__tests__/http-provider-store.test.ts index a29748606..852e0a4d0 100644 --- a/src/__tests__/http-provider-store.test.ts +++ b/src/__tests__/http-provider-store.test.ts @@ -260,4 +260,20 @@ describe('http provider: legacy singleton migration', () => { // Migration now completed: legacy marked migrated. expect(await legacySingletonActive()).toBe(false); }); + + it('clearLegacyMigrationMarker reactivates a re-written legacy singleton (review #5)', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider, legacySingletonActive, clearLegacyMigrationMarker } = await import( + '../providers/http/store.js' + ); + await migrateLegacyHttpProvider({ name: 'company' }); + // After migration the legacy dir is a dormant snapshot. + expect(await legacySingletonActive()).toBe(false); + + // A later `source add-http` / `init --http` clears the marker when it + // re-writes the legacy config; the singleton is then active again so the + // dispatcher will sync it (without this it would stay dormant forever). + await clearLegacyMigrationMarker(); + expect(await legacySingletonActive()).toBe(true); + }); }); diff --git a/src/hooks.ts b/src/hooks.ts index a15050bc0..88d64b880 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1443,6 +1443,10 @@ export async function injectHooksToAllTools(toolPaths: Record reconcilePiExtension(resolvedBaseDir), (e) => log.warn(`Failed to inject Pi hook: ${e.message}`), @@ -1456,6 +1460,10 @@ export async function injectHooksToAllTools(toolPaths: Record log.warn(`Failed to inject hook into ${tool}: ${e.message}`), ); } else if (OPENCLAW_TOOLS.has(tool)) { + // Only count when the OpenClaw workspace actually resolves; otherwise the + // adapter no-ops and would inflate the success count. + const { resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); + if (!await resolveOpenclawWorkspaceDir()) continue; await attempt( async () => { const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); @@ -1464,6 +1472,8 @@ export async function injectHooksToAllTools(toolPaths: Record log.warn(`Failed to inject OpenClaw hook into ${tool}: ${e.message}`), ); } else if (tool === 'hermes') { + const { getHermesHome } = await import('./hermes-home.js'); + if (!await pathExists(getHermesHome())) continue; await attempt( async () => { const { injectHermesHooks } = await import('./hermes-hooks.js'); @@ -1472,11 +1482,14 @@ export async function injectHooksToAllTools(toolPaths: Record log.warn(`Failed to inject Hermes hook: ${e.message}`), ); } else if (tool === 'opencode') { + if (!await pathExists(path.join(resolvedBaseDir, '.config', 'opencode')) + && !await pathExists(path.join(resolvedBaseDir, '.opencode'))) continue; await attempt( () => reconcileOpencodePlugin(resolvedBaseDir), (e) => log.warn(`Failed to inject OpenCode hook into ${tool}: ${e.message}`), ); } else if (tool === 'omp') { + if (!await pathExists(path.join(resolvedBaseDir, '.omp'))) continue; await attempt( () => reconcileOmpExtension(), (e) => log.warn(`Failed to inject OMP hook into ${tool}: ${e.message}`), diff --git a/src/provider-command.ts b/src/provider-command.ts index 0a321bcde..045675e0b 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -25,6 +25,26 @@ import { import { syncResourceProviders } from './providers/resource-registry.js'; import type { HttpProviderConfig } from './providers/types.js'; +/** + * Run `fn` while holding the machine-level provider lock, so the single-provider + * check-and-write in `provider add` and `migrate-legacy` cannot interleave and + * both pass the empty/one-provider gate (review #6/P3). Callers still enforce + * the gate; the lock only makes the check-then-act atomic across processes. + */ +async function withProviderLock(fn: () => Promise): Promise { + const { acquireLock, releaseLock } = await import('./update.js'); + const lockPath = path.join(getUserHome(), '.teamai', 'providers', '.add.lock'); + if (!(await acquireLock(lockPath))) { + log.error('Another `teamai provider` operation is in progress. Try again in a moment.'); + process.exit(1); + } + try { + return await fn(); + } finally { + await releaseLock(lockPath); + } +} + interface AddHttpOptions { name: string; adapter?: string; @@ -63,17 +83,11 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P }; // Serialize the whole check-and-write under a machine-level lock so two - // concurrent `provider add` runs cannot both see an empty registry and each - // create a provider (the single-provider gate below is otherwise a racy - // check-then-act). The lock also covers init + publish so a rollback cannot - // interleave with another add. - const { acquireLock, releaseLock } = await import('./update.js'); - const lockPath = path.join(getUserHome(), '.teamai', 'providers', '.add.lock'); - if (!(await acquireLock(lockPath))) { - log.error('Another `teamai provider` operation is in progress. Try again in a moment.'); - process.exit(1); - } - try { + // concurrent `provider add` / `migrate-legacy` runs cannot both see an empty + // registry and each create a provider (the single-provider gate below is + // otherwise a racy check-then-act). The lock also covers init + publish so a + // rollback cannot interleave with another add. + await withProviderLock(async () => { if (await getHttpProviderConfig(opts.name)) { log.error(`Provider "${opts.name}" already exists. Remove it first or choose another name.`); process.exit(1); @@ -127,9 +141,7 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P log.error(`Failed to add provider "${config.name}": ${(e as Error).message}`); process.exit(1); } - } finally { - await releaseLock(lockPath); - } + }); log.success(`Added HTTP provider "${config.name}" (${config.adapter}) → ${config.endpoint}`); } @@ -198,25 +210,29 @@ export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise log.error((e as Error).message); process.exit(1); } - // Single-provider gate (issue #404 phase 2): a legacy migration must not - // create a SECOND provider alongside an existing one. Only a provider with a - // DIFFERENT name is a foreign second provider — an entry under this same - // target name is either an already-finished migration (idempotent re-run) or - // one interrupted after the registry write but before the marker, both of - // which the store function resolves. Gating on "any entry" here would break - // that idempotency/resume (a re-run would error instead of no-op), so only - // reject a differently-named provider. - const foreign = (await listHttpProviderConfigs()).filter((p) => p.name !== opts.name); - if (foreign.length > 0) { - log.error( - `A named HTTP provider ("${foreign[0].name}") already exists; migrating the legacy ` - + 'singleton would create a second one, which is not supported yet (issue #404 phase 4).', - ); - process.exit(1); - } - const config = await migrateLegacyHttpProvider({ - name: opts.name, - priority: parsePriority(opts.priority), + // Hold the same lock as `provider add` so the gate below and the migration + // cannot interleave with a concurrent add/migrate (review P3). + const config = await withProviderLock(async () => { + // Single-provider gate (issue #404 phase 2): a legacy migration must not + // create a SECOND provider alongside an existing one. Only a provider with a + // DIFFERENT name is a foreign second provider — an entry under this same + // target name is either an already-finished migration (idempotent re-run) or + // one interrupted after the registry write but before the marker, both of + // which the store function resolves. Gating on "any entry" here would break + // that idempotency/resume (a re-run would error instead of no-op), so only + // reject a differently-named provider. + const foreign = (await listHttpProviderConfigs()).filter((p) => p.name !== opts.name); + if (foreign.length > 0) { + log.error( + `A named HTTP provider ("${foreign[0].name}") already exists; migrating the legacy ` + + 'singleton would create a second one, which is not supported yet (issue #404 phase 4).', + ); + process.exit(1); + } + return migrateLegacyHttpProvider({ + name: opts.name, + priority: parsePriority(opts.priority), + }); }); if (!config) { log.info('No legacy HTTP local agent to migrate (or it was already migrated).'); diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index 6fdbc6a86..c86e7a3ef 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -3412,6 +3412,14 @@ export async function initLocalAgentHttp(options: { await ensureDir(getLocalAgentHome()); await saveLocalAgentConfig(config); + // Legacy path only: writing a fresh singleton config must clear any stale + // `migrated-to` marker left by an earlier migrate-then-remove, or + // legacySingletonActive() would stay false and the dispatcher would never + // sync this new config (review #5). Named-provider init has no such marker. + if (!httpProviderContext.getStore()) { + const { clearLegacyMigrationMarker } = await import('../../store.js'); + await clearLegacyMigrationMarker(); + } if (options.token) { // Named providers keep the credential in their own 0600 file; the legacy // singleton keeps its historical ~/.teamai/token location. @@ -3470,6 +3478,15 @@ export interface LocalAgentSummary { * alongside git cross-team sources. */ export async function describeLocalAgent(): Promise { + // A migrated legacy singleton is only a rollback snapshot, not an active + // source — the named provider now owns delivery. Don't list it (review P2). + // (Only relevant outside a provider context; a named provider never has this + // marker in its own home.) + if (!httpProviderContext.getStore()) { + const { legacySingletonActive } = await import('../../store.js'); + const legacyExists = await pathExists(getConfigPath()); + if (legacyExists && !(await legacySingletonActive())) return null; + } const config = await loadLocalAgentConfig(); if (!config) return null; @@ -3553,13 +3570,38 @@ export async function removeAllAgentHooks(): Promise { await saveAgentHookManifest({}); } +/** + * Whether any OTHER teamai install still relies on the shared built-in hooks: + * a user-scope config, OR any project-scope partition config. Used to decide + * whether a named provider's teardown may remove the built-in dispatch hooks — + * it must not when a git/self install of ANY scope still needs them. Checking + * only the user scope would miss a project-only Git TeamAI install. + */ +async function hasOtherTeamaiInstall(): Promise { + const { loadLocalConfig } = await import('../../../../config.js'); + if (await loadLocalConfig()) return true; + // Enumerate project-scope partitions (~/.teamai/projects//config.yaml). + try { + const { projectsRootDir } = await import('../../../../utils/partition.js'); + const root = projectsRootDir(); + for (const slug of await listDirs(root)) { + if (await pathExists(path.join(root, slug, 'config.yaml'))) return true; + } + } catch { + // No partitions dir / unreadable → treat as no other install. + } + return false; +} + /** * Tear down the HTTP local-agent bypass: uninstall every resource recorded in the * manifest (skills/rules/claudemd, across all scopes) from the AI tool dirs, then * remove the whole ~/.teamai/local-agent/ directory (config + manifest). * * Best-effort per resource: a single failed uninstall is logged and skipped so a - * stale entry cannot block the teardown. + * stale entry cannot block the teardown. If any resource uninstall failed, the + * provider home + manifest are KEPT (not deleted) so cleanup can be retried + * (review #6) — a destroyed manifest would orphan the leftover resources. */ export async function removeLocalAgentHttp(): Promise { const config = await loadLocalAgentConfig(); @@ -3575,6 +3617,7 @@ export async function removeLocalAgentHttp(): Promise { const kinds: CommandResourceKind[] = ['skill', 'rule', 'claudemd']; const manifest = await loadManifest(); + let uninstallFailed = false; for (const [key, scopeManifest] of Object.entries(manifest.scopes)) { const { scope, workspacePath } = parseScopeKey(key); for (const kind of kinds) { @@ -3582,7 +3625,8 @@ export async function removeLocalAgentHttp(): Promise { try { await uninstallResource({ config, kind, slug, scope, workspacePath }); } catch (e) { - log.debug(`local-agent: failed to uninstall ${kind} "${slug}": ${(e as Error).message}`); + uninstallFailed = true; + log.warn(`local-agent: failed to uninstall ${kind} "${slug}": ${(e as Error).message}`); } } } @@ -3598,25 +3642,31 @@ export async function removeLocalAgentHttp(): Promise { // (git/self/legacy user config) still relies on those shared built-in hooks — // otherwise removing them would break a coexisting install. const providerCtx = httpProviderContext.getStore(); - if (providerCtx) { - const { loadLocalConfig } = await import('../../../../config.js'); - const otherInstall = await loadLocalConfig(); - if (!otherInstall) { - try { - const teamConfig = createLocalAgentTeamConfig(config.endpoint); - await reconcileHooksToAllTools( - scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), - getUserHome(), - [], - getManagedHooksPath('user'), - { removeAll: true }, - ); - } catch (e) { - log.warn(`[local-agent] built-in hook removal failed: ${(e as Error).message}`); - } + if (providerCtx && !(await hasOtherTeamaiInstall())) { + try { + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + await reconcileHooksToAllTools( + scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), + getUserHome(), + [], + getManagedHooksPath('user'), + { removeAll: true }, + ); + } catch (e) { + log.warn(`[local-agent] built-in hook removal failed: ${(e as Error).message}`); } } + // If any resource uninstall failed (file locked, permission denied), KEEP the + // state home and manifest so the leftover resources can be cleaned up on a + // retry — deleting the manifest here would orphan them permanently (review #6). + if (uninstallFailed) { + throw new Error( + 'Some resources could not be uninstalled; kept the provider state so cleanup ' + + 'can be retried. Re-run once the underlying issue (locked file / permissions) is resolved.', + ); + } + await remove(getLocalAgentHome()); // A named provider's credential lives outside its state home; remove it too. if (providerCtx) await remove(providerCtx.credentialPath); diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts index 9152941ba..2186bfd6c 100644 --- a/src/providers/http/store.ts +++ b/src/providers/http/store.ts @@ -184,6 +184,17 @@ export async function legacySingletonActive(): Promise { return !(await pathExists(legacyMarkerPath())); } +/** + * Clear a stale `migrated-to` marker so a freshly (re)written legacy singleton + * is active again. Needed when a user migrates the singleton to a named + * provider, removes that provider, then re-runs `source add-http` / `init + * --http`: that writes a new legacy config, but the leftover marker would keep + * legacySingletonActive() false forever and the dispatcher would never sync it. + */ +export async function clearLegacyMigrationMarker(): Promise { + await remove(legacyMarkerPath()); +} + interface LegacyConfigShape { endpoint?: string; token?: string; From e39751b0aa9de2b820080e1d21c5c6c2dbe9e28c Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 20:00:01 +0800 Subject: [PATCH 09/14] fix(providers): address fifth-round code-review P1 findings (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth review round on PR #780. config.ts / usage-tracker.ts stay false positives (empty diff vs origin/main). The rest are fixed: - Propagate the named-provider context to the detached plugin-reconcile worker (review #3): AsyncLocalStorage does not cross a process boundary, so maybeReconcilePlugins passes the provider name via TEAMAI_HTTP_PROVIDER_NAME and runPluginReconcileWorker re-enters the context — a named provider's backend plugins now install/update instead of the worker silently reading the legacy dir. - `source remove-http` refuses when the legacy singleton has been migrated (review #4): the retained rollback snapshot's copied manifest would otherwise uninstall resources the named provider is actively using; it now points at `provider remove` instead. - Teardown keeps state on ANY partial failure, not just resource uninstall (reviews #5/#6): removeAllAgentHooks reports failure and keeps its manifest; plugin teardown and built-in hook removal failures now also set the keep-state flag, so hooks/plugins are never orphaned with their ownership records destroyed. - teardown's other-install check also covers a legacy in-tree /.teamai/config.yaml governing the cwd (review #7), not only user config and partitions, so removing a provider never strips shared built-in hooks a project-only Git install still needs. P2 (unused GitResourceProvider / registry class) kept as the phase-1 abstraction contract by maintainer preference. P3 (source add-http / init --http not under the provider lock) — those compat entries already refuse when a named provider exists; the lock covers the provider-native add/migrate paths. Tests: plugin-reconcile worker re-enters provider context from env. --- src/__tests__/http-provider-multi.test.ts | 42 ++++++++++ src/providers/http/adapters/clawpro/client.ts | 77 +++++++++++++++---- src/source.ts | 14 ++++ 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/__tests__/http-provider-multi.test.ts b/src/__tests__/http-provider-multi.test.ts index a4139eeaa..1a136542d 100644 --- a/src/__tests__/http-provider-multi.test.ts +++ b/src/__tests__/http-provider-multi.test.ts @@ -124,4 +124,46 @@ describe('multiple HTTP providers: hook dispatch', () => { expect(results[0].ok).toBe(false); expect(results[0].message).toBe('sync FAILED: network down'); }); + + it('plugin reconcile worker re-enters the named provider context from env (review #3)', async () => { + // The detached worker gets the provider name via env (AsyncLocalStorage does + // not cross process boundaries). runPluginReconcileWorker must re-establish + // that context so it talks to the provider's OWN endpoint (read from the + // provider home), not the legacy dir. We observe the endpoint the worker's + // get-config fetch hits to prove the context was re-entered. + const { upsertHttpProviderConfig, httpProviderExecutionContext } = await import('../providers/http/store.js'); + const { withHttpProvider, initLocalAgentHttp, runPluginReconcileWorker } = await import( + '../providers/http/adapters/clawpro/client.js' + ); + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://company-be/api', priority: 50 }); + await withHttpProvider(httpProviderExecutionContext('company'), () => + initLocalAgentHttp({ endpoint: 'https://company-be/api', force: true }), + ); + // A legacy singleton with a DIFFERENT endpoint — if the context were not + // re-entered, the worker would read this one instead. + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy-be/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + + const fetchedUrls: string[] = []; + const fetchMock = vi.fn(async (url: string) => { + fetchedUrls.push(String(url)); + return new Response(JSON.stringify({ plugins: [] })); + }); + vi.stubGlobal('fetch', fetchMock); + process.env.TEAMAI_HTTP_PROVIDER_NAME = 'company'; + try { + await runPluginReconcileWorker(); + } finally { + delete process.env.TEAMAI_HTTP_PROVIDER_NAME; + vi.unstubAllGlobals(); + } + + // The worker fetched get-config against the NAMED provider's endpoint, not + // the legacy one — proving the provider context was re-established. + expect(fetchedUrls.some((u) => u.includes('company-be'))).toBe(true); + expect(fetchedUrls.some((u) => u.includes('legacy-be'))).toBe(false); + }); }); diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index c86e7a3ef..2100926b6 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -955,14 +955,32 @@ async function maybeReconcilePlugins(context: LocalAgentContext): Promise const localAgentId = `${tool}-${resolveLocalAgentId(context)}`; const { spawn } = await import('node:child_process'); if (!process.argv[1]) { log.debug('[local-agent] plugin reconcile: no CLI entrypoint (argv[1]), skipping'); return; } + // AsyncLocalStorage context does NOT cross the process boundary, so a named + // provider's identity must be passed explicitly and re-established in the + // worker — otherwise the detached process reads the legacy dir and a named + // provider's backend plugins are never installed/updated (issue #404). + const providerCtx = httpProviderContext.getStore(); const child = spawn(process.execPath, [process.argv[1], 'source', 'reconcile-plugins'], - { detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env, TEAMAI_PLUGIN_LOCAL_AGENT_ID: localAgentId } }); + { detached: true, windowsHide: true, stdio: 'ignore', env: { + ...process.env, + TEAMAI_PLUGIN_LOCAL_AGENT_ID: localAgentId, + ...(providerCtx ? { TEAMAI_HTTP_PROVIDER_NAME: providerCtx.name } : {}), + } }); child.unref(); } catch (e) { log.debug(`[local-agent] plugin reconcile spawn skipped: ${(e as Error).message}`); } } /** Detached worker: pull get-config and reconcile plugins once, guarded by a reconcile lock. */ export async function runPluginReconcileWorker(): Promise { + // Re-establish the named-provider context the spawning process passed via env + // (AsyncLocalStorage does not cross process boundaries). Without this the + // worker would read the legacy dir and never reconcile a named provider's + // plugins. No env var → legacy singleton, exactly as before. + const providerName = process.env.TEAMAI_HTTP_PROVIDER_NAME?.trim(); + if (providerName && !httpProviderContext.getStore()) { + const { httpProviderExecutionContext } = await import('../../store.js'); + return withHttpProvider(httpProviderExecutionContext(providerName), () => runPluginReconcileWorker()); + } const config = await loadLocalAgentConfig(); if (!config) return; const lockPath = path.join(getLocalAgentHome(), 'plugin-reconcile.lock'); @@ -3537,13 +3555,19 @@ export async function teardownLocalAgentPlugins(): Promise { * each tool's settings, then clear the manifest. Best-effort; used by * `source remove-http` and `teamai uninstall` teardown (issue #238). Safe to call * when no config / no manifest exists. + * + * Returns true when every recorded hook was removed. When some removals failed + * the manifest is KEPT (not cleared) and false is returned, so a caller tearing + * a provider down can preserve state for a retry instead of orphaning the hooks + * whose ownership record it would otherwise destroy (issue #404, review #5). */ -export async function removeAllAgentHooks(): Promise { +export async function removeAllAgentHooks(): Promise { const config = await loadLocalAgentConfig(); - if (!config) return; + if (!config) return true; const manifest = await loadAgentHookManifest(); const slugs = Object.keys(manifest); - if (slugs.length === 0) return; + if (slugs.length === 0) return true; + let allRemoved = true; for (const slug of slugs) { const rec = manifest[slug]; try { @@ -3564,10 +3588,14 @@ export async function removeAllAgentHooks(): Promise { await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); } } catch (e) { - log.debug(`agent hook [${slug}] teardown failed: ${(e as Error).message}`); + allRemoved = false; + log.warn(`agent hook [${slug}] teardown failed: ${(e as Error).message}`); } } - await saveAgentHookManifest({}); + // Only clear the manifest when everything was removed; keeping it on failure + // lets a retry find and clean the leftover hooks. + if (allRemoved) await saveAgentHookManifest({}); + return allRemoved; } /** @@ -3578,9 +3606,18 @@ export async function removeAllAgentHooks(): Promise { * only the user scope would miss a project-only Git TeamAI install. */ async function hasOtherTeamaiInstall(): Promise { - const { loadLocalConfig } = await import('../../../../config.js'); + const { loadLocalConfig, detectProjectConfig } = await import('../../../../config.js'); if (await loadLocalConfig()) return true; - // Enumerate project-scope partitions (~/.teamai/projects//config.yaml). + // A project-scope install governing the current directory — covers both the + // partitioned form (~/.teamai/projects//) and a legacy in-tree + // /.teamai/config.yaml, which the partition enumeration below misses. + try { + if (await detectProjectConfig(process.cwd())) return true; + } catch { + // unreadable project config → fall through to partition scan + } + // Enumerate project-scope partitions (~/.teamai/projects//config.yaml) + // so an install for a DIFFERENT project on this machine is also detected. try { const { projectsRootDir } = await import('../../../../utils/partition.js'); const root = projectsRootDir(); @@ -3610,14 +3647,23 @@ export async function removeLocalAgentHttp(): Promise { return; } + // Any teardown step that fails to fully clean up sets this — the state home + // (config + manifest + plugins.json) is then KEPT so a retry can finish the + // job. Deleting it after a partial teardown would orphan the leftover + // resources/hooks/plugins and destroy the ownership records needed to find + // them (issue #404, reviews #5/#6). + let uninstallFailed = false; + // Tear down installed plugins before removing teamai's local-agent state. try { await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); - } catch (e) { log.warn(`[local-agent] plugin teardown failed: ${(e as Error).message}`); } + } catch (e) { + uninstallFailed = true; + log.warn(`[local-agent] plugin teardown failed: ${(e as Error).message}`); + } const kinds: CommandResourceKind[] = ['skill', 'rule', 'claudemd']; const manifest = await loadManifest(); - let uninstallFailed = false; for (const [key, scopeManifest] of Object.entries(manifest.scopes)) { const { scope, workspacePath } = parseScopeKey(key); for (const kind of kinds) { @@ -3632,7 +3678,7 @@ export async function removeLocalAgentHttp(): Promise { } } - await removeAllAgentHooks(); + if (!await removeAllAgentHooks()) uninstallFailed = true; // Remove the built-in teamai hooks this provider's initLocalAgentHttp injected // via injectHooksToAllTools — removeAllAgentHooks only clears backend-delivered @@ -3653,16 +3699,17 @@ export async function removeLocalAgentHttp(): Promise { { removeAll: true }, ); } catch (e) { + uninstallFailed = true; log.warn(`[local-agent] built-in hook removal failed: ${(e as Error).message}`); } } - // If any resource uninstall failed (file locked, permission denied), KEEP the - // state home and manifest so the leftover resources can be cleaned up on a - // retry — deleting the manifest here would orphan them permanently (review #6). + // If ANY teardown step failed (locked file, permission denied), KEEP the state + // home and manifest so cleanup can be retried — deleting them here would + // orphan the leftover resources/hooks/plugins permanently (reviews #5/#6). if (uninstallFailed) { throw new Error( - 'Some resources could not be uninstalled; kept the provider state so cleanup ' + 'Some resources/hooks/plugins could not be removed; kept the provider state so cleanup ' + 'can be retried. Re-run once the underlying issue (locked file / permissions) is resolved.', ); } diff --git a/src/source.ts b/src/source.ts index e3edae048..d3fd46c2a 100644 --- a/src/source.ts +++ b/src/source.ts @@ -338,6 +338,20 @@ export async function sourceRemoveHttp(options: GlobalOptions): Promise { log.info('[dry-run] Would remove the HTTP source'); return; } + // A migrated legacy singleton is only a rollback snapshot; a named provider + // now owns those resources. Removing the snapshot's copied manifest would + // uninstall resources the named provider is actively using, so refuse and + // point the user at `provider remove` (issue #404, review #4). + const { legacySingletonActive, listHttpProviderConfigs } = await import('./providers/http/store.js'); + const namedProviders = await listHttpProviderConfigs(); + if (namedProviders.length > 0 && !(await legacySingletonActive())) { + log.error( + 'The legacy HTTP source has been migrated to a named provider; the old ' + + 'directory is only a rollback snapshot. Remove the provider instead: ' + + `\`teamai provider remove ${namedProviders[0].name}\`.`, + ); + return; + } const { removeLocalAgentHttp } = await import('./local-agent.js'); await removeLocalAgentHttp(); } From fe47f504072eaf4c26b695118847a88c66ed38e2 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 20:17:39 +0800 Subject: [PATCH 10/14] fix(providers): address sixth-round code-review P1/P2 findings (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth review round on PR #780. config.ts / usage-tracker.ts stay false positives (empty diff vs origin/main; upstream #775 owns those lines). The rest: - Redact the migration token in staging BEFORE publishing the provider home (review #3): the credential is written to its isolated 0600 file and stripped from the staged config.json, then the dir is atomically moved — so the published config.json never contains the token, even in a crash window. - teardownAllPlugins reports an aggregate success flag (review #4): it swallowed each plugin's uninstall error and returned void, so the caller's uninstallFailed never tripped. removeLocalAgentHttp now checks the return value and keeps state (plugins.json) on failure. - provider-add rollback keeps state when its teardown could not fully clean up (review #5): it drops only the registry entry (so dispatch won't load a broken provider) and preserves the home for a retriable `provider remove`, instead of destroying the ownership manifest. - `teamai uninstall` warns when a named provider's teardown was incomplete before deleting ~/.teamai (review #6), so orphaned external hooks/plugins are surfaced rather than silently stranded. - teardown's other-install check now also detects a legacy in-tree /.teamai config governing the cwd (review #7 residual). - Fix the credential path in join-member.md — it is ~/.teamai/credentials/, not the provider home (review P2 doc). P2 (source list showing a migrated snapshot) was already fixed in the prior round and is verified hidden; P2 (unused abstraction) and P3 (compat entries not under the provider lock) are as previously decided. --- skill-data/setup/references/join-member.md | 7 ++-- src/plugin-lifecycle.ts | 10 ++++- src/provider-command.ts | 21 +++++++++- src/providers/http/adapters/clawpro/client.ts | 7 +++- src/providers/http/store.ts | 40 ++++++++++--------- src/uninstall.ts | 23 ++++++++++- 6 files changed, 79 insertions(+), 29 deletions(-) diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 24787b03d..3f0c2565f 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -89,9 +89,10 @@ This is a read-only consumer mode — `push` / `contribute` are not available, b skills and rules still sync. **Named HTTP provider (isolated state):** instead of the global `init --http` -singleton, an HTTP backend can be mounted as a *named* provider whose config, -credential (0600, outside config) and manifest are isolated under -`~/.teamai/providers/http//`: +singleton, an HTTP backend can be mounted as a *named* provider whose config and +manifest are isolated under `~/.teamai/providers/http//`, with its +credential stored separately (0600) at `~/.teamai/credentials/`, never in a +config file: ```bash teamai provider add http https://your-team-host/api --name --token diff --git a/src/plugin-lifecycle.ts b/src/plugin-lifecycle.ts index b91f03e13..4d99ce1bc 100644 --- a/src/plugin-lifecycle.ts +++ b/src/plugin-lifecycle.ts @@ -214,10 +214,14 @@ export async function reconcilePlugins( * Tear down all locally-tracked plugins. * * Called when uninstalling teamai itself. Each plugin is handled independently; - * a failure only warns and continues. + * a failure only warns and continues. Returns true when every plugin was torn + * down, false when any uninstall_cmd failed — so a caller removing a provider + * can keep its state (plugins.json) for a retry instead of deleting the record + * of a plugin still on the system (issue #404, review #4). */ -export async function teardownAllPlugins(deps: ReconcileDeps): Promise { +export async function teardownAllPlugins(deps: ReconcileDeps): Promise { const plugins = await deps.readPlugins(); + let allTornDown = true; for (const [slug, state] of Object.entries(plugins)) { try { deps.log.debug(`plugin ${slug}: tearing down`); @@ -227,9 +231,11 @@ export async function teardownAllPlugins(deps: ReconcileDeps): Promise { }); deps.log.debug(`plugin ${slug}: torn down`); } catch (err) { + allTornDown = false; deps.log.warn(`plugin ${slug} teardown failed: ${(err as Error).message}`); } } + return allTornDown; } const PLUGIN_CMD_FIELDS = new Set([ diff --git a/src/provider-command.ts b/src/provider-command.ts index 045675e0b..744ec603a 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -131,13 +131,30 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P } await upsertHttpProviderConfig(config); } catch (e) { + // Roll back what init wrote. Only delete the provider state when teardown + // fully succeeded — if teardown could not remove everything (e.g. an + // injected hook is locked), KEEP the state + manifest so the leftover can + // be cleaned up on a retry rather than orphaned with its ownership record + // destroyed (issue #404, review #5). teardown throws on partial failure. + let teardownOk = true; try { await backend.teardown(config); } catch (teardownErr) { + teardownOk = false; log.warn(`Rollback teardown for "${config.name}" hit an error: ${(teardownErr as Error).message}`); } - await removeHttpProviderState(config.name); - await removeHttpProviderConfig(config.name); + if (teardownOk) { + await removeHttpProviderState(config.name); + await removeHttpProviderConfig(config.name); + } else { + // Drop only the registry entry so hook dispatch won't load a broken + // provider, but keep the state home for a retriable `provider remove`. + await removeHttpProviderConfig(config.name); + log.warn( + `Kept partial state for "${config.name}" (teardown incomplete); ` + + `run \`teamai provider remove ${config.name}\` after resolving the issue.`, + ); + } log.error(`Failed to add provider "${config.name}": ${(e as Error).message}`); process.exit(1); } diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index 2100926b6..1ff2ea699 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -3655,8 +3655,13 @@ export async function removeLocalAgentHttp(): Promise { let uninstallFailed = false; // Tear down installed plugins before removing teamai's local-agent state. + // teardownAllPlugins swallows each plugin's error internally and reports an + // aggregate success flag, so check the return value (a thrown error would be + // an unexpected failure of the teardown machinery itself). try { - await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); + if (!await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]'))) { + uninstallFailed = true; + } } catch (e) { uninstallFailed = true; log.warn(`[local-agent] plugin teardown failed: ${(e as Error).message}`); diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts index 2186bfd6c..1b1ea6465 100644 --- a/src/providers/http/store.ts +++ b/src/providers/http/store.ts @@ -246,36 +246,38 @@ export async function migrateLegacyHttpProvider(options: { } await remove(home); + // Extract the credential first. Prefer the legacy inline token, then the + // legacy ~/.teamai/token file. + const token = + legacy.token ?? + (await readFileSafe(path.join(teamaiHome(), 'token')))?.trim() ?? + undefined; + // Stage a full copy, then atomically move it into place so an interrupted // migration never leaves a half-populated provider home. const staging = `${home}.migrating`; await remove(staging); await ensureDir(path.dirname(home)); await fse.copy(legacyHome(), staging); - // Never carry the migration marker (not present yet) or a raw token into the - // named home — the token moves to the isolated credential file below. + // Never carry the migration marker (not present yet) into the named home. await remove(path.join(staging, MIGRATED_MARKER)); - await fse.move(staging, home); - - // Extract the credential to the isolated 0600 file. Prefer the legacy inline - // token, then the legacy ~/.teamai/token file. - const token = - legacy.token ?? - (await readFileSafe(path.join(teamaiHome(), 'token')))?.trim() ?? - undefined; + // Redact the inline token from the staged config.json BEFORE publishing the + // directory — the credential belongs only in the isolated 0600 file, and the + // published home must never contain it, not even in the crash window between + // move and a later cleanup (issue #404, review #3). + const stagedConfigPath = path.join(staging, 'config.json'); + const stagedConfig = await readJson>(stagedConfigPath); + if (stagedConfig && 'token' in stagedConfig) { + delete stagedConfig.token; + await writeJsonAtomic(stagedConfigPath, stagedConfig); + } + // Write the isolated credential before publishing the home, so the credential + // exists the moment the provider becomes visible. if (token) { await ensureDir(path.dirname(httpProviderCredentialPath(options.name))); await writeTokenFile(httpProviderCredentialPath(options.name), token); - // Strip the inline token from the migrated config.json so the credential - // lives only in the isolated file. - const migratedConfig = await readJson>( - path.join(home, 'config.json'), - ); - if (migratedConfig && 'token' in migratedConfig) { - delete migratedConfig.token; - await writeJsonAtomic(path.join(home, 'config.json'), migratedConfig); - } } + await fse.move(staging, home); await upsertHttpProviderConfig(config); diff --git a/src/uninstall.ts b/src/uninstall.ts index 9c5a17ba1..2cb99e704 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -866,7 +866,8 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { * teardown() (uninstall all manifest resources + remove agent hooks + stop * plugins) inside its own isolated context (issue #404). */ -async function teardownPlugins(): Promise { +async function teardownPlugins(): Promise<{ incompleteProviders: string[] }> { + const incompleteProviders: string[] = []; try { const { teardownLocalAgentPlugins } = await import('./local-agent.js'); await teardownLocalAgentPlugins(); @@ -884,12 +885,18 @@ async function teardownPlugins(): Promise { removeLocalAgentHttp(), ); } catch (e) { + // removeLocalAgentHttp throws when it could not fully clean up (locked + // file / permission). ~/.teamai is about to be deleted, so its manifest + // will be gone — record the provider so we can warn the user that some + // external tool hooks/plugins may need manual cleanup (issue #404 #6). + incompleteProviders.push(config.name); log.warn(`teardown for provider "${config.name}" failed: ${(e as Error).message}`); } } } catch (e) { log.warn(`HTTP provider teardown failed: ${e instanceof Error ? e.message : String(e)}`); } + return { incompleteProviders }; } async function executeRemoval(plan: RemovalPlan): Promise { @@ -1108,7 +1115,19 @@ async function executeRemoval(plan: RemovalPlan): Promise { // (g) Remove ~/.teamai/ directory (last — earlier steps read from it) if (plan.teamaiHomeExists) { // Tear down plugins first: their manifest/config live under ~/.teamai/local-agent. - await teardownPlugins(); + const { incompleteProviders } = await teardownPlugins(); + if (incompleteProviders.length > 0) { + // We are about to delete ~/.teamai, which holds the ownership manifests + // for these providers' resources/hooks/plugins. Their teardown did not + // fully complete, so warn the user that some external tool files may + // remain and need manual cleanup — deleting the manifest silently would + // hide that. + log.warn( + `These HTTP providers did not fully tear down: ${incompleteProviders.join(', ')}. ` + + 'Some tool hooks/plugins they installed may remain; their tracking manifests are ' + + 'about to be removed with ~/.teamai, so any leftovers must be cleaned up manually.', + ); + } try { await remove(plan.teamaiHome); log.success(`Removed ${plan.teamaiHome}/`); From bb05600907d7c5c490670e6e81d86f35f2d3e0ce Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 20:46:41 +0800 Subject: [PATCH 11/14] fix(providers): address seventh-round code-review P1 findings (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh review round on PR #780. config.ts / usage-tracker.ts remain false positives (empty diff vs origin/main — upstream #775 owns those lines; asked maintainer to dismiss). The genuinely new findings — all flaws in the previous round's own fixes — are addressed: - Give the kept-on-failure state a real CLI retry path (review #3): a failed add that keeps its home now also writes provider.json there, and `provider remove` recovers a provider from its home config when the registry entry is gone, then keeps the home again if teardown still can't finish. The retry `provider remove ` the message promises now actually works. - `teamai uninstall` no longer deletes ~/.teamai when a provider teardown was incomplete (review #4): it keeps the dir (and its ownership manifests) and tells the user to resolve the issue and re-run, so the leftover external hooks/plugins stay recoverable instead of stranded. - Stop stripping the SHARED built-in dispatch hooks on a plain `provider remove` (reviews #5/#7): they serve every teamai install and no-op without config, so a leftover is harmless while an erroneous removal breaks a coexisting install — and another install can't be enumerated reliably (in-tree /.teamai lives anywhere). Full built-in-hook removal now happens only under `teamai uninstall` (TEAMAI_UNINSTALL=1) when no other install remains. Tests: clean rollback vs. keep-on-teardown-failure, and provider remove recovering a registry-less home. --- src/__tests__/provider-command.test.ts | 52 +++++++++++++++---- src/provider-command.ts | 41 ++++++++++++--- src/providers/http/adapters/clawpro/client.ts | 22 +++++--- src/providers/http/store.ts | 31 +++++++++++ src/uninstall.ts | 41 ++++++++++----- 5 files changed, 148 insertions(+), 39 deletions(-) diff --git a/src/__tests__/provider-command.test.ts b/src/__tests__/provider-command.test.ts index e82677e51..8a909bc56 100644 --- a/src/__tests__/provider-command.test.ts +++ b/src/__tests__/provider-command.test.ts @@ -127,15 +127,19 @@ describe('provider migrate-legacy: single-provider gate (issue #404 phase 2)', ( }); describe('provider add http: registry published only after init succeeds (issue #404)', () => { - it('rolls back and leaves no registry entry when adapter init fails', async () => { - // Force the ClawPro adapter's initialize to fail. + it('cleanly rolls back (no registry, no home) when init fails and teardown succeeds', async () => { + // init fails; teardown succeeds → clean rollback removes everything. vi.doMock('../providers/http/registry.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getHttpAdapter: (name: string) => { const backend = actual.getHttpAdapter(name); - return { ...backend, initialize: async () => { throw new Error('bad token'); } }; + return { + ...backend, + initialize: async () => { throw new Error('bad token'); }, + teardown: async () => {}, + }; }, }; }); @@ -151,13 +155,22 @@ describe('provider add http: registry published only after init succeeds (issue expect(fse.existsSync(httpProviderHome('company'))).toBe(false); }); - it('fails the add when init injected no hooks at all (delivers nothing)', async () => { - // Simulate every tool's hook injection failing: attempted > 0, succeeded 0. - vi.doMock('../hooks.js', async (importOriginal) => { - const actual = await importOriginal(); + it('keeps a retriable state home (no registry) when init AND rollback teardown fail', async () => { + // init fails; teardown also fails (e.g. an injected hook is locked) → keep + // the home + a self-describing provider.json so `provider remove` can retry, + // but drop the registry entry so dispatch won't load a broken provider. + vi.doMock('../providers/http/registry.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - injectHooksToAllTools: vi.fn(async () => ({ attempted: 2, succeeded: 0 })), + getHttpAdapter: (name: string) => { + const backend = actual.getHttpAdapter(name); + return { + ...backend, + initialize: async () => { throw new Error('bad token'); }, + teardown: async () => { throw new Error('hook file locked'); }, + }; + }, }; }); @@ -166,9 +179,28 @@ describe('provider add http: registry published only after init succeeds (issue providerAddHttp('https://a/api', { name: 'company', token: 'x' }), ).rejects.toThrow(/process.exit\(1\)/); - // Rolled back: no registry entry, no leftover home. - const { listHttpProviderConfigs, httpProviderHome } = await import('../providers/http/store.js'); + const { listHttpProviderConfigs, httpProviderHome, readHttpProviderHomeConfig } = await import( + '../providers/http/store.js' + ); + // No registry entry (dispatch won't load it) … expect(await listHttpProviderConfigs()).toEqual([]); + // … but the home + provider.json survive so `provider remove` can recover it. + expect(fse.existsSync(httpProviderHome('company'))).toBe(true); + expect((await readHttpProviderHomeConfig('company'))?.endpoint).toBe('https://a/api'); + }); + + it('provider remove recovers a home whose registry entry was dropped (review #3)', async () => { + // Seed a home with a self-describing provider.json but NO registry entry — + // the state a failed add leaves behind. + const { writeHttpProviderHomeConfig, httpProviderHome, getHttpProviderConfig } = await import( + '../providers/http/store.js' + ); + await writeHttpProviderHomeConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + expect(await getHttpProviderConfig('company')).toBeUndefined(); + + const { providerRemove } = await import('../provider-command.js'); + // teardown for a bare/no-endpoint config no-ops (no manifest); remove succeeds. + await providerRemove('company'); expect(fse.existsSync(httpProviderHome('company'))).toBe(false); }); }); diff --git a/src/provider-command.ts b/src/provider-command.ts index 744ec603a..6963a4be0 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -10,7 +10,9 @@ import { getUserHome } from './utils/home.js'; import { listHttpProviderConfigs, getHttpProviderConfig, + readHttpProviderHomeConfig, upsertHttpProviderConfig, + writeHttpProviderHomeConfig, removeHttpProviderConfig, removeHttpProviderState, migrateLegacyHttpProvider, @@ -147,9 +149,13 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P await removeHttpProviderState(config.name); await removeHttpProviderConfig(config.name); } else { - // Drop only the registry entry so hook dispatch won't load a broken - // provider, but keep the state home for a retriable `provider remove`. + // Drop the registry entry so hook dispatch won't load a broken provider, + // but keep the state home for a retriable `provider remove`. Persist the + // self-describing provider.json into the home so `provider remove` can + // recover this config even without a registry entry (review #3) — init + // may have failed before upsert wrote it. await removeHttpProviderConfig(config.name); + await writeHttpProviderHomeConfig(config); log.warn( `Kept partial state for "${config.name}" (teardown incomplete); ` + `run \`teamai provider remove ${config.name}\` after resolving the issue.`, @@ -196,15 +202,36 @@ export async function providerSync(): Promise { /** `teamai provider remove ` */ export async function providerRemove(name: string): Promise { - const config = await getHttpProviderConfig(name); + let config = await getHttpProviderConfig(name); if (!config) { - log.error(`No HTTP provider named "${name}".`); - process.exit(1); + // No registry entry — but a failed `provider add` (or an interrupted one) + // may have left a state home whose registry record was dropped so hook + // dispatch would not load a broken provider. Recover that provider's config + // from its own home so this command can still finish the cleanup (the retry + // path review #3 asks for). Only error out when there is truly nothing. + config = await readHttpProviderHomeConfig(name); + if (!config) { + log.error(`No HTTP provider named "${name}".`); + process.exit(1); + } } // Tear down the provider's resources first, then drop its config and state so - // a failed teardown does not orphan installed resources. + // a failed teardown does not orphan installed resources. teardown throws when + // it could not fully clean up, leaving the home for another retry. const provider = createHttpResourceProvider(config); - await provider.teardown(); + try { + await provider.teardown(); + } catch (e) { + // Drop the registry entry (if any) so dispatch won't keep loading it, but + // keep the state home so cleanup can be retried once the issue is resolved. + await removeHttpProviderConfig(name); + log.error( + `Could not fully remove provider "${name}": ${(e as Error).message} ` + + 'Kept its state for a retry — re-run `teamai provider remove ' + + `${name}\` after resolving the issue.`, + ); + process.exit(1); + } await removeHttpProviderConfig(name); await removeHttpProviderState(name); log.success(`Removed HTTP provider "${name}".`); diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index 1ff2ea699..c32ac3173 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -3685,15 +3685,21 @@ export async function removeLocalAgentHttp(): Promise { if (!await removeAllAgentHooks()) uninstallFailed = true; - // Remove the built-in teamai hooks this provider's initLocalAgentHttp injected - // via injectHooksToAllTools — removeAllAgentHooks only clears backend-delivered - // agent hooks, so without this a standalone `provider add` → `provider remove` - // (or a failed-add rollback) would leave the dispatch hooks running in every - // tool. Only do this for a named provider AND only when no OTHER teamai install - // (git/self/legacy user config) still relies on those shared built-in hooks — - // otherwise removing them would break a coexisting install. const providerCtx = httpProviderContext.getStore(); - if (providerCtx && !(await hasOtherTeamaiInstall())) { + // The built-in teamai dispatch hooks are SHARED infrastructure: the same + // entries serve every teamai install (git/self/other providers), and with no + // config for a tool's cwd every handler no-ops gracefully. Removing them on a + // single `provider remove` is therefore asymmetric — a leftover hook is + // harmless, but an erroneous removal breaks a coexisting install, and whether + // another install exists cannot be determined reliably (a legacy in-tree + // /.teamai can live anywhere on disk; review #5/#7). So we do NOT + // strip the built-in hooks here. `teamai uninstall` — the "remove everything" + // path — owns full built-in-hook removal via its own enumeration. + // + // Only when this provider is being torn down AS PART OF `teamai uninstall` + // (TEAMAI_UNINSTALL=1) and no other install remains do we also clear them, so + // a provider added standalone still gets its hooks removed at uninstall time. + if (providerCtx && process.env.TEAMAI_UNINSTALL === '1' && !(await hasOtherTeamaiInstall())) { try { const teamConfig = createLocalAgentTeamConfig(config.endpoint); await reconcileHooksToAllTools( diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts index 1b1ea6465..b3176c106 100644 --- a/src/providers/http/store.ts +++ b/src/providers/http/store.ts @@ -131,6 +131,37 @@ export async function getHttpProviderConfig(name: string): Promise p.name === name); } +/** + * Read a provider's self-describing config from its own state home + * (`~/.teamai/providers/http//provider.json`), independent of the + * registry. Used to recover a provider whose registry entry was dropped after a + * failed add/remove so `provider remove` can still find and finish its cleanup + * (issue #404, review #3). Returns undefined when the home has no config. + */ +export async function readHttpProviderHomeConfig(name: string): Promise { + try { + assertValidProviderName(name); + } catch { + return undefined; + } + const cfg = await readJson( + path.join(httpProviderHome(name), PROVIDER_CONFIG_FILE), + ); + return cfg?.name ? cfg : undefined; +} + +/** + * Persist a provider's self-describing config into its own state home WITHOUT + * touching the registry. Used by a failed `provider add` that keeps partial + * state for a retriable `provider remove`, so the recovery read above can find + * the config even though the registry entry was dropped (issue #404, review #3). + */ +export async function writeHttpProviderHomeConfig(config: HttpProviderConfig): Promise { + assertValidProviderName(config.name); + await ensureDir(httpProviderHome(config.name)); + await writeJsonAtomic(path.join(httpProviderHome(config.name), PROVIDER_CONFIG_FILE), config); +} + /** Add or replace an HTTP provider in the registry, persisting its config. */ export async function upsertHttpProviderConfig(config: HttpProviderConfig): Promise { assertValidProviderName(config.name); diff --git a/src/uninstall.ts b/src/uninstall.ts index 2cb99e704..97b68f201 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -879,6 +879,13 @@ async function teardownPlugins(): Promise<{ incompleteProviders: string[] }> { './providers/http/store.js' ); const { withHttpProvider, removeLocalAgentHttp } = await import('./local-agent.js'); + // Signal the provider teardown that this is the full-uninstall path, so it + // also removes the shared built-in dispatch hooks (a standalone + // `provider remove` deliberately leaves those to avoid breaking a + // coexisting install — issue #404 #5/#7). + const priorUninstallFlag = process.env.TEAMAI_UNINSTALL; + process.env.TEAMAI_UNINSTALL = '1'; + try { for (const config of await listHttpProviderConfigs()) { try { await withHttpProvider(httpProviderExecutionContext(config.name), () => @@ -893,6 +900,10 @@ async function teardownPlugins(): Promise<{ incompleteProviders: string[] }> { log.warn(`teardown for provider "${config.name}" failed: ${(e as Error).message}`); } } + } finally { + if (priorUninstallFlag === undefined) delete process.env.TEAMAI_UNINSTALL; + else process.env.TEAMAI_UNINSTALL = priorUninstallFlag; + } } catch (e) { log.warn(`HTTP provider teardown failed: ${e instanceof Error ? e.message : String(e)}`); } @@ -1117,22 +1128,24 @@ async function executeRemoval(plan: RemovalPlan): Promise { // Tear down plugins first: their manifest/config live under ~/.teamai/local-agent. const { incompleteProviders } = await teardownPlugins(); if (incompleteProviders.length > 0) { - // We are about to delete ~/.teamai, which holds the ownership manifests - // for these providers' resources/hooks/plugins. Their teardown did not - // fully complete, so warn the user that some external tool files may - // remain and need manual cleanup — deleting the manifest silently would - // hide that. + // A provider's teardown could not fully clean up (locked file / perms). + // ~/.teamai holds the ownership manifests needed to find and finish that + // cleanup, so do NOT delete it — that would strand the external + // hooks/plugins with no way to retry. Keep it and tell the user to + // resolve the issue and re-run, exactly like `provider remove` does. log.warn( - `These HTTP providers did not fully tear down: ${incompleteProviders.join(', ')}. ` - + 'Some tool hooks/plugins they installed may remain; their tracking manifests are ' - + 'about to be removed with ~/.teamai, so any leftovers must be cleaned up manually.', + `Kept ${plan.teamaiHome}/ because these HTTP providers did not fully tear down: ` + + `${incompleteProviders.join(', ')}. Their tool hooks/plugins may still be installed and ` + + 'their tracking manifests are needed to remove them. Resolve the underlying issue ' + + '(locked file / permissions) and run `teamai uninstall` again.', ); - } - try { - await remove(plan.teamaiHome); - log.success(`Removed ${plan.teamaiHome}/`); - } catch (e) { - log.warn(`Failed to remove ${plan.teamaiHome}: ${(e as Error).message}`); + } else { + try { + await remove(plan.teamaiHome); + log.success(`Removed ${plan.teamaiHome}/`); + } catch (e) { + log.warn(`Failed to remove ${plan.teamaiHome}: ${(e as Error).message}`); + } } } From aa127fb8faaf5cdc0878750abc716b10424bf5eb Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 21:23:51 +0800 Subject: [PATCH 12/14] fix(providers): trim unused abstraction, drop migration snapshot (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth review round on PR #780, resolved per maintainer decisions: - Delete the unused speculative abstraction (review P2): remove GitResourceProvider (no production consumer), the ResourceProviderRegistry class (only syncResourceProviders is used — kept), and the HttpBackendAdapter.routes()/HttpRoutes API (no caller). The live contract (ResourceProvider, syncResourceProviders, HttpResourceProvider, HttpBackendAdapter) stays. - Migration no longer keeps a rollback snapshot (reviews #6/#7): it now DELETES ~/.teamai/local-agent/ after publishing the provider. This removes an entire class of snapshot bugs — no stale bindings/manifest to revive on a later `source add-http`, no double plugin uninstall on `teamai uninstall`. The migrated signal is the dir's absence, so the `migrated-to` marker and clearLegacyMigrationMarker are gone and legacySingletonActive is just "does the legacy config exist". - `provider remove` resolves the name case-insensitively and acts on the canonical registered name (review #3), so on a case-insensitive FS `remove Foo` can't delete `foo`'s state while stranding its registry entry. - `provider remove` deactivates first (drops the registry entry) then tears down (review #4), under the provider lock, so a concurrent hook can't reinstall mid-teardown and a teardown failure can't leave the registry pointing at deleted state. - `provider remove` did NOT strip shared built-in hooks — kept per the round-7 decision (maintainer confirmed): those serve every install and no-op without config; removal stays with `teamai uninstall`. config.ts / usage-tracker.ts remain false positives (not in this PR's diff); asked the maintainer to dismiss. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/join-member.md | 8 +- src/__tests__/http-provider-store.test.ts | 46 ++++--- src/__tests__/provider-command.test.ts | 15 +++ .../resource-provider-registry.test.ts | 121 +----------------- src/provider-command.ts | 67 +++++----- src/providers/git/resource-provider.ts | 60 --------- src/providers/http/adapters/clawpro/client.ts | 23 +--- src/providers/http/adapters/clawpro/index.ts | 7 - src/providers/http/store.ts | 68 ++++------ src/providers/index.ts | 4 +- src/providers/resource-registry.ts | 43 +------ src/providers/types.ts | 13 +- src/source.ts | 12 +- 15 files changed, 131 insertions(+), 360 deletions(-) delete mode 100644 src/providers/git/resource-provider.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index c8639ad49..ac03d18c9 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1878,7 +1878,7 @@ To move an existing single HTTP backend (`init --http` / `source add-http`) onto teamai provider migrate-legacy --name company ``` -This promotes `~/.teamai/local-agent/` to a named provider (copying its state and extracting its credential to the isolated `0600` file) and keeps the old directory as a rollback snapshot. It is idempotent. +This promotes `~/.teamai/local-agent/` to a named provider (copying its state and extracting its credential to the isolated `0600` file), then removes the old directory. It is idempotent — once migrated, re-running is a no-op. --- diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 4cb6f7fea..95b3f041f 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1810,7 +1810,7 @@ teamai provider remove company teamai provider migrate-legacy --name company ``` -这会把 `~/.teamai/local-agent/` 提升为具名 provider(复制其状态,并把凭据抽取到隔离的 `0600` 文件),并保留旧目录作为回滚快照。该操作幂等。 +这会把 `~/.teamai/local-agent/` 提升为具名 provider(复制其状态,并把凭据抽取到隔离的 `0600` 文件),随后删除旧目录。该操作幂等——迁移完成后重跑为 no-op。 --- diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 3f0c2565f..e45ff6334 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -101,10 +101,10 @@ teamai provider remove ``` To move an existing `init --http` singleton onto this model without losing state, -run `teamai provider migrate-legacy --name ` (it keeps the old directory as -a rollback snapshot; idempotent). Only one HTTP provider is supported per install -for now — mounting several concurrently needs cross-provider ownership -arbitration, which is a later phase (issue #404). +run `teamai provider migrate-legacy --name ` (it copies the state, isolates +the credential, then removes the old directory; idempotent). Only one HTTP +provider is supported per install for now — mounting several concurrently needs +cross-provider ownership arbitration, which is a later phase (issue #404). **Claude Code kept in a different directory (`CLAUDE_CONFIG_DIR`):** `init` records that directory (as `toolRoots.claude` in the local config) and syncs every Claude diff --git a/src/__tests__/http-provider-store.test.ts b/src/__tests__/http-provider-store.test.ts index 852e0a4d0..f738c2f93 100644 --- a/src/__tests__/http-provider-store.test.ts +++ b/src/__tests__/http-provider-store.test.ts @@ -189,9 +189,9 @@ describe('http provider: legacy singleton migration', () => { expect(fs.readFileSync(httpProviderCredentialPath('company'), 'utf-8').trim()).toBe('legacy-token'); expect((await fse.readJson(path.join(httpProviderHome('company'), 'config.json'))).token).toBeUndefined(); - // The legacy dir is kept as a rollback snapshot, but marked migrated. - expect(fs.existsSync(legacyDir)).toBe(true); - expect(fs.existsSync(path.join(legacyDir, 'migrated-to'))).toBe(true); + // The legacy dir is DELETED (no rollback snapshot) — so it no longer counts + // as an active singleton and cannot be revived or double-uninstalled. + expect(fs.existsSync(legacyDir)).toBe(false); expect(await legacySingletonActive()).toBe(false); }); @@ -229,8 +229,8 @@ describe('http provider: legacy singleton migration', () => { const { migrateLegacyHttpProvider, httpProviderHome, getHttpProviderConfig } = await import( '../providers/http/store.js' ); - // Simulate a crash after the state-dir move but before the registry/marker - // write: a home dir exists but no registry entry and no marker. + // Simulate a crash after the state-dir move but before the registry write: + // a home dir exists but no registry entry. await fse.ensureDir(httpProviderHome('company')); await fse.writeFile(path.join(httpProviderHome('company'), 'stale.txt'), 'leftover'); @@ -242,14 +242,14 @@ describe('http provider: legacy singleton migration', () => { expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://legacy/api'); }); - it('resumes when a prior attempt already wrote the registry entry but no marker', async () => { + it('resumes when a prior attempt already wrote the registry entry', async () => { await seedLegacy('legacy-token'); const { migrateLegacyHttpProvider, upsertHttpProviderConfig, legacySingletonActive } = await import( '../providers/http/store.js' ); // Simulate a crash AFTER upsert (registry entry with the legacy endpoint) - // but BEFORE the marker: legacy is still active. A retry must resume, not - // fail with "already exists". + // but BEFORE the legacy dir was deleted: legacy is still active. A retry + // must resume, not fail with "already exists". await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://legacy/api', priority: 50, }); @@ -257,23 +257,35 @@ describe('http provider: legacy singleton migration', () => { const config = await migrateLegacyHttpProvider({ name: 'company' }); expect(config).not.toBeNull(); - // Migration now completed: legacy marked migrated. + // Migration completed: the legacy dir is deleted, so it is no longer active. expect(await legacySingletonActive()).toBe(false); }); - it('clearLegacyMigrationMarker reactivates a re-written legacy singleton (review #5)', async () => { + it('deletes the legacy dir on migration (no snapshot) and is idempotent', async () => { await seedLegacy('t'); - const { migrateLegacyHttpProvider, legacySingletonActive, clearLegacyMigrationMarker } = await import( + const { migrateLegacyHttpProvider, legacySingletonActive } = await import( '../providers/http/store.js' ); - await migrateLegacyHttpProvider({ name: 'company' }); - // After migration the legacy dir is a dormant snapshot. + const first = await migrateLegacyHttpProvider({ name: 'company' }); + expect(first).not.toBeNull(); + // No rollback snapshot is kept — the legacy dir is gone, so it is inactive + // and a second migration is a no-op (returns null). expect(await legacySingletonActive()).toBe(false); + expect(await migrateLegacyHttpProvider({ name: 'company2' })).toBeNull(); + }); + + it('a re-written legacy config after migrate+remove is active again by presence (review #5)', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider, legacySingletonActive } = await import( + '../providers/http/store.js' + ); + await migrateLegacyHttpProvider({ name: 'company' }); + expect(await legacySingletonActive()).toBe(false); // legacy dir deleted - // A later `source add-http` / `init --http` clears the marker when it - // re-writes the legacy config; the singleton is then active again so the - // dispatcher will sync it (without this it would stay dormant forever). - await clearLegacyMigrationMarker(); + // Simulate `source add-http` / `init --http` writing a fresh legacy config + // after the provider was removed: presence alone makes it active again, with + // no marker bookkeeping to get stuck. + await seedLegacy('t2'); expect(await legacySingletonActive()).toBe(true); }); }); diff --git a/src/__tests__/provider-command.test.ts b/src/__tests__/provider-command.test.ts index 8a909bc56..fc5a5abc7 100644 --- a/src/__tests__/provider-command.test.ts +++ b/src/__tests__/provider-command.test.ts @@ -203,4 +203,19 @@ describe('provider add http: registry published only after init succeeds (issue await providerRemove('company'); expect(fse.existsSync(httpProviderHome('company'))).toBe(false); }); + + it('provider remove resolves the name case-insensitively and clears the registry (review #3)', async () => { + const { upsertHttpProviderConfig, listHttpProviderConfigs, httpProviderHome } = await import( + '../providers/http/store.js' + ); + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + + const { providerRemove } = await import('../provider-command.js'); + // Different-case input must resolve to the registered `company` and remove + // BOTH its state home and its registry record — never delete state while + // leaving a dangling registry entry (case-insensitive FS hazard). + await providerRemove('Company'); + expect(await listHttpProviderConfigs()).toEqual([]); + expect(fse.existsSync(httpProviderHome('company'))).toBe(false); + }); }); diff --git a/src/__tests__/resource-provider-registry.test.ts b/src/__tests__/resource-provider-registry.test.ts index 1dee80b8a..c30da75fe 100644 --- a/src/__tests__/resource-provider-registry.test.ts +++ b/src/__tests__/resource-provider-registry.test.ts @@ -1,16 +1,12 @@ import { describe, it, expect, vi } from 'vitest'; -import { - ResourceProviderRegistry, - syncResourceProviders, -} from '../providers/resource-registry.js'; -import { GitResourceProvider } from '../providers/git/resource-provider.js'; +import { syncResourceProviders } from '../providers/resource-registry.js'; import type { ResourceProvider, SyncContext, ProviderResult, } from '../providers/types.js'; -/** A minimal stub ResourceProvider for registry/sync tests. */ +/** A minimal stub ResourceProvider for sync tests. */ function stubProvider( name: string, priority: number, @@ -45,33 +41,6 @@ function stubProvider( const HOOK: SyncContext = { trigger: 'hook' }; -describe('ResourceProviderRegistry', () => { - it('rejects a duplicate provider name', () => { - const registry = new ResourceProviderRegistry(); - registry.register(stubProvider('team', 100)); - expect(() => registry.register(stubProvider('team', 50))).toThrow( - /already registered/, - ); - }); - - it('looks up a provider by name', () => { - const registry = new ResourceProviderRegistry(); - const p = stubProvider('team', 100); - registry.register(p); - expect(registry.get('team')).toBe(p); - expect(registry.get('missing')).toBeUndefined(); - expect(registry.size).toBe(1); - }); - - it('lists providers by descending priority, then name', () => { - const registry = new ResourceProviderRegistry(); - registry.register(stubProvider('bbb', 50)); - registry.register(stubProvider('aaa', 100)); - registry.register(stubProvider('ccc', 50)); - expect(registry.list().map((p) => p.name)).toEqual(['aaa', 'bbb', 'ccc']); - }); -}); - describe('syncResourceProviders', () => { it('isolates a failing provider from the others', async () => { const good = stubProvider('good', 10, { @@ -126,89 +95,3 @@ describe('syncResourceProviders', () => { expect(order).toEqual(['low', 'high']); }); }); - -describe('GitResourceProvider', () => { - it('is a git provider whose push capability tracks writability', () => { - const writable = new GitResourceProvider( - 'core', - 100, - 'https://example.com/repo.git', - async () => ({ changed: false }), - async () => {}, - true, - ); - expect(writable.type).toBe('git'); - expect(writable.capabilities).toEqual({ - pull: true, - push: true, - report: false, - commands: false, - }); - - const readOnly = new GitResourceProvider( - 'shared', - 50, - 'https://example.com/shared.git', - async () => ({ changed: false }), - async () => {}, - false, - ); - expect(readOnly.capabilities.push).toBe(false); - }); - - it('delegates sync to the injected operation and reports its result', async () => { - const syncOp = vi.fn(async () => ({ changed: true, message: 'pulled 3 skills' })); - const provider = new GitResourceProvider( - 'core', - 100, - 'https://example.com/repo.git', - syncOp, - async () => {}, - true, - ); - - const result = await provider.sync({ trigger: 'pull', cwd: '/work' }); - - expect(syncOp).toHaveBeenCalledWith({ trigger: 'pull', cwd: '/work' }); - expect(result).toEqual({ - provider: 'core', - ok: true, - changed: true, - message: 'pulled 3 skills', - }); - }); - - it('delegates teardown to the injected operation', async () => { - const teardownOp = vi.fn(async () => {}); - const provider = new GitResourceProvider( - 'core', - 100, - 'https://example.com/repo.git', - async () => ({ changed: false }), - teardownOp, - true, - ); - - await provider.teardown(); - - expect(teardownOp).toHaveBeenCalledOnce(); - }); - - it('describes itself with the repo as endpoint', async () => { - const provider = new GitResourceProvider( - 'core', - 100, - 'https://example.com/repo.git', - async () => ({ changed: false }), - async () => {}, - true, - ); - expect(await provider.describe()).toEqual({ - name: 'core', - type: 'git', - priority: 100, - capabilities: { pull: true, push: true, report: false, commands: false }, - endpoint: 'https://example.com/repo.git', - }); - }); -}); diff --git a/src/provider-command.ts b/src/provider-command.ts index 6963a4be0..aa50abf4b 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -202,39 +202,46 @@ export async function providerSync(): Promise { /** `teamai provider remove ` */ export async function providerRemove(name: string): Promise { - let config = await getHttpProviderConfig(name); - if (!config) { - // No registry entry — but a failed `provider add` (or an interrupted one) - // may have left a state home whose registry record was dropped so hook - // dispatch would not load a broken provider. Recover that provider's config - // from its own home so this command can still finish the cleanup (the retry - // path review #3 asks for). Only error out when there is truly nothing. - config = await readHttpProviderHomeConfig(name); + await withProviderLock(async () => { + // Resolve case-insensitively and then use the provider's OWN canonical name + // for every subsequent op. On a case-insensitive filesystem `Foo` and `foo` + // share a state dir, so acting on the raw input would delete one provider's + // state while leaving the other's registry record (review #3). + const registered = (await listHttpProviderConfigs()).find( + (p) => p.name === name || p.name.toLowerCase() === name.toLowerCase(), + ); + // A failed/interrupted `provider add` may have left a state home whose + // registry record was dropped so dispatch would not load a broken provider. + // Recover its config from its own home so cleanup can still finish. + const config = registered ?? (await readHttpProviderHomeConfig(name)); if (!config) { log.error(`No HTTP provider named "${name}".`); process.exit(1); } - } - // Tear down the provider's resources first, then drop its config and state so - // a failed teardown does not orphan installed resources. teardown throws when - // it could not fully clean up, leaving the home for another retry. - const provider = createHttpResourceProvider(config); - try { - await provider.teardown(); - } catch (e) { - // Drop the registry entry (if any) so dispatch won't keep loading it, but - // keep the state home so cleanup can be retried once the issue is resolved. - await removeHttpProviderConfig(name); - log.error( - `Could not fully remove provider "${name}": ${(e as Error).message} ` - + 'Kept its state for a retry — re-run `teamai provider remove ' - + `${name}\` after resolving the issue.`, - ); - process.exit(1); - } - await removeHttpProviderConfig(name); - await removeHttpProviderState(name); - log.success(`Removed HTTP provider "${name}".`); + const canonical = config.name; + + // Deactivate FIRST (drop the registry entry) so a concurrent session hook + // cannot re-install resources during teardown, and so a later write failure + // can never leave the registry pointing at deleted state (review #4). The + // state home is preserved until teardown confirms a clean removal. + await removeHttpProviderConfig(canonical); + + const provider = createHttpResourceProvider(config); + try { + await provider.teardown(); + } catch (e) { + // Registry entry is already gone (dispatch won't load it); keep the state + // home so cleanup can be retried once the issue is resolved. + log.error( + `Could not fully remove provider "${canonical}": ${(e as Error).message} ` + + `Kept its state for a retry — re-run \`teamai provider remove ${canonical}\` ` + + 'after resolving the issue.', + ); + process.exit(1); + } + await removeHttpProviderState(canonical); + log.success(`Removed HTTP provider "${canonical}".`); + }); } interface MigrateLegacyOptions { @@ -284,7 +291,7 @@ export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise } log.success( `Migrated legacy HTTP local agent to provider "${config.name}" ` + - `(the old ~/.teamai/local-agent/ is kept as a rollback snapshot).`, + '(the old ~/.teamai/local-agent/ has been removed).', ); } diff --git a/src/providers/git/resource-provider.ts b/src/providers/git/resource-provider.ts deleted file mode 100644 index 43221558f..000000000 --- a/src/providers/git/resource-provider.ts +++ /dev/null @@ -1,60 +0,0 @@ -// ─── Git resource provider ─────────────────────────────── -// -// Wraps the existing team-repo sync (git clone + resource deploy) as a -// ResourceProvider so a git backend sits alongside HTTP backends under one -// registry. This is a thin shell: the actual pull/teardown behavior is injected -// as closures by the caller, so wrapping introduces ZERO behavior change — the -// closures run the same code paths as before the abstraction existed. -// -// See issue #404, phase 1. - -import type { - ResourceProvider, - ProviderCapabilities, - ProviderResult, - ProviderSummary, - SyncContext, -} from '../types.js'; - -/** Sync a git-backed source. Returns whether anything changed on disk. */ -export type GitSyncOperation = (context: SyncContext) => Promise<{ changed: boolean; message?: string }>; - -/** Remove a git-backed source's local state (clone, worktrees, manifests). */ -export type GitTeardownOperation = () => Promise; - -export class GitResourceProvider implements ResourceProvider { - readonly type = 'git' as const; - readonly capabilities: ProviderCapabilities; - - constructor( - readonly name: string, - readonly priority: number, - /** Team repo remote URL, for `describe`. */ - private readonly repo: string, - private readonly syncOperation: GitSyncOperation, - private readonly teardownOperation: GitTeardownOperation, - /** Whether this git source accepts `teamai push` (main repo yes, cross-team source no). */ - writable: boolean, - ) { - this.capabilities = { pull: true, push: writable, report: false, commands: false }; - } - - async sync(context: SyncContext): Promise { - const { changed, message } = await this.syncOperation(context); - return { provider: this.name, ok: true, changed, message }; - } - - async describe(): Promise { - return { - name: this.name, - type: this.type, - priority: this.priority, - capabilities: this.capabilities, - endpoint: this.repo, - }; - } - - async teardown(): Promise { - await this.teardownOperation(); - } -} diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts index c32ac3173..c6f13beb0 100644 --- a/src/providers/http/adapters/clawpro/client.ts +++ b/src/providers/http/adapters/clawpro/client.ts @@ -3430,14 +3430,9 @@ export async function initLocalAgentHttp(options: { await ensureDir(getLocalAgentHome()); await saveLocalAgentConfig(config); - // Legacy path only: writing a fresh singleton config must clear any stale - // `migrated-to` marker left by an earlier migrate-then-remove, or - // legacySingletonActive() would stay false and the dispatcher would never - // sync this new config (review #5). Named-provider init has no such marker. - if (!httpProviderContext.getStore()) { - const { clearLegacyMigrationMarker } = await import('../../store.js'); - await clearLegacyMigrationMarker(); - } + // Migration now DELETES the legacy dir (no marker), so a freshly (re)written + // legacy config here is active again purely by its presence — nothing to + // clear. (Named-provider init writes under the provider home, not here.) if (options.token) { // Named providers keep the credential in their own 0600 file; the legacy // singleton keeps its historical ~/.teamai/token location. @@ -3496,15 +3491,9 @@ export interface LocalAgentSummary { * alongside git cross-team sources. */ export async function describeLocalAgent(): Promise { - // A migrated legacy singleton is only a rollback snapshot, not an active - // source — the named provider now owns delivery. Don't list it (review P2). - // (Only relevant outside a provider context; a named provider never has this - // marker in its own home.) - if (!httpProviderContext.getStore()) { - const { legacySingletonActive } = await import('../../store.js'); - const legacyExists = await pathExists(getConfigPath()); - if (legacyExists && !(await legacySingletonActive())) return null; - } + // Migration deletes the legacy dir outright (no rollback snapshot), so a + // migrated singleton simply no longer exists here and loadLocalAgentConfig + // returns null — nothing special to filter for `source list`. const config = await loadLocalAgentConfig(); if (!config) return null; diff --git a/src/providers/http/adapters/clawpro/index.ts b/src/providers/http/adapters/clawpro/index.ts index e0512e981..afe3d9c8c 100644 --- a/src/providers/http/adapters/clawpro/index.ts +++ b/src/providers/http/adapters/clawpro/index.ts @@ -10,13 +10,11 @@ import type { HttpBackendAdapter, HttpProviderConfig, - HttpRoutes, ProviderResult, ProviderSummary, SyncContext, } from '../../../types.js'; import { - DEFAULT_ROUTES, withHttpProvider, initLocalAgentHttp, reportAndSyncFromHook, @@ -30,11 +28,6 @@ import { httpProviderExecutionContext } from '../../store.js'; export class ClawProAdapter implements HttpBackendAdapter { readonly name = 'clawpro'; - routes(_config: HttpProviderConfig): HttpRoutes { - // ClawPro uses the default /api/local-agent/* layout. - return { ...DEFAULT_ROUTES }; - } - async initialize(config: HttpProviderConfig, token?: string): Promise { await withHttpProvider(httpProviderExecutionContext(config.name), () => initLocalAgentHttp({ endpoint: config.endpoint, token, force: true }), diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts index b3176c106..78a005cc8 100644 --- a/src/providers/http/store.ts +++ b/src/providers/http/store.ts @@ -34,8 +34,6 @@ const CREDENTIALS_DIR = 'credentials'; const REGISTRY_FILE = 'settings.json'; const PROVIDER_CONFIG_FILE = 'provider.json'; const LEGACY_DIR = 'local-agent'; -/** Marker written into the legacy dir once it has been migrated to a named provider. */ -const MIGRATED_MARKER = 'migrated-to'; function teamaiHome(): string { return path.join(getUserHome(), '.teamai'); @@ -201,29 +199,14 @@ function legacyHome(): string { return path.join(teamaiHome(), LEGACY_DIR); } -function legacyMarkerPath(): string { - return path.join(legacyHome(), MIGRATED_MARKER); -} - /** - * True when a legacy ~/.teamai/local-agent/ singleton still owns the backend — - * i.e. it exists and has NOT been migrated to a named provider. The hook - * dispatcher uses this to keep running the legacy path until migration. + * True when a legacy ~/.teamai/local-agent/ singleton owns the backend — i.e. + * its config.json exists. Migration DELETES this dir (no marker/snapshot), so + * its mere presence is the "active" signal and a re-written config after a + * migrate+remove is active again with no extra bookkeeping. */ export async function legacySingletonActive(): Promise { - if (!(await pathExists(path.join(legacyHome(), 'config.json')))) return false; - return !(await pathExists(legacyMarkerPath())); -} - -/** - * Clear a stale `migrated-to` marker so a freshly (re)written legacy singleton - * is active again. Needed when a user migrates the singleton to a named - * provider, removes that provider, then re-runs `source add-http` / `init - * --http`: that writes a new legacy config, but the leftover marker would keep - * legacySingletonActive() false forever and the dispatcher would never sync it. - */ -export async function clearLegacyMigrationMarker(): Promise { - await remove(legacyMarkerPath()); + return pathExists(path.join(legacyHome(), 'config.json')); } interface LegacyConfigShape { @@ -235,10 +218,14 @@ interface LegacyConfigShape { /** * Promote the legacy singleton to a named HTTP provider. Copies the legacy * state home into the provider's home via a staging directory + atomic rename, - * moves the credential to the isolated 0600 file, and only then writes the - * migration marker into the legacy dir — which is kept as a rollback snapshot, - * not deleted. Idempotent: a second call after a successful migration is a - * no-op. + * moves the credential to the isolated 0600 file, publishes the provider, and + * then DELETES the legacy dir. No rollback snapshot is kept: retaining it caused + * a whole class of "snapshot revival" / "double uninstall" bugs (a later + * `source add-http` reviving stale bindings/manifest, `teamai uninstall` + * running a plugin uninstall_cmd twice — issue #404 reviews #6/#7), and the + * legacy dir's own disappearance is the migrated signal (no marker needed). + * + * Idempotent: once the legacy dir is gone, a second call returns null. * * @returns the provider config it registered, or null when there was no legacy * singleton to migrate. @@ -251,8 +238,7 @@ export async function migrateLegacyHttpProvider(options: { assertValidProviderName(options.name); const legacyConfigPath = path.join(legacyHome(), 'config.json'); const legacy = await readJson(legacyConfigPath); - if (!legacy?.endpoint) return null; - if (await pathExists(legacyMarkerPath())) return null; // already migrated + if (!legacy?.endpoint) return null; // nothing (or already migrated → dir gone) const config: HttpProviderConfig = { name: options.name, @@ -261,15 +247,13 @@ export async function migrateLegacyHttpProvider(options: { priority: options.priority ?? legacy.priority ?? 50, }; - // Retriability: because the legacy marker is written LAST, reaching this - // point means the previous attempt (if any) was interrupted before it - // finished — the legacy backend is still authoritative. Any partial state the - // previous attempt left (a registry entry, a half-copied home) therefore - // carries no unique data and belongs to THIS same migration, so we resume by - // discarding and rebuilding it rather than failing with "already exists". - // - // A registry entry for this name whose endpoint DIFFERS from the legacy one is - // a genuine foreign `provider add` conflict, not our leftover — reject that. + // Retriability: the legacy dir is deleted LAST, so reaching this point means + // any previous attempt was interrupted before it finished — the legacy backend + // is still authoritative. Partial state a previous attempt left (a registry + // entry, a half-copied home) carries no unique data and belongs to THIS same + // migration, so we discard and rebuild it rather than failing. A registry + // entry for this name whose endpoint DIFFERS is a foreign `provider add` + // conflict — reject that. const home = httpProviderHome(options.name); const existingEntry = await getHttpProviderConfig(options.name); if (existingEntry && existingEntry.endpoint !== config.endpoint) { @@ -290,8 +274,6 @@ export async function migrateLegacyHttpProvider(options: { await remove(staging); await ensureDir(path.dirname(home)); await fse.copy(legacyHome(), staging); - // Never carry the migration marker (not present yet) into the named home. - await remove(path.join(staging, MIGRATED_MARKER)); // Redact the inline token from the staged config.json BEFORE publishing the // directory — the credential belongs only in the isolated 0600 file, and the // published home must never contain it, not even in the crash window between @@ -312,10 +294,10 @@ export async function migrateLegacyHttpProvider(options: { await upsertHttpProviderConfig(config); - // Mark the legacy dir migrated LAST, so a crash before this point re-runs the - // migration rather than orphaning the backend. The legacy dir stays on disk - // as a rollback snapshot. - await writeJsonAtomic(legacyMarkerPath(), { name: options.name, migratedAt: new Date().toISOString() }); + // Delete the legacy dir LAST. A crash before this re-runs the migration (the + // legacy config is still there); after it, the legacy singleton no longer + // exists so there is nothing to revive or double-uninstall. + await remove(legacyHome()); return config; } diff --git a/src/providers/index.ts b/src/providers/index.ts index e6d103512..8ccc4e060 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -12,10 +12,8 @@ export type { SyncContext, HttpBackendAdapter, HttpProviderConfig, - HttpRoutes, } from './types.js'; -export { ResourceProviderRegistry, syncResourceProviders } from './resource-registry.js'; -export { GitResourceProvider } from './git/resource-provider.js'; +export { syncResourceProviders } from './resource-registry.js'; export { TGitProvider } from './tgit/index.js'; export { GitHubProvider } from './github/index.js'; export { GitLabProvider } from './gitlab/index.js'; diff --git a/src/providers/resource-registry.ts b/src/providers/resource-registry.ts index 0e88615fe..bde66bf69 100644 --- a/src/providers/resource-registry.ts +++ b/src/providers/resource-registry.ts @@ -1,9 +1,8 @@ -// ─── Resource provider registry ────────────────────────── +// ─── Resource provider sync ────────────────────────────── // -// Holds the named ResourceProviders mounted for a run and drives a -// failure-isolated sync across them. This is the sync-mechanism layer (git / -// http); it is distinct from the git-host registry in ./registry.ts, which maps -// a repo URL to a GitProvider (github/tgit/…). +// Drives a failure-isolated sync across the mounted resource providers. This is +// the sync-mechanism layer (git / http); it is distinct from the git-host +// registry in ./registry.ts, which maps a repo URL to a GitProvider. // // See issue #404. Multi-provider ownership arbitration, primary-write selection // and cross-provider failover are a later phase and intentionally live outside @@ -12,40 +11,6 @@ import type { ResourceProvider, SyncContext, ProviderResult } from './types.js'; import { log } from '../utils/logger.js'; -/** - * A set of uniquely-named resource providers. Registration rejects duplicate - * names; listing returns providers by descending priority, then name, so - * ordering is deterministic regardless of registration order. - */ -export class ResourceProviderRegistry { - private readonly providers = new Map(); - - /** Register a provider. Throws if the name is already taken. */ - register(provider: ResourceProvider): void { - if (this.providers.has(provider.name)) { - throw new Error(`Resource provider "${provider.name}" is already registered.`); - } - this.providers.set(provider.name, provider); - } - - /** Look up a provider by name, or undefined if not registered. */ - get(name: string): ResourceProvider | undefined { - return this.providers.get(name); - } - - /** All providers, ordered by descending priority then name. */ - list(): ResourceProvider[] { - return [...this.providers.values()].sort( - (a, b) => b.priority - a.priority || a.name.localeCompare(b.name), - ); - } - - /** Number of registered providers. */ - get size(): number { - return this.providers.size; - } -} - /** * Sync every provider that can pull or report, isolating failures: one * provider throwing or timing out never aborts the others. Each provider's diff --git a/src/providers/types.ts b/src/providers/types.ts index c836cab2f..c97fe9281 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -308,15 +308,6 @@ export interface ResourceProvider { teardown(): Promise; } -/** Logical route names an HTTP backend exposes, resolved to paths per adapter. */ -export interface HttpRoutes { - projects: string; - report: string; - sync: string; - ack: string; - getConfig: string; -} - /** Persisted configuration for one named HTTP provider. */ export interface HttpProviderConfig { /** Unique provider name (also the state-directory segment). */ @@ -332,12 +323,10 @@ export interface HttpProviderConfig { /** * Translates one HTTP backend's wire format to the common ResourceProvider * shape. The HTTP provider owns transport concerns generically; the adapter - * owns only protocol differences (routes and payload/command shapes). + * owns only protocol differences (payload/command shapes). */ export interface HttpBackendAdapter { readonly name: string; - /** Route table for this backend, given its config (defaults + overrides). */ - routes(config: HttpProviderConfig): HttpRoutes; /** One-time setup for a newly added provider (e.g. persist a token). */ initialize?(config: HttpProviderConfig, token?: string): Promise; /** Run report/sync/command execution for this provider. */ diff --git a/src/source.ts b/src/source.ts index d3fd46c2a..1daa11442 100644 --- a/src/source.ts +++ b/src/source.ts @@ -338,17 +338,15 @@ export async function sourceRemoveHttp(options: GlobalOptions): Promise { log.info('[dry-run] Would remove the HTTP source'); return; } - // A migrated legacy singleton is only a rollback snapshot; a named provider - // now owns those resources. Removing the snapshot's copied manifest would - // uninstall resources the named provider is actively using, so refuse and - // point the user at `provider remove` (issue #404, review #4). + // When a named HTTP provider is configured and there is no legacy singleton, + // `source remove-http` is the wrong tool — the named provider owns delivery. + // Point the user at `provider remove` rather than no-op confusingly (#404). const { legacySingletonActive, listHttpProviderConfigs } = await import('./providers/http/store.js'); const namedProviders = await listHttpProviderConfigs(); if (namedProviders.length > 0 && !(await legacySingletonActive())) { log.error( - 'The legacy HTTP source has been migrated to a named provider; the old ' - + 'directory is only a rollback snapshot. Remove the provider instead: ' - + `\`teamai provider remove ${namedProviders[0].name}\`.`, + `An HTTP provider ("${namedProviders[0].name}") is configured (not a legacy HTTP source). ` + + `Remove it with \`teamai provider remove ${namedProviders[0].name}\`.`, ); return; } From e718b4a1170a1075ba6488ed9328241d1e5c1661 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 21:33:39 +0800 Subject: [PATCH 13/14] fix(providers): prevent migration-window double dispatch + token leftover (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth review round on PR #780. - Run the legacy singleton fallback ONLY when no named provider is configured (review #3): once a named provider exists it owns delivery, so during the migration window (registry entry published, legacy dir not yet deleted) the hook dispatcher no longer runs both — which had caused duplicate report / command execution / resource writes. - Migration removes the shared legacy ~/.teamai/token when it was the credential source (review P2): the token now lives only in the isolated 0600 credential file, so the old plaintext copy is deleted instead of stranded after a later `provider remove`. Not changed: - #4 (provider remove leaving shared built-in dispatch hooks): kept per the maintainer's explicit decision — those hooks serve every install and no-op without config; full removal stays with `teamai uninstall`. - #1/#2 (config.ts / usage-tracker.ts): false positives, not in this PR's diff (upstream #775); asked the maintainer to dismiss. - P3 (compat entries not under the provider lock): they refuse when a named provider exists, so they cannot create a second active backend. Tests: legacy ~/.teamai/token removed on migration. --- src/__tests__/http-provider-store.test.ts | 5 ++++- src/hook-handlers.ts | 19 ++++++++++++------- src/providers/http/store.ts | 10 ++++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/__tests__/http-provider-store.test.ts b/src/__tests__/http-provider-store.test.ts index f738c2f93..d325353f9 100644 --- a/src/__tests__/http-provider-store.test.ts +++ b/src/__tests__/http-provider-store.test.ts @@ -195,13 +195,16 @@ describe('http provider: legacy singleton migration', () => { expect(await legacySingletonActive()).toBe(false); }); - it('extracts the token from the legacy ~/.teamai/token file when config has none', async () => { + it('extracts the token from the legacy ~/.teamai/token file and removes the original (review P2)', async () => { await seedLegacy(); await fse.writeFile(path.join(teamai(), 'token'), 'file-token\n'); const { migrateLegacyHttpProvider, httpProviderCredentialPath } = await import('../providers/http/store.js'); await migrateLegacyHttpProvider({ name: 'company' }); + // Moved into the isolated 0600 credential … expect(fs.readFileSync(httpProviderCredentialPath('company'), 'utf-8').trim()).toBe('file-token'); + // … and the shared plaintext ~/.teamai/token is deleted, not stranded. + expect(fs.existsSync(path.join(teamai(), 'token'))).toBe(false); }); it('is idempotent: a second migration is a no-op', async () => { diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 94fefe169..7697ce3a3 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -526,13 +526,18 @@ const localAgentHandler: HookHandler = { for (const r of results) { if (r.hookOutput) outputs.push(r.hookOutput); } - } - - const { legacySingletonActive } = await import('./providers/http/store.js'); - if (await legacySingletonActive()) { - const { reportAndSyncFromHook } = await import('./local-agent.js'); - const legacyOutput = await reportAndSyncFromHook(stdin, tool); - if (legacyOutput) outputs.push(legacyOutput); + } else { + // Legacy singleton fallback runs ONLY when no named provider is + // configured. Once a named provider exists it has taken over delivery, so + // running the legacy path too would double-dispatch (duplicate report / + // command execution) during the migration window where the registry entry + // is published but the legacy dir is not yet deleted (issue #404). + const { legacySingletonActive } = await import('./providers/http/store.js'); + if (await legacySingletonActive()) { + const { reportAndSyncFromHook } = await import('./local-agent.js'); + const legacyOutput = await reportAndSyncFromHook(stdin, tool); + if (legacyOutput) outputs.push(legacyOutput); + } } return outputs.length > 0 ? outputs.join('\n') : null; diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts index 78a005cc8..e2ce1515e 100644 --- a/src/providers/http/store.ts +++ b/src/providers/http/store.ts @@ -299,5 +299,15 @@ export async function migrateLegacyHttpProvider(options: { // exists so there is nothing to revive or double-uninstall. await remove(legacyHome()); + // Also remove the shared legacy ~/.teamai/token if it was the credential + // source — the token now lives only in the isolated 0600 credential file, so + // leaving the old plaintext copy would strand it after `provider remove` + // (issue #404, review P2). Only delete when it actually matched what we moved. + if (token && !legacy.token) { + const legacyTokenPath = path.join(teamaiHome(), 'token'); + const legacyTokenValue = (await readFileSafe(legacyTokenPath))?.trim(); + if (legacyTokenValue === token) await remove(legacyTokenPath); + } + return config; } From 6dfc6720604c1bafa0d248f912c1049db92e7a66 Mon Sep 17 00:00:00 2001 From: review Date: Thu, 24 Sep 2026 21:50:13 +0800 Subject: [PATCH 14/14] refactor(providers): drop the unusable --priority CLI flag (#404) Tenth review round on PR #780 (P2 cleanup). - Remove the `--priority` flag from `provider add http` and `provider migrate-legacy`, and the parsePriority helper. With a single provider it never affects behavior, so exposing and persisting it is the unused/speculative config the review rules warn against. The `priority` data-model field stays (defaulted to 50, reserved for the later arbitration phase) but is no longer user-configurable. Docs and the generated command reference updated to match. Not changed (as previously decided): #1/#2 config.ts/usage-tracker.ts are false positives outside this PR's diff (upstream #775); provider remove intentionally leaves the shared built-in dispatch hooks (maintainer decision); the compat entries refuse a second backend so they need no shared lock. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/core/references/commands.md | 2 -- src/index.ts | 2 -- src/provider-command.ts | 34 ++++++++++---------------- 5 files changed, 15 insertions(+), 27 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ac03d18c9..0ce31a003 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1870,7 +1870,7 @@ teamai provider remove company The provider's state lives under `~/.teamai/providers/http//`; its token is stored `0600` at `~/.teamai/credentials/`, never in a config file. On every session, hook dispatch syncs it, reporting a per-provider result so a failing backend surfaces as a failure rather than a false success. -> **One HTTP provider at a time.** Mounting several HTTP backends concurrently needs cross-provider ownership arbitration (so same-name resources don't overwrite or delete each other) — that is a later phase (issue #404). Until then `provider add http` refuses a second provider. The `--priority` flag is recorded for that future phase and does not yet affect behavior. +> **One HTTP provider at a time.** Mounting several HTTP backends concurrently needs cross-provider ownership arbitration (so same-name resources don't overwrite or delete each other) — that is a later phase (issue #404). Until then `provider add http` refuses a second provider, so there is no priority to configure yet. To move an existing single HTTP backend (`init --http` / `source add-http`) onto the named-provider model, run: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 95b3f041f..f66452470 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1802,7 +1802,7 @@ teamai provider remove company 该 provider 的状态存放在 `~/.teamai/providers/http//`;其 token 以 `0600` 权限单独存于 `~/.teamai/credentials/`,绝不写入任何配置文件。每次 session 中,hook dispatch 会同步它,并返回每个 provider 的结果——后端失败会如实报为失败,而非假成功。 -> **目前每台机器只支持一个 HTTP provider。** 同时挂载多个 HTTP 后端需要跨 provider 的归属仲裁(避免同名资源互相覆盖或删除),这是后续阶段(issue #404)。在此之前,`provider add http` 会拒绝添加第二个 provider。`--priority` 参数为该未来阶段预留,目前不影响行为。 +> **目前每台机器只支持一个 HTTP provider。** 同时挂载多个 HTTP 后端需要跨 provider 的归属仲裁(避免同名资源互相覆盖或删除),这是后续阶段(issue #404)。在此之前,`provider add http` 会拒绝添加第二个 provider,因此暂无优先级可配置。 要把已有的单个 HTTP 后端(`init --http` / `source add-http`)迁移到具名 provider 模型,运行: diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 2aabd054b..f6a923394 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -170,13 +170,11 @@ Generated: do not edit by hand. Regenerate with - `--name ` — Unique name for this provider - `--adapter ` — Protocol adapter (default: clawpro) - `--token ` — API token (stored 0600 outside config, never committed) - - `--priority ` — Arbitration priority; higher wins (default: 50) - `teamai provider list` — List configured HTTP providers - `teamai provider sync` — Sync all configured HTTP providers now - `teamai provider remove ` — Remove an HTTP provider and clean up its resources - `teamai provider migrate-legacy` — Promote the legacy ~/.teamai/local-agent/ singleton to a named provider - `--name ` — Name for the migrated provider - - `--priority ` — Arbitration priority (default: 50) ## update diff --git a/src/index.ts b/src/index.ts index c20e7132f..c169f09a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -566,7 +566,6 @@ providerAddCmd .requiredOption('--name ', 'Unique name for this provider') .option('--adapter ', 'Protocol adapter (default: clawpro)') .option('--token ', 'API token (stored 0600 outside config, never committed)') - .option('--priority ', 'Arbitration priority; higher wins (default: 50)') .action(async (endpoint: string, cmdOpts) => { const { providerAddHttp } = await import('./provider-command.js'); await providerAddHttp(endpoint, cmdOpts); @@ -600,7 +599,6 @@ providerCmd .command('migrate-legacy') .description('Promote the legacy ~/.teamai/local-agent/ singleton to a named provider') .requiredOption('--name ', 'Name for the migrated provider') - .option('--priority ', 'Arbitration priority (default: 50)') .action(async (cmdOpts) => { const { providerMigrateLegacy } = await import('./provider-command.js'); await providerMigrateLegacy(cmdOpts); diff --git a/src/provider-command.ts b/src/provider-command.ts index aa50abf4b..f61a5f576 100644 --- a/src/provider-command.ts +++ b/src/provider-command.ts @@ -27,6 +27,13 @@ import { import { syncResourceProviders } from './providers/resource-registry.js'; import type { HttpProviderConfig } from './providers/types.js'; +/** + * Default priority stamped on a provider's config. Reserved for the later + * multi-provider arbitration phase; with a single provider it has no effect and + * is deliberately not exposed as a CLI flag (issue #404, review P2). + */ +const DEFAULT_PROVIDER_PRIORITY = 50; + /** * Run `fn` while holding the machine-level provider lock, so the single-provider * check-and-write in `provider add` and `migrate-legacy` cannot interleave and @@ -51,10 +58,9 @@ interface AddHttpOptions { name: string; adapter?: string; token?: string; - priority?: string; } -/** `teamai provider add http --name --adapter --token --priority` */ +/** `teamai provider add http --name --adapter --token` */ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): Promise { if (!opts.name) { log.error('A provider name is required: --name '); @@ -76,12 +82,13 @@ export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): P process.exit(1); } - const priority = parsePriority(opts.priority); const config: HttpProviderConfig = { name: opts.name, adapter, endpoint: endpoint.trim().replace(/\/+$/, ''), - priority, + // priority is a data-model field reserved for the later arbitration phase; + // with a single provider it has no effect, so it is not user-configurable. + priority: DEFAULT_PROVIDER_PRIORITY, }; // Serialize the whole check-and-write under a machine-level lock so two @@ -246,10 +253,9 @@ export async function providerRemove(name: string): Promise { interface MigrateLegacyOptions { name: string; - priority?: string; } -/** `teamai provider migrate-legacy --name --priority` */ +/** `teamai provider migrate-legacy --name` */ export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise { if (!opts.name) { log.error('A provider name is required: --name '); @@ -280,10 +286,7 @@ export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise ); process.exit(1); } - return migrateLegacyHttpProvider({ - name: opts.name, - priority: parsePriority(opts.priority), - }); + return migrateLegacyHttpProvider({ name: opts.name }); }); if (!config) { log.info('No legacy HTTP local agent to migrate (or it was already migrated).'); @@ -295,17 +298,6 @@ export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise ); } -/** Parse a --priority value, defaulting to 50 and rejecting non-integers. */ -function parsePriority(raw?: string): number { - if (raw === undefined) return 50; - const n = Number(raw); - if (!Number.isInteger(n)) { - log.error(`--priority must be an integer, got "${raw}".`); - process.exit(1); - } - return n; -} - /** Adapter names this build supports, for CLI help. */ export function providerAdapters(): string[] { return availableHttpAdapters();