mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix: improve namespace-based multi-user
This commit is contained in:
@@ -438,6 +438,8 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
|
|||||||
const messageLower = message.toLowerCase();
|
const messageLower = message.toLowerCase();
|
||||||
const axiosCode = (error as any)?.code;
|
const axiosCode = (error as any)?.code;
|
||||||
const httpStatus = (error as any)?.response?.status;
|
const httpStatus = (error as any)?.response?.status;
|
||||||
|
const responseError = (error as any)?.response?.data?.error;
|
||||||
|
const responseErrorText = typeof responseError === 'string' ? responseError : '';
|
||||||
|
|
||||||
if (axiosCode === 'ECONNREFUSED' || axiosCode === 'ETIMEDOUT' || axiosCode === 'ENOTFOUND' ||
|
if (axiosCode === 'ECONNREFUSED' || axiosCode === 'ETIMEDOUT' || axiosCode === 'ENOTFOUND' ||
|
||||||
messageLower.includes('econnrefused') || messageLower.includes('etimedout') ||
|
messageLower.includes('econnrefused') || messageLower.includes('etimedout') ||
|
||||||
@@ -446,6 +448,14 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
|
|||||||
console.error(chalk.yellow('Unable to connect to HAPI server'));
|
console.error(chalk.yellow('Unable to connect to HAPI server'));
|
||||||
console.error(chalk.gray(` Server URL: ${configuration.serverUrl}`));
|
console.error(chalk.gray(` Server URL: ${configuration.serverUrl}`));
|
||||||
console.error(chalk.gray(' Please check your network connection or server status'));
|
console.error(chalk.gray(' Please check your network connection or server status'));
|
||||||
|
} else if (httpStatus === 403 && responseErrorText === 'Machine access denied') {
|
||||||
|
console.error(chalk.red('Machine access denied.'));
|
||||||
|
console.error(chalk.gray(' This machineId is already registered under a different namespace.'));
|
||||||
|
console.error(chalk.gray(' Fix: run `hapi auth logout`, or set a separate HAPI_HOME per namespace.'));
|
||||||
|
} else if (httpStatus === 403 && responseErrorText === 'Session access denied') {
|
||||||
|
console.error(chalk.red('Session access denied.'));
|
||||||
|
console.error(chalk.gray(' This session belongs to a different namespace.'));
|
||||||
|
console.error(chalk.gray(' Use the matching CLI_API_TOKEN or switch namespaces.'));
|
||||||
} else if (httpStatus === 401 || httpStatus === 403 ||
|
} else if (httpStatus === 401 || httpStatus === 403 ||
|
||||||
messageLower.includes('unauthorized') || messageLower.includes('forbidden')) {
|
messageLower.includes('unauthorized') || messageLower.includes('forbidden')) {
|
||||||
console.error(chalk.red('Authentication error:'), message);
|
console.error(chalk.red('Authentication error:'), message);
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ The `CLI_API_TOKEN` is a shared secret that authenticates:
|
|||||||
|
|
||||||
It's auto-generated on first server start and saved to `~/.hapi/settings.json`.
|
It's auto-generated on first server start and saved to `~/.hapi/settings.json`.
|
||||||
|
|
||||||
|
### Do you support multiple accounts?
|
||||||
|
|
||||||
|
Yes. We support lightweight multi-account access via namespaces for shared team servers. See [Namespace (Advanced)](/guide/namespace).
|
||||||
|
|
||||||
### Can I use HAPI without Telegram?
|
### Can I use HAPI without Telegram?
|
||||||
|
|
||||||
Yes. Telegram is optional. You can use the web app directly in any browser or install it as a PWA.
|
Yes. Telegram is optional. You can use the web app directly in any browser or install it as a PWA.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Namespace (Advanced)
|
||||||
|
|
||||||
|
Namespaces are intended for small teams sharing a single public HAPI server. Each team member uses a different namespace to isolate their sessions and machines without running separate servers.
|
||||||
|
|
||||||
|
This is not a default setup path for most users.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- The server uses a single base `CLI_API_TOKEN`.
|
||||||
|
- Clients append `:<namespace>` to the token for isolation.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. On the server, configure only the base token:
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI_API_TOKEN="your-base-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. For each user, append a namespace in the client token:
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI_API_TOKEN="your-base-token:alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Web login and Telegram binding should use the same `base:namespace` token.
|
||||||
|
|
||||||
|
## Limitations and gotchas
|
||||||
|
|
||||||
|
- Server-side `CLI_API_TOKEN` must not include `:<namespace>`. If it does, the server will strip the suffix and log a warning.
|
||||||
|
- Namespaces are isolated: sessions, machines, and users are not visible across namespaces.
|
||||||
|
- One machine ID cannot be reused across namespaces.
|
||||||
|
- To run multiple namespaces on one machine, use a separate `HAPI_HOME` per namespace, or clear the machine ID with `hapi auth logout` before switching.
|
||||||
|
- Remote spawn is namespace-scoped. If you need remote spawning for multiple namespaces on the same machine, run a separate daemon per namespace (use separate `HAPI_HOME`).
|
||||||
@@ -211,6 +211,7 @@ export class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initSchema(): void {
|
private initSchema(): void {
|
||||||
|
// Step 1: Create tables and indexes that don't depend on new columns
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -230,7 +231,6 @@ export class Store {
|
|||||||
seq INTEGER DEFAULT 0
|
seq INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag);
|
CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS machines (
|
CREATE TABLE IF NOT EXISTS machines (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -245,7 +245,6 @@ export class Store {
|
|||||||
active_at INTEGER,
|
active_at INTEGER,
|
||||||
seq INTEGER DEFAULT 0
|
seq INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -268,9 +267,9 @@ export class Store {
|
|||||||
UNIQUE(platform, platform_user_id)
|
UNIQUE(platform, platform_user_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform);
|
CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace);
|
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Step 2: Migrate existing tables (add missing columns)
|
||||||
const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||||
const sessionColumnNames = new Set(sessionColumns.map((c) => c.name))
|
const sessionColumnNames = new Set(sessionColumns.map((c) => c.name))
|
||||||
|
|
||||||
@@ -295,6 +294,13 @@ export class Store {
|
|||||||
if (!userColumnNames.has('namespace')) {
|
if (!userColumnNames.has('namespace')) {
|
||||||
this.db.exec("ALTER TABLE users ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'")
|
this.db.exec("ALTER TABLE users ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3: Create indexes that depend on namespace column (after migration)
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace);
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): StoredSession {
|
getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): StoredSession {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs'
|
|||||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
|
import { parseAccessToken } from '../utils/accessToken'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
machineId?: string
|
machineId?: string
|
||||||
@@ -53,6 +54,28 @@ function isWeakToken(token: string): boolean {
|
|||||||
return weakPatterns.some(p => p.test(token))
|
return weakPatterns.some(p => p.test(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CliApiTokenSource = 'env' | 'file'
|
||||||
|
|
||||||
|
function normalizeCliApiToken(rawToken: string, source: CliApiTokenSource): { token: string; didStrip: boolean } {
|
||||||
|
const parsed = parseAccessToken(rawToken)
|
||||||
|
if (!parsed) {
|
||||||
|
if (rawToken.includes(':')) {
|
||||||
|
console.warn(`[WARN] CLI_API_TOKEN from ${source} contains ":" but is not a valid token. Server expects a base token without namespace.`)
|
||||||
|
}
|
||||||
|
return { token: rawToken, didStrip: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawToken.includes(':')) {
|
||||||
|
return { token: rawToken, didStrip: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[WARN] CLI_API_TOKEN from ${source} includes namespace suffix "${parsed.namespace}". ` +
|
||||||
|
'Server expects the base token only; stripping the suffix.'
|
||||||
|
)
|
||||||
|
return { token: parsed.baseToken, didStrip: true }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read settings from file, preserving all existing fields.
|
* Read settings from file, preserving all existing fields.
|
||||||
* Returns null if file exists but cannot be parsed (to avoid data loss).
|
* Returns null if file exists but cannot be parsed (to avoid data loss).
|
||||||
@@ -99,18 +122,19 @@ export async function getOrCreateCliApiToken(dataDir: string): Promise<CliApiTok
|
|||||||
// 1. Environment variable has highest priority (backward compatible)
|
// 1. Environment variable has highest priority (backward compatible)
|
||||||
const envToken = process.env.CLI_API_TOKEN
|
const envToken = process.env.CLI_API_TOKEN
|
||||||
if (envToken) {
|
if (envToken) {
|
||||||
if (isWeakToken(envToken)) {
|
const normalized = normalizeCliApiToken(envToken, 'env')
|
||||||
|
if (isWeakToken(normalized.token)) {
|
||||||
console.warn('[WARN] CLI_API_TOKEN appears to be weak. Consider using a stronger secret.')
|
console.warn('[WARN] CLI_API_TOKEN appears to be weak. Consider using a stronger secret.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist env token to file if not already saved (prevents token loss on env var issues)
|
// Persist env token to file if not already saved (prevents token loss on env var issues)
|
||||||
const settings = await readSettings(settingsFile)
|
const settings = await readSettings(settingsFile)
|
||||||
if (settings !== null && !settings.cliApiToken) {
|
if (settings !== null && !settings.cliApiToken) {
|
||||||
settings.cliApiToken = envToken
|
settings.cliApiToken = normalized.token
|
||||||
await writeSettings(settingsFile, settings)
|
await writeSettings(settingsFile, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token: envToken, source: 'env', isNew: false, filePath: settingsFile }
|
return { token: normalized.token, source: 'env', isNew: false, filePath: settingsFile }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Read from settings file
|
// 2. Read from settings file
|
||||||
@@ -124,7 +148,12 @@ export async function getOrCreateCliApiToken(dataDir: string): Promise<CliApiTok
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (settings.cliApiToken) {
|
if (settings.cliApiToken) {
|
||||||
return { token: settings.cliApiToken, source: 'file', isNew: false, filePath: settingsFile }
|
const normalized = normalizeCliApiToken(settings.cliApiToken, 'file')
|
||||||
|
if (normalized.didStrip) {
|
||||||
|
settings.cliApiToken = normalized.token
|
||||||
|
await writeSettings(settingsFile, settings)
|
||||||
|
}
|
||||||
|
return { token: normalized.token, source: 'file', isNew: false, filePath: settingsFile }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate new token and save
|
// 3. Generate new token and save
|
||||||
|
|||||||
Reference in New Issue
Block a user