diff --git a/.env.example b/.env.example index 5e1b43c25..562580ab6 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,17 @@ # HUABU_BASIC_AUTH_USER= # HUABU_BASIC_AUTH_PASS= +# ── Personal Alpha Canary redeployment ── +# Source-run `pnpm start:web` only. When enabled, the authenticated owner sees +# Settings controls that persist an exact branch from fixed remote origin, +# defaulting to alpha when unconfigured, and can run +# `scripts/start-huabu.sh --non-interactive` on this host. The script +# updates the checkout in place and may leave the service offline on failure. +# This is a personal-development convenience, not a production deployer. +# HUABU_CANARY_REDEPLOY_ENABLED=1 +# Non-interactive redeployment waits up to 300 seconds by default. +# HUABU_CANARY_READINESS_TIMEOUT_SECONDS=300 + # ── Storage (restart required) ── # Structured records: disk (default), sqlite or postgres. The two axes are # independent, so every pairing of an implemented record backend with an diff --git a/README.md b/README.md index 11efdcbc6..18d54db9d 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ Then run `pnpm start:web`. Huabu rejects a non-loopback bind when allowed hosts Huabu currently serves HTTP. Use a trusted private network or terminate HTTPS with deployment infrastructure such as Caddy, Nginx, Tailscale Serve, or a cloud load balancer. Do not put a Basic Auth deployment on an untrusted network without transport encryption. +For a personal Alpha Canary started from a repository checkout, set `HUABU_CANARY_REDEPLOY_ENABLED=1` before `pnpm start:web`. The authenticated owner can configure an exact branch from fixed remote `origin` in Settings, compare it with the running commit, and invoke the checked-in `scripts/start-huabu.sh --non-interactive` redeployment instead of connecting through SSH. An empty configuration uses `alpha`. This helper updates the checkout in place and does not provide rollback or service recovery; see [Alpha Canary deployment](docs/architecture/canary-deployment.md). + ### Local quality checks (optional) The repository ships opt-in git hooks that give you fast feedback before diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 692752ef2..231aaeffe 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { existsSync, unlinkSync } from 'node:fs'; +import { unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -21,15 +21,14 @@ import { acpAgentletRoutes, acpProfilesRoutes, acpThreadsRoutes, + connectionTokenRoutes, externalAgentRuntimeConfigRoutes, + getExternalAgentRuntimeConfig, getAgentProfileRegistry, - getSupervisedAgentletId, installAcpProfileCachePort, mountAgenetes, resolveDaemonEntry, } from './modules/agent/acp/index.js'; -import { buildLegacyCommandProfiles } from './modules/agent/acp/legacy-profile-migration.js'; -import { listProfiles as listLegacyAcpProfiles } from './modules/agent/acp/profile-store.js'; import { initializeAgentDefaults } from './modules/agent/agent-defaults.js'; import agentDefaultsRoutes from './modules/agent/agent-defaults.route.js'; import agentRoutes from './modules/agent/agent.route.js'; @@ -46,6 +45,7 @@ import integrationsRoutes from './modules/integrations/integrations.route.js'; import interactiveViewRoutes from './modules/interactive-view/interactive-view.route.js'; import { isPublicRfsSkillBootstrapRequest } from './modules/remote_fs/public-skill.js'; import rfsRoutes from './modules/remote_fs/rfs.route.js'; +import canaryRedeployRoutes from './modules/security/canary-redeploy.route.js'; import { createCorsOptions } from './modules/security/cors.js'; import deploymentRoutes from './modules/security/deployment.route.js'; import { @@ -258,6 +258,9 @@ app.register(artifactRoute, { prefix: '/api/canvas' }); app.register(llmRoutes, { prefix: '/api/llm' }); app.register(integrationsRoutes, { prefix: '/api/integrations' }); app.register(deploymentRoutes, { prefix: '/api/deployment' }); +app.register(canaryRedeployRoutes, { + prefix: '/api/deployment/canary', +}); app.register(interactiveViewRoutes, { prefix: '/api/interactive-views' }); app.register(skillsRoutes, { prefix: '/api/skills' }); app.register(workspaceRoutes, { prefix: '/api/workspace' }); @@ -297,6 +300,7 @@ const agentletGateway = mountAgenetes(app, { connectionToken: getConnectionToken(), dataDir: getDataDir(), daemonEntryPath: resolveDaemonEntry() ?? '', + getMaxAgents: () => getExternalAgentRuntimeConfig().maxAgents, // Host-namespaced env isolation: the agentlet daemon and every external // agent it spawns are host-agnostic and must receive their Huabu // coordinates only through explicit injection (per-agent reachback env), @@ -310,15 +314,7 @@ const agentletGateway = mountAgenetes(app, { profiles: { storageDir: join(getDataDir(), 'agent-profiles'), legacyStorageDir: join(getDataDir(), 'agent-team'), - legacyCommandProfiles: existsSync( - join(getDataDir(), 'agent-profiles', 'registry.json'), - ) - ? [] - : buildLegacyCommandProfiles( - listLegacyAcpProfiles(), - getSupervisedAgentletId(), - process.cwd(), - ), + legacyCommandProfiles: [], }, }); let unregisterHarnessDiscovery: (() => void) | undefined; @@ -363,6 +359,7 @@ app.register(acpAgentletRoutes, { prefix: '/api/acp' }); app.register(acpAgentCliRoutes, { prefix: '/api/acp' }); app.register(acpThreadsRoutes, { prefix: '/api/acp' }); app.register(externalAgentRuntimeConfigRoutes, { prefix: '/api/acp' }); +app.register(connectionTokenRoutes, { prefix: '/api/acp' }); app.log.info( '[acp] agentlet Gateway mounted — embedded agentlet will start on server ready', ); diff --git a/apps/server/src/connection-token.test.ts b/apps/server/src/connection-token.test.ts new file mode 100644 index 000000000..3967f9294 --- /dev/null +++ b/apps/server/src/connection-token.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + persisted: null as string | null, + writable: true, + setSecret: vi.fn(), + setDaemonToken: vi.fn(), + restart: vi.fn(), + disconnect: vi.fn(), + maxAgents: 7, +})); + +vi.mock('./security/secret-store.js', () => ({ + getPersistedSecret: () => mocks.persisted, + isSecretStoreWritable: () => mocks.writable, + setSecret: mocks.setSecret, +})); + +vi.mock('./modules/agent/acp/runtime-config.js', () => ({ + getExternalAgentRuntimeConfig: () => ({ + idleTimeoutSecs: 600, + maxAgents: mocks.maxAgents, + }), +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getDaemonAuth: () => ({ setDaemonToken: mocks.setDaemonToken }), + getDaemonSupervisor: () => ({ restart: mocks.restart }), + getAgentletGateway: () => ({ + getAgentlets: () => [{ disconnect: mocks.disconnect }], + }), +})); + +import { + _resetConnectionTokenForTests, + buildAgentletConnectionCommand, + getConnectionToken, + initializeConnectionToken, + setConnectionToken, +} from './connection-token.js'; + +beforeEach(() => { + _resetConnectionTokenForTests(); + mocks.persisted = null; + mocks.writable = true; + mocks.setSecret.mockResolvedValue(undefined); + vi.clearAllMocks(); + delete process.env.HUABU_CONNECTION_TOKEN; +}); + +afterEach(() => { + delete process.env.HUABU_CONNECTION_TOKEN; +}); + +describe('connection token resolution', () => { + it('prefers persisted, environment, then one generated fallback', () => { + mocks.persisted = 'stored-token'; + expect(initializeConnectionToken()).toEqual({ + source: 'stored', + writable: true, + }); + expect(getConnectionToken()).toBe('stored-token'); + + _resetConnectionTokenForTests(); + mocks.persisted = null; + process.env.HUABU_CONNECTION_TOKEN = 'environment-token'; + expect(initializeConnectionToken().source).toBe('environment'); + expect(getConnectionToken()).toBe('environment-token'); + + _resetConnectionTokenForTests(); + delete process.env.HUABU_CONNECTION_TOKEN; + expect(initializeConnectionToken().source).toBe('generated'); + const generated = getConnectionToken(); + expect(generated).toMatch(/^[0-9a-f]{64}$/); + expect(getConnectionToken()).toBe(generated); + }); + + it('persists before switching auth and restarting connected agentlets', async () => { + initializeConnectionToken(); + await setConnectionToken('new-token'); + + expect(mocks.setSecret).toHaveBeenCalledWith( + 'integration:agentlet:connection-token', + 'new-token', + ); + expect(mocks.setDaemonToken).toHaveBeenLastCalledWith('new-token'); + expect(mocks.disconnect).toHaveBeenCalledWith('connection_token_changed'); + expect(mocks.restart).toHaveBeenCalledOnce(); + expect(getConnectionToken()).toBe('new-token'); + }); + + it('keeps the active token when persistence fails', async () => { + process.env.HUABU_CONNECTION_TOKEN = 'old-token'; + initializeConnectionToken(); + mocks.setSecret.mockRejectedValueOnce(new Error('write failed')); + + await expect(setConnectionToken('new-token')).rejects.toThrow( + 'write failed', + ); + expect(getConnectionToken()).toBe('old-token'); + expect(mocks.disconnect).not.toHaveBeenCalled(); + expect(mocks.restart).not.toHaveBeenCalled(); + }); + + it('clears to the environment fallback', async () => { + mocks.persisted = 'stored-token'; + process.env.HUABU_CONNECTION_TOKEN = 'environment-token'; + initializeConnectionToken(); + + await setConnectionToken(null); + + expect(getConnectionToken()).toBe('environment-token'); + expect(mocks.setDaemonToken).toHaveBeenLastCalledWith('environment-token'); + }); +}); + +describe('Agentlet connection command', () => { + it('derives secure and insecure endpoints and reports warnings', () => { + process.env.HUABU_CONNECTION_TOKEN = "token'quoted"; + initializeConnectionToken(); + + expect(buildAgentletConnectionCommand('https://huabu.example.com')).toEqual( + { + command: + "agentlet daemon --server 'wss://huabu.example.com/api/acp/agent' --max-agents 7 --token 'token'\"'\"'quoted'", + warnings: [], + }, + ); + expect(buildAgentletConnectionCommand('http://localhost:3001')).toEqual({ + command: + "agentlet daemon --server 'ws://localhost:3001/api/acp/agent' --max-agents 7 --token 'token'\"'\"'quoted' --allow-insecure", + warnings: ['loopback', 'insecure'], + }); + }); + + it('rejects origins with paths or unsupported protocols', () => { + expect(() => + buildAgentletConnectionCommand('https://example.com/path'), + ).toThrow('Origin must be an HTTP(S) origin without a path'); + expect(() => buildAgentletConnectionCommand('file:///tmp/huabu')).toThrow( + 'Origin must be an HTTP(S) origin without a path', + ); + }); +}); diff --git a/apps/server/src/connection-token.ts b/apps/server/src/connection-token.ts index 2e15c0454..bef40ae48 100644 --- a/apps/server/src/connection-token.ts +++ b/apps/server/src/connection-token.ts @@ -3,6 +3,26 @@ import { randomBytes } from 'node:crypto'; +import { + getAgentletGateway, + getDaemonAuth, + getDaemonSupervisor, +} from '@agenetes/agentlet-host'; + +import { getExternalAgentRuntimeConfig } from './modules/agent/acp/runtime-config.js'; +import { SECRET_IDS } from './security/secret-ids.js'; +import { + getPersistedSecret, + isSecretStoreWritable, + setSecret, +} from './security/secret-store.js'; + +import type { + AgentletConnectionCommandResponse, + ConnectionTokenConfig, + ConnectionTokenSource, +} from '@huabu/shared'; + /** * The global connection token used to authenticate the embedded * agentlet transport (L2 `@agenetes/agentlet-host`) and every agent @@ -13,18 +33,153 @@ import { randomBytes } from 'node:crypto'; * the server process, so agent reachback credentials survive an * agentlet daemon restart. * - * Two runtime layouts, mirroring {@link ./data-dir.ts}: - * ─ `HUABU_CONNECTION_TOKEN` env var — explicit override (e.g. the - * Electron main process can pin a value across restarts). - * ─ Otherwise a fresh 256-bit hex token is minted once per boot and - * cached for the process lifetime. + * The generated fallback is available during module composition. After the + * SecretStore initializes, {@link initializeConnectionToken} activates the + * persisted/environment/generated precedence before the server listens. */ -let cached: string | null = null; +let generatedToken: string | null = null; +let activeToken: string | null = null; +let activeSource: ConnectionTokenSource | null = null; +let mutationQueue = Promise.resolve(); + +function getGeneratedToken(): string { + generatedToken ??= randomBytes(32).toString('hex'); + return generatedToken; +} + +function getEnvironmentToken(): string | null { + return process.env.HUABU_CONNECTION_TOKEN?.trim() || null; +} + +function resolveFallback(): { + token: string; + source: Exclude; +} { + const environment = getEnvironmentToken(); + return environment + ? { token: environment, source: 'environment' } + : { token: getGeneratedToken(), source: 'generated' }; +} + +function activateConnectionToken( + token: string, + source: ConnectionTokenSource, +): void { + activeToken = token; + activeSource = source; + getDaemonAuth().setDaemonToken(token); +} export function getConnectionToken(): string { - if (cached) return cached; - const fromEnv = process.env.HUABU_CONNECTION_TOKEN; - cached = - fromEnv && fromEnv.length > 0 ? fromEnv : randomBytes(32).toString('hex'); - return cached; + if (activeToken) return activeToken; + const fallback = resolveFallback(); + activeToken = fallback.token; + activeSource = fallback.source; + return activeToken; +} + +export function initializeConnectionToken(): ConnectionTokenConfig { + const stored = getPersistedSecret(SECRET_IDS.agentletConnectionToken); + if (stored) activateConnectionToken(stored, 'stored'); + else { + const fallback = resolveFallback(); + activateConnectionToken(fallback.token, fallback.source); + } + return getConnectionTokenConfig(); +} + +export function getConnectionTokenConfig(): ConnectionTokenConfig { + if (!activeSource) getConnectionToken(); + return { + source: activeSource ?? 'generated', + writable: isSecretStoreWritable(), + }; +} + +function disconnectAgentlets(): void { + const gateway = getAgentletGateway(); + for (const connection of gateway?.getAgentlets({ status: 'connected' }) ?? + []) { + connection.disconnect('connection_token_changed'); + } +} + +export function setConnectionToken( + token: string | null, +): Promise { + const mutation = mutationQueue.then(async () => { + await setSecret(SECRET_IDS.agentletConnectionToken, token); + const next = token + ? { token, source: 'stored' as const } + : resolveFallback(); + const changed = next.token !== getConnectionToken(); + activateConnectionToken(next.token, next.source); + if (changed) { + disconnectAgentlets(); + getDaemonSupervisor().restart(); + } + return getConnectionTokenConfig(); + }); + mutationQueue = mutation.then( + () => undefined, + () => undefined, + ); + return mutation; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function isLoopbackHostname(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ); +} + +export class InvalidAgentletConnectionOriginError extends Error {} + +export function buildAgentletConnectionCommand( + originValue: string, +): AgentletConnectionCommandResponse { + const origin = new URL(originValue); + if ( + !['http:', 'https:'].includes(origin.protocol) || + origin.username || + origin.password || + origin.pathname !== '/' || + origin.search || + origin.hash + ) { + throw new InvalidAgentletConnectionOriginError( + 'Origin must be an HTTP(S) origin without a path', + ); + } + const insecure = origin.protocol === 'http:'; + const endpoint = `${insecure ? 'ws:' : 'wss:'}//${origin.host}/api/acp/agent`; + const maxAgents = getExternalAgentRuntimeConfig().maxAgents; + const command = [ + 'agentlet daemon', + `--server ${quotePosix(endpoint)}`, + `--max-agents ${maxAgents}`, + `--token ${quotePosix(getConnectionToken())}`, + ...(insecure ? ['--allow-insecure'] : []), + ].join(' '); + return { + command, + warnings: [ + ...(isLoopbackHostname(origin.hostname) ? (['loopback'] as const) : []), + ...(insecure ? (['insecure'] as const) : []), + ], + }; +} + +export function _resetConnectionTokenForTests(): void { + generatedToken = null; + activeToken = null; + activeSource = null; + mutationQueue = Promise.resolve(); } diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts index a02f85b31..194dd8d08 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts @@ -12,8 +12,12 @@ import type { FastifyInstance } from 'fastify'; const mocks = vi.hoisted(() => ({ getProfile: vi.fn(), discover: vi.fn() })); vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => ({ getProfile: mocks.getProfile }), - getSupervisedAgentletId: () => 'supervised', - getAgentletGateway: () => ({ discoverHarnesses: mocks.discover }), + getAgentletGateway: () => ({ + getAgentlet: () => ({ status: 'connected' }), + discoverHarnesses: mocks.discover, + }), + resolveConnectedAgentletId: (target: string) => + target === 'legacy-host' ? 'remote-machine' : target, })); let app: FastifyInstance | undefined; @@ -52,7 +56,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', }); expect(response.statusCode).toBe(200); @@ -72,7 +76,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', remoteAddress: '192.0.2.10', }); @@ -92,7 +96,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', remoteAddress: '192.0.2.10', }); @@ -108,14 +112,16 @@ describe('ACP agent CLI route', () => { ), { prefix: '/api/acp' }, ); - const response = await app.inject('/api/acp/agent-cli'); + const response = await app.inject( + '/api/acp/agent-cli?agentletId=machine-a', + ); expect(response.statusCode).toBe(503); expect(response.json().code).toBe('harness_discovery_unavailable'); expect(response.json()).not.toHaveProperty('agents'); }); it('queries the edited Profile machine and projects Custom for older catalogues', async () => { - mocks.getProfile.mockReturnValue({ agentletId: 'remote-machine' }); + mocks.getProfile.mockReturnValue({ agentletId: 'legacy-host' }); mocks.discover.mockResolvedValue({ harnesses: [] }); app = Fastify({ logger: false }); await app.register(createAcpAgentCliRoutes(), { prefix: '/api/acp' }); diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.ts b/apps/server/src/modules/agent/acp/agent-cli.route.ts index 116e1915a..a173f3737 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts @@ -17,8 +17,8 @@ import { getAgentletGateway, - getSupervisedAgentletId, getAgentProfileRegistry, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { CUSTOM_COMMAND_CAPABILITIES, @@ -36,19 +36,29 @@ import type { } from '@huabu/shared'; import type { FastifyPluginAsync } from 'fastify'; -async function detectAgentClis(profileId?: string): Promise { +async function detectAgentClis(target: { + profileId?: string; + agentletId?: string; +}): Promise { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const profile = profileId - ? getAgentProfileRegistry()?.getProfile(profileId) + const profile = target.profileId + ? getAgentProfileRegistry()?.getProfile(target.profileId) : undefined; - if (profileId && !profile) throw new Error('Agent Profile is unavailable'); - const result = await gateway.discoverHarnesses( - profile?.agentletId ?? getSupervisedAgentletId(), - { - prepareWorkspaces: false, - }, - ); + if (target.profileId && !profile) + throw new Error('Agent Profile is unavailable'); + const requestedAgentletId = profile?.agentletId ?? target.agentletId; + if (!requestedAgentletId) throw new Error('Agentlet target is required'); + const agentletId = profile + ? resolveConnectedAgentletId(requestedAgentletId) + : requestedAgentletId; + if (!agentletId) throw new Error('Agentlet is not connected'); + const connection = gateway.getAgentlet(agentletId); + if (connection?.status !== 'connected') + throw new Error('Agentlet is not connected'); + const result = await gateway.discoverHarnesses(agentletId, { + prepareWorkspaces: false, + }); if (result.harnesses.some((entry) => entry.id === CUSTOM_COMMAND_WRAPPER_ID)) return result.harnesses; return [ @@ -96,7 +106,7 @@ export function createAcpAgentCliRoutes( message: 'Agent Profile is unavailable', }); } - return { agents: await detect(parsed.data.profileId) }; + return { agents: await detect(parsed.data) }; } catch (error) { request.log.warn( { err: error }, diff --git a/apps/server/src/modules/agent/acp/connection-token.route.test.ts b/apps/server/src/modules/agent/acp/connection-token.route.test.ts new file mode 100644 index 000000000..27d0fcfda --- /dev/null +++ b/apps/server/src/modules/agent/acp/connection-token.route.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import connectionTokenRoutes from './connection-token.route.js'; + +const mocks = vi.hoisted(() => ({ + InvalidOriginError: class InvalidOriginError extends Error {}, + buildCommand: vi.fn(), + getConfig: vi.fn(), + isOwner: vi.fn(), + setToken: vi.fn(), +})); + +vi.mock('../../../connection-token.js', () => ({ + InvalidAgentletConnectionOriginError: mocks.InvalidOriginError, + buildAgentletConnectionCommand: mocks.buildCommand, + getConnectionTokenConfig: mocks.getConfig, + setConnectionToken: mocks.setToken, +})); + +vi.mock('../../security/owner.js', () => ({ + isOwnerRequest: mocks.isOwner, +})); + +let app: FastifyInstance | undefined; + +async function setup() { + app = Fastify({ logger: false }); + await app.register(connectionTokenRoutes, { prefix: '/api/acp' }); + return app; +} + +afterEach(async () => { + await app?.close(); + app = undefined; + vi.resetAllMocks(); +}); + +describe('connection token routes', () => { + it('keeps token settings owner-only and never returns token material', async () => { + mocks.isOwner.mockReturnValue(false); + const server = await setup(); + + const response = await server.inject('/api/acp/connection-token'); + + expect(response.statusCode).toBe(403); + expect(mocks.getConfig).not.toHaveBeenCalled(); + expect(response.body).not.toContain('token-value'); + }); + + it('returns only the credential source and writability', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.getConfig.mockReturnValue({ source: 'stored', writable: true }); + const server = await setup(); + + const response = await server.inject('/api/acp/connection-token'); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ source: 'stored', writable: true }); + }); + + it('validates updates and propagates persistence failures', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.setToken.mockRejectedValue(new Error('credential store unavailable')); + const server = await setup(); + + const invalid = await server.inject({ + method: 'PUT', + url: '/api/acp/connection-token', + payload: { token: '' }, + }); + const failed = await server.inject({ + method: 'PUT', + url: '/api/acp/connection-token', + payload: { token: 'replacement' }, + }); + + expect(invalid.statusCode).toBe(400); + expect(failed.statusCode).toBe(500); + expect(mocks.setToken).toHaveBeenCalledOnce(); + expect(mocks.setToken).toHaveBeenCalledWith('replacement'); + }); + + it('returns a no-store command response for a valid browser origin', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.buildCommand.mockReturnValue({ + command: "agentlet daemon --token 'secret'", + warnings: ['loopback'], + }); + const server = await setup(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'http://localhost:5173' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.headers.pragma).toBe('no-cache'); + expect(mocks.buildCommand).toHaveBeenCalledWith('http://localhost:5173'); + }); + + it('rejects malformed and non-origin command inputs', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.buildCommand.mockImplementation(() => { + throw new mocks.InvalidOriginError('invalid origin'); + }); + const server = await setup(); + + const malformed = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'not-a-url' }, + }); + const withPath = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'https://example.com/path' }, + }); + + expect(malformed.statusCode).toBe(400); + expect(withPath.statusCode).toBe(400); + }); + + it('rejects a browser origin that differs from the request origin', async () => { + mocks.isOwner.mockReturnValue(true); + const server = await setup(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + headers: { origin: 'https://huabu.example' }, + payload: { origin: 'https://other.example' }, + }); + + expect(response.statusCode).toBe(400); + expect(mocks.buildCommand).not.toHaveBeenCalled(); + }); + + it('does not disguise command-generation failures as invalid input', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.buildCommand.mockImplementation(() => { + throw new Error('runtime config unavailable'); + }); + const server = await setup(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'https://huabu.example' }, + }); + + expect(response.statusCode).toBe(500); + }); +}); diff --git a/apps/server/src/modules/agent/acp/connection-token.route.ts b/apps/server/src/modules/agent/acp/connection-token.route.ts new file mode 100644 index 000000000..0f57b7cdb --- /dev/null +++ b/apps/server/src/modules/agent/acp/connection-token.route.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + agentletConnectionCommandRequestSchema, + connectionTokenUpdateSchema, +} from '@huabu/shared'; + +import { + buildAgentletConnectionCommand, + getConnectionTokenConfig, + InvalidAgentletConnectionOriginError, + setConnectionToken, +} from '../../../connection-token.js'; +import { isOwnerRequest } from '../../security/owner.js'; + +import type { + AgentletConnectionCommandRequest, + AgentletConnectionCommandResponse, + ApiResult, + ConnectionTokenConfig, + ConnectionTokenUpdate, +} from '@huabu/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +const connectionTokenRoutes: FastifyPluginAsync = async (app) => { + app.get<{ Reply: ApiResult }>( + '/connection-token', + async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: + 'Forbidden: connection token settings require owner authorization', + }); + } + return getConnectionTokenConfig(); + }, + ); + + app.put<{ + Body: ConnectionTokenUpdate; + Reply: ApiResult; + }>('/connection-token', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: + 'Forbidden: connection token settings require owner authorization', + }); + } + const parsed = connectionTokenUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ message: parsed.error.issues[0]?.message ?? 'Invalid body' }); + } + return reply.send(await setConnectionToken(parsed.data.token)); + }); + + app.post<{ + Body: AgentletConnectionCommandRequest; + Reply: ApiResult; + }>('/connection-command', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: 'Forbidden: connection command requires owner authorization', + }); + } + const parsed = agentletConnectionCommandRequestSchema.safeParse( + request.body, + ); + if (!parsed.success) { + return reply + .status(400) + .send({ message: parsed.error.issues[0]?.message ?? 'Invalid body' }); + } + const requestOrigin = request.headers.origin; + if (requestOrigin && requestOrigin !== parsed.data.origin) { + return reply + .status(400) + .send({ message: 'Browser origin does not match request origin' }); + } + try { + const response = buildAgentletConnectionCommand(parsed.data.origin); + return reply + .header('Cache-Control', 'no-store') + .header('Pragma', 'no-cache') + .send(response); + } catch (error) { + if (error instanceof InvalidAgentletConnectionOriginError) { + return reply.status(400).send({ message: 'Invalid browser origin' }); + } + throw error; + } + }); +}; + +export default connectionTokenRoutes; diff --git a/apps/server/src/modules/agent/acp/daemon-auth.test.ts b/apps/server/src/modules/agent/acp/daemon-auth.test.ts index 7fad62a5c..975015cea 100644 --- a/apps/server/src/modules/agent/acp/daemon-auth.test.ts +++ b/apps/server/src/modules/agent/acp/daemon-auth.test.ts @@ -32,7 +32,7 @@ function makeAgentletHello(): AgentletHelloParams { agentletId: 'test:agentlet', agentletProfile: { bridge: { name: 'agentlet', version: '1.0.0' }, - machine: { hostname: 'test', platform: 'linux' }, + machine: { hostname: 'test', platform: 'linux', arch: 'x64' }, capabilities: { autoRestart: true, bufferLimit: 1000 }, }, }; diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.ts b/apps/server/src/modules/agent/acp/external-agent-realization.ts index 563117060..f10d864f8 100644 --- a/apps/server/src/modules/agent/acp/external-agent-realization.ts +++ b/apps/server/src/modules/agent/acp/external-agent-realization.ts @@ -6,6 +6,7 @@ import { ensureAcpSession, resolveAcpAgentletId, } from '@agenetes/acp-driver'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { canvasAcpNamespace } from '../../workspace/paths.js'; import { @@ -123,8 +124,10 @@ async function ensureSessionFromCanonicalSpec( ? { ...resolvedEnvironment, ...spec.spec.env } : undefined; const record = await agenetes.record(spec.namespace, spec.threadId); + const requestedAgentletId = resolveAcpAgentletId(spec); return ensureAcpSession({ - agentletId: resolveAcpAgentletId(spec), + agentletId: + resolveConnectedAgentletId(requestedAgentletId) ?? requestedAgentletId, threadId: spec.threadId, binding: spec.spec.binding, namespace: spec.namespace, diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts index e948a7029..3f3f935ef 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts @@ -63,7 +63,17 @@ function setup(initialMachines = ['machine-a']) { }), }; const gateway = { - getAgentlets: () => initialMachines.map((agentletId) => ({ agentletId })), + getAgentlets: () => + initialMachines.map((agentletId) => ({ + agentletId, + agentletProfile: { + machine: { + hostname: agentletId, + platform: 'linux', + arch: 'x64', + }, + }, + })), onAgentletsChanged: (handler: (event: Event) => void) => { listener = handler; return vi.fn(); @@ -116,6 +126,7 @@ describe('automatic ordinary Profile provisioning', () => { }, ); expect(context.profiles[0]).toMatchObject({ + alias: 'GitHub Copilot (machine-a: linux x64)', agentletId: 'machine-a', workingDirPath: '/home/user/.agentlet/workspace/copilot', launch: { kind: 'acp-command', command: 'copilot --acp' }, @@ -277,7 +288,7 @@ describe('automatic ordinary Profile provisioning', () => { const registry = createAgentProfileRegistry({ storageDir }); const legacy = registry.createProfile({ launchKind: 'acp-command', - alias: 'GitHub Copilot (machine-a)', + alias: 'GitHub Copilot (machine-a: linux x64)', agentletId: 'machine-a', workingDirPath: '/custom/work', command: 'copilot --acp --model old-model', @@ -328,7 +339,7 @@ describe('automatic ordinary Profile provisioning', () => { .listProfiles() .find((profile) => profile.launch.kind === 'acp-harness'); expect(typed).toMatchObject({ - alias: 'GitHub Copilot (machine-a) [copilot]', + alias: 'GitHub Copilot (machine-a: linux x64) [copilot]', workingDirPath: observation.harnesses[0].workingDirPath, launch: { kind: 'acp-harness', diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts index 3271b5c20..12c2133d8 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts @@ -3,6 +3,8 @@ import { CUSTOM_COMMAND_WRAPPER_ID } from '@agentlet/protocol'; +import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js'; + import type { AgentProfile, AgentProfileRegistry, @@ -76,7 +78,16 @@ export function mergeProfileCustomData( } interface DiscoveryGateway { - getAgentlets(filter: { status: 'connected' }): Array<{ agentletId: string }>; + getAgentlets(filter: { status: 'connected' }): Array<{ + agentletId: string; + agentletProfile?: { + machine?: { + hostname?: string; + platform?: string; + arch?: string; + }; + }; + }>; onAgentletsChanged( handler: (event: { agentletId: string; @@ -149,7 +160,12 @@ export function registerHarnessProfileDiscovery({ ); }); if (existing) continue; - const defaultAlias = `${harness.displayName} (${agentletId})`; + const connection = gateway + .getAgentlets({ status: 'connected' }) + .find((candidate) => candidate.agentletId === agentletId); + const defaultAlias = `${harness.displayName} (${formatAgentletDeviceDisplayName( + connection?.agentletProfile?.machine, + )})`; const common = { agentletId, alias: diff --git a/apps/server/src/modules/agent/acp/index.ts b/apps/server/src/modules/agent/acp/index.ts index eda581423..e2d0430e9 100644 --- a/apps/server/src/modules/agent/acp/index.ts +++ b/apps/server/src/modules/agent/acp/index.ts @@ -4,7 +4,6 @@ export { mountAgenetes, getAgentProfileRegistry, - getSupervisedAgentletId, ACP_UPGRADE_PATH, } from '@agenetes/agentlet-host'; export type { @@ -17,6 +16,8 @@ export { default as acpAgentCliRoutes } from './agent-cli.route.js'; export { default as acpProfilesRoutes } from './profiles.route.js'; export { default as acpAgentletRoutes } from './daemon.route.js'; export { default as externalAgentRuntimeConfigRoutes } from './runtime-config.route.js'; +export { default as connectionTokenRoutes } from './connection-token.route.js'; +export { getExternalAgentRuntimeConfig } from './runtime-config.js'; /** @deprecated Use {@link acpAgentletRoutes} instead. */ export { default as acpDaemonRoutes } from './daemon.route.js'; diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts deleted file mode 100644 index 2b8afabb5..000000000 --- a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { describe, expect, it } from 'vitest'; - -import { buildLegacyCommandProfiles } from './legacy-profile-migration.js'; - -import type { AcpAgentProfile } from '@huabu/shared'; - -function makeProfile( - overrides: Partial = {}, -): AcpAgentProfile { - return { - id: 'legacy-profile', - displayName: 'Legacy Profile', - cliId: 'copilot', - command: 'copilot --acp', - cwd: '/workspace', - autoRestart: true, - createdAt: 1, - updatedAt: 1, - ...overrides, - }; -} - -describe('buildLegacyCommandProfiles', () => { - it('preserves ordinary profile identity and launch fields', () => { - expect( - buildLegacyCommandProfiles( - [makeProfile()], - 'local-agentlet', - '/server-cwd', - ), - ).toEqual([ - { - id: 'legacy-profile', - alias: 'Legacy Profile', - agentletId: 'local-agentlet', - command: 'copilot --acp', - workingDirPath: '/workspace', - metadata: { cliId: 'copilot' }, - }, - ]); - }); - - it('uses the inherited host directory when an old profile omitted cwd', () => { - const [profile] = buildLegacyCommandProfiles( - [makeProfile({ cwd: undefined })], - 'local-agentlet', - '/server-cwd', - ); - - expect(profile?.workingDirPath).toBe('/server-cwd'); - }); - - it('leaves legacy Agent Team records unmigrated', () => { - expect( - buildLegacyCommandProfiles( - [makeProfile({ id: 'team', cliId: 'agent-team' })], - 'local-agentlet', - '/server-cwd', - ), - ).toEqual([]); - }); - - it('fails explicitly for an ordinary record without a command', () => { - expect(() => - buildLegacyCommandProfiles( - [makeProfile({ command: undefined })], - 'local-agentlet', - '/server-cwd', - ), - ).toThrow("Legacy command Profile 'legacy-profile' has no command"); - }); -}); diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.ts deleted file mode 100644 index 474278013..000000000 --- a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import type { CreateAcpCommandProfileInput } from '@agenetes/agent-profile'; -import type { AcpAgentProfile } from '@huabu/shared'; - -/** - * Convert spawnable legacy ACP profiles into unified command Profiles. - * - * Older records may omit `cwd`; the old launcher then inherited the host - * process directory, so migration makes that implicit behavior explicit. - */ -export function buildLegacyCommandProfiles( - profiles: AcpAgentProfile[], - agentletId: string, - defaultWorkingDir: string, -): CreateAcpCommandProfileInput[] { - return profiles.flatMap((profile) => { - if (profile.cliId === 'agent-team') return []; - if (!profile.command?.trim()) { - throw new Error(`Legacy command Profile '${profile.id}' has no command`); - } - return [ - { - id: profile.id, - alias: profile.displayName, - agentletId, - command: profile.command, - workingDirPath: profile.cwd ?? defaultWorkingDir, - metadata: { cliId: profile.cliId }, - }, - ]; - }); -} diff --git a/apps/server/src/modules/agent/acp/profile-store.test.ts b/apps/server/src/modules/agent/acp/profile-store.test.ts deleted file mode 100644 index cc70c2b81..000000000 --- a/apps/server/src/modules/agent/acp/profile-store.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { listProfiles } from './profile-store.js'; - -let directory: string; -let previousDataDir: string | undefined; - -const commandProfile = { - id: 'command', - displayName: 'Copilot', - cliId: 'copilot', - command: 'copilot --acp', - cwd: '/workspace', - autoRestart: true, - createdAt: 1, - updatedAt: 1, -}; - -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'huabu-legacy-profiles-')); - previousDataDir = process.env.HUABU_DATA_DIR; - process.env.HUABU_DATA_DIR = directory; -}); - -afterEach(() => { - if (previousDataDir === undefined) delete process.env.HUABU_DATA_DIR; - else process.env.HUABU_DATA_DIR = previousDataDir; - rmSync(directory, { recursive: true, force: true }); -}); - -describe('read-only legacy Profile import source', () => { - it('returns no records for an absent file', () => { - expect(listProfiles()).toEqual([]); - }); - - it('reads ordinary Profiles without rewriting retired Team data', () => { - const file = join(directory, 'agent-profiles.json'); - const original = JSON.stringify({ - schemaVersion: 1, - profiles: [ - commandProfile, - { - id: 'retired-team', - cliId: 'agent-team', - agentTeam: { agentDir: '/team' }, - }, - { ...commandProfile, id: 'team-disguised-as-command', agentTeam: {} }, - ], - }); - writeFileSync(file, original); - expect(listProfiles()).toEqual([commandProfile]); - expect(readFileSync(file, 'utf8')).toBe(original); - }); - - it.each([ - '{"profiles": []}', - '{"schemaVersion": 999, "profiles": []}', - '{"schemaVersion": 1, "profiles": [{}]}', - '{invalid', - ])( - 'rejects corrupt or unsupported data rather than silently losing it', - (text) => { - writeFileSync(join(directory, 'agent-profiles.json'), text); - expect(() => listProfiles()).toThrow(); - }, - ); -}); diff --git a/apps/server/src/modules/agent/acp/profile-store.ts b/apps/server/src/modules/agent/acp/profile-store.ts deleted file mode 100644 index 21437bc56..000000000 --- a/apps/server/src/modules/agent/acp/profile-store.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { acpAgentProfileSchema } from '@huabu/shared'; - -import { getDataDir } from '../../../data-dir.js'; -import { logger } from '../../../utils/logger.js'; - -import type { AcpAgentProfile } from '@huabu/shared'; - -/** Read the pre-registry command data once during migration; never rewrite it. */ -export function listProfiles(): AcpAgentProfile[] { - let text: string; - try { - text = readFileSync(join(getDataDir(), 'agent-profiles.json'), 'utf8'); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return []; - } - throw error; - } - const file: unknown = JSON.parse(text); - if ( - !file || - typeof file !== 'object' || - !('schemaVersion' in file) || - file.schemaVersion !== 1 || - !('profiles' in file) || - !Array.isArray(file.profiles) - ) { - throw new Error('Unsupported legacy command Profile file'); - } - - const profiles: AcpAgentProfile[] = []; - let retired = 0; - for (const raw of file.profiles) { - if ( - raw && - typeof raw === 'object' && - (raw.cliId === 'agent-team' || - Object.prototype.hasOwnProperty.call(raw, 'agentTeam')) - ) { - retired += 1; - continue; - } - const parsed = acpAgentProfileSchema.safeParse(raw); - if (!parsed.success) { - throw new Error( - `Invalid legacy command Profile: ${parsed.error.message}`, - ); - } - profiles.push(parsed.data); - } - if (retired) { - logger.warn( - { retired }, - '[acp] legacy Agent Team records retained as data only', - ); - } - return profiles.sort((left, right) => left.createdAt - right.createdAt); -} diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts index bd206adb8..ed5032b9a 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.test.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ initializeDefaults: vi.fn(), discoverHarnesses: vi.fn(), buildHarnessLaunch: vi.fn(), + connectedIds: new Set(['machine-a', 'remote-machine']), })); vi.mock('../agent-defaults.js', () => ({ @@ -32,11 +33,31 @@ vi.mock('@agenetes/agentlet-host', () => ({ getDaemonSupervisor: () => ({ getStatus: () => ({ online: true, restartAttempt: 0 }), }), - getSupervisedAgentletId: () => 'machine-a', getAgentletGateway: () => ({ discoverHarnesses: mocks.discoverHarnesses, buildHarnessLaunch: mocks.buildHarnessLaunch, + getAgentlets: () => + [...mocks.connectedIds].map((agentletId) => ({ + agentletId, + status: 'connected', + connectedAt: new Date('2026-01-01T00:00:00.000Z'), + agentletProfile: { + bridge: { name: 'agentlet', version: '1.0.0' }, + machine: { + hostname: `${agentletId}-host`, + platform: 'linux', + arch: 'x64', + }, + }, + })), }), + resolveConnectedAgentletId: (target: string) => { + if (mocks.connectedIds.has(target)) return target; + const matches = [...mocks.connectedIds].filter( + (agentletId) => `${agentletId}-host` === target, + ); + return matches.length === 1 ? matches[0] : undefined; + }, })); vi.mock('./profile-schema-cache.js', () => ({ @@ -92,7 +113,12 @@ describe('ordinary Profile catalog routes', () => { const response = await server.inject({ method: 'POST', url: '/api/acp/profiles', - payload: { alias: 'Typed', workingDirPath: '/work', launch }, + payload: { + alias: 'Typed', + agentletId: 'machine-a', + workingDirPath: '/work', + launch, + }, }); expect(response.statusCode).toBe(200); expect(mocks.discoverHarnesses).toHaveBeenCalledWith('machine-a', { @@ -122,6 +148,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Typed', + agentletId: 'machine-a', workingDirPath: '/work', launch: { kind: 'acp-harness', harnessId: 'copilot' }, }, @@ -139,6 +166,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Copilot', + agentletId: 'machine-a', workingDirPath: '/work/project', launch: commandProfile.launch, metadata: { cliId: 'copilot' }, @@ -166,12 +194,51 @@ describe('ordinary Profile catalog routes', () => { expect(response.json()).toMatchObject({ profiles: [commandProfile], selectableProfileIds: ['command-1'], + connectedDevices: [ + expect.objectContaining({ + agentletId: 'machine-a', + displayName: 'machine-a-host: linux x64', + profileCount: 1, + }), + expect.objectContaining({ + agentletId: 'remote-machine', + profileCount: 0, + }), + ], }); + expect(mocks.registry.createProfile).not.toHaveBeenCalled(); expect(mocks.initializeDefaults).not.toHaveBeenCalled(); expect(mocks.discoverHarnesses).not.toHaveBeenCalled(); }); + it('maps one hostname-era Profile to the unique connected device', async () => { + const legacyProfile = { + ...commandProfile, + id: 'legacy-command', + agentletId: 'machine-a-host', + }; + mocks.registry.listProfiles.mockReturnValue([legacyProfile]); + const server = await setup(); + const response = await server.inject('/api/acp/profiles'); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + profiles: [legacyProfile], + selectableProfileIds: ['legacy-command'], + connectedDevices: [ + expect.objectContaining({ + agentletId: 'machine-a', + profileCount: 1, + }), + expect.objectContaining({ + agentletId: 'remote-machine', + profileCount: 0, + }), + ], + }); + }); + it('rejects caller-created automatic provenance', async () => { const server = await setup(); const response = await server.inject({ @@ -179,6 +246,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Forged', + agentletId: 'machine-a', workingDirPath: '/work', launch: commandProfile.launch, customData: { discoveredAgent: source }, @@ -419,7 +487,10 @@ describe('ordinary Profile catalog routes', () => { const response = await server.inject({ method: 'POST', url: '/api/acp/profile-launch-preview', - payload: { launch: commandProfile.launch }, + payload: { + agentletId: 'machine-a', + launch: commandProfile.launch, + }, }); expect(response.statusCode).toBe(503); expect(response.json().code).toBe('harness_preview_unavailable'); diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts index cf06c373d..0386a0a76 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.ts @@ -29,7 +29,7 @@ import { getAgentProfileRegistry, getAgentletGateway, getDaemonSupervisor, - getSupervisedAgentletId, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -49,7 +49,9 @@ import { getAgentDefaults, initializeAgentDefaults, } from '../agent-defaults.js'; +import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js'; +import type { AgentletConnection } from '@agenetes/agentlet-host'; import type { AcpProfileMutationResponse, AcpProfilesListResponse, @@ -68,6 +70,16 @@ function denyRemote(request: FastifyRequest, reply: FastifyReply): boolean { return true; } +function getConnectedAgentlets(): AgentletConnection[] { + return getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []; +} + +function isAgentletConnected(agentletId: string): boolean { + return getConnectedAgentlets().some( + (connection) => connection.agentletId === agentletId, + ); +} + async function validateHarnessLaunch( launch: AgentProfileView['launch'], agentletId: string, @@ -78,7 +90,10 @@ async function validateHarnessLaunch( try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const result = await gateway.discoverHarnesses(agentletId, { + const resolvedAgentletId = resolveConnectedAgentletId(agentletId); + if (!resolvedAgentletId) + throw new Error('The selected Agentlet is not connected'); + const result = await gateway.discoverHarnesses(resolvedAgentletId, { prepareWorkspaces: false, }); const harness = result.harnesses.find( @@ -107,7 +122,7 @@ async function validateHarnessLaunch( }); return false; } - await gateway.buildHarnessLaunch(agentletId, { launch }); + await gateway.buildHarnessLaunch(resolvedAgentletId, { launch }); return true; } catch (error) { request.log.warn( @@ -146,10 +161,18 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - return await gateway.buildHarnessLaunch( - profile?.agentletId ?? getSupervisedAgentletId(), - { launch: parsed.data.launch }, - ); + const agentletId = profile + ? resolveConnectedAgentletId(profile.agentletId) + : parsed.data.agentletId; + if (!agentletId || !isAgentletConnected(agentletId)) { + return reply.status(409).send({ + code: 'agentlet_unavailable', + message: 'The selected Agentlet is not connected.', + }); + } + return await gateway.buildHarnessLaunch(agentletId, { + launch: parsed.data.launch, + }); } catch (error) { request.log.warn({ err: error }, 'Profile launch preview failed'); return reply.status(503).send({ @@ -172,9 +195,45 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { code: 'profile_registry_unavailable', }); } + const profiles = registry.listProfiles(); + const connected = getConnectedAgentlets(); + const profileCounts = new Map(); + for (const profile of profiles) { + const agentletId = resolveConnectedAgentletId(profile.agentletId); + if (!agentletId) continue; + profileCounts.set(agentletId, (profileCounts.get(agentletId) ?? 0) + 1); + } + const connectedDevices = connected + .map((connection) => ({ + agentletId: connection.agentletId, + displayName: formatAgentletDeviceDisplayName( + connection.agentletProfile?.machine, + ), + ...(connection.agentletProfile?.machine?.hostname + ? { hostname: connection.agentletProfile.machine.hostname } + : {}), + ...(connection.agentletProfile?.machine?.platform + ? { platform: connection.agentletProfile.machine.platform } + : {}), + ...(connection.agentletProfile?.machine?.arch + ? { arch: connection.agentletProfile.machine.arch } + : {}), + version: connection.agentletProfile?.bridge.version ?? 'unknown', + connectedAt: connection.connectedAt.toISOString(), + profileCount: profileCounts.get(connection.agentletId) ?? 0, + })) + .sort( + (left, right) => + (left.hostname ?? left.agentletId).localeCompare( + right.hostname ?? right.agentletId, + ) || left.agentletId.localeCompare(right.agentletId), + ); return { - profiles: registry.listProfiles(), - selectableProfileIds: registry.listSelectableProfileIds(), + profiles, + selectableProfileIds: profiles + .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) + .map((profile) => profile.id), + connectedDevices, agentlet: getDaemonSupervisor().getStatus(), agentDefaults: getAgentDefaults(), }; @@ -206,7 +265,13 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { code: 'profile_registry_unavailable', }); } - const agentletId = getSupervisedAgentletId(); + const agentletId = parsed.data.agentletId; + if (!isAgentletConnected(agentletId)) { + return reply.status(409).send({ + code: 'agentlet_unavailable', + message: 'The selected Agentlet is not connected.', + }); + } const launch = parsed.data.launch; if (!(await validateHarnessLaunch(launch, agentletId, request, reply))) return; diff --git a/apps/server/src/modules/agent/acp/runtime-config.test.ts b/apps/server/src/modules/agent/acp/runtime-config.test.ts index 82989679f..b005dec41 100644 --- a/apps/server/src/modules/agent/acp/runtime-config.test.ts +++ b/apps/server/src/modules/agent/acp/runtime-config.test.ts @@ -31,17 +31,23 @@ describe('external-agent runtime config', () => { rmSync(dataDir, { recursive: true, force: true }); }); - it('uses ten minutes when no config has been persisted', () => { + it('uses ten minutes and ten agents when no config has been persisted', () => { expect(getExternalAgentRuntimeConfig()).toEqual( DEFAULT_EXTERNAL_AGENT_RUNTIME_CONFIG, ); }); it('persists disabled idle suspension atomically', () => { - expect(setExternalAgentRuntimeConfig({ idleTimeoutSecs: 0 })).toEqual({ + expect( + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 0, maxAgents: 25 }), + ).toEqual({ + idleTimeoutSecs: 0, + maxAgents: 25, + }); + expect(getExternalAgentRuntimeConfig()).toEqual({ idleTimeoutSecs: 0, + maxAgents: 25, }); - expect(getExternalAgentRuntimeConfig()).toEqual({ idleTimeoutSecs: 0 }); expect( JSON.parse( readFileSync( @@ -49,18 +55,29 @@ describe('external-agent runtime config', () => { 'utf8', ), ), - ).toEqual({ idleTimeoutSecs: 0 }); + ).toEqual({ idleTimeoutSecs: 0, maxAgents: 25 }); }); it('rejects finite timeouts outside one minute through one day', () => { expect(() => - setExternalAgentRuntimeConfig({ idleTimeoutSecs: 59 }), + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 59, maxAgents: 10 }), ).toThrow(); expect(() => - setExternalAgentRuntimeConfig({ idleTimeoutSecs: 86_401 }), + setExternalAgentRuntimeConfig({ + idleTimeoutSecs: 86_401, + maxAgents: 10, + }), ).toThrow(); expect(() => - setExternalAgentRuntimeConfig({ idleTimeoutSecs: 61 }), + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 61, maxAgents: 10 }), ).toThrow(); }); + + it('rejects non-positive, fractional, and unsafe agent limits', () => { + for (const maxAgents of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 600, maxAgents }), + ).toThrow(); + } + }); }); diff --git a/apps/server/src/modules/agent/acp/runtime-config.ts b/apps/server/src/modules/agent/acp/runtime-config.ts index 9a8c54e2f..c4abb8394 100644 --- a/apps/server/src/modules/agent/acp/runtime-config.ts +++ b/apps/server/src/modules/agent/acp/runtime-config.ts @@ -15,6 +15,7 @@ import type { ExternalAgentRuntimeConfig } from '@huabu/shared'; export const DEFAULT_EXTERNAL_AGENT_RUNTIME_CONFIG: ExternalAgentRuntimeConfig = { idleTimeoutSecs: 600, + maxAgents: 10, }; const log = getLogger('external-agent-runtime-config'); diff --git a/apps/server/src/modules/agent/acp/service.ts b/apps/server/src/modules/agent/acp/service.ts index 24d5fae3f..62a6d0194 100644 --- a/apps/server/src/modules/agent/acp/service.ts +++ b/apps/server/src/modules/agent/acp/service.ts @@ -22,10 +22,8 @@ import { randomUUID } from 'node:crypto'; -import { - getAgentProfileRegistry, - getSupervisedAgentletId, -} from '@agenetes/agentlet-host'; +import { AcpServiceError } from '@agenetes/acp-driver'; +import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; import { renderExternalAgentInputs } from './preprocessor.js'; import { getProfileSessionPreferences } from './profile-session-preferences.js'; @@ -183,18 +181,18 @@ export function buildAcpWorkloadSpec( const { binding, threadId } = opts; const canvasId = opts.canvasId ?? ''; const profile = resolveProfileSnapshot(binding.profileId); - let agentletId: string; - let cwd: string | undefined; - let recipe: AcpBindingRecipe | null; - if (profile) { - agentletId = profile.agentletId; - cwd = profile.workingDirPath; - recipe = recipeFromProfileSnapshot(profile, binding.alias); - } else { - agentletId = getSupervisedAgentletId(); - cwd = opts.cwd; - recipe = resolveBindingRecipe(binding.profileId); + if (!profile) { + throw new AcpServiceError( + 'profile_missing', + `Agent Profile '${binding.profileId}' is unavailable.`, + ); } + const agentletId = profile.agentletId; + let cwd: string | undefined = profile.workingDirPath; + let recipe: AcpBindingRecipe | null = recipeFromProfileSnapshot( + profile, + binding.alias, + ); const workingDirPath = opts.launchOverrides?.workingDirPath; cwd = workingDirPath ?? cwd; diff --git a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts index 732e764eb..0434f5611 100644 --- a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts +++ b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts @@ -20,7 +20,6 @@ vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => ({ getProfile: () => mocks.profile, }), - getSupervisedAgentletId: () => 'supervised-agentlet', })); vi.mock('../agenetes/drivers.js', () => ({ @@ -137,4 +136,14 @@ describe('buildAcpWorkloadSpec', () => { 'Node constraints', ]); }); + + it('rejects a missing Profile instead of inventing a placement', () => { + expect(() => + buildAcpWorkloadSpec({ + binding: { profileId: 'missing', alias: 'Missing' }, + threadId: 'thread-a', + canvasId: 'canvas-a', + }), + ).toThrow("Agent Profile 'missing' is unavailable."); + }); }); diff --git a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts index c8fdf0d8a..9c2b3a8ea 100644 --- a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts @@ -30,7 +30,8 @@ vi.mock('@agenetes/acp-driver', () => ({ })); vi.mock('@agenetes/agentlet-host', () => ({ - getSupervisedAgentletId: () => 'agentlet-supervised', + resolveConnectedAgentletId: (agentletId: string) => + agentletId === 'legacy-host' ? 'device-uuid' : agentletId, })); vi.mock('./external-agent-realization.js', () => ({ @@ -119,6 +120,7 @@ describe('ACP cached-meta across awaited persistence', () => { usage: null, metaUpdatedAt: 32, }); + const server = await createApp(); const response = await server.inject({ @@ -139,7 +141,31 @@ describe('ACP cached-meta across awaited persistence', () => { }); }); - it('falls back to the supervised agentlet for a thread with no record', async () => { + it('finds a live session through unique hostname-era placement resolution', async () => { + mocks.record = externalRecord('legacy-host'); + mocks.live.set('device-uuid\u0000thread-1', { + availableCommands: [{ name: 'review', description: 'Review changes' }], + commandsUpdatedAt: 5, + availableModes: [], + currentModeId: null, + availableModels: [], + currentModelId: null, + configOptions: [], + selections: {}, + sessionInfo: null, + usage: null, + metaUpdatedAt: 6, + }); + const response = await (await createApp()).inject(CACHED_META_URL); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'thread', + availableCommands: [{ name: 'review' }], + }); + }); + + it('does not invent a placement for a thread with no record', async () => { mocks.live.set('agentlet-supervised\u0000thread-1', { availableCommands: [], commandsUpdatedAt: 3, @@ -160,11 +186,7 @@ describe('ACP cached-meta across awaited persistence', () => { url: CACHED_META_URL, }); - expect(response.json()).toMatchObject({ - source: 'thread', - commandsUpdatedAt: 3, - sessionMeta: { updatedAt: 4 }, - }); + expect(response.json()).toMatchObject({ source: 'none' }); }); it('answers a dormant thread from the metadata its record kept', async () => { diff --git a/apps/server/src/modules/agent/acp/threads.route.test.ts b/apps/server/src/modules/agent/acp/threads.route.test.ts index 0a1f91c1b..4f9549346 100644 --- a/apps/server/src/modules/agent/acp/threads.route.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.test.ts @@ -19,10 +19,6 @@ vi.mock('@agenetes/acp-driver', () => ({ acpSessionRegistry: { get: () => mocks.live }, })); -vi.mock('@agenetes/agentlet-host', () => ({ - getSupervisedAgentletId: () => 'agentlet-1', -})); - vi.mock('./external-agent-realization.js', () => ({ externalAgentRealization: { realize: mocks.realize, diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index 58947fc60..1950b9cbe 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { acpSessionRegistry } from '@agenetes/acp-driver'; -import { getSupervisedAgentletId } from '@agenetes/agentlet-host'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { acpPermissionDecisionSchema, @@ -140,7 +140,7 @@ export async function awaitSchemaQuiescence( async function resolveThreadAgentletId( threadId: string, canvasId?: string, -): Promise { +): Promise { if (canvasId) { const record = await agenetes.record( canvasAcpNamespace(canvasId), @@ -152,10 +152,11 @@ async function resolveThreadAgentletId( typeof driverSpec === 'object' && typeof (driverSpec as { agentletId?: unknown }).agentletId === 'string' ) { - return (driverSpec as { agentletId: string }).agentletId; + const agentletId = (driverSpec as { agentletId: string }).agentletId; + return resolveConnectedAgentletId(agentletId) ?? agentletId; } } - return getSupervisedAgentletId(); + return undefined; } /** @@ -285,7 +286,9 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { } const { canvasId, profileId } = parsed.data; const agentletId = await resolveThreadAgentletId(threadId, canvasId); - const live = acpSessionRegistry.get(agentletId, threadId); + const live = agentletId + ? acpSessionRegistry.get(agentletId, threadId) + : undefined; if (live) { return { source: 'thread', diff --git a/apps/server/src/modules/agent/agent-defaults.route.test.ts b/apps/server/src/modules/agent/agent-defaults.route.test.ts index 7a98899d1..7832a8512 100644 --- a/apps/server/src/modules/agent/agent-defaults.route.test.ts +++ b/apps/server/src/modules/agent/agent-defaults.route.test.ts @@ -26,7 +26,8 @@ vi.mock('./agent-defaults.js', () => ({ vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => mocks.registryReady ? { getProfile: mocks.getProfile } : null, - getAgentletGateway: () => ({ getAgentlet: mocks.getAgentlet }), + resolveConnectedAgentletId: (target: string) => + mocks.getAgentlet(target)?.status === 'connected' ? target : undefined, })); vi.mock('./acp/profile-schema-cache.js', () => ({ getProfileSchemaCache: mocks.getCache, diff --git a/apps/server/src/modules/agent/agent-defaults.route.ts b/apps/server/src/modules/agent/agent-defaults.route.ts index bb205ebcd..9412298bd 100644 --- a/apps/server/src/modules/agent/agent-defaults.route.ts +++ b/apps/server/src/modules/agent/agent-defaults.route.ts @@ -3,7 +3,7 @@ import { getAgentProfileRegistry, - getAgentletGateway, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -60,8 +60,7 @@ function projectDefaults(defaults: AgentDefaults): AgentDefaultsResponse { ? 'offline' : !profile ? 'deleted' - : getAgentletGateway()?.getAgentlet(profile.agentletId)?.status === - 'connected' + : resolveConnectedAgentletId(profile.agentletId) ? 'available' : 'offline', // Missing observations and editable CLI labels do not prove lack of support. @@ -78,7 +77,8 @@ const agentDefaultsRoutes: FastifyPluginAsync = async (app) => { app.addHook('preHandler', async (request, reply) => { if (!isOwnerRequest(request)) { return reply.status(403).send({ - message: 'Forbidden: Agent defaults require owner authorization', + message: + 'Forbidden: Utility Agent settings require owner authorization', }); } }); @@ -96,7 +96,8 @@ const agentDefaultsRoutes: FastifyPluginAsync = async (app) => { const parsed = agentDefaultsSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ - message: parsed.error.issues[0]?.message ?? 'Invalid Agent defaults', + message: + parsed.error.issues[0]?.message ?? 'Invalid Utility Agent settings', code: 'validation_failed', }); } diff --git a/apps/server/src/modules/agent/agent-defaults.test.ts b/apps/server/src/modules/agent/agent-defaults.test.ts index f900a4d99..0610c7cda 100644 --- a/apps/server/src/modules/agent/agent-defaults.test.ts +++ b/apps/server/src/modules/agent/agent-defaults.test.ts @@ -12,11 +12,8 @@ import { import type { AgentDefaults, AgentProfileView } from '@huabu/shared'; vi.mock('@agenetes/agentlet-host', () => ({ - getAgentletGateway: () => ({ - getAgentlet: (id: string) => ({ - status: id === 'connected' ? 'connected' : 'disconnected', - }), - }), + resolveConnectedAgentletId: (id: string) => + id === 'connected' || id === 'legacy-host' ? 'connected' : undefined, })); function profile( @@ -59,6 +56,19 @@ describe('installation Agent defaults', () => { } }); + it('offers a uniquely resolved hostname-era candidate to initialization', () => { + const initialize = vi + .spyOn(AgentDefaultsService.prototype, 'initializeAgentDefaults') + .mockReturnValue({ profileId: null, functionalModel: '' }); + try { + const legacy = profile('legacy', 'legacy-host'); + initializeAgentDefaults([legacy]); + expect(initialize).toHaveBeenCalledWith([legacy]); + } finally { + initialize.mockRestore(); + } + }); + it('requires an explicit external default instead of falling back to Huabu', () => { const { service, storage } = setup(); expect(() => service.requireDefaultAgentProfileId()).toThrow( diff --git a/apps/server/src/modules/agent/agent-defaults.ts b/apps/server/src/modules/agent/agent-defaults.ts index 8b1a3e5c1..0accc5686 100644 --- a/apps/server/src/modules/agent/agent-defaults.ts +++ b/apps/server/src/modules/agent/agent-defaults.ts @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { getAgentletGateway } from '@agenetes/agentlet-host'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { agentDefaultsSchema } from '@huabu/shared'; @@ -85,7 +85,7 @@ export class AgentDefaultsService { if (profileId === null) { throw new AgentDefaultsError( 'default_profile_unconfigured', - 'Select a default Agent in Settings', + 'Select a Utility Agent in Settings', ); } return profileId; @@ -134,9 +134,7 @@ export const initializeAgentDefaults = ( profiles: readonly AgentProfileView[], ): AgentDefaults => service.initializeAgentDefaults( - profiles.filter( - (profile) => - getAgentletGateway()?.getAgentlet(profile.agentletId)?.status === - 'connected', + profiles.filter((profile) => + resolveConnectedAgentletId(profile.agentletId), ), ); diff --git a/apps/server/src/modules/agent/agent-node.service.test.ts b/apps/server/src/modules/agent/agent-node.service.test.ts index 8136f3f74..7918fd830 100644 --- a/apps/server/src/modules/agent/agent-node.service.test.ts +++ b/apps/server/src/modules/agent/agent-node.service.test.ts @@ -39,7 +39,7 @@ function createHarness(options?: { nodeApplied?: boolean; edgeApplied?: boolean; edgeError?: Error; - defaultProfileId?: string | null; + fallbackProfileId?: string | null; }) { const execute = vi .fn() @@ -50,10 +50,10 @@ function createHarness(options?: { execute.mockResolvedValueOnce(output(options?.edgeApplied ?? true)); } const service = new AgentNodeService({ - getDefaultProfileId: () => - options?.defaultProfileId === undefined + getFallbackProfileId: () => + options?.fallbackProfileId === undefined ? 'profile-a' - : options.defaultProfileId, + : options.fallbackProfileId, getProfileRegistry: () => ({ getProfile: (profileId) => profileId === 'profile-a' @@ -84,20 +84,6 @@ function createHarness(options?: { } describe('AgentNodeService', () => { - it('creates from an explicit Built-In default without requiring an external Profile', async () => { - const { service, execute } = createHarness({ - defaultProfileId: 'huabu', - selectableIds: [], - }); - const result = await service.create({ - canvasId: 'canvas-a', - position: { x: 0, y: 0 }, - }); - expect(result.profileId).toBe('huabu'); - expect( - execute.mock.calls[0][0].commands[0].nodes[0].data.agentBinding, - ).toEqual({ kind: 'internal' }); - }); it('creates one external Question Node and then its lineage edge', async () => { const { service, execute } = createHarness(); @@ -283,7 +269,7 @@ describe('AgentNodeService', () => { ); }); - it('uses the configured default only when no Profile was supplied', async () => { + it('uses the first selectable fallback only when no Profile was supplied', async () => { const { service, execute } = createHarness(); const result = await service.create({ canvasId: 'canvas-a', @@ -299,20 +285,14 @@ describe('AgentNodeService', () => { }); }); - it('does not fall back when the default is unconfigured or deleted', async () => { - for (const defaultProfileId of [null, 'deleted-profile']) { - const { service, execute } = createHarness({ defaultProfileId }); - await expect( - service.create({ - canvasId: 'canvas-a', - position: { x: 1, y: 2 }, - }), - ).rejects.toMatchObject({ - code: defaultProfileId - ? 'profile_not_selectable' - : 'default_profile_unconfigured', - }); - expect(execute).not.toHaveBeenCalled(); - } + it('rejects creation when no selectable fallback exists', async () => { + const { service, execute } = createHarness({ fallbackProfileId: null }); + await expect( + service.create({ + canvasId: 'canvas-a', + position: { x: 1, y: 2 }, + }), + ).rejects.toMatchObject({ code: 'default_profile_unconfigured' }); + expect(execute).not.toHaveBeenCalled(); }); }); diff --git a/apps/server/src/modules/agent/agent-node.service.ts b/apps/server/src/modules/agent/agent-node.service.ts index adddd7306..23ab2cad4 100644 --- a/apps/server/src/modules/agent/agent-node.service.ts +++ b/apps/server/src/modules/agent/agent-node.service.ts @@ -13,12 +13,12 @@ import { type Point, } from '@huabu/shared'; -import { getAgentDefaults } from './agent-defaults.js'; import { InvalidAgentLaunchOverridesError, parseAgentLaunchOverrides, } from './agent-launch-overrides.js'; import { + getFirstSelectableAgentProfileId, requireSelectableAgentProfile, SelectableAgentProfileError, type SelectableAgentProfile, @@ -89,7 +89,7 @@ interface StoredNode { interface AgentNodeServiceDependencies { getProfileRegistry: () => AgentProfileRegistryPort | null; - getDefaultProfileId?: () => string | null; + getFallbackProfileId?: () => string | null; readCanvasNodes: (canvasId: string) => Promise; execute: (input: { canvasId: string; @@ -108,6 +108,7 @@ async function defaultReadCanvasNodes( const DEFAULT_DEPENDENCIES: AgentNodeServiceDependencies = { getProfileRegistry: () => null, + getFallbackProfileId: getFirstSelectableAgentProfileId, readCanvasNodes: defaultReadCanvasNodes, execute: executeOnServer, }; @@ -217,13 +218,13 @@ export class AgentNodeService { const profileId = input.profileId ?? - (this.dependencies.getDefaultProfileId - ? this.dependencies.getDefaultProfileId() - : getAgentDefaults().profileId); + (this.dependencies.getFallbackProfileId + ? this.dependencies.getFallbackProfileId() + : getFirstSelectableAgentProfileId()); if (!profileId) { throw new AgentNodeCreationError( 'default_profile_unconfigured', - 'Connect an external Agent or select Built-In Pi as the default in Settings.', + 'Connect an external Agent before creating a conversation.', ); } let binding: AgentBinding; diff --git a/apps/server/src/modules/agent/agentlet-device-display.test.ts b/apps/server/src/modules/agent/agentlet-device-display.test.ts new file mode 100644 index 000000000..a1ed05980 --- /dev/null +++ b/apps/server/src/modules/agent/agentlet-device-display.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { formatAgentletDeviceDisplayName } from './agentlet-device-display.js'; + +describe('formatAgentletDeviceDisplayName', () => { + it('combines hostname and environment without exposing device identity', () => { + expect( + formatAgentletDeviceDisplayName({ + hostname: 'huabu', + platform: 'linux', + arch: 'x64', + }), + ).toBe('huabu: linux x64'); + }); + + it('omits missing metadata and retains a non-identity fallback', () => { + expect(formatAgentletDeviceDisplayName({ hostname: 'huabu' })).toBe( + 'huabu', + ); + expect( + formatAgentletDeviceDisplayName({ platform: 'linux', arch: 'arm64' }), + ).toBe('Agentlet: linux arm64'); + expect(formatAgentletDeviceDisplayName()).toBe('Agentlet'); + }); +}); diff --git a/apps/server/src/modules/agent/agentlet-device-display.ts b/apps/server/src/modules/agent/agentlet-device-display.ts new file mode 100644 index 000000000..e67aa270a --- /dev/null +++ b/apps/server/src/modules/agent/agentlet-device-display.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +interface AgentletMachineDisplayMetadata { + hostname?: string; + platform?: string; + arch?: string; +} + +export function formatAgentletDeviceDisplayName( + machine?: AgentletMachineDisplayMetadata, +): string { + const hostname = machine?.hostname?.trim() || 'Agentlet'; + const environment = [machine?.platform, machine?.arch] + .map((value) => value?.trim()) + .filter(Boolean) + .join(' '); + return environment ? `${hostname}: ${environment}` : hostname; +} diff --git a/apps/server/src/modules/agent/functional-text.test.ts b/apps/server/src/modules/agent/functional-text.test.ts index e2bc2ef4e..5471208ce 100644 --- a/apps/server/src/modules/agent/functional-text.test.ts +++ b/apps/server/src/modules/agent/functional-text.test.ts @@ -201,7 +201,10 @@ describe('external functional text', () => { async (outcome) => { vi.useFakeTimers(); const controller = new AbortController(); - let resolveCreation!: (handle: { run: typeof mocks.run }) => void; + let resolveCreation!: (handle: { + run: typeof mocks.run; + close: typeof mocks.close; + }) => void; mocks.create.mockImplementationOnce( () => new Promise((resolve) => { @@ -222,9 +225,10 @@ describe('external functional text', () => { await vi.advanceTimersByTimeAsync(FUNCTIONAL_TEXT_TIMEOUT_MS); else controller.abort(new Error('caller cancelled')); await result; - resolveCreation({ run: mocks.run }); + resolveCreation({ run: mocks.run, close: mocks.close }); await vi.advanceTimersByTimeAsync(0); expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.close).toHaveBeenCalledOnce(); }, ); diff --git a/apps/server/src/modules/agent/functional-text.ts b/apps/server/src/modules/agent/functional-text.ts index af99758bf..9615268c6 100644 --- a/apps/server/src/modules/agent/functional-text.ts +++ b/apps/server/src/modules/agent/functional-text.ts @@ -38,7 +38,7 @@ export async function runFunctionalText( if (!profileId) { throw new AgentDefaultsError( 'default_profile_unconfigured', - 'Select a default Agent in Settings to generate metadata', + 'Select a Utility Agent in Settings to generate metadata', ); } if (profileId === 'huabu') { @@ -134,6 +134,9 @@ export async function runFunctionalText( const execute = async (): Promise => { signal.throwIfAborted(); const handle = await agenetes.create(spec); + if (signal.aborted) { + await handle.close(); + } signal.throwIfAborted(); const folder = createTranscriptFolder(); let completed = false; diff --git a/apps/server/src/modules/agent/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts index 4eda46f96..0ce944a7c 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts @@ -1,13 +1,38 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const host = vi.hoisted(() => ({ + profiles: [] as Array<{ id: string; alias: string; agentletId: string }>, + connectedIds: [] as string[], +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getAgentProfileRegistry: () => ({ + getProfile: (profileId: string) => + host.profiles.find((profile) => profile.id === profileId), + listProfiles: () => host.profiles, + }), + resolveConnectedAgentletId: (target: string) => { + if (host.connectedIds.includes(target)) return target; + const matches = host.connectedIds.filter( + (agentletId) => `${agentletId}-host` === target, + ); + return matches.length === 1 ? matches[0] : undefined; + }, +})); import { listAvailableAgentProfiles, requireAvailableAgentProfile, } from './selectable-agent-profile.js'; +afterEach(() => { + host.profiles = []; + host.connectedIds = []; +}); + describe('listAvailableAgentProfiles', () => { it('prepends Huabu and projects available Profile identities', () => { const profiles = new Map([ @@ -24,45 +49,49 @@ describe('listAvailableAgentProfiles', () => { ]); expect( - listAvailableAgentProfiles( - { - getProfile: (id: string) => profiles.get(id), - listSelectableProfileIds: () => ['profile-a', 'profile-b'], - }, - 'profile-b', - ), + listAvailableAgentProfiles({ + getProfile: (id: string) => profiles.get(id), + listSelectableProfileIds: () => ['profile-a', 'profile-b'], + }), ).toEqual([ { id: 'huabu', alias: 'Built-In Pi' }, - { id: 'profile-a', alias: 'Researcher' }, - { id: 'profile-b', alias: 'Builder', default: true }, + { id: 'profile-a', alias: 'Researcher', default: true }, + { id: 'profile-b', alias: 'Builder' }, ]); }); it('keeps the Huabu Profile available while the registry is unavailable', () => { - expect(listAvailableAgentProfiles(null, null)).toEqual([ + expect(listAvailableAgentProfiles(null)).toEqual([ { id: 'huabu', alias: 'Built-In Pi' }, ]); }); - it('does not mark another Profile as default when the selected one is missing', () => { - expect( - listAvailableAgentProfiles( - { - getProfile: () => ({ id: 'other', alias: 'Other' }), - listSelectableProfileIds: () => ['other'], - }, - 'deleted', - ), - ).toEqual([ + it('projects only Profiles on currently connected Agentlets by default', () => { + host.profiles = [ + { id: 'online', alias: 'Online', agentletId: 'device-a' }, + { id: 'offline', alias: 'Offline', agentletId: 'device-b' }, + ]; + host.connectedIds = ['device-a']; + + expect(listAvailableAgentProfiles()).toEqual([ + { id: 'huabu', alias: 'Built-In Pi' }, + { id: 'online', alias: 'Online', default: true }, + ]); + }); + + it('projects a hostname-era Profile when exactly one device reports that hostname', () => { + host.profiles = [ + { id: 'legacy', alias: 'Legacy', agentletId: 'device-a-host' }, + ]; + host.connectedIds = ['device-a']; + + expect(listAvailableAgentProfiles()).toEqual([ { id: 'huabu', alias: 'Built-In Pi' }, - { id: 'other', alias: 'Other' }, + { id: 'legacy', alias: 'Legacy', default: true }, ]); }); it('accepts the Huabu Profile without an external registry', () => { expect(() => requireAvailableAgentProfile('huabu', null)).not.toThrow(); - expect(listAvailableAgentProfiles(null, 'huabu')).toEqual([ - { id: 'huabu', alias: 'Built-In Pi', default: true }, - ]); }); }); diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts index 0b3a7bcb3..a67e1d315 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.ts @@ -1,12 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; +import { + getAgentProfileRegistry, + resolveConnectedAgentletId, +} from '@agenetes/agentlet-host'; import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared'; -import { getAgentDefaults } from './agent-defaults.js'; - import type { CustomData } from '@huabu/shared'; export interface SelectableAgentProfile { @@ -20,6 +21,19 @@ interface AgentProfileRegistryPort { listSelectableProfileIds(): string[]; } +function getConnectedProfileRegistry(): AgentProfileRegistryPort | null { + const registry = getAgentProfileRegistry(); + if (!registry) return null; + return { + getProfile: (profileId) => registry.getProfile(profileId), + listSelectableProfileIds: () => + registry + .listProfiles() + .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) + .map((profile) => profile.id), + }; +} + export interface AvailableAgentProfileSummary { id: string; alias: string; @@ -38,7 +52,7 @@ export class SelectableAgentProfileError extends Error { export function requireSelectableAgentProfile( profileId: string, - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): SelectableAgentProfile { if (!registry) { throw new SelectableAgentProfileError( @@ -62,20 +76,25 @@ export function requireSelectableAgentProfile( export function requireAvailableAgentProfile( profileId: string, - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): void { if (profileId === HUABU_AGENT_PROFILE_ID) return; requireSelectableAgentProfile(profileId, registry); } +export function getFirstSelectableAgentProfileId( + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), +): string | null { + return registry?.listSelectableProfileIds()[0] ?? null; +} + export function listAvailableAgentProfiles( - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), - defaultProfileId: string | null = getAgentDefaults().profileId, + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): AvailableAgentProfileSummary[] { + const defaultProfileId = getFirstSelectableAgentProfileId(registry); const huabu = { id: HUABU_AGENT_PROFILE_ID, alias: 'Built-In Pi', - ...(defaultProfileId === HUABU_AGENT_PROFILE_ID ? { default: true } : {}), } as const; if (!registry) { return [huabu]; diff --git a/apps/server/src/modules/canvas/agent-node-edit.test.ts b/apps/server/src/modules/canvas/agent-node-edit.test.ts index b7e42d201..895fc7254 100644 --- a/apps/server/src/modules/canvas/agent-node-edit.test.ts +++ b/apps/server/src/modules/canvas/agent-node-edit.test.ts @@ -12,26 +12,20 @@ import { SelectableAgentProfileError } from '../agent/selectable-agent-profile.j import type * as SelectableProfiles from '../agent/selectable-agent-profile.js'; const mocks = vi.hoisted(() => ({ - defaults: vi.fn(), + firstProfile: vi.fn(), profile: vi.fn(), })); -vi.mock('../agent/agent-defaults.js', () => ({ - getAgentDefaults: mocks.defaults, -})); - vi.mock('../agent/selectable-agent-profile.js', async (importOriginal) => ({ ...(await importOriginal()), + getFirstSelectableAgentProfileId: mocks.firstProfile, requireSelectableAgentProfile: mocks.profile, })); describe('new Agent Node default binding', () => { beforeEach(() => { vi.resetAllMocks(); - mocks.defaults.mockReturnValue({ - profileId: 'external-default', - functionalModel: '', - }); + mocks.firstProfile.mockReturnValue('external-default'); mocks.profile.mockReturnValue({ id: 'external-default', alias: 'External', @@ -56,24 +50,12 @@ describe('new Agent Node default binding', () => { ])('preserves an explicitly supplied binding: %j', (agentBinding) => { const data = { agentBinding }; expect(withDefaultAgentBinding(data)).toBe(data); - expect(mocks.defaults).not.toHaveBeenCalled(); - expect(mocks.profile).not.toHaveBeenCalled(); - }); - - it('uses an explicit Built-In default without resolving external Profiles', () => { - mocks.defaults.mockReturnValue({ - profileId: 'huabu', - functionalModel: 'external-model', - }); - expect(withDefaultAgentBinding({ label: 'New Agent' })).toEqual({ - label: 'New Agent', - agentBinding: { kind: 'internal' }, - }); + expect(mocks.firstProfile).not.toHaveBeenCalled(); expect(mocks.profile).not.toHaveBeenCalled(); }); - it('reports an unconfigured default instead of silently choosing internal', () => { - mocks.defaults.mockReturnValue({ profileId: null, functionalModel: '' }); + it('reports that no external Profile is available', () => { + mocks.firstProfile.mockReturnValue(null); expect(() => withDefaultAgentBinding({})).toThrow(AgentNodeEditError); expect(mocks.profile).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/modules/canvas/agent-node-edit.ts b/apps/server/src/modules/canvas/agent-node-edit.ts index a2365729a..b4974f385 100644 --- a/apps/server/src/modules/canvas/agent-node-edit.ts +++ b/apps/server/src/modules/canvas/agent-node-edit.ts @@ -11,12 +11,12 @@ import { } from '@huabu/shared/canvas-engine'; import { agenetes } from '../agent/agenetes/drivers.js'; -import { getAgentDefaults } from '../agent/agent-defaults.js'; import { parseAgentLaunchOverrides } from '../agent/agent-launch-overrides.js'; import { agentNodeBinding } from '../agent/agent-node-binding.js'; import { agentThreadResolver } from '../agent/agent-thread-resolver.js'; import { effectiveConversationTitle } from '../agent/conversation-title.service.js'; import { + getFirstSelectableAgentProfileId, requireSelectableAgentProfile, SelectableAgentProfileError, type SelectableAgentProfile, @@ -43,10 +43,10 @@ export function withDefaultAgentBinding( data: Record, ): Record { if (data.agentBinding) return data; - const profileId = getAgentDefaults().profileId; + const profileId = getFirstSelectableAgentProfileId(); if (!profileId) { throw new AgentNodeEditError( - 'Connect an external Agent or select Built-In Pi as the default in Settings.', + 'Connect an external Agent before creating a conversation.', ); } if (profileId === HUABU_AGENT_PROFILE_ID) { diff --git a/apps/server/src/modules/security/canary-redeploy.route.test.ts b/apps/server/src/modules/security/canary-redeploy.route.test.ts new file mode 100644 index 000000000..8910b6d7f --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.route.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import canaryRedeployRoutes from './canary-redeploy.route.js'; + +import type { FastifyInstance } from 'fastify'; + +describe('Canary redeployment routes', () => { + let app: FastifyInstance; + let dataDir: string; + const originalEnabled = process.env.HUABU_CANARY_REDEPLOY_ENABLED; + const originalDataDir = process.env.HUABU_DATA_DIR; + + beforeEach(async () => { + delete process.env.HUABU_CANARY_REDEPLOY_ENABLED; + dataDir = mkdtempSync(join(tmpdir(), 'huabu-canary-route-')); + process.env.HUABU_DATA_DIR = dataDir; + app = Fastify({ logger: false }); + await app.register(canaryRedeployRoutes, { + prefix: '/api/deployment/canary', + }); + }); + + afterEach(async () => { + await app.close(); + if (originalEnabled === undefined) { + delete process.env.HUABU_CANARY_REDEPLOY_ENABLED; + } else { + process.env.HUABU_CANARY_REDEPLOY_ENABLED = originalEnabled; + } + if (originalDataDir === undefined) { + delete process.env.HUABU_DATA_DIR; + } else { + process.env.HUABU_DATA_DIR = originalDataDir; + } + rmSync(dataDir, { recursive: true, force: true }); + }); + + it('lets the local owner inspect a disabled capability', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/deployment/canary', + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + available: false, + reason: 'disabled', + branch: 'alpha', + configuredBranch: null, + }); + }); + + it('rejects non-owner callers', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/deployment/canary', + remoteAddress: '192.0.2.10', + }); + expect(response.statusCode).toBe(403); + }); + + it('validates action request bodies and reports unavailable redeployment', async () => { + const malformed = await app.inject({ + method: 'POST', + url: '/api/deployment/canary/redeploy', + payload: { branch: 'main' }, + }); + expect(malformed.statusCode).toBe(400); + expect(malformed.json()).toMatchObject({ code: 'validation_failed' }); + + const unavailable = await app.inject({ + method: 'POST', + url: '/api/deployment/canary/redeploy', + payload: { expectedBranch: 'alpha' }, + }); + expect(unavailable.statusCode).toBe(503); + expect(unavailable.json()).toMatchObject({ + code: 'canary_redeploy_unavailable', + }); + }); + + it('validates branch configuration before capability checks', async () => { + const malformed = await app.inject({ + method: 'PUT', + url: '/api/deployment/canary/config', + payload: { branch: '' }, + }); + expect(malformed.statusCode).toBe(400); + expect(malformed.json()).toMatchObject({ code: 'validation_failed' }); + + const unavailable = await app.inject({ + method: 'PUT', + url: '/api/deployment/canary/config', + payload: { branch: 'x/alpha' }, + }); + expect(unavailable.statusCode).toBe(503); + expect(unavailable.json()).toMatchObject({ + code: 'canary_redeploy_unavailable', + }); + }); +}); diff --git a/apps/server/src/modules/security/canary-redeploy.route.ts b/apps/server/src/modules/security/canary-redeploy.route.ts new file mode 100644 index 000000000..f08214e2d --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.route.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + canaryCheckRequestSchema, + canaryRedeployConfigUpdateSchema, + canaryRedeployRequestSchema, + type ApiResult, + type CanaryCheckRequest, + type CanaryRedeployConfigUpdate, + type CanaryRedeployRequest, + type CanaryRedeployStatusResponse, +} from '@huabu/shared'; + +import { + CanaryRedeployError, + checkCanaryRemote, + getCanaryRedeployStatus, + requestCanaryRedeploy, + setCanaryRedeployConfig, +} from './canary-redeploy.js'; +import { isOwnerRequest } from './owner.js'; + +import type { FastifyPluginAsync, FastifyReply } from 'fastify'; + +function sendCanaryError( + reply: FastifyReply, + error: unknown, + fallback: string, +) { + if (error instanceof CanaryRedeployError) { + const status = + error.code === 'operation_in_progress' || error.code === 'stale_branch' + ? 409 + : error.code === 'branch_invalid' + ? 400 + : error.code === 'branch_unavailable' + ? 422 + : error.code === 'config_invalid' + ? 500 + : 503; + return reply.status(status).send({ + message: error.message, + code: `canary_${error.code}`, + }); + } + return reply.status(500).send({ + message: fallback, + code: 'canary_internal_error', + }); +} + +const canaryRedeployRoutes: FastifyPluginAsync = async (app) => { + app.addHook('preHandler', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: 'Forbidden: Canary redeployment requires owner authorization', + }); + } + }); + + app.get<{ Reply: ApiResult }>( + '/', + async (request, reply) => { + try { + return await getCanaryRedeployStatus(); + } catch (error) { + request.log.error({ err: error }, 'Unable to read Canary status'); + return sendCanaryError( + reply, + error, + 'Unable to load Canary redeployment status', + ); + } + }, + ); + + app.put<{ + Body: CanaryRedeployConfigUpdate; + Reply: ApiResult; + }>('/config', async (request, reply) => { + const parsed = canaryRedeployConfigUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + message: + parsed.error.issues[0]?.message ?? + 'Invalid Canary branch configuration', + code: 'validation_failed', + }); + } + try { + return await setCanaryRedeployConfig(parsed.data); + } catch (error) { + request.log.warn({ err: error }, 'Unable to save Canary branch'); + return sendCanaryError( + reply, + error, + 'Unable to save Canary branch configuration', + ); + } + }); + + app.post<{ + Body: CanaryCheckRequest; + Reply: ApiResult; + }>('/check', async (request, reply) => { + const parsed = canaryCheckRequestSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + message: + parsed.error.issues[0]?.message ?? 'Invalid Canary check request', + code: 'validation_failed', + }); + } + try { + return await checkCanaryRemote(); + } catch (error) { + request.log.warn({ err: error }, 'Canary update check failed'); + return sendCanaryError(reply, error, 'Unable to check Canary branch'); + } + }); + + app.post<{ + Body: CanaryRedeployRequest; + Reply: ApiResult; + }>('/redeploy', async (request, reply) => { + const parsed = canaryRedeployRequestSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + message: + parsed.error.issues[0]?.message ?? 'Invalid Canary redeploy request', + code: 'validation_failed', + }); + } + try { + const status = await requestCanaryRedeploy(parsed.data.expectedBranch); + return reply.status(202).send(status); + } catch (error) { + request.log.error({ err: error }, 'Unable to start Canary redeployment'); + return sendCanaryError( + reply, + error, + 'Canary redeployment is unavailable', + ); + } + }); +}; + +export default canaryRedeployRoutes; diff --git a/apps/server/src/modules/security/canary-redeploy.test.ts b/apps/server/src/modules/security/canary-redeploy.test.ts new file mode 100644 index 000000000..009f43e84 --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { execFileSync, spawnSync } from 'node:child_process'; +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + checkCanaryRemote, + getCanaryRedeployStatus, + requestCanaryRedeploy, + resetCanaryRedeployStateForTest, + resolveCanaryCapability, + setCanaryRedeployConfig, + writeCanaryRedeployResult, +} from './canary-redeploy.js'; + +describe('Canary redeployment service', () => { + let root: string; + let remote: string; + let dataDir: string; + const originalEnv = { + enabled: process.env.HUABU_CANARY_REDEPLOY_ENABLED, + repoRoot: process.env.HUABU_REPO_ROOT, + deployedSha: process.env.HUABU_DEPLOYED_SHA, + dataDir: process.env.HUABU_DATA_DIR, + }; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'huabu-canary-repo-')); + remote = mkdtempSync(join(tmpdir(), 'huabu-canary-remote-')); + dataDir = mkdtempSync(join(tmpdir(), 'huabu-canary-data-')); + mkdirSync(join(root, 'scripts')); + for (const name of ['start-huabu.sh', 'canary-redeploy-runner.mjs']) { + const file = join(root, 'scripts', name); + writeFileSync(file, '#!/usr/bin/env bash\nexit 0\n'); + chmodSync(file, 0o755); + } + + execFileSync('git', ['init', '--bare', remote]); + execFileSync('git', ['init', '-b', 'alpha'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'canary@example.test'], { + cwd: root, + }); + execFileSync('git', ['config', 'user.name', 'Canary Test'], { cwd: root }); + writeFileSync(join(root, 'README.md'), 'canary\n'); + execFileSync('git', ['add', '.'], { cwd: root }); + execFileSync('git', ['commit', '-m', 'Initial Canary revision'], { + cwd: root, + }); + execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: root }); + execFileSync('git', ['push', '-u', 'origin', 'alpha'], { cwd: root }); + + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(); + process.env.HUABU_CANARY_REDEPLOY_ENABLED = '1'; + process.env.HUABU_REPO_ROOT = root; + process.env.HUABU_DEPLOYED_SHA = sha; + process.env.HUABU_DATA_DIR = dataDir; + resetCanaryRedeployStateForTest(); + }); + + afterEach(() => { + const restore = (key: string, value: string | undefined) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }; + restore('HUABU_CANARY_REDEPLOY_ENABLED', originalEnv.enabled); + restore('HUABU_REPO_ROOT', originalEnv.repoRoot); + restore('HUABU_DEPLOYED_SHA', originalEnv.deployedSha); + restore('HUABU_DATA_DIR', originalEnv.dataDir); + resetCanaryRedeployStateForTest(); + rmSync(root, { recursive: true, force: true }); + rmSync(remote, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + }); + + it('requires explicit enablement and executable repository scripts', () => { + expect(resolveCanaryCapability()).toMatchObject({ + available: true, + reason: 'available', + repoRoot: root, + }); + + delete process.env.HUABU_CANARY_REDEPLOY_ENABLED; + expect(resolveCanaryCapability()).toMatchObject({ + available: false, + reason: 'disabled', + }); + }); + + it('compares the startup revision with origin/alpha', async () => { + const status = await checkCanaryRemote(); + expect(status).toMatchObject({ + available: true, + branch: 'alpha', + configuredBranch: null, + updateAvailable: false, + runningSha: status.remoteSha, + }); + expect(status.checkedAt).toEqual(expect.any(Number)); + }); + + it('persists and checks one exact nested origin branch', async () => { + execFileSync('git', ['branch', 'x/alpha'], { cwd: root }); + execFileSync('git', ['push', 'origin', 'x/alpha'], { cwd: root }); + + await expect( + setCanaryRedeployConfig({ branch: 'x/alpha' }), + ).resolves.toMatchObject({ + branch: 'x/alpha', + configuredBranch: 'x/alpha', + updateAvailable: false, + }); + await expect(checkCanaryRemote()).resolves.toMatchObject({ + branch: 'x/alpha', + configuredBranch: 'x/alpha', + }); + + expect( + JSON.parse( + readFileSync(join(dataDir, 'canary-redeploy-config.json'), 'utf8'), + ), + ).toEqual({ version: 1, branch: 'x/alpha' }); + }); + + it('uses alpha only for an explicit reset and rejects invalid or unavailable refs', async () => { + await expect( + setCanaryRedeployConfig({ branch: '-bad' }), + ).rejects.toMatchObject({ code: 'branch_invalid' }); + await expect( + setCanaryRedeployConfig({ branch: 'HEAD' }), + ).rejects.toMatchObject({ code: 'branch_invalid' }); + await expect( + setCanaryRedeployConfig({ branch: 'missing' }), + ).rejects.toMatchObject({ code: 'branch_unavailable' }); + await expect( + setCanaryRedeployConfig({ branch: null }), + ).resolves.toMatchObject({ + branch: 'alpha', + configuredBranch: null, + }); + }); + + it('fails explicitly for malformed persisted configuration', async () => { + writeFileSync( + join(dataDir, 'canary-redeploy-config.json'), + '{"version":1,"branch":"bad..branch"}', + ); + await expect(getCanaryRedeployStatus()).rejects.toMatchObject({ + code: 'config_invalid', + }); + }); + + it('rejects stale confirmations and configuration changes during a persistent redeploy', async () => { + await expect(requestCanaryRedeploy('x/alpha')).rejects.toMatchObject({ + code: 'stale_branch', + }); + + writeFileSync( + join(dataDir, 'canary-redeploy-status.json'), + JSON.stringify({ + state: 'running', + branch: 'alpha', + startedAt: 10, + runnerPid: process.pid, + }), + ); + await expect( + setCanaryRedeployConfig({ branch: null }), + ).rejects.toMatchObject({ + code: 'operation_in_progress', + }); + }); + + it('persists only the bounded redeployment result contract', async () => { + await writeCanaryRedeployResult({ + state: 'failed', + branch: 'x/alpha', + startedAt: 10, + completedAt: 20, + exitCode: 1, + message: 'Redeploy script exited with status 1', + }); + + await expect(getCanaryRedeployStatus()).resolves.toMatchObject({ + redeploy: { + state: 'failed', + branch: 'x/alpha', + exitCode: 1, + }, + }); + }); + + it('records a detached runner failure without exposing command output', () => { + const hook = join(root, 'failing-hook.sh'); + const statusPath = join(dataDir, 'runner-status.json'); + const logPath = join(dataDir, 'runner.log'); + writeFileSync(hook, '#!/usr/bin/env bash\necho private-output\nexit 7\n'); + chmodSync(hook, 0o755); + + const runner = join( + process.cwd(), + '..', + '..', + 'scripts', + 'canary-redeploy-runner.mjs', + ); + const result = spawnSync( + process.execPath, + [runner, hook, statusPath, logPath, '100', 'x/alpha'], + { encoding: 'utf8' }, + ); + + expect(result.status).toBe(1); + const status = readFileSync(statusPath, 'utf8'); + expect(JSON.parse(status)).toMatchObject({ + state: 'failed', + branch: 'x/alpha', + startedAt: 100, + exitCode: 7, + }); + expect(status).not.toContain('private-output'); + expect(readFileSync(logPath, 'utf8')).toContain('Redeploying x/alpha'); + expect(readFileSync(logPath, 'utf8')).toContain('private-output'); + }); + + it('rejects malformed runner branch arguments before executing the hook', () => { + const marker = join(dataDir, 'unexpected-hook-run'); + const hook = join(root, 'marker-hook.sh'); + writeFileSync(hook, `#!/usr/bin/env bash\ntouch '${marker}'\n`); + chmodSync(hook, 0o755); + const runner = join( + process.cwd(), + '..', + '..', + 'scripts', + 'canary-redeploy-runner.mjs', + ); + + const result = spawnSync( + process.execPath, + [ + runner, + hook, + join(dataDir, 'invalid-status.json'), + join(dataDir, 'invalid.log'), + '100', + '-bad', + ], + { encoding: 'utf8' }, + ); + + expect(result.status).toBe(2); + expect(() => readFileSync(marker)).toThrow(); + }); +}); diff --git a/apps/server/src/modules/security/canary-redeploy.ts b/apps/server/src/modules/security/canary-redeploy.ts new file mode 100644 index 000000000..df2fbbdfa --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.ts @@ -0,0 +1,522 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { execFile, execFileSync, spawn } from 'node:child_process'; +import { accessSync, constants, existsSync } from 'node:fs'; +import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { z } from 'zod'; + +import { + canaryBranchSchema, + canaryRedeployResultSchema, + canaryRedeployStatusResponseSchema, + type CanaryBranch, + type CanaryRedeployConfigUpdate, + type CanaryRedeployResult, + type CanaryRedeployStatusResponse, +} from '@huabu/shared'; + +import { getDataDir } from '../../data-dir.js'; +import { atomicWriteJson, readJsonStrict } from '../../utils/fs.js'; +import { getLogger } from '../../utils/logger.js'; + +const execFileAsync = promisify(execFile); +const log = getLogger('canary-redeploy'); +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const DEFAULT_BRANCH = 'alpha'; + +const configRecordSchema = z + .object({ + version: z.literal(1), + branch: canaryBranchSchema.nullable(), + }) + .strict(); + +const legacyRedeployResultSchema = canaryRedeployResultSchema.omit({ + branch: true, +}); + +interface CanaryCapability { + available: boolean; + reason: CanaryRedeployStatusResponse['reason']; + repoRoot: string | null; + scriptPath: string | null; + runnerPath: string | null; + runningSha: string | null; +} + +interface StoredRedeployResult { + result: CanaryRedeployResult; + runnerPid: number | null; +} + +type CanaryOperation = 'check' | 'configure' | 'redeploy'; + +let cachedRemote: { + branch: CanaryBranch; + remoteSha: string; + checkedAt: number; +} | null = null; +let activeOperation: CanaryOperation | null = null; +let redeployRequestInFlight = false; + +export class CanaryRedeployError extends Error { + constructor( + readonly code: + | 'branch_invalid' + | 'branch_unavailable' + | 'config_invalid' + | 'operation_in_progress' + | 'redeploy_unavailable' + | 'stale_branch', + message: string, + ) { + super(message); + this.name = 'CanaryRedeployError'; + } +} + +function enabled(env: NodeJS.ProcessEnv): boolean { + return env.HUABU_CANARY_REDEPLOY_ENABLED === '1'; +} + +function validSha(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase() ?? ''; + return SHA_PATTERN.test(normalized) ? normalized : null; +} + +export function resolveCanaryCapability( + env: NodeJS.ProcessEnv = process.env, +): CanaryCapability { + if (!enabled(env)) { + return { + available: false, + reason: 'disabled', + repoRoot: null, + scriptPath: null, + runnerPath: null, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + + const configuredRoot = env.HUABU_REPO_ROOT; + if (!configuredRoot) { + return { + available: false, + reason: 'repository-unavailable', + repoRoot: null, + scriptPath: null, + runnerPath: null, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + + const repoRoot = resolve(configuredRoot); + const scriptPath = join(repoRoot, 'scripts', 'start-huabu.sh'); + const runnerPath = join(repoRoot, 'scripts', 'canary-redeploy-runner.mjs'); + if (!existsSync(scriptPath) || !existsSync(runnerPath)) { + return { + available: false, + reason: 'script-unavailable', + repoRoot, + scriptPath, + runnerPath, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + try { + accessSync(scriptPath, constants.X_OK); + } catch { + return { + available: false, + reason: 'script-unavailable', + repoRoot, + scriptPath, + runnerPath, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + + return { + available: true, + reason: 'available', + repoRoot, + scriptPath, + runnerPath, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; +} + +export function canaryConfigPath(): string { + return join(getDataDir(), 'canary-redeploy-config.json'); +} + +export function canaryStatusPath(): string { + return join(getDataDir(), 'canary-redeploy-status.json'); +} + +export function canaryLogPath(): string { + return join(getDataDir(), 'logs', 'canary-redeploy.log'); +} + +function readCanaryConfig(): { + branch: CanaryBranch; + configuredBranch: CanaryBranch | null; +} { + let stored: unknown; + try { + stored = readJsonStrict(canaryConfigPath()); + } catch { + throw new CanaryRedeployError( + 'config_invalid', + 'Stored Canary branch configuration is unreadable', + ); + } + if (stored === null) { + return { branch: DEFAULT_BRANCH, configuredBranch: null }; + } + const parsed = configRecordSchema.safeParse(stored); + if (!parsed.success) { + throw new CanaryRedeployError( + 'config_invalid', + 'Stored Canary branch configuration is invalid', + ); + } + try { + validateBranchFormat(parsed.data.branch ?? DEFAULT_BRANCH); + } catch { + throw new CanaryRedeployError( + 'config_invalid', + 'Stored Canary branch configuration is invalid', + ); + } + return { + branch: parsed.data.branch ?? DEFAULT_BRANCH, + configuredBranch: parsed.data.branch, + }; +} + +async function readRedeployResult(): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(canaryStatusPath(), 'utf8')); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return null; + throw error; + } + const current = canaryRedeployResultSchema.safeParse(parsed); + let result: CanaryRedeployResult; + if (current.success) { + result = current.data; + } else { + const legacy = legacyRedeployResultSchema.safeParse(parsed); + if (!legacy.success) { + throw new Error('Canary redeployment status is invalid'); + } + result = { ...legacy.data, branch: DEFAULT_BRANCH }; + } + const runnerPid = + parsed && + typeof parsed === 'object' && + 'runnerPid' in parsed && + Number.isSafeInteger(parsed.runnerPid) && + Number(parsed.runnerPid) > 0 + ? Number(parsed.runnerPid) + : null; + return { result, runnerPid }; +} + +function processIsRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +async function redeployIsActive(): Promise { + const stored = await readRedeployResult(); + if ( + stored?.result.state === 'succeeded' || + stored?.result.state === 'failed' + ) { + redeployRequestInFlight = false; + return false; + } + const persistentRunnerActive = Boolean( + stored?.result.state === 'running' && + stored.runnerPid !== null && + processIsRunning(stored.runnerPid), + ); + return ( + persistentRunnerActive || + (redeployRequestInFlight && + (stored?.result.state === 'requested' || + stored?.result.state === 'running')) + ); +} + +async function beginOperation(operation: CanaryOperation): Promise { + if (activeOperation) { + throw new CanaryRedeployError( + 'operation_in_progress', + 'Another Canary operation is already in progress', + ); + } + activeOperation = operation; + try { + if (await redeployIsActive()) { + throw new CanaryRedeployError( + 'operation_in_progress', + 'Canary redeployment is already in progress', + ); + } + } catch (error) { + activeOperation = null; + throw error; + } +} + +function validateBranchFormat(branch: CanaryBranch): void { + if (branch.startsWith('-')) { + throw new CanaryRedeployError( + 'branch_invalid', + `Invalid Canary branch: ${branch}`, + ); + } + try { + execFileSync('git', ['check-ref-format', '--branch', branch], { + stdio: 'ignore', + timeout: 5_000, + }); + execFileSync('git', ['check-ref-format', `refs/heads/${branch}`], { + stdio: 'ignore', + timeout: 5_000, + }); + } catch { + throw new CanaryRedeployError( + 'branch_invalid', + `Invalid Canary branch: ${branch}`, + ); + } +} + +async function resolveRemoteBranch( + repoRoot: string, + branch: CanaryBranch, +): Promise { + validateBranchFormat(branch); + const fullRef = `refs/heads/${branch}`; + let stdout: string; + try { + ({ stdout } = await execFileAsync( + 'git', + ['ls-remote', '--exit-code', '--refs', 'origin', fullRef], + { + cwd: repoRoot, + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 64 * 1024, + }, + )); + } catch { + throw new CanaryRedeployError( + 'branch_unavailable', + `Unable to resolve origin/${branch}`, + ); + } + const fields = stdout.trim().split(/\s+/); + const remoteSha = validSha(fields[0]); + if (!remoteSha || fields[1] !== fullRef || fields.length !== 2) { + throw new CanaryRedeployError( + 'branch_unavailable', + `Unable to resolve origin/${branch}`, + ); + } + return remoteSha; +} + +export async function writeCanaryRedeployResult( + result: CanaryRedeployResult, +): Promise { + const parsed = canaryRedeployResultSchema.parse(result); + const statusPath = canaryStatusPath(); + await mkdir(dirname(statusPath), { recursive: true }); + const temporaryPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(parsed, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporaryPath, statusPath); +} + +export async function getCanaryRedeployStatus(): Promise { + const capability = resolveCanaryCapability(); + const config = readCanaryConfig(); + const storedRedeploy = await readRedeployResult(); + let redeploy = storedRedeploy?.result ?? null; + if ( + redeploy?.state === 'running' && + storedRedeploy?.runnerPid !== null && + storedRedeploy?.runnerPid !== undefined && + !processIsRunning(storedRedeploy.runnerPid) + ) { + redeploy = { + state: 'failed', + branch: redeploy.branch, + startedAt: redeploy.startedAt, + completedAt: Date.now(), + exitCode: -1, + message: 'Redeploy runner stopped before recording an outcome', + }; + await writeCanaryRedeployResult(redeploy); + } + if (redeploy?.state === 'succeeded' || redeploy?.state === 'failed') { + redeployRequestInFlight = false; + } + const remote = cachedRemote?.branch === config.branch ? cachedRemote : null; + return canaryRedeployStatusResponseSchema.parse({ + available: capability.available, + reason: capability.reason, + branch: config.branch, + configuredBranch: config.configuredBranch, + runningSha: capability.runningSha, + remoteSha: remote?.remoteSha ?? null, + updateAvailable: + capability.runningSha && remote?.remoteSha + ? capability.runningSha !== remote.remoteSha + : null, + checkedAt: remote?.checkedAt ?? null, + redeploy, + }); +} + +export async function setCanaryRedeployConfig( + update: CanaryRedeployConfigUpdate, +): Promise { + const capability = resolveCanaryCapability(); + if (!capability.available || !capability.repoRoot) { + throw new CanaryRedeployError( + 'redeploy_unavailable', + 'Canary redeployment is unavailable', + ); + } + await beginOperation('configure'); + try { + const branch = update.branch ?? DEFAULT_BRANCH; + const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch); + atomicWriteJson(canaryConfigPath(), { + version: 1, + branch: update.branch, + }); + cachedRemote = { branch, remoteSha, checkedAt: Date.now() }; + return await getCanaryRedeployStatus(); + } finally { + activeOperation = null; + } +} + +export async function checkCanaryRemote(): Promise { + const capability = resolveCanaryCapability(); + if (!capability.available || !capability.repoRoot) { + return getCanaryRedeployStatus(); + } + await beginOperation('check'); + try { + const { branch } = readCanaryConfig(); + const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch); + cachedRemote = { branch, remoteSha, checkedAt: Date.now() }; + return await getCanaryRedeployStatus(); + } finally { + activeOperation = null; + } +} + +export async function requestCanaryRedeploy( + expectedBranch: CanaryBranch, +): Promise { + const capability = resolveCanaryCapability(); + if ( + !capability.available || + !capability.repoRoot || + !capability.scriptPath || + !capability.runnerPath + ) { + throw new CanaryRedeployError( + 'redeploy_unavailable', + 'Canary redeployment is unavailable', + ); + } + await beginOperation('redeploy'); + let branch: CanaryBranch | null = null; + let startedAt: number | null = null; + try { + branch = readCanaryConfig().branch; + if (expectedBranch !== branch) { + throw new CanaryRedeployError( + 'stale_branch', + `Canary branch changed from ${expectedBranch} to ${branch}; review and confirm again`, + ); + } + const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch); + cachedRemote = { branch, remoteSha, checkedAt: Date.now() }; + await access(capability.scriptPath, constants.X_OK); + startedAt = Date.now(); + await writeCanaryRedeployResult({ + state: 'requested', + branch, + startedAt, + }); + + const child = spawn( + process.execPath, + [ + capability.runnerPath, + capability.scriptPath, + canaryStatusPath(), + canaryLogPath(), + String(startedAt), + branch, + ], + { + cwd: capability.repoRoot, + detached: true, + stdio: 'ignore', + env: process.env, + }, + ); + child.once('error', (error) => { + redeployRequestInFlight = false; + log.error({ err: error }, 'Canary redeploy runner failed to start'); + void writeCanaryRedeployResult({ + state: 'failed', + branch: branch ?? DEFAULT_BRANCH, + startedAt: startedAt ?? Date.now(), + completedAt: Date.now(), + exitCode: -1, + message: 'Unable to start redeploy runner', + }).catch((statusError: unknown) => { + log.error( + { err: statusError }, + 'Unable to persist Canary runner start failure', + ); + }); + }); + child.unref(); + redeployRequestInFlight = true; + return await getCanaryRedeployStatus(); + } finally { + activeOperation = null; + } +} + +export function resetCanaryRedeployStateForTest(): void { + cachedRemote = null; + activeOperation = null; + redeployRequestInFlight = false; +} diff --git a/apps/server/src/prompt/external-agent/access-huabu.md b/apps/server/src/prompt/external-agent/access-huabu.md index 288834cf6..482028b28 100644 --- a/apps/server/src/prompt/external-agent/access-huabu.md +++ b/apps/server/src/prompt/external-agent/access-huabu.md @@ -242,7 +242,7 @@ Use an Agent when open-ended work benefits from interpretation or a durable visi ### 8.1 Create and start an Agent -Plain text creates a visible Agent with the external Profile selected in Global Settings and immediately submits its first prompt. An unconfigured or deleted default produces an explicit error; no other Profile is substituted: +Plain text creates a visible Agent with the first selectable external Profile and immediately submits its first prompt. This server-side fallback does not read the Web client's browser-local recent selection: ```bash SSE="$(curl -fsS -N -H "$AUTH" -H "Content-Type: text/plain" \ diff --git a/apps/server/src/prompt/external-agent/agents.md b/apps/server/src/prompt/external-agent/agents.md index 144ee08fb..e0ed17d8d 100644 --- a/apps/server/src/prompt/external-agent/agents.md +++ b/apps/server/src/prompt/external-agent/agents.md @@ -8,7 +8,7 @@ Load this guide when work should be handled by a visible Agent conversation inst curl -fsS -H "$AUTH" "$HUABU_RFS_URL/agent/profiles" ``` -The entry marked `default: true` is the external Profile selected in Global Settings. If no entry is marked, configure a default or explicitly choose an available Profile; do not assume the first entry is the default. +The entry marked `default: true` is the first selectable external Profile used when Agent creation omits `profileId`. If no entry is marked, explicitly choose an available Profile; do not assume Built-In Pi is an implicit fallback. The `huabu` Profile uses Huabu's configured model provider. Other Profiles use their own configured runtimes and do not depend on that provider. diff --git a/apps/server/src/security/environment-secret-store.ts b/apps/server/src/security/environment-secret-store.ts index ef8b11305..17a18361b 100644 --- a/apps/server/src/security/environment-secret-store.ts +++ b/apps/server/src/security/environment-secret-store.ts @@ -22,6 +22,9 @@ export class EnvironmentSecretStore implements SecretStore { if (id === SECRET_IDS.rapidApiKey) { return process.env.RAPIDAPI_KEY ?? null; } + if (id === SECRET_IDS.agentletConnectionToken) { + return process.env.HUABU_CONNECTION_TOKEN?.trim() || null; + } if (id === SECRET_IDS.inkOcrApiKey) { return process.env.VISION_KEY?.trim() || null; } diff --git a/apps/server/src/security/secret-ids.ts b/apps/server/src/security/secret-ids.ts index 280eee3a1..2f5f6dc67 100644 --- a/apps/server/src/security/secret-ids.ts +++ b/apps/server/src/security/secret-ids.ts @@ -5,6 +5,7 @@ export const SECRET_IDS = { imageApiKey: 'llm:image:api-key', tavilyApiKey: 'integration:tavily:api-key', rapidApiKey: 'integration:rapidapi:api-key', + agentletConnectionToken: 'integration:agentlet:connection-token', inkOcrApiKey: 'integration:azure-vision:api-key', inkOcrConfig: 'integration:azure-vision:config', copilotOAuth: 'oauth:github-copilot:credentials', diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2112d7c56..02609afd6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,6 +5,7 @@ import './load-env.js'; import './setup-proxy.js'; import { app } from './app.js'; import { resolveBindHost } from './bind-host.js'; +import { initializeConnectionToken } from './connection-token.js'; import { prewarmOAuthCredentials } from './modules/agent/oauth.js'; import { resolveDeploymentConfig } from './modules/security/deployment-config.js'; import { @@ -53,6 +54,7 @@ async function start(): Promise { } await initializeSecretStore(); + initializeConnectionToken(); await app.listen({ port: PORT, host: HOST }); // When bound to a wildcard address, "localhost" is still the URL a // browser on this machine would use — but log both so operators on a diff --git a/apps/web/e2e/overlay-panels.spec.ts b/apps/web/e2e/overlay-panels.spec.ts index 5de3ad20f..c0a199ddc 100644 --- a/apps/web/e2e/overlay-panels.spec.ts +++ b/apps/web/e2e/overlay-panels.spec.ts @@ -108,6 +108,51 @@ test('overlays never resize or pan Canvas and isolate mouse, wheel, touch and ke .not.toBe(before.transform); }); +test('panel descendants chain vertical wheel to their outer scroller', async ({ + page, +}) => { + await openNewCanvas(page); + await page.getByRole('button', { name: /open chat panel/i }).click(); + await settlePanels(page); + const before = await geometry(page); + const panel = page.locator('[data-canvas-panel="right"]'); + const fixture = await panel.evaluateHandle((element) => { + const outer = document.createElement('div'); + outer.dataset.testid = 'nested-scroll-outer'; + Object.assign(outer.style, { + position: 'absolute', + inset: '80px 24px auto 24px', + zIndex: '100', + height: '120px', + overflowY: 'auto', + }); + const target = document.createElement('div'); + target.dataset.testid = 'nested-scroll-target'; + Object.assign(target.style, { + height: '40px', + overflow: 'hidden', + }); + target.textContent = 'Wheel target'; + const spacer = document.createElement('div'); + spacer.style.height = '600px'; + outer.append(target, spacer); + element.append(outer); + return outer; + }); + + const target = page.getByTestId('nested-scroll-target'); + await target.hover(); + await page.mouse.wheel(0, 200); + await expect + .poll(() => + fixture.evaluate((element) => (element as HTMLElement).scrollTop), + ) + .toBeGreaterThan(0); + expect(await geometry(page)).toEqual(before); + + await fixture.dispose(); +}); + test('node preview never moves Canvas even when the target is obstructed', async ({ page, }, testInfo) => { diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 58d44bc9b..52d205e4b 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -16,6 +16,10 @@ const enc = encodeURIComponent; export const routes = { // ── Deployment ──────────────────────────────────────────────────── deploymentReadiness: '/deployment/readiness', + canaryRedeployStatus: '/deployment/canary', + canaryRedeployConfig: '/deployment/canary/config', + canaryRedeployCheck: '/deployment/canary/check', + canaryRedeploy: '/deployment/canary/redeploy', agentDefaults: '/agent/defaults', // ── Workspace ───────────────────────────────────────────────────── @@ -146,8 +150,12 @@ export const routes = { agentChangeReviewConfig: '/agent-change-review/config', // ── ACP (external agent bridge) ─────────────────────────────────── - acpAgentCli: (profileId?: string) => - `/acp/agent-cli${profileId ? `?profileId=${enc(profileId)}` : ''}`, + acpAgentCli: (target: { profileId?: string; agentletId?: string }) => { + const params = target.profileId + ? `profileId=${enc(target.profileId)}` + : `agentletId=${enc(target.agentletId ?? '')}`; + return `/acp/agent-cli?${params}`; + }, // Profiles (loopback-only) — user-managed spawn recipes. acpProfiles: '/acp/profiles', acpProfileLaunchPreview: '/acp/profile-launch-preview', @@ -156,6 +164,8 @@ export const routes = { acpAgentlet: '/acp/agentlet', acpAgentletRestart: '/acp/agentlet/restart', acpRuntimeConfig: '/acp/runtime-config', + acpConnectionToken: '/acp/connection-token', + acpConnectionCommand: '/acp/connection-command', acpThreadCachedMeta: ( threadId: string, canvasId?: string, diff --git a/apps/web/src/api/acp.test.ts b/apps/web/src/api/acp.test.ts index 26697c41d..a4841ea6d 100644 --- a/apps/web/src/api/acp.test.ts +++ b/apps/web/src/api/acp.test.ts @@ -18,19 +18,19 @@ describe('ACP Profile editing API', () => { it('routes discovery to the saved Profile and encodes its identity', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}')); vi.stubGlobal('fetch', fetch); - await listAcpAgentClis('remote/#1'); + await listAcpAgentClis({ profileId: 'remote/#1' }); expect(fetch).toHaveBeenCalledWith( expect.stringMatching(/\/api\/acp\/agent-cli\?profileId=remote%2F%231$/), expect.any(Object), ); }); - it('uses supervised-daemon discovery for creation', async () => { + it('routes creation discovery to the selected Agentlet', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}')); vi.stubGlobal('fetch', fetch); - await listAcpAgentClis(); + await listAcpAgentClis({ agentletId: 'device/#1' }); expect(fetch).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/acp\/agent-cli$/), + expect.stringMatching(/\/api\/acp\/agent-cli\?agentletId=device%2F%231$/), expect.any(Object), ); }); diff --git a/apps/web/src/api/acp.ts b/apps/web/src/api/acp.ts index d03fc3576..f8965f44f 100644 --- a/apps/web/src/api/acp.ts +++ b/apps/web/src/api/acp.ts @@ -18,6 +18,8 @@ * recipes with revision-checked launch and working-directory edits. * - `POST /api/acp/profile-launch-preview` — daemon-built launch preview. * - `GET/POST /api/acp/daemon` — daemon liveness + manual restart. + * - `GET/PUT /api/acp/connection-token` — masked credential configuration. + * - `POST /api/acp/connection-command` — explicit owner-only command reveal. * - `GET /api/acp/threads/:threadId/cached-meta` — cached capabilities. * - thread control POSTs — canonical realization plus per-session knobs. */ @@ -27,6 +29,7 @@ import { routes } from './_routes'; import type { AcpAgentCliListResponse, + AcpAgentCliQuery, AcpAgentletStatus, AcpAgentletStatusResponse, AcpPermissionDecisionRequest, @@ -45,11 +48,15 @@ import type { SetAcpSessionModeRequest, SetAcpSessionModeResponse, ExternalAgentRuntimeConfig, + ConnectionTokenConfig, + ConnectionTokenUpdate, + AgentletConnectionCommandResponse, WarmAcpSessionRequest, WarmAcpSessionResponse, } from '@huabu/shared'; export type { + AcpAgentCliQuery, AcpAgentCliInfo, AcpAgentCliListResponse, AcpAgentProfile, @@ -76,6 +83,10 @@ export type { SetAcpSessionModeRequest, SetAcpSessionModeResponse, ExternalAgentRuntimeConfig, + ConnectionTokenConfig, + ConnectionTokenUpdate, + AgentletConnectionCommandResponse, + ConnectedAgentletDevice, WarmAcpSessionRequest, WarmAcpSessionResponse, } from '@huabu/shared'; @@ -83,12 +94,12 @@ export type { // ── Agent CLI detection ────────────────────────────────────────────── /** - * Read the supervised daemon's catalogue, or the saved Profile's target daemon. + * Read the explicitly selected Agentlet's catalogue. */ export async function listAcpAgentClis( - profileId?: string, + target: AcpAgentCliQuery, ): Promise { - return apiFetch(routes.acpAgentCli(profileId), { + return apiFetch(routes.acpAgentCli(target), { fallbackMessage: 'Failed to detect installed agent CLIs', }); } @@ -103,7 +114,7 @@ export async function listAcpProfiles(): Promise { } /** - * Create a command Profile on the local agentlet. The server allocates its id. + * Create a command Profile on its explicitly selected Agentlet. */ export async function createAcpProfile( payload: CreateAcpProfileBody, @@ -199,6 +210,33 @@ export async function updateExternalAgentRuntimeConfig( }); } +export async function getConnectionTokenConfig(): Promise { + return apiFetch(routes.acpConnectionToken, { + fallbackMessage: 'Failed to read the Agentlet connection token settings', + }); +} + +export async function updateConnectionToken( + update: ConnectionTokenUpdate, +): Promise { + return apiFetch(routes.acpConnectionToken, { + method: 'PUT', + json: update, + fallbackMessage: 'Failed to update the Agentlet connection token', + }); +} + +export async function createAgentletConnectionCommand(): Promise { + return apiFetch( + routes.acpConnectionCommand, + { + method: 'POST', + json: { origin: window.location.origin }, + fallbackMessage: 'Failed to create the Agentlet connection command', + }, + ); +} + /** * Fetch the GET-only capability observation for a thread and its Profile. * This never creates a workload or starts an ACP process. diff --git a/apps/web/src/api/deployment.ts b/apps/web/src/api/deployment.ts index 18436a235..e3c02f2da 100644 --- a/apps/web/src/api/deployment.ts +++ b/apps/web/src/api/deployment.ts @@ -4,10 +4,48 @@ import { apiFetch } from './_client'; import { routes } from './_routes'; -import type { DeploymentReadinessResponse } from '@huabu/shared'; +import type { + CanaryRedeployConfigUpdate, + CanaryRedeployStatusResponse, + DeploymentReadinessResponse, +} from '@huabu/shared'; export function getDeploymentReadiness(): Promise { return apiFetch(routes.deploymentReadiness, { fallbackMessage: 'Failed to load deployment readiness', }); } + +export function getCanaryRedeployStatus(): Promise { + return apiFetch(routes.canaryRedeployStatus, { + fallbackMessage: 'Failed to load Canary redeployment status', + }); +} + +export function checkCanaryRedeploy(): Promise { + return apiFetch(routes.canaryRedeployCheck, { + method: 'POST', + json: {}, + fallbackMessage: 'Failed to check the Canary branch', + }); +} + +export function updateCanaryRedeployConfig( + update: CanaryRedeployConfigUpdate, +): Promise { + return apiFetch(routes.canaryRedeployConfig, { + method: 'PUT', + json: update, + fallbackMessage: 'Failed to save the Canary branch', + }); +} + +export function requestCanaryRedeploy( + expectedBranch: string, +): Promise { + return apiFetch(routes.canaryRedeploy, { + method: 'POST', + json: { expectedBranch }, + fallbackMessage: 'Failed to start Canary redeployment', + }); +} diff --git a/apps/web/src/components/Nodes/question/questionCompose.test.ts b/apps/web/src/components/Nodes/question/questionCompose.test.ts index 6ea5dae2f..759c30e50 100644 --- a/apps/web/src/components/Nodes/question/questionCompose.test.ts +++ b/apps/web/src/components/Nodes/question/questionCompose.test.ts @@ -5,10 +5,8 @@ import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; const saveDraft = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const associateNode = vi.hoisted(() => vi.fn()); -const getDefaults = vi.hoisted(() => vi.fn()); -vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: getDefaults, -})); +const listProfiles = vi.hoisted(() => vi.fn()); +vi.mock('@/api/acp', () => ({ listAcpProfiles: listProfiles })); vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() })); vi.mock('@/api/canvas', async (importOriginal) => ({ ...(await importOriginal()), @@ -54,23 +52,34 @@ const view = { }, }; +const profileSnapshot = { + profiles: [ + { + id: 'global-profile', + alias: 'Global Profile', + agentletId: 'machine', + workingDirPath: '/workspace', + launch: { kind: 'acp-command' as const, command: 'agent' }, + }, + ], + selectableProfileIds: ['global-profile'], + agentlet: null, + agentDefaults: null, +}; + beforeEach(() => { + localStorage.clear(); saveDraft.mockClear(); associateNode.mockReset(); - getDefaults.mockReset().mockResolvedValue({ - defaults: { - profileId: 'global-profile', - functionalModel: 'utility-model', - }, - selectionState: 'available', - modelCapability: 'unknown', - }); + listProfiles.mockReset().mockResolvedValue(profileSnapshot); useAcpProfilesStore.setState({ loaded: false, error: null, profiles: [], + selectableProfileIds: [], agentDefaults: null, defaultsError: null, + recentConversationProfileId: null, }); vi.mocked(toast).mockClear(); useCanvasStore.getState()._setStateNoAutosave({ @@ -187,7 +196,7 @@ describe('Question conversation presentation', () => { ).toEqual({ kind: 'internal' }); }); - it('creates and focuses the global default instead of inheriting the Canvas selection', async () => { + it('creates and focuses the browser fallback instead of inheriting the Canvas selection', async () => { const binding = { kind: 'external' as const, profileId: 'profile-1', @@ -210,7 +219,7 @@ describe('Question conversation presentation', () => { ).toEqual({ kind: 'external', profileId: 'global-profile', - alias: 'global-profile', + alias: 'Global Profile', }); expect(addNode).toHaveBeenCalledWith( expect.objectContaining({ @@ -218,7 +227,7 @@ describe('Question conversation presentation', () => { agentBinding: { kind: 'external', profileId: 'global-profile', - alias: 'global-profile', + alias: 'Global Profile', }, agentMode: 'ask', }), @@ -227,10 +236,10 @@ describe('Question conversation presentation', () => { }); it('does not create or open a node when defaults are unconfigured', async () => { - getDefaults.mockResolvedValueOnce({ - defaults: { profileId: null, functionalModel: '' }, - selectionState: 'unconfigured', - modelCapability: 'unknown', + listProfiles.mockResolvedValueOnce({ + ...profileSnapshot, + profiles: [], + selectableProfileIds: [], }); const addNode = vi.fn(); @@ -248,11 +257,7 @@ describe('Question conversation presentation', () => { }); it('creates a Built-In Question in operate mode without loading external Profiles', async () => { - getDefaults.mockResolvedValueOnce({ - defaults: { profileId: 'huabu', functionalModel: '' }, - selectionState: 'available', - modelCapability: 'supported', - }); + useAcpProfilesStore.setState({ recentConversationProfileId: 'huabu' }); const addNode = vi.fn().mockReturnValue('question-built-in'); const created = await createQuestionNodeAndCompose({ addNode, @@ -272,7 +277,7 @@ describe('Question conversation presentation', () => { it('discards delayed creation after the Canvas changes', async () => { let resolve!: (value: unknown) => void; - getDefaults.mockReturnValueOnce( + listProfiles.mockReturnValueOnce( new Promise((done) => { resolve = done; }), @@ -284,11 +289,7 @@ describe('Question conversation presentation', () => { placementPoint: { x: 0, y: 0 }, }); useCanvasStore.setState({ canvasId: 'canvas-2' }); - resolve({ - defaults: { profileId: 'global-profile', functionalModel: '' }, - selectionState: 'available', - modelCapability: 'unknown', - }); + resolve(profileSnapshot); expect(await pending).toBeNull(); expect(addNode).not.toHaveBeenCalled(); }); @@ -323,7 +324,7 @@ describe('Question conversation presentation', () => { { kind: 'internal' as const }, { kind: 'external' as const, profileId: 'chosen', alias: 'Chosen Agent' }, ])( - 'preserves the existing node selection %o despite a different global default', + 'preserves the existing node selection %o despite a different browser fallback', (binding) => { useAcpProfilesStore.setState({ loaded: true, @@ -354,7 +355,7 @@ describe('Question conversation presentation', () => { 'ask', ); expect(saveDraft).not.toHaveBeenCalled(); - expect(getDefaults).not.toHaveBeenCalled(); + expect(listProfiles).not.toHaveBeenCalled(); }, ); diff --git a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx index 315182fba..5efa68d9a 100644 --- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx +++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx @@ -29,13 +29,11 @@ const mocks = vi.hoisted(() => ({ captureGrounding: vi.fn(), blobToDataUrl: vi.fn(), getViewport: vi.fn(), - getDefaults: vi.fn(), + listProfiles: vi.fn(), popoverAnchor: null as unknown, })); -vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: mocks.getDefaults, -})); +vi.mock('@/api/acp', () => ({ listAcpProfiles: mocks.listProfiles })); vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() })); vi.mock('@xyflow/react', async (original) => ({ @@ -147,15 +145,18 @@ beforeEach(() => { ]; useAcpProfilesStore.setState({ profiles, + selectableProfileIds: ['default-profile'], agentDefaults: { profileId: 'default-profile', functionalModel: '' }, + recentConversationProfileId: 'default-profile', loaded: true, error: null, defaultsError: null, }); - mocks.getDefaults.mockReset().mockResolvedValue({ - defaults: { profileId: 'default-profile', functionalModel: '' }, - selectionState: 'available', - modelCapability: 'unknown', + mocks.listProfiles.mockReset().mockResolvedValue({ + profiles, + selectableProfileIds: ['default-profile'], + agentlet: null, + agentDefaults: null, }); useGesturePreviewStore.setState({ sketchStrokeSelection: { 'sketch-1': ['stroke-1'] }, @@ -211,11 +212,11 @@ afterEach(() => { }); describe('StrokeSelectionToolbar Ink submission', () => { - it('loads and snapshots the default external Profile for a new Ink Question', async () => { + it('loads and snapshots the browser fallback for a new Ink Question', async () => { mocks.dispatch.mockResolvedValueOnce({ status: 'completed' }); const button = await renderToolbar(); await act(async () => button.click()); - expect(mocks.getDefaults).toHaveBeenCalledOnce(); + expect(mocks.listProfiles).toHaveBeenCalledOnce(); expect(mocks.createQuestion).toHaveBeenCalledWith( expect.objectContaining({ binding: { @@ -232,8 +233,8 @@ describe('StrokeSelectionToolbar Ink submission', () => { ); }); - it('keeps the Ink selection and creates nothing when defaults are unavailable', async () => { - mocks.getDefaults.mockRejectedValueOnce(new Error('Server unavailable')); + it('keeps the Ink selection and creates nothing when Profiles are unavailable', async () => { + mocks.listProfiles.mockRejectedValueOnce(new Error('Server unavailable')); const button = await renderToolbar(); await act(async () => button.click()); expect(mocks.createQuestion).not.toHaveBeenCalled(); @@ -248,12 +249,8 @@ describe('StrokeSelectionToolbar Ink submission', () => { ); }); - it('restores operate mode for new Ink Questions with a Built-In default', async () => { - mocks.getDefaults.mockResolvedValueOnce({ - defaults: { profileId: 'huabu', functionalModel: '' }, - selectionState: 'available', - modelCapability: 'supported', - }); + it('restores operate mode for new Ink Questions with a recent Built-In selection', async () => { + useAcpProfilesStore.setState({ recentConversationProfileId: 'huabu' }); const button = await renderToolbar(); await act(async () => button.click()); expect(mocks.createQuestion).toHaveBeenCalledWith( @@ -267,11 +264,12 @@ describe('StrokeSelectionToolbar Ink submission', () => { ); }); - it('requires a configured default instead of falling back to the internal Agent', async () => { - mocks.getDefaults.mockResolvedValueOnce({ - defaults: { profileId: null, functionalModel: '' }, - selectionState: 'unconfigured', - modelCapability: 'unknown', + it('requires an external Profile instead of falling back to the internal Agent', async () => { + mocks.listProfiles.mockResolvedValueOnce({ + profiles: [], + selectableProfileIds: [], + agentlet: null, + agentDefaults: null, }); const button = await renderToolbar(); await act(async () => button.click()); @@ -284,7 +282,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { 'does not create an Ink Question after %s changes during default loading', async (change) => { let resolveDefaults!: (value: unknown) => void; - mocks.getDefaults.mockImplementationOnce( + mocks.listProfiles.mockImplementationOnce( () => new Promise((resolve) => { resolveDefaults = resolve; @@ -304,9 +302,10 @@ describe('StrokeSelectionToolbar Ink submission', () => { }); } resolveDefaults({ - defaults: { profileId: 'default-profile', functionalModel: '' }, - selectionState: 'available', - modelCapability: 'unknown', + profiles: useAcpProfilesStore.getState().profiles, + selectableProfileIds: ['default-profile'], + agentlet: null, + agentDefaults: null, }); }); expect(mocks.createQuestion).not.toHaveBeenCalled(); @@ -601,7 +600,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { expect(mocks.prepare).toHaveBeenCalledWith( expect.objectContaining({ mode: 'operate' }), ); - expect(mocks.getDefaults).not.toHaveBeenCalled(); + expect(mocks.listProfiles).not.toHaveBeenCalled(); expect(mocks.createQuestion).not.toHaveBeenCalled(); }); @@ -647,7 +646,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { }), }), ); - expect(mocks.getDefaults).not.toHaveBeenCalled(); + expect(mocks.listProfiles).not.toHaveBeenCalled(); }); it('creates and dispatches at most once for rapid activation', async () => { @@ -704,7 +703,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { expect(mocks.createQuestion).toHaveBeenCalledTimes(1); expect(mocks.dispatch).toHaveBeenCalledTimes(2); - expect(mocks.getDefaults).toHaveBeenCalledTimes(1); + expect(mocks.listProfiles).toHaveBeenCalledTimes(1); }); it('retains an ambiguous reservation until Stop confirms no acceptance', async () => { diff --git a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx index a4c19c0f9..af111ac14 100644 --- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx +++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx @@ -44,6 +44,7 @@ import { isOutsideCanvasInteraction } from '@/hooks/shortcuts/isEditableTarget'; import { useIsNotMouse } from '@/hooks/useInputMode'; import { loadDefaultAgentBinding, + selectDefaultConversationProfileId, useAcpProfilesStore, } from '@/store/acpProfilesStore'; import useCanvasStore from '@/store/canvasStore'; @@ -150,8 +151,8 @@ export const StrokeSelectionToolbar = () => { targetThreadId ? selectThreadLastAction(state, targetThreadId) : null, ); const agentProfiles = useAcpProfilesStore((state) => state.profiles); - const defaultProfileId = useAcpProfilesStore( - (state) => state.agentDefaults?.profileId, + const recentProfileId = useAcpProfilesStore( + selectDefaultConversationProfileId, ); const currentLassoIdentity = useCallback( @@ -529,9 +530,9 @@ export const StrokeSelectionToolbar = () => { } if (!candidate.target) { const name = - defaultProfileId === 'huabu' + recentProfileId === 'huabu' ? t('settings.builtInPi') - : (agentProfiles.find((profile) => profile.id === defaultProfileId) + : (agentProfiles.find((profile) => profile.id === recentProfileId) ?.alias ?? t('toolbar.defaultInkAgentTarget')); return { label: t('toolbar.newInkAgentTarget', { name }), @@ -554,7 +555,7 @@ export const StrokeSelectionToolbar = () => { }; }, [ agentProfiles, - defaultProfileId, + recentProfileId, cachedTargetBinding, cachedTargetMode, candidate, diff --git a/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx b/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx index 5606e68fd..484a20d72 100644 --- a/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx +++ b/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx @@ -52,7 +52,7 @@ export function MoveSelectionPanel({ const nameRef = useRef(null); const [selectedDestination, setSelectedDestination] = useState(''); const [newSpaceTitle, setNewSpaceTitle] = useState(''); - const [createSourcePreview, setCreateSourcePreview] = useState(true); + const [createSourcePreview, setCreateSourcePreview] = useState(false); const creatingNewSpace = selectedDestination === NEW_SPACE_DESTINATION; const destinationCanvasId = options.some( (option) => option.value === selectedDestination, diff --git a/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx b/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx index 688f7317c..4d9801cd4 100644 --- a/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx +++ b/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx @@ -287,8 +287,9 @@ describe('MoveSelectionPopover', () => { const trigger = await openPanel(); const checkbox = document.querySelector('[type="checkbox"]'); - act(() => checkbox?.click()); expect(checkbox?.checked).toBe(false); + act(() => checkbox?.click()); + expect(checkbox?.checked).toBe(true); act(() => document.body.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }), @@ -300,7 +301,7 @@ describe('MoveSelectionPopover', () => { ); expect( document.querySelector('[type="checkbox"]')?.checked, - ).toBe(true); + ).toBe(false); }); it('locks submission and keeps the captured selection while the move is pending', async () => { @@ -331,7 +332,7 @@ describe('MoveSelectionPopover', () => { 'source', expect.objectContaining({ selectedNodeIds: ['node-selected'], - createSourcePreview: true, + createSourcePreview: false, }), ); await act(async () => finish?.(moveResult)); @@ -423,7 +424,7 @@ describe('MoveSelectionPopover', () => { ); expect(document.querySelector('[role="dialog"]')).not.toBeNull(); expect(document.querySelector('[type="checkbox"]')).toBe(checkbox); - expect(checkbox?.checked).toBe(false); + expect(checkbox?.checked).toBe(true); }); it.each(Object.entries(knownErrors))( @@ -661,7 +662,7 @@ describe('MoveSelectionPopover', () => { expect(moveCanvasSelection).toHaveBeenCalledWith('source', { selectedNodeIds: ['node-selected'], destination: { kind: 'new', title: 'New destination' }, - createSourcePreview: true, + createSourcePreview: false, expectedSourceVersion: 3, }); expect(useWorkspaceStore.getState().spaceTitles).toEqual( @@ -708,6 +709,11 @@ describe('MoveSelectionPopover', () => { document.querySelectorAll('[role="option"]'), ).find((button) => button.textContent === 'Destination'); act(() => destination?.click()); + act(() => + document + .querySelector('input[type="checkbox"]') + ?.click(), + ); const move = Array.from(document.querySelectorAll('button')).find( (button) => button.textContent === 'moveSelection.confirm', ); @@ -721,7 +727,7 @@ describe('MoveSelectionPopover', () => { }); }); - it('defaults the source Preview checkbox on and allows disabling it', async () => { + it('defaults the source Preview checkbox off and allows enabling it', async () => { listCanvases.mockResolvedValue({ canvases: [{ canvasId: 'destination', title: 'Destination' }], }); @@ -747,8 +753,8 @@ describe('MoveSelectionPopover', () => { const checkbox = document.querySelector( 'input[type="checkbox"]', ); - expect(checkbox?.checked).toBe(true); - act(() => checkbox?.click()); expect(checkbox?.checked).toBe(false); + act(() => checkbox?.click()); + expect(checkbox?.checked).toBe(true); }); }); diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx new file mode 100644 index 000000000..1ab9d8a38 --- /dev/null +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + afterEach, + assert, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { i18n } from '@/i18n'; + +import { WorkingDirectoryOverride } from './WorkingDirectoryOverride'; + +let container: HTMLDivElement; +let root: Root; + +async function render( + props: React.ComponentProps, +) { + await act(async () => root.render()); +} + +async function openPopover() { + const trigger = container.querySelector('button'); + assert(trigger); + await act(async () => trigger.click()); +} + +function nodeOverrideInput(): HTMLInputElement { + const input = document.body.querySelector( + 'input[aria-label="Node override"]', + ); + assert(input); + return input; +} + +beforeEach(async () => { + await i18n.changeLanguage('en'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +describe('WorkingDirectoryOverride', () => { + it('shows Profile inheritance and updates it without persisting a copy', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileAlias: 'First Profile', + profileWorkingDirPath: '/profiles/first', + editable: true, + saving: false, + onSave, + }); + expect(document.body.textContent).not.toContain( + 'Inheriting Profile directory', + ); + await openPopover(); + + const input = nodeOverrideInput(); + expect(input.placeholder).toBe('/profiles/first'); + expect(document.body.textContent).toContain( + 'Inheriting Profile directory: /profiles/first', + ); + expect(document.body.textContent).toContain('First Profile'); + + await render({ + profileAlias: 'Second Profile', + profileWorkingDirPath: '/profiles/second', + editable: true, + saving: false, + onSave, + }); + const updatedInput = document.body.querySelector( + 'input[aria-label="Node override"]', + ); + expect(updatedInput?.value).toBe(''); + expect(updatedInput?.placeholder).toBe('/profiles/second'); + expect(document.body.textContent).toContain('Second Profile'); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('preserves an explicit override across Profile changes and clears to null', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileAlias: 'First Profile', + profileWorkingDirPath: '/profiles/first', + workingDirPath: '/node/work', + editable: true, + saving: false, + onSave, + }); + await openPopover(); + await render({ + profileAlias: 'Second Profile', + profileWorkingDirPath: '/profiles/second', + workingDirPath: '/node/work', + editable: true, + saving: false, + onSave, + }); + const input = nodeOverrideInput(); + expect(input.value).toBe('/node/work'); + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + assert(valueSetter); + valueSetter.call(input, ''); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); + expect(onSave).toHaveBeenCalledWith(null); + }); + + it('keeps locked explicit and inherited directories available read-only', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileAlias: 'Default Profile', + profileWorkingDirPath: '/profiles/default', + workingDirPath: '/node/work', + editable: false, + saving: false, + onSave, + }); + expect(container.textContent).not.toContain('/node/work'); + await openPopover(); + expect(document.body.textContent).toContain('/node/work'); + expect(document.body.querySelector('input')).toBeNull(); + + await render({ + profileAlias: 'Default Profile', + profileWorkingDirPath: '/profiles/default', + editable: false, + saving: false, + onSave, + }); + expect(container.querySelector('button')).not.toBeNull(); + expect(document.body.textContent).toContain('/profiles/default'); + expect(document.body.querySelector('input')).toBeNull(); + }); + + it('surfaces server validation errors without replacing the draft', async () => { + const onSave = vi.fn().mockRejectedValue(new Error('Must be absolute')); + await render({ + profileAlias: 'Default Profile', + profileWorkingDirPath: '/profiles/default', + editable: true, + saving: false, + onSave, + }); + await openPopover(); + const input = nodeOverrideInput(); + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + assert(valueSetter); + valueSetter.call(input, 'relative/path'); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); + expect(document.body.querySelector('[role="alert"]')?.textContent).toBe( + 'Must be absolute', + ); + expect(input.value).toBe('relative/path'); + }); + + it('marks an explicit override and restores inheritance explicitly', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileAlias: 'Default Profile', + profileWorkingDirPath: '/profiles/default', + workingDirPath: '/node/work', + editable: true, + saving: false, + onSave, + }); + expect( + container.querySelector( + '[aria-label="Node working directory: /node/work"]', + ), + ).not.toBeNull(); + await openPopover(); + const restore = [...document.body.querySelectorAll('button')].find( + (button) => button.textContent === 'Restore Profile inheritance', + ); + assert(restore); + await act(async () => restore.click()); + expect(onSave).toHaveBeenCalledWith(null); + }); +}); diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx new file mode 100644 index 000000000..56dc8e492 --- /dev/null +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Folder } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/Common/Button'; +import { cn } from '@/components/Common/cn'; +import { Popover } from '@/components/Common/Popover'; +import { TextInput } from '@/components/Common/TextInput'; + +interface WorkingDirectoryOverrideProps { + profileAlias: string; + profileWorkingDirPath: string; + workingDirPath?: string; + editable: boolean; + saving: boolean; + onSave: (workingDirPath: string | null) => Promise; +} + +export function WorkingDirectoryOverride({ + profileAlias, + profileWorkingDirPath, + workingDirPath, + editable, + saving, + onSave, +}: WorkingDirectoryOverrideProps) { + const { t } = useTranslation(); + const triggerRef = useRef(null); + const inputRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [draft, setDraft] = useState(workingDirPath ?? ''); + const [error, setError] = useState(null); + + useEffect(() => { + setDraft(workingDirPath ?? ''); + setError(null); + }, [workingDirPath]); + + const hasOverride = Boolean(workingDirPath); + const effectivePath = workingDirPath ?? profileWorkingDirPath; + const triggerTitle = hasOverride + ? t('chat.workingDirectoryOverrideActive', { path: workingDirPath }) + : t('chat.workingDirectoryOverrideInherited', { + path: profileWorkingDirPath, + }); + + const commit = async () => { + const trimmed = draft.trim(); + if (trimmed === (workingDirPath ?? '')) return; + setError(null); + try { + await onSave(trimmed || null); + } catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : t('chat.workingDirectoryOverrideSaveFailed'), + ); + } + }; + + return ( + <> + + {isOpen ? ( + setIsOpen(false)} + onOpenAutoFocus={() => inputRef.current?.focus()} + className="w-[min(24rem,var(--popover-available-width))] p-3" + > +
+
+
+ {t('chat.workingDirectoryOverride')} +
+
+ {editable + ? t('chat.workingDirectoryOverrideDescription') + : t('chat.workingDirectoryNodeOnlyLocked')} +
+
+
+
+ {t('chat.workingDirectoryProfile')} +
+
{profileAlias}
+ {profileWorkingDirPath ? ( + <> +
+ {t('chat.workingDirectoryProfileDefault')} +
+
+ {profileWorkingDirPath} +
+ + ) : null} +
+ {t('chat.workingDirectoryEffective')} +
+
+ {effectivePath} +
+
+ {editable ? ( +
+ + setDraft(event.target.value)} + onBlur={() => void commit()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.currentTarget.blur(); + } else if (event.key === 'Escape') { + setDraft(workingDirPath ?? ''); + setError(null); + } + }} + placeholder={profileWorkingDirPath} + aria-label={t('chat.workingDirectoryNodeOverride')} + mono + disabled={saving} + className="w-full" + /> +
+ {draft.trim() + ? t('chat.workingDirectoryNodeOnly') + : t('chat.workingDirectoryInherited', { + path: profileWorkingDirPath, + })} +
+ {error ? ( +
+ {error} +
+ ) : null} + {workingDirPath ? ( + + ) : null} +
+ ) : null} +
+
+ ) : null} + + ); +} diff --git a/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx b/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx index 03a120284..f21428391 100644 --- a/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx +++ b/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx @@ -234,5 +234,5 @@ export function useAddAgentEditor( _onRefreshProfiles?: () => void | Promise, ): { openEditor: () => void; editor: ReactNode } { const openSettings = useSettingsUiStore((s) => s.open); - return { openEditor: () => openSettings('agents'), editor: null }; + return { openEditor: () => openSettings('agent'), editor: null }; } diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx index e33afb503..77c67e5ee 100644 --- a/apps/web/src/components/Panels/ChatPanel/index.tsx +++ b/apps/web/src/components/Panels/ChatPanel/index.tsx @@ -30,7 +30,10 @@ import { useActivelyViewingQuestionNode } from '@/hooks/useActivelyViewingQuesti import { useBuiltinThreadSettings } from '@/hooks/useBuiltinThreadSettings'; import { ChatSessionProvider, type ChatSession } from '@/hooks/useChatSession'; import { useInternalSlashCommands } from '@/hooks/useInternalSlashCommands'; -import { useAcpProfilesStore } from '@/store/acpProfilesStore'; +import { + rememberConversationAgentBinding, + useAcpProfilesStore, +} from '@/store/acpProfilesStore'; import { useAcpThreadChangesStore } from '@/store/acpThreadChangesStore'; import useCanvasStore from '@/store/canvasStore'; import { useChatPreferencesStore } from '@/store/chatPreferencesStore'; @@ -47,6 +50,7 @@ import { acknowledgeConversationResult, awaitConversationDraft, saveConversationDraft, + saveConversationWorkingDirectoryOverride, resolveConversationAgentBinding, resolveConversationOwnerSource, } from '@/store/conversationOwner'; @@ -75,6 +79,7 @@ import { ChangeReviewCard } from './ChangeReviewCard'; import { parseSlashInvocations } from './parseSlashInvocations'; import { saveChatAsQuestion } from './saveChatAsQuestion'; import { ThreadChatInput } from './ThreadChatInput'; +import { WorkingDirectoryOverride } from './WorkingDirectoryOverride'; import { useAgentStream } from '../../../hooks/useAgentStream'; import { useChatHistory } from '../../../hooks/useChatHistory'; import { MessageList } from '../../Messages/MessageList'; @@ -166,6 +171,7 @@ export const ChatPanel = ({ conversationOwnerSource?.agentBindingPolicy === 'fixed' || conversationOwnerSource?.bindingState === 'bound'; const [savingAgentDraft, setSavingAgentDraft] = useState(false); + const [savingWorkingDirectory, setSavingWorkingDirectory] = useState(false); const activelyViewingOwner = useActivelyViewingQuestionNode( activeConversationView?.presentationAnchor.nodeId ?? '', ); @@ -808,13 +814,41 @@ export const ChatPanel = ({ // read-only. Picking an agent rebinds the *current* (empty) thread in // place; it never mints a new thread. const threadHasUserMessage = messages.some((m) => m.role === 'user'); - const agentSelectorEditable = + const preparationEditable = !viewingQuestionBindingIsFixed && (activeConversationView ? conversationOwnerSource?.bindingState !== 'bound' : !threadHasUserMessage) && !savingAgentDraft && !isLoading; + const agentSelectorEditable = preparationEditable && !savingWorkingDirectory; + const selectedExternalProfile = + agentBinding.kind === 'external' + ? acpProfiles.find((profile) => profile.id === agentBinding.profileId) + : undefined; + const workingDirectoryOverride = + conversationOwnerSource?.agentLaunchOverrides?.workingDirPath; + const showWorkingDirectoryOverride = + !!activeConversationView && + agentBinding.kind === 'external' && + ((!!selectedExternalProfile && !!selectedExternalProfile.workingDirPath) || + !!workingDirectoryOverride); + const handleSaveWorkingDirectory = useCallback( + async (workingDirPath: string | null) => { + if (!activeConversationView) return; + setSavingWorkingDirectory(true); + try { + await saveConversationWorkingDirectoryOverride( + activeConversationView, + workingDirPath, + ); + onCommit?.(); + } finally { + setSavingWorkingDirectory(false); + } + }, + [activeConversationView, onCommit], + ); const handleSelectAgent = useCallback( async (choice: AgentChoice) => { // Agent binding is immutable once a turn starts (1 thread = 1 binding). @@ -846,6 +880,9 @@ export const ChatPanel = ({ setSavingAgentDraft(false); } } + if (!activeConversationView) { + rememberConversationAgentBinding(choice.binding); + } setAgentBinding(threadId, choice.binding, canvasId || undefined); setThreadLastAction(threadId, choice.mode); onCommit?.(); @@ -1054,16 +1091,37 @@ export const ChatPanel = ({ slashLoading={slashLoading} onSlashMenuIntent={refreshSlashCommands} agentSelectorSlot={ - +
+ + {showWorkingDirectoryOverride ? ( + + ) : null} +
} acpSelectorsSlot={ agentBinding.kind === 'external' ? ( diff --git a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx index 132f9b6aa..760a81bca 100644 --- a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx +++ b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx @@ -73,19 +73,20 @@ vi.mock('@/api/conversationTitles', () => ({ vi.mock('@/api/acp', async (importOriginal) => ({ ...(await importOriginal()), listAcpProfiles: async () => ({ - profiles: [], - selectableProfileIds: [], + profiles: [ + { + id: 'global-profile', + alias: 'Global Profile', + agentletId: 'machine', + workingDirPath: '/workspace', + launch: { kind: 'acp-command', command: 'agent' }, + }, + ], + selectableProfileIds: ['global-profile'], agentlet: null, agentDefaults: { profileId: 'global-profile', functionalModel: '' }, }), })); -vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: async () => ({ - defaults: { profileId: 'global-profile', functionalModel: '' }, - selectionState: 'available', - modelCapability: 'unknown', - }), -})); vi.mock('../ChatPanel', () => ({ ChatPanel: ({ diff --git a/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx b/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx new file mode 100644 index 000000000..8b852f61a --- /dev/null +++ b/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CanaryRedeploySettings } from './CanaryRedeploySettings'; + +import type { ModalProps } from '@/components/Common/Modal'; +import type { CanaryRedeployStatusResponse } from '@huabu/shared'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + check: vi.fn(), + save: vi.fn(), + redeploy: vi.fn(), + toast: vi.fn(), + t: (key: string) => key, +})); + +vi.mock('@/api/deployment', () => ({ + getCanaryRedeployStatus: mocks.getStatus, + checkCanaryRedeploy: mocks.check, + updateCanaryRedeployConfig: mocks.save, + requestCanaryRedeploy: mocks.redeploy, +})); +vi.mock('@/components/Common/Toast', () => ({ toast: mocks.toast })); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: mocks.t, + }), +})); +vi.mock('@/components/Common/Modal', () => ({ + Modal: ({ isOpen, footer }: ModalProps) => + isOpen ?
{footer}
: null, +})); + +const availableStatus: CanaryRedeployStatusResponse = { + available: true, + reason: 'available', + branch: 'alpha', + configuredBranch: null, + runningSha: 'a'.repeat(40), + remoteSha: 'b'.repeat(40), + updateAvailable: true, + checkedAt: 1, + redeploy: null, +}; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mocks.getStatus.mockResolvedValue(availableStatus); + mocks.check.mockResolvedValue(availableStatus); + mocks.save.mockResolvedValue({ + ...availableStatus, + branch: 'x/alpha', + configuredBranch: 'x/alpha', + }); + mocks.redeploy.mockResolvedValue({ + ...availableStatus, + redeploy: { state: 'requested', branch: 'alpha', startedAt: 2 }, + }); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +async function renderSettings() { + await act(async () => { + root.render(); + }); +} + +function button(label: string): HTMLButtonElement { + const result = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label, + ); + expect(result).toBeDefined(); + return result as HTMLButtonElement; +} + +describe('CanaryRedeploySettings', () => { + it('stays hidden when Canary redeployment is disabled', async () => { + mocks.getStatus.mockResolvedValueOnce({ + ...availableStatus, + available: false, + reason: 'disabled', + }); + await renderSettings(); + expect(container.textContent).toBe(''); + }); + + it('checks on mount and requires confirmation before redeploying', async () => { + await renderSettings(); + expect(mocks.check).toHaveBeenCalledOnce(); + + act(() => button('settings.canaryRedeployAction').click()); + await act(async () => { + button('settings.canaryConfirmAction').click(); + }); + + expect(mocks.redeploy).toHaveBeenCalledOnce(); + expect(mocks.redeploy).toHaveBeenCalledWith('alpha'); + expect(mocks.toast).toHaveBeenCalledWith('settings.canaryRedeployStarted', { + tone: 'info', + duration: 10_000, + }); + }); + + it('persists a configured branch and uses an empty value for the alpha default', async () => { + await renderSettings(); + const input = container.querySelector('input'); + expect(input?.placeholder).toBe('alpha'); + expect(input?.value).toBe(''); + + act(() => { + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setValue?.call(input, 'x/alpha'); + input?.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => button('settings.canaryBranchSave').click()); + + expect(mocks.save).toHaveBeenCalledWith({ branch: 'x/alpha' }); + expect(input?.value).toBe('x/alpha'); + + act(() => button('settings.canaryRedeployAction').click()); + await act(async () => { + button('settings.canaryConfirmAction').click(); + }); + expect(mocks.redeploy).toHaveBeenCalledWith('x/alpha'); + }); +}); diff --git a/apps/web/src/components/Settings/CanaryRedeploySettings.tsx b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx new file mode 100644 index 000000000..22c37f33f --- /dev/null +++ b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + checkCanaryRedeploy, + getCanaryRedeployStatus, + requestCanaryRedeploy, + updateCanaryRedeployConfig, +} from '@/api/deployment'; +import { Button } from '@/components/Common/Button'; +import { Modal } from '@/components/Common/Modal'; +import { TextInput } from '@/components/Common/TextInput'; +import { toast } from '@/components/Common/Toast'; +import { SettingRow } from '@/components/Settings/Common/SettingRow'; + +import type { CanaryRedeployStatusResponse } from '@huabu/shared'; + +function shortSha(sha: string | null): string { + return sha?.slice(0, 7) ?? 'unknown'; +} + +export function CanaryRedeploySettings() { + const { t } = useTranslation(); + const branchInputId = useId(); + const [status, setStatus] = useState( + null, + ); + const [branchDraft, setBranchDraft] = useState(''); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [saving, setSaving] = useState(false); + const [requesting, setRequesting] = useState(false); + const [confirming, setConfirming] = useState(false); + const confirmRef = useRef(null); + + const check = useCallback( + async (showToast: boolean) => { + setChecking(true); + try { + const next = await checkCanaryRedeploy(); + setStatus(next); + if (showToast) { + toast( + next.updateAvailable + ? t('settings.canaryUpdateAvailable', { branch: next.branch }) + : t('settings.canaryUpToDate', { branch: next.branch }), + { tone: next.updateAvailable ? 'info' : 'success' }, + ); + } + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.canaryCheckFailed'), + { tone: 'danger' }, + ); + } finally { + setChecking(false); + } + }, + [t], + ); + + useEffect(() => { + let active = true; + void getCanaryRedeployStatus() + .then((initial) => { + if (!active) return; + setStatus(initial); + setBranchDraft(initial.configuredBranch ?? ''); + if (initial.available) void check(false); + }) + .catch((error: unknown) => { + if (!active) return; + setStatus(null); + toast( + error instanceof Error + ? error.message + : t('settings.canaryStatusFailed'), + { tone: 'danger' }, + ); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [check, t]); + + const saveBranch = useCallback(async () => { + setSaving(true); + try { + const next = await updateCanaryRedeployConfig({ + branch: branchDraft.trim() || null, + }); + setStatus(next); + setBranchDraft(next.configuredBranch ?? ''); + toast(t('settings.canaryBranchSaved', { branch: next.branch }), { + tone: 'success', + }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.canaryBranchSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setSaving(false); + } + }, [branchDraft, t]); + + const redeploy = useCallback(async () => { + if (!status) return; + setRequesting(true); + try { + const next = await requestCanaryRedeploy(status.branch); + setStatus(next); + setConfirming(false); + toast(t('settings.canaryRedeployStarted', { branch: next.branch }), { + tone: 'info', + duration: 10_000, + }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.canaryRedeployFailed'), + { tone: 'danger' }, + ); + } finally { + setRequesting(false); + } + }, [status, t]); + + if (loading || !status?.available) return null; + + const outcome = status.redeploy + ? t(`settings.canaryState_${status.redeploy.state}`) + : t('settings.canaryNeverRedeployed'); + const redeployInProgress = + status.redeploy?.state === 'requested' || + status.redeploy?.state === 'running'; + const busy = checking || saving || requesting || redeployInProgress; + const description = t('settings.canaryDescription', { + branch: status.branch, + running: shortSha(status.runningSha), + remote: shortSha(status.remoteSha), + redeployBranch: status.redeploy?.branch ?? status.branch, + outcome, + }); + + return ( + <> + +
+ + +
+
+ +
+ setBranchDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !busy) void saveBranch(); + }} + /> + +
+
+ { + if (!requesting) setConfirming(false); + }} + title={t('settings.canaryConfirmTitle', { branch: status.branch })} + description={t('settings.canaryConfirmDescription', { + branch: status.branch, + })} + initialFocusRef={confirmRef} + closeOnBackdropClick={!requesting} + closeOnEscape={!requesting} + footer={ + <> + + + + } + /> + + ); +} diff --git a/apps/web/src/components/Settings/Common/SettingRow.tsx b/apps/web/src/components/Settings/Common/SettingRow.tsx index acf5b462b..bba51833d 100644 --- a/apps/web/src/components/Settings/Common/SettingRow.tsx +++ b/apps/web/src/components/Settings/Common/SettingRow.tsx @@ -3,6 +3,8 @@ import React from 'react'; +import { cn } from '@/components/Common/cn'; + interface SettingRowProps { /** Primary label for the setting. Omit when the section heading already names it. */ title?: React.ReactNode; @@ -18,12 +20,15 @@ interface SettingRowProps { className?: string; /** Reduces vertical padding for subordinate settings. */ density?: 'default' | 'compact'; + /** Places controls beside the label or in a full-width row below it. */ + layout?: 'inline' | 'stacked'; } /** * A single setting row inside a {@link SettingSection} card. Renders the - * title (and optional description) on the left and a control on the right. - * The row itself is borderless — dividers come from the parent section. + * title (and optional description) with its controls. Inline rows place the + * control on the right; stacked rows place it full-width below the text. The + * row itself is borderless — dividers come from the parent section. */ export const SettingRow: React.FC = ({ title, @@ -33,10 +38,18 @@ export const SettingRow: React.FC = ({ children, className = '', density = 'default', + layout = 'inline', }) => { + const stacked = layout === 'stacked'; + return (
{leading &&
{leading}
} @@ -59,7 +72,7 @@ export const SettingRow: React.FC = ({ )}
-
{children}
+
{children}
); }; diff --git a/apps/web/src/components/Settings/SettingsModal.test.tsx b/apps/web/src/components/Settings/SettingsModal.test.tsx index a64c92e48..236f7e074 100644 --- a/apps/web/src/components/Settings/SettingsModal.test.tsx +++ b/apps/web/src/components/Settings/SettingsModal.test.tsx @@ -9,13 +9,15 @@ import { SettingsModal } from './SettingsModal'; import type { Root } from 'react-dom/client'; +type RequestedTab = 'builtIn' | 'capabilities' | null; + const mocks = vi.hoisted(() => ({ init: vi.fn(), load: vi.fn(), clear: vi.fn(), llmInit: vi.fn(), profileId: 'external', - requestedTab: null as 'builtIn' | null, + requestedTab: null as RequestedTab, })); vi.mock('react-i18next', () => ({ @@ -48,7 +50,7 @@ vi.mock('@/store/deploymentReadinessStore', () => ({ vi.mock('@/store/settingsUiStore', () => ({ useSettingsUiStore: ( selector: (state: { - requestedTab: 'builtIn' | null; + requestedTab: RequestedTab; clearRequestedTab: typeof mocks.clear; }) => unknown, ) => @@ -66,17 +68,26 @@ vi.mock('./agent-profiles/ExternalAgentsSettings', () => ({ vi.mock('./DeploymentReadinessNotice', () => ({ DeploymentReadinessNotice: () => null, })); +vi.mock('./sections/AgentBehaviorSettings', () => ({ + AgentBehaviorSettings: () =>
, +})); +vi.mock('./sections/ExternalAgentRuntimeSettings', () => ({ + ExternalAgentRuntimeSettings: () =>
, +})); vi.mock('./sections/GeneralSettings', () => ({ - GeneralSettings: () => null, + GeneralSettings: () =>
, })); vi.mock('./sections/LLMSettings', () => ({ - LLMSettings: () =>
, + LLMSettings: () =>
, })); vi.mock('./sections/ImageProviderSettings', () => ({ - ImageProviderSettings: () => null, + ImageProviderSettings: () =>
, })); vi.mock('./sections/IntegrationsSettings', () => ({ - IntegrationsSettings: () => null, + IntegrationsSettings: () =>
, +})); +vi.mock('./sections/InkOcrSettings', () => ({ + InkOcrSettings: () =>
, })); globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -91,83 +102,154 @@ beforeEach(() => { document.body.appendChild(container); root = createRoot(container); }); + afterEach(() => { act(() => root.unmount()); container.remove(); }); -describe('Settings default Agent placement', () => { - it('hides Pi provider settings for external defaults while retaining Profile management', async () => { - await act(async () => { - root.render(); - }); +async function renderModal(isOpen = true) { + await act(async () => { + root.render(); + }); +} + +function findTab(label: string): HTMLButtonElement { + const tab = [...container.querySelectorAll('nav button')].find( + (button) => button.textContent === label, + ); + expect(tab).toBeDefined(); + return tab as HTMLButtonElement; +} + +describe('Settings information architecture', () => { + it('co-locates Agent defaults, Profiles, behavior, and runtime settings', async () => { + await renderModal(); + expect( - container.querySelectorAll('[data-testid="agent-defaults"]'), - ).toHaveLength(1); - expect(container.querySelector('[data-testid="legacy-llm"]')).toBeNull(); - expect(mocks.llmInit).not.toHaveBeenCalled(); + container.querySelector('[data-testid="agent-defaults"]'), + ).not.toBeNull(); expect( container.querySelector('[data-testid="profile-management"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-behavior"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-runtime"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="built-in-settings"]'), ).toBeNull(); + const profiles = container.querySelector( + '[data-testid="profile-management"]', + ); + const defaults = container.querySelector('[data-testid="agent-defaults"]'); + if (!profiles || !defaults) { + throw new Error('Expected Agent Profiles and Utility Agent sections'); + } + expect( + profiles.compareDocumentPosition(defaults) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(profiles.parentElement?.classList.contains('mb-4')).toBe(true); + expect(mocks.init).toHaveBeenCalled(); + expect(mocks.llmInit).not.toHaveBeenCalled(); + }); + + it('keeps Huabu-owned capabilities separate from Agent configuration', async () => { + await renderModal(); + + await act(async () => { + findTab('settings.capabilities').click(); + }); - const tabs = [...container.querySelectorAll('nav button')]; - const externalTab = tabs.find( - (tab) => tab.textContent === 'settings.externalAgents', - ) as HTMLButtonElement; - await act(async () => externalTab.click()); + expect(container.textContent).toContain('settings.capabilitiesDescription'); + expect( + container + .querySelector('[data-testid="capability-sections"]') + ?.classList.contains('space-y-4'), + ).toBe(true); + expect( + container.querySelector('[data-testid="image-settings"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="integration-settings"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="ocr-settings"]'), + ).not.toBeNull(); expect( container.querySelector('[data-testid="agent-defaults"]'), ).toBeNull(); expect( container.querySelector('[data-testid="profile-management"]'), + ).toBeNull(); + expect(mocks.llmInit).not.toHaveBeenCalled(); + }); + + it('leaves only non-Agent preferences in General', async () => { + await renderModal(); + + await act(async () => { + findTab('settings.general').click(); + }); + + expect( + container.querySelector('[data-testid="general-settings"]'), ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-behavior"]'), + ).toBeNull(); + expect(container.querySelector('[data-testid="agent-runtime"]')).toBeNull(); + expect(container.querySelector('[data-testid="ocr-settings"]')).toBeNull(); + }); +}); - const huabuTab = tabs.find( - (tab) => tab.textContent === 'settings.huabuAgent', - ) as HTMLButtonElement; - await act(async () => huabuTab.click()); +describe('Built-In Pi placement', () => { + it('shows Built-In settings with the unified Agent surface when selected by default', async () => { + mocks.profileId = 'huabu'; + await renderModal(); + + expect( + container.querySelector('[data-testid="built-in-settings"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-defaults"]'), + ).not.toBeNull(); expect( - container.querySelectorAll('[data-testid="agent-defaults"]'), - ).toHaveLength(1); - expect(container.querySelector('[data-testid="legacy-llm"]')).toBeNull(); + container.querySelector('[data-testid="profile-management"]'), + ).not.toBeNull(); + expect(mocks.llmInit).toHaveBeenCalled(); }); - it('hides Pi settings again after closing an explicit repair visit', async () => { + it('preserves the focused Built-In repair visit without changing the default', async () => { mocks.requestedTab = 'builtIn'; - await act(async () => - root.render(), - ); + await renderModal(); + expect( - container.querySelector('[data-testid="legacy-llm"]'), + container.querySelector('[data-testid="built-in-settings"]'), ).not.toBeNull(); expect( container.querySelector('[data-testid="agent-defaults"]'), ).toBeNull(); + expect( + container.querySelector('[data-testid="profile-management"]'), + ).toBeNull(); + expect(mocks.init).not.toHaveBeenCalled(); + mocks.requestedTab = null; - await act(async () => - root.render(), - ); - await act(async () => - root.render(), - ); - expect(container.querySelector('[data-testid="legacy-llm"]')).toBeNull(); + await renderModal(false); + await renderModal(); + + expect( + container.querySelector('[data-testid="built-in-settings"]'), + ).toBeNull(); expect( container.querySelector('[data-testid="agent-defaults"]'), ).not.toBeNull(); + expect( + container.querySelector('[data-testid="profile-management"]'), + ).not.toBeNull(); }); - - it.each(['default', 'thread repair'])( - 'shows Built-In providers for %s without changing the default', - async (source) => { - if (source === 'default') mocks.profileId = 'huabu'; - else mocks.requestedTab = 'builtIn'; - await act(async () => - root.render(), - ); - expect( - container.querySelector('[data-testid="legacy-llm"]'), - ).not.toBeNull(); - expect(mocks.llmInit).toHaveBeenCalled(); - }, - ); }); diff --git a/apps/web/src/components/Settings/SettingsModal.tsx b/apps/web/src/components/Settings/SettingsModal.tsx index 78548673a..2de7e1d53 100644 --- a/apps/web/src/components/Settings/SettingsModal.tsx +++ b/apps/web/src/components/Settings/SettingsModal.tsx @@ -22,6 +22,8 @@ import { type ExternalAgentsNavigation, } from './agent-profiles/ExternalAgentsSettings'; import { DeploymentReadinessNotice } from './DeploymentReadinessNotice'; +import { AgentBehaviorSettings } from './sections/AgentBehaviorSettings'; +import { ExternalAgentRuntimeSettings } from './sections/ExternalAgentRuntimeSettings'; import { GeneralSettings } from './sections/GeneralSettings'; import { ImageProviderSettings } from './sections/ImageProviderSettings'; import { InkOcrSettings } from './sections/InkOcrSettings'; @@ -34,15 +36,12 @@ type SettingsTab = SettingsTabId; interface TabDef { id: SettingsTab; /** i18n key for the tab label. */ - labelKey: - | 'settings.general' - | 'settings.huabuAgent' - | 'settings.externalAgents'; + labelKey: 'settings.general' | 'settings.agent' | 'settings.capabilities'; } const TABS: TabDef[] = [ - { id: 'huabuAgent', labelKey: 'settings.huabuAgent' }, - { id: 'agents', labelKey: 'settings.externalAgents' }, + { id: 'agent', labelKey: 'settings.agent' }, + { id: 'capabilities', labelKey: 'settings.capabilities' }, { id: 'general', labelKey: 'settings.general' }, ]; @@ -57,10 +56,9 @@ interface SettingsModalProps { * pane, so the panel height stays fixed as more settings are added. * * Each tab renders the existing self-contained `*Settings` components: - * - **General** — language and canvas display preferences - * - **Huabu Agent** — global defaults, backend-specific models and optional capabilities - * (image generation, web search, YouTube transcripts) - * - **External Agents** — ACP profile management + * - **Agent** — Utility Agent, Built-In Pi setup, external Profiles, and behavior + * - **Capabilities** — Huabu-owned image, search, transcript, and OCR services + * - **General** — application, canvas, input, and update preferences * * The app version sits at the bottom of the left tab rail (a product-wide * fact, decoupled from any single tab). @@ -80,6 +78,7 @@ export const SettingsModal: React.FC = ({ const clearRequestedTab = useSettingsUiStore((s) => s.clearRequestedTab); const [activeTab, setActiveTab] = useState(TABS[0].id); const showBuiltIn = activeTab === 'builtIn' || defaultProfileId === 'huabu'; + const isAgentTab = activeTab === 'agent' || activeTab === 'builtIn'; const [externalAgentsNavigation, setExternalAgentsNavigation] = useState(null); const titleId = useId(); @@ -110,7 +109,7 @@ export const SettingsModal: React.FC = ({ }, [isOpen, requestedTab, clearRequestedTab]); useEffect(() => { - if (!isOpen && activeTab === 'builtIn') setActiveTab('huabuAgent'); + if (!isOpen && activeTab === 'builtIn') setActiveTab('agent'); }, [isOpen, activeTab]); useEffect(() => { @@ -121,10 +120,15 @@ export const SettingsModal: React.FC = ({ // Load each registry only when its owning tab is visible. useEffect(() => { if (!isOpen) return; - if ((activeTab === 'huabuAgent' || activeTab === 'builtIn') && showBuiltIn) + const targetTab = requestedTab ?? activeTab; + if (targetTab === 'agent') void acpInit(); + if ( + targetTab === 'builtIn' || + (targetTab === 'agent' && defaultProfileId === 'huabu') + ) { void llmInit(); - if (activeTab === 'agents') void acpInit(); - }, [isOpen, activeTab, showBuiltIn, llmInit, acpInit]); + } + }, [isOpen, requestedTab, activeTab, defaultProfileId, llmInit, acpInit]); // Close on Escape. useEffect(() => { @@ -152,7 +156,7 @@ export const SettingsModal: React.FC = ({ const activeLabelKey = TABS.find((tab) => tab.id === activeTab)?.labelKey ?? 'settings.general'; const contentTitle = - activeTab === 'agents' && externalAgentsNavigation + activeTab === 'agent' && externalAgentsNavigation ? externalAgentsNavigation.title : activeTab === 'builtIn' ? t('settings.builtInPi') @@ -199,8 +203,7 @@ export const SettingsModal: React.FC = ({ {TABS.map(({ id, labelKey }) => { const active = - id === activeTab || - (id === 'huabuAgent' && activeTab === 'builtIn'); + id === activeTab || (id === 'agent' && activeTab === 'builtIn'); return (
diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx index be29d10f2..5bd6b05aa 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx @@ -145,6 +145,30 @@ async function editModel(value: string) { } describe('Agent defaults Settings', () => { + it('places the Utility Agent explanation inside the Profile row card', async () => { + await render(); + const description = [...container.querySelectorAll('p')].find( + (element) => + element.textContent === 'settings.agentDefaultsSectionDescription', + ); + expect(description?.closest('.ring-1')).not.toBeNull(); + }); + + it('omits the status row when there is no status to show', async () => { + mocks.state.profiles = [ + { + ...mocks.state.profiles[0], + launch: { kind: 'acp-harness', harnessId: 'copilot' }, + }, + ]; + mocks.get.mockResolvedValueOnce({ + ...initial, + defaults: { ...initial.defaults, functionalModel: '' }, + }); + await render(); + expect(container.querySelectorAll('.ring-1 > *')).toHaveLength(2); + }); + it('allows Built-In while the external catalogue is unavailable, retaining the external model', async () => { mocks.state.loaded = false; mocks.state.error = new Error('Registry unavailable'); diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx index 730ab335e..5058dbd11 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx @@ -129,6 +129,15 @@ export function AgentDefaultsSettings() { ?.launch.kind === 'acp-command' ? 'unsupported' : 'unknown'; + const showStatus = + missing || + draft?.profileId === null || + (snapshot?.defaults.profileId === draft?.profileId && + snapshot?.selectionState === 'offline') || + (!isBuiltIn && + Boolean(draft?.functionalModel.trim()) && + modelCapability !== 'supported') || + Boolean(error || profilesError || saving || saved); return ( @@ -143,7 +152,7 @@ export function AgentDefaultsSettings() { <> onChange(event.target.value)}> + @@ -142,6 +155,18 @@ function renderEditor( editing?: AgentProfileView, clis = agents, loaded = true, + connectedDevices: ConnectedAgentletDevice[] = [ + { + agentletId: editing?.agentletId ?? 'device-1', + displayName: 'Test device: linux x64', + hostname: 'Test device', + platform: 'linux', + arch: 'x64', + version: '1.0.0', + connectedAt: '2026-01-01T00:00:00.000Z', + profileCount: 0, + }, + ], ) { if (!container) { container = document.createElement('div'); @@ -156,6 +181,9 @@ function renderEditor( : ({ mode: 'create' } as const))} detectedClis={clis} detectionLoaded={loaded} + connectedDevices={connectedDevices} + agentletId={editing?.agentletId ?? 'device-1'} + onAgentletChange={vi.fn()} onClose={onClose} onSaved={onSaved} />, @@ -188,7 +216,9 @@ async function settlePreview() { }); } function chooseCustom() { - const select = container?.querySelector('select'); + const select = container?.querySelector( + 'select[aria-label="settings.agent"]', + ); act(() => { if (select) select.value = 'custom'; select?.dispatchEvent(new Event('change', { bubbles: true })); @@ -235,9 +265,40 @@ describe('AgentProfileEditor', () => { alias: 'Renamed', customData: legacy.customData, }); + expect(api.preview).not.toHaveBeenCalled(); }); + it('renders human-readable device labels while retaining UUID identity details', () => { + renderEditor(); + const machine = container?.querySelector( + 'select[aria-label="settings.profileMachine"]', + ); + expect(machine?.selectedOptions[0]?.textContent).toContain( + 'Test device: linux x64', + ); + expect(machine?.selectedOptions[0]?.textContent).toContain('device-1'); + }); + + it('maps a hostname-era Profile to the unique connected device label', () => { + renderEditor({ ...legacy, agentletId: 'legacy-host' }, agents, true, [ + { + agentletId: 'device-uuid', + displayName: 'legacy-host: linux x64', + hostname: 'legacy-host', + platform: 'linux', + arch: 'x64', + version: '1.0.0', + connectedAt: '2026-01-01T00:00:00.000Z', + profileCount: 1, + }, + ]); + + expect(container?.textContent).toContain( + 'legacy-host: linux x64 (device-uuid)', + ); + }); + it('edits custom command and cwd without changing wrapper, machine, metadata, or custom data', async () => { renderEditor({ ...legacy, revision: 9 }); input('settings.launchCommand', 'new-agent --custom'); @@ -268,7 +329,9 @@ describe('AgentProfileEditor', () => { it('lists known wrappers with unsupported choices disabled and one Custom option', () => { renderEditor(); - const select = container?.querySelector('select'); + const select = container?.querySelector( + 'select[aria-label="settings.agent"]', + ); expect(select?.value).toBe('copilot'); expect( [...(select?.options ?? [])].filter( @@ -293,6 +356,7 @@ describe('AgentProfileEditor', () => { expect(saveButton()?.disabled).toBe(true); await settlePreview(); expect(api.preview).toHaveBeenCalledWith({ + agentletId: 'device-1', launch: { kind: 'acp-harness', harnessId: 'copilot', @@ -308,6 +372,7 @@ describe('AgentProfileEditor', () => { await act(async () => saveButton()?.click()); expect(api.create).toHaveBeenCalledWith({ alias: 'GitHub Copilot (project)', + agentletId: 'device-1', workingDirPath: 'C:\\work\\project', launch: { kind: 'acp-harness', @@ -382,7 +447,11 @@ describe('AgentProfileEditor', () => { undefined, agents.map((agent) => ({ ...agent, launchPreviewVersion: undefined })), ); - expect(container?.querySelector('select')?.value).toBe('custom'); + expect( + container?.querySelector( + 'select[aria-label="settings.agent"]', + )?.value, + ).toBe('custom'); expect(container?.textContent).toContain( 'settings.structuredLaunchUnavailable', ); diff --git a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx index 9a7abcdb1..6ffbc3816 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx @@ -3,11 +3,18 @@ import { CommandProfileForm } from './CommandProfileForm'; -import type { AcpAgentCliInfo, AgentProfileView } from '@huabu/shared'; +import type { + AcpAgentCliInfo, + AgentProfileView, + ConnectedAgentletDevice, +} from '@huabu/shared'; type AgentProfileEditorProps = { detectedClis: AcpAgentCliInfo[]; detectionLoaded: boolean; + connectedDevices: ConnectedAgentletDevice[]; + agentletId: string; + onAgentletChange: (agentletId: string) => void; onClose: () => void; onSaved: () => Promise; } & ({ mode: 'create' } | { mode: 'edit-command'; profile: AgentProfileView }); @@ -23,6 +30,9 @@ export function AgentProfileEditor(props: AgentProfileEditorProps) { editing={props.mode === 'create' ? null : props.profile} detectedClis={props.detectedClis} detectionLoaded={props.detectionLoaded} + connectedDevices={props.connectedDevices} + agentletId={props.agentletId} + onAgentletChange={props.onAgentletChange} onClose={props.onClose} onSaved={props.onSaved} /> diff --git a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx index 7955e606f..107be07be 100644 --- a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx +++ b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx @@ -25,12 +25,19 @@ import { ProfileEditActions } from './ProfileEditActions'; import { ReadOnlyField } from './ReadOnlyField'; import { useProfileLaunchPreview } from './useProfileLaunchPreview'; -import type { AcpAgentCliInfo, AgentProfileView } from '@huabu/shared'; +import type { + AcpAgentCliInfo, + AgentProfileView, + ConnectedAgentletDevice, +} from '@huabu/shared'; interface CommandProfileFormProps { editing: AgentProfileView | null; detectedClis: AcpAgentCliInfo[]; detectionLoaded: boolean; + connectedDevices: ConnectedAgentletDevice[]; + agentletId: string; + onAgentletChange: (agentletId: string) => void; onClose: () => void; onSaved: () => Promise; } @@ -68,11 +75,26 @@ function displayNameFor(agent: string, cwd: string) { return folder ? `${agent} (${folder})` : agent; } +function resolveConnectedDevice( + target: string, + devices: ConnectedAgentletDevice[], +): ConnectedAgentletDevice | undefined { + const exact = devices.find((device) => device.agentletId === target); + if (exact) return exact; + const hostnameMatches = devices.filter( + (device) => device.hostname === target, + ); + return hostnameMatches.length === 1 ? hostnameMatches[0] : undefined; +} + /** One capability-driven form for both structured and raw-command Profiles. */ export function CommandProfileForm({ editing, detectedClis, detectionLoaded, + connectedDevices, + agentletId, + onAgentletChange, onClose, onSaved, }: CommandProfileFormProps) { @@ -156,7 +178,10 @@ export function CommandProfileForm({ }; const preview = useProfileLaunchPreview( !custom && structuredSupported - ? { launch, ...(editing ? { profileId: editing.id } : {}) } + ? { + launch, + ...(editing ? { profileId: editing.id } : { agentletId }), + } : null, ); const executionChanged = launchChanged || cwdChanged; @@ -169,7 +194,12 @@ export function CommandProfileForm({ !preview.plan || !!preview.error || (launchChanged && !approvalSupported && !!editing))); - const saveDisabled = saving || !cliId || invalidExecution; + const saveDisabled = + saving || + !cliId || + !agentletId || + (!editing && !connectedDevices.length) || + invalidExecution; const knownControlsDisabled = saving || !structuredSupported || !!preview.error; const options = [ @@ -182,6 +212,12 @@ export function CommandProfileForm({ })), { value: 'custom', label: t('settings.customCommand') }, ]; + const editingDevice = editing + ? resolveConnectedDevice(editing.agentletId, connectedDevices) + : undefined; + const editingDeviceLabel = editingDevice + ? `${editingDevice.displayName} (${editingDevice.agentletId})` + : `${t('settings.agentUnavailable')} (${editing?.agentletId ?? ''})`; async function save() { if (saveDisabled) return; @@ -198,6 +234,7 @@ export function CommandProfileForm({ } else { await createAcpProfile({ alias: displayName.trim() || defaultName, + agentletId, workingDirPath: cwd.trim(), launch, metadata: { cliId }, @@ -226,6 +263,27 @@ export function CommandProfileForm({ return (
+ + + {editing ? ( + + ) : ( + setRecentTurnCount(Number(value))} + title={t('settings.recentChatTurns')} + ariaLabel={t('settings.recentChatTurns')} + /> + + + ); +} diff --git a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx new file mode 100644 index 000000000..0c5bcbcb8 --- /dev/null +++ b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + createConnectionCommand, + getConnectionTokenConfig, + getRuntimeConfig, + updateConnectionToken, + updateRuntimeConfig, + toast, +} = vi.hoisted(() => ({ + createConnectionCommand: vi.fn(), + getConnectionTokenConfig: vi.fn(), + getRuntimeConfig: vi.fn(), + updateConnectionToken: vi.fn(), + updateRuntimeConfig: vi.fn(), + toast: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@/api/acp', () => ({ + createAgentletConnectionCommand: createConnectionCommand, + getConnectionTokenConfig, + getExternalAgentRuntimeConfig: getRuntimeConfig, + updateConnectionToken, + updateExternalAgentRuntimeConfig: updateRuntimeConfig, +})); +vi.mock('@/components/Common/Toast', () => ({ toast })); + +import { ExternalAgentRuntimeSettings } from './ExternalAgentRuntimeSettings'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + getRuntimeConfig.mockResolvedValue({ + idleTimeoutSecs: 600, + maxAgents: 10, + }); + updateRuntimeConfig.mockResolvedValue({ + idleTimeoutSecs: 1800, + maxAgents: 10, + }); + getConnectionTokenConfig.mockResolvedValue({ + source: 'stored', + writable: true, + }); + updateConnectionToken.mockResolvedValue({ + source: 'stored', + writable: true, + }); + createConnectionCommand.mockResolvedValue({ + command: + "agentlet daemon --server 'wss://huabu.example/api/acp/agent' --max-agents 10 --token 'secret'", + warnings: [], + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +describe('ExternalAgentRuntimeSettings', () => { + it('places responsive token controls below the full-width description', async () => { + await act(async () => { + root.render(); + }); + + const tokenInput = container.querySelector( + '#agentlet-connection-token', + ); + if (!tokenInput) throw new Error('Connection token input not found'); + + const controls = tokenInput.parentElement; + const controlRow = controls?.parentElement; + const settingRow = controlRow?.parentElement; + expect(settingRow?.classList.contains('flex-col')).toBe(true); + expect(settingRow?.classList.contains('items-stretch')).toBe(true); + expect(controlRow?.classList.contains('w-full')).toBe(true); + expect(controls?.classList.contains('flex-wrap')).toBe(true); + expect(controls?.classList.contains('w-full')).toBe(true); + expect(tokenInput.classList.contains('flex-1')).toBe(true); + expect(tokenInput.classList.contains('basis-56')).toBe(true); + + const buttons = controls?.querySelectorAll('button') ?? []; + expect(buttons.length).toBeGreaterThan(0); + for (const button of buttons) { + expect(button.classList.contains('shrink-0')).toBe(true); + expect(button.classList.contains('whitespace-nowrap')).toBe(true); + } + }); + + it('preserves the process limit when saving the idle timeout', async () => { + await act(async () => { + root.render(); + }); + + const idleTimeout = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.includes('settings.tenMinutesDefault'), + ); + if (!idleTimeout) throw new Error('Idle-timeout selector not found'); + + await act(async () => { + idleTimeout.click(); + }); + + const thirtyMinutes = [...document.body.querySelectorAll('button')].find( + (button) => button.textContent === 'settings.thirtyMinutes', + ); + if (!thirtyMinutes) throw new Error('Thirty-minute option not found'); + + await act(async () => { + thirtyMinutes.click(); + }); + + expect(updateRuntimeConfig).toHaveBeenCalledWith({ + idleTimeoutSecs: 1800, + maxAgents: 10, + }); + expect(toast).toHaveBeenCalledWith( + 'settings.externalAgentIdleTimeoutSaved', + { tone: 'success' }, + ); + }); + + it('does not render the active token and saves a replacement', async () => { + await act(async () => { + root.render(); + }); + + expect(container.textContent).not.toContain('secret'); + const tokenInput = container.querySelector( + '#agentlet-connection-token', + ); + if (!tokenInput) throw new Error('Connection token input not found'); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(tokenInput, 'replacement-token'); + tokenInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + const saveButton = [ + ...(tokenInput.parentElement?.querySelectorAll('button') ?? []), + ].find((button) => button.textContent === 'settings.saveChanges'); + if (!saveButton) throw new Error('Connection token save button not found'); + await act(async () => { + saveButton.click(); + }); + + expect(updateConnectionToken).toHaveBeenCalledWith({ + token: 'replacement-token', + }); + expect(container.textContent).not.toContain('replacement-token'); + }); + + it('copies the generated command directly without rendering it', async () => { + await act(async () => { + root.render(); + }); + + const copyButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'settings.agentletCommandCopy', + ); + if (!copyButton) throw new Error('Copy command button not found'); + await act(async () => { + copyButton.click(); + }); + + expect(createConnectionCommand).toHaveBeenCalledOnce(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('wss://huabu.example/api/acp/agent'), + ); + expect(container.textContent).not.toContain('wss://huabu.example'); + expect(toast).toHaveBeenCalledWith('settings.agentletCommandCopied', { + tone: 'success', + }); + }); + + it('clears only the stored override', async () => { + await act(async () => { + root.render(); + }); + + const clearButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'settings.agentletTokenClear', + ); + if (!clearButton) throw new Error('Clear token button not found'); + await act(async () => { + clearButton.click(); + }); + + expect(updateConnectionToken).toHaveBeenCalledWith({ token: null }); + }); +}); diff --git a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx new file mode 100644 index 000000000..6715b2e2e --- /dev/null +++ b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Info } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + createAgentletConnectionCommand, + getConnectionTokenConfig, + getExternalAgentRuntimeConfig, + updateConnectionToken, + updateExternalAgentRuntimeConfig, +} from '@/api/acp'; +import { Button } from '@/components/Common/Button'; +import { Input } from '@/components/Common/Input'; +import { Select } from '@/components/Common/Select'; +import { TextInput } from '@/components/Common/TextInput'; +import { toast } from '@/components/Common/Toast'; +import { SettingRow } from '@/components/Settings/Common/SettingRow'; +import { copyToClipboard } from '@/utils/io/clipboard'; + +import type { ConnectionTokenConfig } from '@huabu/shared'; + +const IDLE_TIMEOUT_PRESETS = new Set(['0', '300', '600', '1800', '3600']); + +export function ExternalAgentRuntimeSettings() { + const { t } = useTranslation(); + const [idleTimeoutSecs, setIdleTimeoutSecs] = useState(600); + const [maxAgents, setMaxAgents] = useState(10); + const [maxAgentsInput, setMaxAgentsInput] = useState('10'); + const [idleTimeoutSelection, setIdleTimeoutSelection] = useState('600'); + const [customMinutes, setCustomMinutes] = useState('10'); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [tokenConfig, setTokenConfig] = useState( + null, + ); + const [tokenInput, setTokenInput] = useState(''); + const [tokenLoading, setTokenLoading] = useState(true); + const [tokenSaving, setTokenSaving] = useState(false); + const [copyingCommand, setCopyingCommand] = useState(false); + + useEffect(() => { + let active = true; + void getExternalAgentRuntimeConfig() + .then((config) => { + if (!active) return; + const value = String(config.idleTimeoutSecs); + setIdleTimeoutSecs(config.idleTimeoutSecs); + setMaxAgents(config.maxAgents); + setMaxAgentsInput(String(config.maxAgents)); + setIdleTimeoutSelection( + IDLE_TIMEOUT_PRESETS.has(value) ? value : 'custom', + ); + if (config.idleTimeoutSecs > 0) { + setCustomMinutes(String(config.idleTimeoutSecs / 60)); + } + }) + .catch((error) => { + if (!active) return; + toast( + error instanceof Error + ? error.message + : t('settings.externalAgentIdleTimeoutLoadFailed'), + { tone: 'danger' }, + ); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [t]); + + useEffect(() => { + let active = true; + void getConnectionTokenConfig() + .then((config) => { + if (active) setTokenConfig(config); + }) + .catch((error) => { + if (!active) return; + toast( + error instanceof Error + ? error.message + : t('settings.agentletTokenLoadFailed'), + { tone: 'danger' }, + ); + }) + .finally(() => { + if (active) setTokenLoading(false); + }); + return () => { + active = false; + }; + }, [t]); + + const saveIdleTimeout = useCallback( + async (nextIdleTimeoutSecs: number) => { + setSaving(true); + try { + const saved = await updateExternalAgentRuntimeConfig({ + idleTimeoutSecs: nextIdleTimeoutSecs, + maxAgents, + }); + setIdleTimeoutSecs(saved.idleTimeoutSecs); + const value = String(saved.idleTimeoutSecs); + setIdleTimeoutSelection( + IDLE_TIMEOUT_PRESETS.has(value) ? value : 'custom', + ); + toast(t('settings.externalAgentIdleTimeoutSaved'), { + tone: 'success', + }); + } catch (error) { + const previous = String(idleTimeoutSecs); + setIdleTimeoutSelection( + IDLE_TIMEOUT_PRESETS.has(previous) ? previous : 'custom', + ); + toast( + error instanceof Error + ? error.message + : t('settings.externalAgentIdleTimeoutSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setSaving(false); + } + }, + [idleTimeoutSecs, maxAgents, t], + ); + + const parsedMaxAgents = Number(maxAgentsInput); + const maxAgentsValid = + Number.isSafeInteger(parsedMaxAgents) && parsedMaxAgents >= 1; + + const saveMaxAgents = useCallback(async () => { + if (!maxAgentsValid) return; + setSaving(true); + try { + const saved = await updateExternalAgentRuntimeConfig({ + idleTimeoutSecs, + maxAgents: parsedMaxAgents, + }); + setMaxAgents(saved.maxAgents); + setMaxAgentsInput(String(saved.maxAgents)); + toast(t('settings.externalAgentMaxAgentsSaved'), { tone: 'success' }); + } catch (error) { + setMaxAgentsInput(String(maxAgents)); + toast( + error instanceof Error + ? error.message + : t('settings.externalAgentMaxAgentsSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setSaving(false); + } + }, [idleTimeoutSecs, maxAgents, maxAgentsValid, parsedMaxAgents, t]); + + const handleIdleTimeoutSelection = useCallback( + (value: string) => { + setIdleTimeoutSelection(value); + if (value !== 'custom') void saveIdleTimeout(Number(value)); + }, + [saveIdleTimeout], + ); + + const parsedCustomMinutes = Number(customMinutes); + const customMinutesValid = + Number.isInteger(parsedCustomMinutes) && + parsedCustomMinutes >= 1 && + parsedCustomMinutes <= 1440; + + const saveConnectionToken = useCallback(async () => { + const token = tokenInput.trim(); + if (!token || !tokenConfig?.writable) return; + setTokenSaving(true); + try { + setTokenConfig(await updateConnectionToken({ token })); + setTokenInput(''); + toast(t('settings.agentletTokenSaved'), { tone: 'success' }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.agentletTokenSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setTokenSaving(false); + } + }, [t, tokenConfig?.writable, tokenInput]); + + const clearConnectionToken = useCallback(async () => { + if (!tokenConfig?.writable) return; + setTokenSaving(true); + try { + setTokenConfig(await updateConnectionToken({ token: null })); + setTokenInput(''); + toast(t('settings.agentletTokenCleared'), { tone: 'success' }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.agentletTokenSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setTokenSaving(false); + } + }, [t, tokenConfig?.writable]); + + const copyConnectionCommand = useCallback(async () => { + setCopyingCommand(true); + try { + const result = await createAgentletConnectionCommand(); + await copyToClipboard(result.command); + const warningKey = result.warnings.includes('insecure') + ? 'settings.agentletCommandCopiedInsecure' + : result.warnings.includes('loopback') + ? 'settings.agentletCommandCopiedLoopback' + : 'settings.agentletCommandCopied'; + toast(t(warningKey), { + tone: result.warnings.length > 0 ? 'warning' : 'success', + }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.agentletCommandCopyFailed'), + { tone: 'danger' }, + ); + } finally { + setCopyingCommand(false); + } + }, [t]); + + return ( + <> + + {t('settings.agentletConnectionToken')} + + + } + description={ + tokenConfig + ? t('settings.agentletConnectionTokenDescription', { + source: t(`settings.agentletTokenSource.${tokenConfig.source}`), + access: tokenConfig.writable + ? '' + : t('settings.agentletTokenReadOnly'), + }) + : t('settings.agentletConnectionTokenDescriptionLoading') + } + > +
+ setTokenInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveConnectionToken(); + }} + placeholder={t('settings.agentletConnectionTokenPlaceholder')} + aria-label={t('settings.agentletConnectionToken')} + autoComplete="new-password" + maxLength={512} + disabled={ + tokenLoading || tokenSaving || tokenConfig?.writable !== true + } + /> + + {tokenConfig?.source === 'stored' ? ( + + ) : null} + +
+
+ +
+ setCustomMinutes(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && customMinutesValid) { + void saveIdleTimeout(parsedCustomMinutes * 60); + } + }} + aria-label={t('settings.customIdleTimeoutMinutes')} + disabled={saving} + /> + + {t('settings.minutes')} + + + + ) : null} +
+
+ +
+ setMaxAgentsInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveMaxAgents(); + }} + aria-label={t('settings.externalAgentMaxAgents')} + disabled={loading || saving} + /> + +
+
+ + ); +} diff --git a/apps/web/src/components/Settings/sections/GeneralSettings.tsx b/apps/web/src/components/Settings/sections/GeneralSettings.tsx index 649708697..daf0a7b64 100644 --- a/apps/web/src/components/Settings/sections/GeneralSettings.tsx +++ b/apps/web/src/components/Settings/sections/GeneralSettings.tsx @@ -1,33 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { - getExternalAgentRuntimeConfig, - updateExternalAgentRuntimeConfig, -} from '@/api/acp'; -import { - getAgentChangeReviewConfig, - updateAgentChangeReviewConfig, -} from '@/api/agentChangeReview'; import { Button } from '@/components/Common/Button'; -import { Input } from '@/components/Common/Input'; import { Select } from '@/components/Common/Select'; -import { toast } from '@/components/Common/Toast'; import { Toggle } from '@/components/Common/Toggle'; +import { CanaryRedeploySettings } from '@/components/Settings/CanaryRedeploySettings'; import { SettingRow } from '@/components/Settings/Common/SettingRow'; import { canCheckForUpdates, useAppUpdate } from '@/hooks/useAppUpdate'; import { getElectronBridge } from '@/hooks/useElectron'; import { useEffectiveInputMode } from '@/hooks/useInputMode'; import { supportedLngs, type SupportedLanguage } from '@/i18n'; import useCanvasStore from '@/store/canvasStore'; -import { - MAX_RECENT_CHAT_TURNS, - MIN_RECENT_CHAT_TURNS, - useChatPreferencesStore, -} from '@/store/chatPreferencesStore'; import { useToolStore, type InputModePreference } from '@/store/toolStore'; import { useWorkspaceStore } from '@/store/workspaceStore'; @@ -42,15 +28,6 @@ const LANGUAGE_OPTIONS = supportedLngs.map((lng) => ({ label: LANGUAGE_LABELS[lng], })); -const IDLE_TIMEOUT_PRESETS = new Set(['0', '300', '600', '1800', '3600']); -const RECENT_TURN_OPTIONS = Array.from( - { length: MAX_RECENT_CHAT_TURNS - MIN_RECENT_CHAT_TURNS + 1 }, - (_, index) => { - const value = String(index + MIN_RECENT_CHAT_TURNS); - return { value, label: value }; - }, -); - /** * General application settings. Language changes persist to `localStorage` * (`huabu.language`) via i18next's language detector cache, while the @@ -72,21 +49,7 @@ export const GeneralSettings: React.FC = () => { const setInputModePreference = useToolStore( (state) => state.setInputModePreference, ); - const recentTurnCount = useChatPreferencesStore( - (state) => state.recentTurnCount, - ); - const setRecentTurnCount = useChatPreferencesStore( - (state) => state.setRecentTurnCount, - ); const effectiveInputMode = useEffectiveInputMode(); - const [idleTimeoutSecs, setIdleTimeoutSecs] = useState(600); - const [idleTimeoutSelection, setIdleTimeoutSelection] = useState('600'); - const [customMinutes, setCustomMinutes] = useState('10'); - const [idleTimeoutLoading, setIdleTimeoutLoading] = useState(true); - const [idleTimeoutSaving, setIdleTimeoutSaving] = useState(false); - const [autoAcceptSpaceChanges, setAutoAcceptSpaceChanges] = useState(false); - const [autoAcceptLoading, setAutoAcceptLoading] = useState(true); - const [autoAcceptSaving, setAutoAcceptSaving] = useState(false); const { status: updateStatus, check: checkForUpdates } = useAppUpdate(); const updaterAvailable = !!getElectronBridge()?.updater; @@ -99,132 +62,6 @@ export const GeneralSettings: React.FC = () => { [i18n], ); - useEffect(() => { - let active = true; - void getExternalAgentRuntimeConfig() - .then((config) => { - if (!active) return; - const value = String(config.idleTimeoutSecs); - setIdleTimeoutSecs(config.idleTimeoutSecs); - setIdleTimeoutSelection( - IDLE_TIMEOUT_PRESETS.has(value) ? value : 'custom', - ); - if (config.idleTimeoutSecs > 0) { - setCustomMinutes(String(config.idleTimeoutSecs / 60)); - } - }) - .catch((error) => { - if (!active) return; - toast( - error instanceof Error - ? error.message - : t('settings.externalAgentIdleTimeoutLoadFailed'), - { tone: 'danger' }, - ); - }) - .finally(() => { - if (active) setIdleTimeoutLoading(false); - }); - return () => { - active = false; - }; - }, [t]); - - useEffect(() => { - let active = true; - void getAgentChangeReviewConfig() - .then((config) => { - if (active) setAutoAcceptSpaceChanges(config.autoAcceptSpaceChanges); - }) - .catch((error) => { - if (!active) return; - toast( - error instanceof Error - ? error.message - : t('settings.autoAcceptAgentChangesLoadFailed'), - { tone: 'danger' }, - ); - }) - .finally(() => { - if (active) setAutoAcceptLoading(false); - }); - return () => { - active = false; - }; - }, [t]); - - const saveAutoAccept = useCallback( - async (enabled: boolean) => { - const previous = autoAcceptSpaceChanges; - setAutoAcceptSpaceChanges(enabled); - setAutoAcceptSaving(true); - try { - const saved = await updateAgentChangeReviewConfig({ - autoAcceptSpaceChanges: enabled, - }); - setAutoAcceptSpaceChanges(saved.autoAcceptSpaceChanges); - } catch (error) { - setAutoAcceptSpaceChanges(previous); - toast( - error instanceof Error - ? error.message - : t('settings.autoAcceptAgentChangesSaveFailed'), - { tone: 'danger' }, - ); - } finally { - setAutoAcceptSaving(false); - } - }, - [autoAcceptSpaceChanges, t], - ); - - const saveIdleTimeout = useCallback( - async (nextIdleTimeoutSecs: number) => { - setIdleTimeoutSaving(true); - try { - const saved = await updateExternalAgentRuntimeConfig({ - idleTimeoutSecs: nextIdleTimeoutSecs, - }); - setIdleTimeoutSecs(saved.idleTimeoutSecs); - const value = String(saved.idleTimeoutSecs); - setIdleTimeoutSelection( - IDLE_TIMEOUT_PRESETS.has(value) ? value : 'custom', - ); - toast(t('settings.externalAgentIdleTimeoutSaved'), { - tone: 'success', - }); - } catch (error) { - const previous = String(idleTimeoutSecs); - setIdleTimeoutSelection( - IDLE_TIMEOUT_PRESETS.has(previous) ? previous : 'custom', - ); - toast( - error instanceof Error - ? error.message - : t('settings.externalAgentIdleTimeoutSaveFailed'), - { tone: 'danger' }, - ); - } finally { - setIdleTimeoutSaving(false); - } - }, - [idleTimeoutSecs, t], - ); - - const handleIdleTimeoutSelection = useCallback( - (value: string) => { - setIdleTimeoutSelection(value); - if (value !== 'custom') void saveIdleTimeout(Number(value)); - }, - [saveIdleTimeout], - ); - - const parsedCustomMinutes = Number(customMinutes); - const customMinutesValid = - Number.isInteger(parsedCustomMinutes) && - parsedCustomMinutes >= 1 && - parsedCustomMinutes <= 1440; - return ( <> { } /> - - void saveAutoAccept(enabled)} - disabled={autoAcceptLoading || autoAcceptSaving} - label={t('settings.autoAcceptAgentChanges')} - /> - {updaterAvailable && ( { )} + {!updaterAvailable && } { ariaLabel={t('settings.inputMode')} /> - - - {idleTimeoutSelection === 'custom' && ( - <> - setCustomMinutes(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && customMinutesValid) { - void saveIdleTimeout(parsedCustomMinutes * 60); - } - }} - aria-label={t('settings.customIdleTimeoutMinutes')} - disabled={idleTimeoutSaving} - /> - - {t('settings.minutes')} - - - - )} -
-
); }; diff --git a/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx b/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx new file mode 100644 index 000000000..59e5386ec --- /dev/null +++ b/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const loadImageConfig = vi.fn(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@/store/deploymentReadinessStore', () => ({ + useDeploymentReadinessStore: (selector: (state: object) => unknown) => + selector({ readiness: { credentials: { writable: true } } }), +})); +vi.mock('@/store/llmStore', () => ({ + useLLMStore: (selector: (state: object) => unknown) => + selector({ + imageConfig: null, + loadImageConfig, + imageError: null, + imageSaving: false, + updateImageConfig: vi.fn(), + }), +})); + +import { ImageProviderSettings } from './ImageProviderSettings'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +describe('ImageProviderSettings', () => { + it('starts collapsed and expands on demand', async () => { + await act(async () => { + root.render(); + }); + + const toggle = container.querySelector( + 'button[aria-expanded="false"]', + ); + expect(toggle?.getAttribute('aria-label')).toBe('settings.imageGeneration'); + expect(toggle?.closest('section')?.textContent).toContain( + 'settings.imageGeneration', + ); + expect(toggle?.closest('section')?.querySelector('.ring-1')).not.toBeNull(); + expect( + container.querySelector('[aria-label="settings.endpoint"]'), + ).toBeNull(); + + await act(async () => { + toggle?.click(); + }); + + expect( + container.querySelector('[aria-label="settings.endpoint"]'), + ).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx index dcae55438..4a1567c0b 100644 --- a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx +++ b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { ChevronDown } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,6 +12,7 @@ import { getImageCapabilities, } from '@huabu/shared'; +import { Button } from '@/components/Common/Button'; import { Select } from '@/components/Common/Select'; import { TextInput } from '@/components/Common/TextInput'; import { ApiKeyRow } from '@/components/Settings/Common/ApiKeyRow'; @@ -46,6 +48,7 @@ const IMAGE_MODEL_FAMILY_OPTIONS = IMAGE_MODEL_FAMILIES.map((f) => ({ */ export const ImageProviderSettings: React.FC = () => { const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState(true); const llmImageConfig = useLLMStore((s) => s.imageConfig); const loadImageConfig = useLLMStore((s) => s.loadImageConfig); const imageError = useLLMStore((s) => s.imageError); @@ -115,130 +118,155 @@ export const ImageProviderSettings: React.FC = () => { ); return ( - - {imageError && ( -

- {imageError} -

- )} - - - saveImage({ provider: v })} + placeholder={t('settings.selectProvider')} + ariaLabel={t('settings.provider')} + className="w-full" + /> + + - - - { - const v = e.target.value; - setImgEndpoint(v); - debouncedSaveImage({ baseUrl: v }); - }} - className="w-full" - /> - - + + + { + const v = e.target.value; + setImgEndpoint(v); + debouncedSaveImage({ baseUrl: v }); + }} + className="w-full" + /> + + - - - { + const next = v as ImageModelFamily; + setImgModelFamily(next); + saveImage({ modelFamily: next }); + }} + /> + + - {t('settings.deployment')}} - description={t('settings.deploymentOptional')} - > - - { - const v = e.target.value; - setImgDeployment(v); - debouncedSaveImage({ model: v }); - }} - className="w-full" - /> - - + {t('settings.deployment')} + } + description={t('settings.deploymentOptional')} + > + + { + const v = e.target.value; + setImgDeployment(v); + debouncedSaveImage({ model: v }); + }} + className="w-full" + /> + + - - - { - const v = e.target.value; - setImgApiVersion(v); - debouncedSaveImage({ apiVersion: v }); - }} - className="w-full" - /> - - + + + { + const v = e.target.value; + setImgApiVersion(v); + debouncedSaveImage({ apiVersion: v }); + }} + className="w-full" + /> + + - - - ({ + value: q, + label: q, + }), + )} + value={imgQuality} + ariaLabel={t('settings.imageQuality')} + className="w-full" + onChange={(v) => { + const next = v as 'low' | 'medium' | 'high' | 'auto'; + setImgQuality(next); + saveImage({ quality: next }); + }} + /> + + - saveImage({ apiKey: key })} - onRemove={() => saveImage({ apiKey: null })} - /> + saveImage({ apiKey: key })} + onRemove={() => saveImage({ apiKey: null })} + /> + + )}
); }; diff --git a/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx b/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx index 6d60fde1e..9de1ecfef 100644 --- a/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx +++ b/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx @@ -35,11 +35,7 @@ export const IntegrationsSettings: React.FC = () => { }, [error]); return ( - +