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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
# ══════════════════════════════════════════════════════════════════

# ── Ports ──
# Fixed Server port for `start:web`; startup fails if it is unavailable.
# Development launchers may advance from this preferred port for parallel use.
# SERVER_PORT=3001
# WEB_PORT=5173
# Preferred standalone handbook port; dev:desktop advances if it is occupied.
Expand All @@ -51,22 +53,22 @@
# ── External ACP agents (Copilot, Claude Code, …) ──
# Pairing itself is done in the Settings popover → "External Agents";
# codes are ephemeral and never written to disk (no env override).
# `ACP_URL` is only honoured by the `start-agentlet-daemon` wrapper when
# pointing the local CLI at a remote / TLS-fronted Huabu instance.
# ACP_URL=wss://my.host/api/acp/agent

# ── Network exposure ──
# By default the server binds to 127.0.0.1 only, so a fresh install is
# never silently exposed to the LAN. Set HUABU_BIND_HOST=0.0.0.0 (or a
# specific NIC IP) to listen on every interface. When you do, you MUST
# also list every hostname/IP clients will use to reach the server in
# HUABU_ALLOWED_HOSTS — requests with any other Host header are
# rejected (defence against DNS rebinding). A non-loopback bind also
# REQUIRES both HUABU_BASIC_AUTH_* values; startup fails closed when
# either remote-access prerequisite is missing. Put HTTPS in front for
# anything beyond a trusted private network.
# specific NIC IP) to listen on every interface. A non-loopback bind requires
# HUABU_PUBLIC_ORIGIN and both HUABU_BASIC_AUTH_* values; startup fails closed
# when any prerequisite is missing. The public origin hostname is automatically
# trusted by the Host/CORS/Origin guards. Put HTTPS in front for anything beyond
# a trusted private network.
# HUABU_BIND_HOST=0.0.0.0
# HUABU_ALLOWED_HOSTS=192.168.1.50,huabu.team-a.example
# Canonical root origin reachable by remote Agentlets. Required for a
# non-loopback bind. Use HTTP only on a trusted private network; otherwise
# terminate HTTPS in front of Huabu. Paths, credentials, queries, and fragments
# are rejected. RFS and Agentlet endpoints are derived from this one value.
# HUABU_PUBLIC_ORIGIN=https://huabu.team-a.example
# Optional additional browser aliases beyond HUABU_PUBLIC_ORIGIN.
# HUABU_ALLOWED_HOSTS=192.168.1.50,huabu-alt.team-a.example

# ── Single-owner remote login ──
# Both values are required together. The authenticated owner can use every
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,14 @@ The remote address must be an origin without credentials, a query, fragment, or

```dotenv
HUABU_BIND_HOST=0.0.0.0
HUABU_ALLOWED_HOSTS=huabu.example.com
HUABU_PUBLIC_ORIGIN=https://huabu.example.com
HUABU_BASIC_AUTH_USER=owner
HUABU_BASIC_AUTH_PASS=<strong-password>
```

Then run `pnpm start:web`. Huabu rejects a non-loopback bind when allowed hosts or either Basic Auth value is missing. The authenticated owner can use all features, including Settings, OAuth, and External Agents. `pnpm dev` applies the same requirement to non-loopback browser clients while preserving zero-configuration local development.
Then run `pnpm start:web`. `HUABU_PUBLIC_ORIGIN` is the root HTTP(S) origin that remote Agentlets use to reach this deployment; Huabu derives both the Canvas RFS URL and Agentlet WebSocket endpoint from it and automatically trusts its hostname in the Host, CORS, and Origin guards. Set optional `HUABU_ALLOWED_HOSTS` only for additional browser aliases. Huabu rejects paths, credentials, queries, fragments, unsupported schemes, and a missing or loopback public origin for non-loopback binds. It also rejects a non-loopback bind when either Basic Auth value is missing. The authenticated owner can use all features, including Settings, OAuth, and External Agents. `pnpm dev` applies the same requirement to non-loopback browser clients while preserving zero-configuration local development.

`pnpm start:web` binds the fixed `SERVER_PORT` (default `3001`) and fails if that port is invalid or unavailable; it never advances to another port. Development launchers may advance from their preferred ports so intentional parallel instances remain possible.

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.

Expand Down
6 changes: 2 additions & 4 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,8 @@ app.addHook('onClose', async () => resetExternalNoteSessions());
// a connection-holding backend will need — a pool nobody closes leaks on
// every restart, and the lifecycle is where that is visible.
app.addHook('onClose', async () => closeStorage());
// Capture the bound TCP port for L1-owned reachback (RFS): the
// canvas-scoped `HUABU_RFS_URL` base is built from this. RFS is
// canvas-coupled and therefore a pure L1 concern, so the port lives in
// L1 rather than being read back out of the L2 transport host.
// Capture the bound TCP port for the local RFS reachback fallback. Public
// deployments use their configured canonical origin instead.
app.addHook('onListen', async () => {
const addr = app.server.address();
if (addr && typeof addr !== 'string') setHostServerPort(addr.port);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/bind-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*
* Operators who explicitly want LAN / remote access set
* `HUABU_BIND_HOST=0.0.0.0` (or a specific interface IP) and pair that
* with `HUABU_ALLOWED_HOSTS` and `HUABU_BASIC_AUTH_*` — see README.
* with `HUABU_PUBLIC_ORIGIN` and `HUABU_BASIC_AUTH_*` — see README.
*
* Lives in its own module (not inlined into `server.ts`) so the default
* can be regression-tested without booting Fastify. The Electron
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/connection-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ beforeEach(() => {
mocks.setSecret.mockResolvedValue(undefined);
vi.clearAllMocks();
delete process.env.HUABU_CONNECTION_TOKEN;
delete process.env.HUABU_PUBLIC_ORIGIN;
});

afterEach(() => {
delete process.env.HUABU_CONNECTION_TOKEN;
delete process.env.HUABU_PUBLIC_ORIGIN;
});

describe('connection token resolution', () => {
Expand Down Expand Up @@ -144,4 +146,18 @@ describe('Agentlet connection command', () => {
'Origin must be an HTTP(S) origin without a path',
);
});

it('uses the configured public origin instead of the browser origin', () => {
process.env.HUABU_CONNECTION_TOKEN = 'token';
process.env.HUABU_PUBLIC_ORIGIN = 'https://public.huabu.example:8443/';
initializeConnectionToken();

expect(
buildAgentletConnectionCommand('http://localhost:5173'),
).toMatchObject({
command:
"agentlet daemon --server 'wss://public.huabu.example:8443/api/acp/agent' --max-agents 7 --token 'token'",
warnings: [],
});
});
});
19 changes: 10 additions & 9 deletions apps/server/src/connection-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
} from '@agenetes/agentlet-host';

import { getExternalAgentRuntimeConfig } from './modules/agent/acp/runtime-config.js';
import {
normalizePublicOrigin,
resolveConfiguredPublicOrigin,
} from './modules/security/public-origin.js';
import { SECRET_IDS } from './security/secret-ids.js';
import {
getPersistedSecret,
Expand Down Expand Up @@ -145,19 +149,16 @@ 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
) {
let normalizedOrigin: string;
try {
normalizedOrigin =
resolveConfiguredPublicOrigin() ?? normalizePublicOrigin(originValue);
} catch {
throw new InvalidAgentletConnectionOriginError(
'Origin must be an HTTP(S) origin without a path',
);
}
const origin = new URL(normalizedOrigin);
const insecure = origin.protocol === 'http:';
const endpoint = `${insecure ? 'ws:' : 'wss:'}//${origin.host}/api/acp/agent`;
const maxAgents = getExternalAgentRuntimeConfig().maxAgents;
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/host-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
* `onListen` hook in {@link ./app.ts}.
*
* This is L1-owned: the canvas-scoped Remote File System reachback
* (`HUABU_RFS_URL = http://127.0.0.1:<port>/api/rfs/<canvasId>`) is a
* pure L1 concern (RFS is canvas-coupled), so the port lives here
* rather than being read back out of the L2 transport host.
* (`HUABU_RFS_URL = http://127.0.0.1:<port>/api/rfs/<canvasId>`) uses
* this port only for the local fallback when no public origin is configured.
* The port lives here rather than being read back out of the L2 transport host.
*/
let serverPort = 0;

Expand All @@ -19,8 +19,8 @@ export function setHostServerPort(port: number): void {

/**
* The bound TCP port, or `0` before the `onListen` hook has fired.
* Used by the spawn orchestrator to build the `HUABU_RFS_URL` reachback
* base injected into agent sessions.
* Used to build the local `HUABU_RFS_URL` fallback injected into agent
* sessions.
*/
export function getHostServerPort(): number {
return serverPort;
Expand Down
20 changes: 7 additions & 13 deletions apps/server/src/modules/agent/acp/agent-cli.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,16 @@ describe('ACP agent CLI route', () => {
{
id: 'copilot',
displayName: 'GitHub Copilot',
binary: 'copilot',
acpArgs: ['--acp'],
autoApprove: null,
installed: true,
status: 'ready' as const,
installHint: 'Install Copilot',
capabilities: { autoApprove: true, customLaunchCommand: false },
},
{
id: 'claude',
displayName: 'Claude Agent',
binary: 'claude-agent-acp',
acpArgs: [],
autoApprove: null,
installed: false,
status: 'adapter-missing' as const,
installHint: 'Install Claude Agent ACP',
capabilities: { autoApprove: false, customLaunchCommand: false },
},
]);
app = Fastify({ logger: false });
Expand All @@ -63,7 +59,7 @@ describe('ACP agent CLI route', () => {
expect(response.json().agents).toEqual(await detect.mock.results[0]?.value);
expect(response.json().agents[1]).toMatchObject({
id: 'claude',
installed: false,
status: 'adapter-missing',
});
});

Expand Down Expand Up @@ -136,10 +132,8 @@ describe('ACP agent CLI route', () => {
expect.objectContaining({
id: 'custom',
capabilities: {
customLaunchCommand: 'supported',
autoApprove: 'unsupported',
modelOverride: 'unsupported',
sessionPersistence: 'unsupported',
customLaunchCommand: true,
autoApprove: false,
},
}),
]);
Expand Down
5 changes: 1 addition & 4 deletions apps/server/src/modules/agent/acp/agent-cli.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,7 @@ async function detectAgentClis(target: {
{
id: CUSTOM_COMMAND_WRAPPER_ID,
displayName: 'Custom command',
binary: CUSTOM_COMMAND_WRAPPER_ID,
acpArgs: [],
autoApprove: null,
installed: false,
status: 'ready',
installHint: '',
capabilities: CUSTOM_COMMAND_CAPABILITIES,
},
Expand Down
Loading
Loading