Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
76e6f21
feat: add UI-triggered Alpha redeployment (#256)
mydmdm Sep 30, 2026
5db0c64
fix: stream interactive Canary startup logs (#257)
mydmdm Sep 30, 2026
e110440
fix(preview): restore nested panel scrolling
mydmdm Sep 30, 2026
0fb844f
Merge pull request #258 from microsoft/fix/issue-251
mydmdm Sep 30, 2026
4ad011d
fix(acp): deduplicate selector options by value
mydmdm Sep 30, 2026
975b920
Harden agentlet capacity reclamation
mydmdm Sep 30, 2026
ef86ca8
Reclaim one-shot Agenetes jobs
mydmdm Sep 30, 2026
3c2a7c5
Configure supervised agent capacity
mydmdm Sep 30, 2026
718f758
Merge pull request #261 from microsoft/fix/issue-235
mydmdm Sep 30, 2026
d3fb8c9
Merge pull request #260 from microsoft/fix/issue-250
mydmdm Sep 30, 2026
fb1fa8f
refactor(settings): unify Agent and capability surfaces
mydmdm Sep 30, 2026
5b58969
Merge pull request #263 from microsoft/fix/issue-253
mydmdm Sep 30, 2026
c6c50a9
fix(settings): refine Agent and capability layout
mydmdm Oct 1, 2026
30d596e
fix(agent): separate Utility Agent from conversations
mydmdm Oct 1, 2026
000cefc
Merge pull request #264 from microsoft/fix/issue-253
mydmdm Oct 1, 2026
c4e6848
fix(settings): align Agent and capability cards
mydmdm Oct 1, 2026
ab0968c
Merge origin/alpha into fix/issue-254
mydmdm Oct 1, 2026
3d909b1
fix(settings): normalize Agent section spacing
mydmdm Oct 1, 2026
307da0e
fix(agenetes): reject live agentlet identity collisions
mydmdm Oct 1, 2026
8e01c1d
fix(agent): configure shared agentlet connection token
mydmdm Oct 1, 2026
4d2c460
fix(settings): clarify on-demand capabilities
mydmdm Oct 1, 2026
ebb0c99
Merge pull request #265 from microsoft/fix/issue-254
mydmdm Oct 1, 2026
582a51b
fix(agent): keep recent conversation Agent browser-local
mydmdm Oct 1, 2026
5b07801
feat(agentlet): persist stable device identity
mydmdm Oct 2, 2026
b6eff77
refactor(agenetes): remove supervised placement identity
mydmdm Oct 2, 2026
edefe4c
fix(agentlet): publish device identity atomically
mydmdm Oct 2, 2026
12acb3d
fix(agenetes): isolate supervisor health status
mydmdm Oct 2, 2026
371021a
feat(agent): manage connected Agentlet devices
mydmdm Oct 2, 2026
9d55adb
Merge pull request #268 from microsoft/fix/issue-254
mydmdm Oct 3, 2026
3c074d6
fix(agent): resolve hostname-era Agentlet placement
mydmdm Oct 4, 2026
94d81e9
fix(agent): use readable Agentlet device labels
mydmdm Oct 4, 2026
00b6aae
fix(agent): expire stale Agentlet connections
mydmdm Oct 4, 2026
abc0494
style(agent): format Agentlet resolver
mydmdm Oct 4, 2026
b70daaa
Merge pull request #271 from microsoft/fix/issue-254
mydmdm Oct 4, 2026
fd44f48
Default Move shortcut option off
mydmdm Oct 4, 2026
864de33
Make Canary redeploy branch configurable
mydmdm Oct 4, 2026
929607e
Merge pull request #272 from microsoft/fix/issue-270
mydmdm Oct 5, 2026
fdee20d
feat: expose Agent Node cwd override
mydmdm Oct 5, 2026
6c4f2da
Merge pull request #273 from microsoft/fix/issue-269
mydmdm Oct 5, 2026
8f63daa
fix: hide Agent Node cwd controls
mydmdm Oct 5, 2026
32dbf18
Merge pull request #274 from microsoft/fix/issue-269
mydmdm Oct 5, 2026
579de14
fix: show inherited Agent Node cwd
mydmdm Oct 5, 2026
0bc805d
Merge pull request #275 from microsoft/fix/issue-269
mydmdm Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch> --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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch> --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
Expand Down
23 changes: 10 additions & 13 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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),
Expand All @@ -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;
Expand Down Expand Up @@ -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',
);
Expand Down
147 changes: 147 additions & 0 deletions apps/server/src/connection-token.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
Loading
Loading