style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -1,28 +1,28 @@
import { describe, test, expect } from "bun:test";
import { getLanIPs } from "../cert.js";
import { describe, test, expect } from 'bun:test'
import { getLanIPs } from '../cert.js'
describe("getLanIPs", () => {
test("returns an array", () => {
const ips = getLanIPs();
expect(Array.isArray(ips)).toBe(true);
});
describe('getLanIPs', () => {
test('returns an array', () => {
const ips = getLanIPs()
expect(Array.isArray(ips)).toBe(true)
})
test("returns only IPv4 addresses", () => {
const ips = getLanIPs();
test('returns only IPv4 addresses', () => {
const ips = getLanIPs()
for (const ip of ips) {
// IPv4 format: x.x.x.x
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/)
}
});
})
test("does not include loopback addresses", () => {
const ips = getLanIPs();
expect(ips).not.toContain("127.0.0.1");
});
test('does not include loopback addresses', () => {
const ips = getLanIPs()
expect(ips).not.toContain('127.0.0.1')
})
test("may be empty in isolated environments", () => {
test('may be empty in isolated environments', () => {
// This test just ensures it doesn't throw
const ips = getLanIPs();
expect(ips.length).toBeGreaterThanOrEqual(0);
});
});
const ips = getLanIPs()
expect(ips.length).toBeGreaterThanOrEqual(0)
})
})

View File

@@ -1,287 +1,306 @@
import { describe, test, expect, mock } from "bun:test";
import { describe, test, expect, mock } from 'bun:test'
import {
__testing,
decodeClientWsMessage,
MAX_CLIENT_WS_PAYLOAD_BYTES,
resolveNewSessionPermissionMode,
type ServerConfig,
} from "../server.js";
} from '../server.js'
import {
authTokensEqual,
decodeWebSocketAuthProtocol,
encodeWebSocketAuthProtocol,
extractWebSocketAuthToken,
} from "../ws-auth.js";
import { buildRcsWsUrl } from "../rcs-upstream.js";
} from '../ws-auth.js'
import { buildRcsWsUrl } from '../rcs-upstream.js'
function makeTestWs(sent: unknown[]) {
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0];
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0]
return {
readyState: 1,
send: mock((message: string) => {
sent.push(JSON.parse(message));
sent.push(JSON.parse(message))
}),
close: mock(() => {}),
raw: null,
isInner: false,
url: "",
origin: "",
protocol: "",
} as unknown as TestWs;
url: '',
origin: '',
protocol: '',
} as unknown as TestWs
}
describe("Server HTTP endpoints", () => {
test("package.json has correct bin and main entries", async () => {
const pkg = await import("../../package.json", { with: { type: "json" } });
expect(pkg.default.name).toBe("acp-link");
expect(pkg.default.main).toBe("./dist/server.js");
expect(pkg.default.bin).toBeDefined();
expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js");
});
describe('Server HTTP endpoints', () => {
test('package.json has correct bin and main entries', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } })
expect(pkg.default.name).toBe('acp-link')
expect(pkg.default.main).toBe('./dist/server.js')
expect(pkg.default.bin).toBeDefined()
expect(pkg.default.bin['acp-link']).toBe('dist/cli/bin.js')
})
test("ServerConfig interface accepts all expected fields", () => {
test('ServerConfig interface accepts all expected fields', () => {
const config: ServerConfig = {
port: 9315,
host: "localhost",
command: "echo",
host: 'localhost',
command: 'echo',
args: [],
cwd: "/tmp",
cwd: '/tmp',
debug: false,
token: "test-token",
token: 'test-token',
https: false,
};
expect(config.port).toBe(9315);
expect(config.token).toBe("test-token");
});
}
expect(config.port).toBe(9315)
expect(config.token).toBe('test-token')
})
test("ServerConfig allows optional fields to be omitted", () => {
test('ServerConfig allows optional fields to be omitted', () => {
const config: ServerConfig = {
port: 9315,
host: "localhost",
command: "echo",
host: 'localhost',
command: 'echo',
args: [],
cwd: "/tmp",
};
expect(config.debug).toBeUndefined();
expect(config.token).toBeUndefined();
expect(config.https).toBeUndefined();
});
});
cwd: '/tmp',
}
expect(config.debug).toBeUndefined()
expect(config.token).toBeUndefined()
expect(config.https).toBeUndefined()
})
})
describe("WebSocket message types", () => {
describe('WebSocket message types', () => {
const clientMessageTypes = [
"connect",
"disconnect",
"new_session",
"prompt",
"permission_response",
"cancel",
"set_session_model",
"list_sessions",
"load_session",
"resume_session",
"ping",
];
'connect',
'disconnect',
'new_session',
'prompt',
'permission_response',
'cancel',
'set_session_model',
'list_sessions',
'load_session',
'resume_session',
'ping',
]
test("all client message types are recognized", () => {
expect(clientMessageTypes.length).toBe(11);
expect(clientMessageTypes).toContain("ping");
expect(clientMessageTypes).toContain("connect");
expect(clientMessageTypes).toContain("cancel");
});
test('all client message types are recognized', () => {
expect(clientMessageTypes.length).toBe(11)
expect(clientMessageTypes).toContain('ping')
expect(clientMessageTypes).toContain('connect')
expect(clientMessageTypes).toContain('cancel')
})
test("decodes supported client message payloads", () => {
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: "ping" });
test('decodes supported client message payloads', () => {
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: 'ping' })
expect(
decodeClientWsMessage(Buffer.from('{"type":"prompt","payload":{"content":[]}}')),
).toEqual({ type: "prompt", payload: { content: [] } });
decodeClientWsMessage(
Buffer.from('{"type":"prompt","payload":{"content":[]}}'),
),
).toEqual({ type: 'prompt', payload: { content: [] } })
expect(
decodeClientWsMessage(new TextEncoder().encode('{"type":"cancel"}').buffer),
).toEqual({ type: "cancel" });
decodeClientWsMessage(
new TextEncoder().encode('{"type":"cancel"}').buffer,
),
).toEqual({ type: 'cancel' })
expect(
decodeClientWsMessage([
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
Buffer.from('next"}}'),
]),
).toEqual({ type: "list_sessions", payload: { cwd: undefined, cursor: "next" } });
});
).toEqual({
type: 'list_sessions',
payload: { cwd: undefined, cursor: 'next' },
})
})
test("rejects malformed typed client payloads", () => {
test('rejects malformed typed client payloads', () => {
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
"Invalid prompt payload",
);
'Invalid prompt payload',
)
expect(() =>
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
).toThrow("Invalid load_session payload");
).toThrow('Invalid load_session payload')
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
"Unknown message type",
);
'Unknown message type',
)
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":123}}',
),
).toThrow("Invalid new_session.permissionMode");
).toThrow('Invalid new_session.permissionMode')
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":{}}}',
),
).toThrow("Invalid new_session.permissionMode");
).toThrow('Invalid new_session.permissionMode')
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":null}}',
),
).toThrow("Invalid new_session.permissionMode");
});
).toThrow('Invalid new_session.permissionMode')
})
test("rejects oversized client message payloads before decoding", () => {
const payload = "x".repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1);
expect(() => decodeClientWsMessage(payload)).toThrow("WebSocket message too large");
});
});
test('rejects oversized client message payloads before decoding', () => {
const payload = 'x'.repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1)
expect(() => decodeClientWsMessage(payload)).toThrow(
'WebSocket message too large',
)
})
})
describe("WebSocket auth protocol", () => {
test("round-trips tokens through a WebSocket subprotocol token", () => {
const protocol = encodeWebSocketAuthProtocol("secret/token+with=symbols");
expect(protocol).toStartWith("rcs.auth.");
expect(protocol).not.toContain("secret/token");
expect(decodeWebSocketAuthProtocol(protocol)).toBe("secret/token+with=symbols");
});
describe('WebSocket auth protocol', () => {
test('round-trips tokens through a WebSocket subprotocol token', () => {
const protocol = encodeWebSocketAuthProtocol('secret/token+with=symbols')
expect(protocol).toStartWith('rcs.auth.')
expect(protocol).not.toContain('secret/token')
expect(decodeWebSocketAuthProtocol(protocol)).toBe(
'secret/token+with=symbols',
)
})
test("ignores query-token style inputs", () => {
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined();
expect(decodeWebSocketAuthProtocol("token=secret")).toBeUndefined();
expect(decodeWebSocketAuthProtocol("other, rcs.auth.")).toBeUndefined();
});
test('ignores query-token style inputs', () => {
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined()
expect(decodeWebSocketAuthProtocol('token=secret')).toBeUndefined()
expect(decodeWebSocketAuthProtocol('other, rcs.auth.')).toBeUndefined()
})
test("prefers Authorization headers and supports protocol auth", () => {
test('prefers Authorization headers and supports protocol auth', () => {
expect(
extractWebSocketAuthToken({
authorization: "Bearer header-token",
protocol: encodeWebSocketAuthProtocol("protocol-token"),
authorization: 'Bearer header-token',
protocol: encodeWebSocketAuthProtocol('protocol-token'),
}),
).toBe("header-token");
).toBe('header-token')
expect(
extractWebSocketAuthToken({
protocol: encodeWebSocketAuthProtocol("protocol-token"),
protocol: encodeWebSocketAuthProtocol('protocol-token'),
}),
).toBe("protocol-token");
});
).toBe('protocol-token')
})
test("compares auth tokens through the shared constant-time path", () => {
expect(authTokensEqual("secret-token", "secret-token")).toBe(true);
expect(authTokensEqual("secret-token", "wrong-token")).toBe(false);
expect(authTokensEqual(undefined, "secret-token")).toBe(false);
});
});
test('compares auth tokens through the shared constant-time path', () => {
expect(authTokensEqual('secret-token', 'secret-token')).toBe(true)
expect(authTokensEqual('secret-token', 'wrong-token')).toBe(false)
expect(authTokensEqual(undefined, 'secret-token')).toBe(false)
})
})
describe("RCS upstream URL normalization", () => {
test("removes legacy token query params from WebSocket URLs", () => {
describe('RCS upstream URL normalization', () => {
test('removes legacy token query params from WebSocket URLs', () => {
expect(
buildRcsWsUrl("http://example.test/acp/ws?token=old-secret&x=1"),
).toBe("ws://example.test/acp/ws?x=1");
});
buildRcsWsUrl('http://example.test/acp/ws?token=old-secret&x=1'),
).toBe('ws://example.test/acp/ws?x=1')
})
test("adds /acp/ws for base URLs", () => {
expect(buildRcsWsUrl("https://example.test/")).toBe(
"wss://example.test/acp/ws",
);
});
});
test('adds /acp/ws for base URLs', () => {
expect(buildRcsWsUrl('https://example.test/')).toBe(
'wss://example.test/acp/ws',
)
})
})
describe("permission mode resolution", () => {
test("uses client requested non-bypass modes", () => {
expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan");
});
describe('permission mode resolution', () => {
test('uses client requested non-bypass modes', () => {
expect(resolveNewSessionPermissionMode('plan', 'acceptEdits')).toBe('plan')
})
test("uses local default when client does not request a mode", () => {
expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits");
});
test('uses local default when client does not request a mode', () => {
expect(resolveNewSessionPermissionMode(undefined, 'acceptEdits')).toBe(
'acceptEdits',
)
})
test("rejects client requested bypassPermissions without local default", () => {
test('rejects client requested bypassPermissions without local default', () => {
expect(() =>
resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits"),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
resolveNewSessionPermissionMode('bypassPermissions', 'acceptEdits'),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
expect(() =>
resolveNewSessionPermissionMode("bypass", "acceptEdits"),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
resolveNewSessionPermissionMode('bypass', 'acceptEdits'),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
expect(() =>
resolveNewSessionPermissionMode("bypasspermissions", "acceptEdits"),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
resolveNewSessionPermissionMode('bypasspermissions', 'acceptEdits'),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
expect(() =>
resolveNewSessionPermissionMode("bypassPermissions", undefined),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
});
resolveNewSessionPermissionMode('bypassPermissions', undefined),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
})
test("rejects unknown client permission modes before forwarding", () => {
test('rejects unknown client permission modes before forwarding', () => {
expect(() =>
resolveNewSessionPermissionMode("unknown-mode", "acceptEdits"),
).toThrow("Invalid permissionMode: unknown-mode");
});
resolveNewSessionPermissionMode('unknown-mode', 'acceptEdits'),
).toThrow('Invalid permissionMode: unknown-mode')
})
test("allows bypassPermissions when local default already enables it", () => {
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions");
expect(resolveNewSessionPermissionMode("bypass", "bypassPermissions")).toBe("bypassPermissions");
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypass")).toBe("bypassPermissions");
});
test('allows bypassPermissions when local default already enables it', () => {
expect(
resolveNewSessionPermissionMode('bypassPermissions', 'bypassPermissions'),
).toBe('bypassPermissions')
expect(resolveNewSessionPermissionMode('bypass', 'bypassPermissions')).toBe(
'bypassPermissions',
)
expect(resolveNewSessionPermissionMode('bypassPermissions', 'bypass')).toBe(
'bypassPermissions',
)
})
test("new_session rejects client bypass before forwarding to the agent", async () => {
const sent: unknown[] = [];
const ws = makeTestWs(sent);
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS;
process.env.ACP_LINK_TEST_INTERNALS = "1";
let unregisterClient = () => {};
let restoreMode = () => {};
test('new_session rejects client bypass before forwarding to the agent', async () => {
const sent: unknown[] = []
const ws = makeTestWs(sent)
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS
process.env.ACP_LINK_TEST_INTERNALS = '1'
let unregisterClient = () => {}
let restoreMode = () => {}
try {
const newSession = mock(async () => ({
sessionId: "should-not-be-created",
}));
sessionId: 'should-not-be-created',
}))
unregisterClient = __testing.registerClient(ws, {
connection: { newSession },
});
restoreMode = __testing.setDefaultPermissionMode("acceptEdits");
})
restoreMode = __testing.setDefaultPermissionMode('acceptEdits')
await __testing.dispatchClientMessage(ws, {
type: "new_session",
type: 'new_session',
payload: {
cwd: "/tmp",
permissionMode: "bypass",
cwd: '/tmp',
permissionMode: 'bypass',
},
});
})
expect(newSession).not.toHaveBeenCalled();
expect(__testing.getClientSessionId(ws)).toBeNull();
expect(newSession).not.toHaveBeenCalled()
expect(__testing.getClientSessionId(ws)).toBeNull()
expect(sent).toEqual([
{
type: "error",
type: 'error',
payload: {
message: expect.stringContaining(
"bypassPermissions requires local ACP_PERMISSION_MODE",
'bypassPermissions requires local ACP_PERMISSION_MODE',
),
},
},
]);
])
} finally {
restoreMode();
unregisterClient();
restoreMode()
unregisterClient()
if (originalTestInternals === undefined) {
delete process.env.ACP_LINK_TEST_INTERNALS;
delete process.env.ACP_LINK_TEST_INTERNALS
} else {
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals;
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals
}
}
});
});
})
})
describe("Heartbeat constants", () => {
test("PERMISSION_TIMEOUT_MS is 5 minutes", () => {
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
expect(PERMISSION_TIMEOUT_MS).toBe(300_000);
});
describe('Heartbeat constants', () => {
test('PERMISSION_TIMEOUT_MS is 5 minutes', () => {
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000
expect(PERMISSION_TIMEOUT_MS).toBe(300_000)
})
test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => {
const HEARTBEAT_INTERVAL_MS = 30_000;
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
});
});
test('HEARTBEAT_INTERVAL_MS is 30 seconds', () => {
const HEARTBEAT_INTERVAL_MS = 30_000
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000)
})
})

View File

@@ -1,69 +1,86 @@
import { describe, test, expect } from "bun:test";
import { isRequest, isResponse, isNotification } from "../types.js";
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js";
import { describe, test, expect } from 'bun:test'
import { isRequest, isResponse, isNotification } from '../types.js'
import type {
JsonRpcRequest,
JsonRpcResponse,
JsonRpcNotification,
} from '../types.js'
describe("isRequest", () => {
test("returns true for a valid JSON-RPC request", () => {
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
expect(isRequest(msg)).toBe(true);
});
describe('isRequest', () => {
test('returns true for a valid JSON-RPC request', () => {
const msg: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'test' }
expect(isRequest(msg)).toBe(true)
})
test("returns true for request with params", () => {
const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } };
expect(isRequest(msg)).toBe(true);
});
test('returns true for request with params', () => {
const msg = {
jsonrpc: '2.0' as const,
id: 'abc',
method: 'test',
params: { x: 1 },
}
expect(isRequest(msg)).toBe(true)
})
test("returns false for response (no method)", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} };
expect(isRequest(msg)).toBe(false);
});
test('returns false for response (no method)', () => {
const msg: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: {} }
expect(isRequest(msg)).toBe(false)
})
test("returns false for notification (no id)", () => {
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
expect(isRequest(msg)).toBe(false);
});
});
test('returns false for notification (no id)', () => {
const msg: JsonRpcNotification = { jsonrpc: '2.0', method: 'notify' }
expect(isRequest(msg)).toBe(false)
})
})
describe("isResponse", () => {
test("returns true for a valid JSON-RPC response with result", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" };
expect(isResponse(msg)).toBe(true);
});
describe('isResponse', () => {
test('returns true for a valid JSON-RPC response with result', () => {
const msg: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: 'ok' }
expect(isResponse(msg)).toBe(true)
})
test("returns true for a valid JSON-RPC error response", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } };
expect(isResponse(msg)).toBe(true);
});
test('returns true for a valid JSON-RPC error response', () => {
const msg: JsonRpcResponse = {
jsonrpc: '2.0',
id: 2,
error: { code: -32600, message: 'bad' },
}
expect(isResponse(msg)).toBe(true)
})
test("returns false for request (has method)", () => {
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
expect(isResponse(msg)).toBe(false);
});
test('returns false for request (has method)', () => {
const msg: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'test' }
expect(isResponse(msg)).toBe(false)
})
test("returns false for notification", () => {
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
expect(isResponse(msg)).toBe(false);
});
});
test('returns false for notification', () => {
const msg: JsonRpcNotification = { jsonrpc: '2.0', method: 'notify' }
expect(isResponse(msg)).toBe(false)
})
})
describe("isNotification", () => {
test("returns true for a valid JSON-RPC notification", () => {
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" };
expect(isNotification(msg)).toBe(true);
});
describe('isNotification', () => {
test('returns true for a valid JSON-RPC notification', () => {
const msg: JsonRpcNotification = { jsonrpc: '2.0', method: 'update' }
expect(isNotification(msg)).toBe(true)
})
test("returns true for notification with params", () => {
const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } };
expect(isNotification(msg)).toBe(true);
});
test('returns true for notification with params', () => {
const msg = {
jsonrpc: '2.0' as const,
method: 'progress',
params: { pct: 50 },
}
expect(isNotification(msg)).toBe(true)
})
test("returns false for request (has id)", () => {
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
expect(isNotification(msg)).toBe(false);
});
test('returns false for request (has id)', () => {
const msg: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'test' }
expect(isNotification(msg)).toBe(false)
})
test("returns false for response (no method)", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null };
expect(isNotification(msg)).toBe(false);
});
});
test('returns false for response (no method)', () => {
const msg: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: null }
expect(isNotification(msg)).toBe(false)
})
})

View File

@@ -2,27 +2,27 @@
* Self-signed certificate generation for HTTPS support
*/
import { X509Certificate } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, networkInterfaces } from "node:os";
import { join } from "node:path";
import { generate } from "selfsigned";
import { X509Certificate } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir, networkInterfaces } from 'node:os'
import { join } from 'node:path'
import { generate } from 'selfsigned'
/**
* Get all LAN IPv4 addresses
*/
export function getLanIPs(): string[] {
const ips: string[] = [];
const nets = networkInterfaces();
const ips: string[] = []
const nets = networkInterfaces()
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
// Skip internal (loopback) and non-IPv4 addresses
if (!net.internal && net.family === "IPv4") {
ips.push(net.address);
if (!net.internal && net.family === 'IPv4') {
ips.push(net.address)
}
}
}
return ips;
return ips
}
/**
@@ -30,31 +30,31 @@ export function getLanIPs(): string[] {
* SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost"
*/
function extractSanIPs(x509: X509Certificate): string[] {
const san = x509.subjectAltName;
if (!san) return [];
const san = x509.subjectAltName
if (!san) return []
const ips: string[] = [];
const ips: string[] = []
// Parse "IP Address:x.x.x.x" entries from SAN string
const parts = san.split(", ");
const parts = san.split(', ')
for (const part of parts) {
const match = part.match(/^IP Address:(.+)$/);
const match = part.match(/^IP Address:(.+)$/)
if (match && match[1]) {
ips.push(match[1]);
ips.push(match[1])
}
}
return ips;
return ips
}
const CERT_DIR = join(homedir(), ".acp-proxy");
const KEY_PATH = join(CERT_DIR, "key.pem");
const CERT_PATH = join(CERT_DIR, "cert.pem");
const CERT_DIR = join(homedir(), '.acp-proxy')
const KEY_PATH = join(CERT_DIR, 'key.pem')
const CERT_PATH = join(CERT_DIR, 'cert.pem')
// Certificate validity in days
const CERT_VALIDITY_DAYS = 365;
const CERT_VALIDITY_DAYS = 365
export interface TlsOptions {
key: string;
cert: string;
key: string
cert: string
}
/**
@@ -64,111 +64,119 @@ export interface TlsOptions {
export async function getOrCreateCertificate(): Promise<TlsOptions> {
// Ensure directory exists
if (!existsSync(CERT_DIR)) {
mkdirSync(CERT_DIR, { recursive: true });
mkdirSync(CERT_DIR, { recursive: true })
}
// Check if certificates already exist and are still valid
if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
const certPem = readFileSync(CERT_PATH, "utf-8");
const keyPem = readFileSync(KEY_PATH, "utf-8");
const certPem = readFileSync(CERT_PATH, 'utf-8')
const keyPem = readFileSync(KEY_PATH, 'utf-8')
try {
const x509 = new X509Certificate(certPem);
const validTo = new Date(x509.validTo);
const now = new Date();
const x509 = new X509Certificate(certPem)
const validTo = new Date(x509.validTo)
const now = new Date()
// Check if cert is expired or will expire within 7 days
const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
const daysUntilExpiry = Math.floor(
(validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
)
if (daysUntilExpiry <= 7) {
// Certificate expired or expiring soon
console.log(`⚠️ Certificate ${daysUntilExpiry <= 0 ? "expired" : `expires in ${daysUntilExpiry} days`}, regenerating...`);
console.log(
`⚠️ Certificate ${daysUntilExpiry <= 0 ? 'expired' : `expires in ${daysUntilExpiry} days`}, regenerating...`,
)
} else {
// Check if current LAN IPs are in the certificate's SAN
const currentLanIPs = getLanIPs();
const certSanIPs = extractSanIPs(x509);
const currentLanIPs = getLanIPs()
const certSanIPs = extractSanIPs(x509)
// Check if all current LAN IPs are covered by the certificate
const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip));
const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip))
if (missingIPs.length === 0) {
console.log(`🔐 Using existing certificate from ${CERT_DIR}`);
console.log(` Valid for ${daysUntilExpiry} more days`);
return { key: keyPem, cert: certPem };
console.log(`🔐 Using existing certificate from ${CERT_DIR}`)
console.log(` Valid for ${daysUntilExpiry} more days`)
return { key: keyPem, cert: certPem }
}
// LAN IP changed, regenerate
console.log(`⚠️ LAN IP changed (missing: ${missingIPs.join(", ")}), regenerating certificate...`);
console.log(
`⚠️ LAN IP changed (missing: ${missingIPs.join(', ')}), regenerating certificate...`,
)
}
} catch {
// Failed to parse certificate, regenerate
console.log(`⚠️ Invalid certificate, regenerating...`);
console.log(`⚠️ Invalid certificate, regenerating...`)
}
}
// Generate new self-signed certificate
console.log(`🔐 Generating self-signed certificate...`);
console.log(`🔐 Generating self-signed certificate...`)
const attrs = [{ name: "commonName", value: "ACP Proxy Server" }];
const attrs = [{ name: 'commonName', value: 'ACP Proxy Server' }]
// Calculate expiry date
const notAfterDate = new Date();
notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS);
const notAfterDate = new Date()
notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS)
// Build altNames: localhost + loopback + all LAN IPs
const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [
{ type: 2, value: "localhost" },
{ type: 7, ip: "127.0.0.1" },
{ type: 7, ip: "::1" },
];
const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> =
[
{ type: 2, value: 'localhost' },
{ type: 7, ip: '127.0.0.1' },
{ type: 7, ip: '::1' },
]
// Add all current LAN IPs
const lanIPs = getLanIPs();
const lanIPs = getLanIPs()
for (const ip of lanIPs) {
altNames.push({ type: 7, ip });
altNames.push({ type: 7, ip })
}
if (lanIPs.length > 0) {
console.log(` Including LAN IPs: ${lanIPs.join(", ")}`);
console.log(` Including LAN IPs: ${lanIPs.join(', ')}`)
}
const pems = await generate(attrs, {
keySize: 2048,
notAfterDate,
algorithm: "sha256",
algorithm: 'sha256',
extensions: [
{
name: "basicConstraints",
name: 'basicConstraints',
cA: true,
},
{
name: "keyUsage",
name: 'keyUsage',
keyCertSign: true,
digitalSignature: true,
keyEncipherment: true,
},
{
name: "extKeyUsage",
name: 'extKeyUsage',
serverAuth: true,
},
{
name: "subjectAltName",
name: 'subjectAltName',
altNames,
},
],
});
})
// Save certificates
writeFileSync(KEY_PATH, pems.private);
writeFileSync(CERT_PATH, pems.cert);
writeFileSync(KEY_PATH, pems.private)
writeFileSync(CERT_PATH, pems.cert)
console.log(`✅ Certificate saved to ${CERT_DIR}`);
console.log(` Valid for ${CERT_VALIDITY_DAYS} days`);
console.log(` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`);
console.log(`✅ Certificate saved to ${CERT_DIR}`)
console.log(` Valid for ${CERT_VALIDITY_DAYS} days`)
console.log(
` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`,
)
return {
key: pems.private,
cert: pems.cert,
};
}
}

View File

@@ -1,18 +1,17 @@
import { buildApplication } from "@stricli/core";
import { createRequire } from "node:module";
import { command } from "./command.js";
import { buildApplication } from '@stricli/core'
import { createRequire } from 'node:module'
import { command } from './command.js'
const require = createRequire(import.meta.url);
const pkg = require("../../package.json") as { version: string };
const require = createRequire(import.meta.url)
const pkg = require('../../package.json') as { version: string }
export const app = buildApplication(command, {
name: "acp-link",
name: 'acp-link',
versionInfo: {
currentVersion: pkg.version,
},
scanner: {
caseStyle: "allow-kebab-for-camel",
caseStyle: 'allow-kebab-for-camel',
allowArgumentEscapeSequence: true,
},
});
})

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node
import { run } from "@stricli/core";
import { app } from "./app.js";
import { buildContext } from "./context.js";
await run(app, process.argv.slice(2), buildContext());
import { run } from '@stricli/core'
import { app } from './app.js'
import { buildContext } from './context.js'
await run(app, process.argv.slice(2), buildContext())

View File

@@ -1,123 +1,145 @@
import { buildCommand, numberParser } from "@stricli/core";
import type { LocalContext } from "./context.js";
import { buildCommand, numberParser } from '@stricli/core'
import type { LocalContext } from './context.js'
export const command = buildCommand({
docs: {
brief: "Start the ACP proxy server",
brief: 'Start the ACP proxy server',
fullDescription:
"Starts a WebSocket proxy server that bridges clients to ACP agents. " +
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
"Use -- to pass arguments to the agent:\n" +
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
"Use --manager to start the Manager Web UI instead:\n" +
" acp-link --manager\n\n" +
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
'Starts a WebSocket proxy server that bridges clients to ACP agents. ' +
'The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n' +
'Use -- to pass arguments to the agent:\n' +
' acp-link /path/to/agent -- --verbose --model gpt-4\n\n' +
'Use --manager to start the Manager Web UI instead:\n' +
' acp-link --manager\n\n' +
'For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.',
},
parameters: {
flags: {
port: {
kind: "parsed",
kind: 'parsed',
parse: numberParser,
brief: "Port to listen on",
default: "9315",
brief: 'Port to listen on',
default: '9315',
},
host: {
kind: "parsed",
kind: 'parsed',
parse: String,
brief: "Host to bind to (use 0.0.0.0 for remote access)",
default: "localhost",
brief: 'Host to bind to (use 0.0.0.0 for remote access)',
default: 'localhost',
},
debug: {
kind: "boolean",
brief: "Enable debug logging to file",
kind: 'boolean',
brief: 'Enable debug logging to file',
default: false,
},
"no-auth": {
kind: "boolean",
brief: "DANGEROUS: Disable authentication (not recommended)",
'no-auth': {
kind: 'boolean',
brief: 'DANGEROUS: Disable authentication (not recommended)',
default: false,
},
https: {
kind: "boolean",
brief: "Enable HTTPS with auto-generated self-signed certificate",
kind: 'boolean',
brief: 'Enable HTTPS with auto-generated self-signed certificate',
default: false,
},
manager: {
kind: "boolean",
brief: "Start Manager Web UI (no proxy)",
kind: 'boolean',
brief: 'Start Manager Web UI (no proxy)',
default: false,
},
group: {
kind: "parsed",
kind: 'parsed',
parse: (value: string) => {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
throw new Error(
`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`,
)
}
return value;
return value
},
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
brief: 'Channel group ID for RCS registration (env: ACP_RCS_GROUP)',
optional: true,
},
},
positional: {
kind: "array",
kind: 'array',
parameter: {
brief: "Agent command and arguments (use -- before agent flags)",
brief: 'Agent command and arguments (use -- before agent flags)',
parse: String,
placeholder: "command",
placeholder: 'command',
},
minimum: 0,
},
},
func: async function (
this: LocalContext,
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
flags: {
port: number
host: string
debug: boolean
'no-auth': boolean
https: boolean
manager: boolean
group: string | undefined
},
...args: readonly string[]
) {
const port = flags.port;
const host = flags.host;
const debug = flags.debug;
const noAuth = flags["no-auth"];
const https = flags.https;
const manager = flags.manager;
const group = flags.group;
const port = flags.port
const host = flags.host
const debug = flags.debug
const noAuth = flags['no-auth']
const https = flags.https
const manager = flags.manager
const group = flags.group
// Manager mode: start web UI only, no proxy
if (manager) {
const { startManager } = await import("../manager/index.js");
await startManager(port);
return;
const { startManager } = await import('../manager/index.js')
await startManager(port)
return
}
// Proxy mode: agent command is required
if (args.length === 0) {
console.error("Error: agent command is required (or use --manager)");
process.exit(1);
console.error('Error: agent command is required (or use --manager)')
process.exit(1)
}
const [command, ...agentArgs] = args;
const cwd = process.cwd();
const [command, ...agentArgs] = args
const cwd = process.cwd()
// Determine auth token
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
let token: string | undefined;
let token: string | undefined
if (noAuth) {
console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
token = undefined;
console.warn(
'⚠️ WARNING: Authentication disabled. This is dangerous for remote access!',
)
token = undefined
} else {
token = process.env.ACP_AUTH_TOKEN;
token = process.env.ACP_AUTH_TOKEN
if (!token) {
// Auto-generate random token
const { randomBytes } = await import("node:crypto");
token = randomBytes(32).toString("hex");
const { randomBytes } = await import('node:crypto')
token = randomBytes(32).toString('hex')
}
}
// Initialize logger
const { initLogger } = await import("../logger.js");
initLogger({ debug });
const { initLogger } = await import('../logger.js')
initLogger({ debug })
// Import and run the server
const { startServer } = await import("../server.js");
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
const { startServer } = await import('../server.js')
await startServer({
port,
host,
command: command!,
args: [...agentArgs],
cwd,
debug,
token,
https,
group,
})
},
});
})

View File

@@ -1,10 +1,9 @@
import type { CommandContext } from "@stricli/core";
import type { CommandContext } from '@stricli/core'
export interface LocalContext extends CommandContext {}
export function buildContext(): LocalContext {
return {
process,
};
}
}

View File

@@ -1,77 +1,81 @@
import pino from "pino";
import { join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import pino from 'pino'
import { join } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs'
let rootLogger: pino.Logger;
let rootLogger: pino.Logger
export interface LoggerConfig {
debug: boolean;
logDir?: string;
debug: boolean
logDir?: string
}
/** Pretty-print config for console output */
const PRETTY_CONFIG = {
colorize: true,
translateTime: "SYS:HH:MM:ss.l",
ignore: "pid,hostname",
} as const;
translateTime: 'SYS:HH:MM:ss.l',
ignore: 'pid,hostname',
} as const
export function initLogger(config: LoggerConfig): pino.Logger {
const { debug, logDir } = config;
const { debug, logDir } = config
if (debug) {
const dir = logDir || join(process.cwd(), ".acp-proxy");
const dir = logDir || join(process.cwd(), '.acp-proxy')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
mkdirSync(dir, { recursive: true })
}
const now = new Date();
const timestamp = now.toISOString()
.replace(/T/, "_")
.replace(/:/g, "-")
.replace(/\..+/, "");
const logFile = join(dir, `acp-proxy-${timestamp}.log`);
const now = new Date()
const timestamp = now
.toISOString()
.replace(/T/, '_')
.replace(/:/g, '-')
.replace(/\..+/, '')
const logFile = join(dir, `acp-proxy-${timestamp}.log`)
// Debug mode: JSON to file + pretty to console (multistream)
rootLogger = pino(
{
level: "trace",
level: 'trace',
timestamp: pino.stdTimeFunctions.isoTime,
},
pino.transport({
targets: [
{ target: "pino/file", options: { destination: logFile } },
{ target: "pino-pretty", options: { ...PRETTY_CONFIG, destination: 1 } },
{ target: 'pino/file', options: { destination: logFile } },
{
target: 'pino-pretty',
options: { ...PRETTY_CONFIG, destination: 1 },
},
],
}),
);
)
console.log(`📝 Debug logging enabled: ${logFile}`);
console.log(`📝 Debug logging enabled: ${logFile}`)
} else {
rootLogger = pino(
{ level: "info", timestamp: pino.stdTimeFunctions.isoTime },
{ level: 'info', timestamp: pino.stdTimeFunctions.isoTime },
pino.transport({
target: "pino-pretty",
target: 'pino-pretty',
options: { ...PRETTY_CONFIG, destination: 1 },
}),
);
)
}
return rootLogger;
return rootLogger
}
/** Get the root logger (auto-creates a default one if not initialized). */
export function getLogger(): pino.Logger {
if (!rootLogger) {
rootLogger = pino(
{ level: "info" },
{ level: 'info' },
pino.transport({
target: "pino-pretty",
target: 'pino-pretty',
options: { ...PRETTY_CONFIG, destination: 1 },
}),
);
)
}
return rootLogger;
return rootLogger
}
/**
@@ -79,5 +83,5 @@ export function getLogger(): pino.Logger {
* Usage: `const log = createLogger("agent"); log.info({ pid }, "spawned")`
*/
export function createLogger(module: string): pino.Logger {
return getLogger().child({ module });
return getLogger().child({ module })
}

View File

@@ -342,4 +342,4 @@ fetchInstances();
setInterval(fetchInstances, 3000);
</script>
</body>
</html>`;
</html>`

View File

@@ -1,44 +1,46 @@
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { ProcessManager } from "./manager.js";
import { createApp } from "./routes.js";
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { ProcessManager } from './manager.js'
import { createApp } from './routes.js'
export async function startManager(port: number): Promise<void> {
const manager = new ProcessManager();
const app = createApp(manager);
const manager = new ProcessManager()
const app = createApp(manager)
// Health check
app.get("/health", (c) => c.json({ status: "ok" }));
app.get('/health', c => c.json({ status: 'ok' }))
let shuttingDown = false;
let shuttingDown = false
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("Shutting down...");
await manager.shutdownAll();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
if (shuttingDown) return
shuttingDown = true
console.log('Shutting down...')
await manager.shutdownAll()
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
const server = serve({ fetch: app.fetch, port });
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
const server = serve({ fetch: app.fetch, port })
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(
`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`,
)
} else {
console.error(`\n Error: ${err.message}\n`);
console.error(`\n Error: ${err.message}\n`)
}
process.exit(1);
});
process.exit(1)
})
console.log();
console.log(` 🖥️ ACP Manager`);
console.log();
console.log(` URL: http://localhost:${port}`);
console.log();
console.log(` Press Ctrl+C to stop`);
console.log();
console.log()
console.log(` 🖥️ ACP Manager`)
console.log()
console.log(` URL: http://localhost:${port}`)
console.log()
console.log(` Press Ctrl+C to stop`)
console.log()
// Keep running
await new Promise(() => {});
await new Promise(() => {})
}

View File

@@ -1,205 +1,217 @@
import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js";
import type { AcpInstance, InstanceSummary, LogEntry } from './types.js'
function log(tag: string, msg: string) {
const ts = new Date().toISOString();
console.log(`[${ts}] [${tag}] ${msg}`);
const ts = new Date().toISOString()
console.log(`[${ts}] [${tag}] ${msg}`)
}
const MAX_LOG_LINES = 2000;
const SHUTDOWN_TIMEOUT_MS = 5000;
const MAX_LOG_LINES = 2000
const SHUTDOWN_TIMEOUT_MS = 5000
export class ProcessManager {
private instances = new Map<string, AcpInstance>();
private instances = new Map<string, AcpInstance>()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private processes = new Map<string, any>();
private processes = new Map<string, any>()
create(group: string, command: string): AcpInstance {
const id = crypto.randomUUID();
const id = crypto.randomUUID()
const instance: AcpInstance = {
id,
group,
command,
status: "running",
status: 'running',
pid: undefined,
startTime: Date.now(),
exitCode: null,
logs: [],
subscribers: new Set(),
};
}
const args = this.parseCommand(command);
const fullArgs = ["--group", group, ...args];
const args = this.parseCommand(command)
const fullArgs = ['--group', group, ...args]
const proc = Bun.spawn(["acp-link", ...fullArgs], {
stdout: "pipe",
stderr: "pipe",
env: { ...Bun.env, ACP_CHILD: "1" },
});
const proc = Bun.spawn(['acp-link', ...fullArgs], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...Bun.env, ACP_CHILD: '1' },
})
instance.pid = proc.pid;
this.instances.set(id, instance);
this.processes.set(id, proc);
log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`);
instance.pid = proc.pid
this.instances.set(id, instance)
this.processes.set(id, proc)
log(
'manager',
`created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(' ')}"`,
)
this.pipeStream(proc.stdout, id, "stdout");
this.pipeStream(proc.stderr, id, "stderr");
this.pipeStream(proc.stdout, id, 'stdout')
this.pipeStream(proc.stderr, id, 'stderr')
proc.exited.then((code) => {
instance.status = code === 0 ? "stopped" : "failed";
instance.exitCode = code;
instance.pid = undefined;
this.processes.delete(id);
log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
this.notifyStatus(instance);
});
proc.exited.then(code => {
instance.status = code === 0 ? 'stopped' : 'failed'
instance.exitCode = code
instance.pid = undefined
this.processes.delete(id)
log(
'manager',
`instance ${id.slice(0, 8)} ${instance.status} exit=${code}`,
)
this.notifyStatus(instance)
})
return instance;
return instance
}
stop(id: string): boolean {
const proc = this.processes.get(id);
if (!proc) return false;
const inst = this.instances.get(id);
log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
proc.kill("SIGTERM");
const proc = this.processes.get(id)
if (!proc) return false
const inst = this.instances.get(id)
log('manager', `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`)
proc.kill('SIGTERM')
// Immediately mark as stopped to prevent stale state
if (inst) {
inst.status = "stopped";
inst.status = 'stopped'
}
return true;
return true
}
remove(id: string): boolean {
const instance = this.instances.get(id);
if (!instance) return false;
if (instance.status === "running") return false;
instance.subscribers.clear();
this.instances.delete(id);
log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
return true;
const instance = this.instances.get(id)
if (!instance) return false
if (instance.status === 'running') return false
instance.subscribers.clear()
this.instances.delete(id)
log('manager', `removed instance ${id.slice(0, 8)} group=${instance.group}`)
return true
}
list(): InstanceSummary[] {
return Array.from(this.instances.values()).map(this.toSummary);
return Array.from(this.instances.values()).map(this.toSummary)
}
get(id: string): AcpInstance | undefined {
return this.instances.get(id);
return this.instances.get(id)
}
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
const instance = this.instances.get(id);
if (!instance) return () => {};
instance.subscribers.add(callback);
return () => instance.subscribers.delete(callback);
const instance = this.instances.get(id)
if (!instance) return () => {}
instance.subscribers.add(callback)
return () => instance.subscribers.delete(callback)
}
async shutdownAll(): Promise<void> {
const running = Array.from(this.processes.entries());
if (running.length === 0) return;
const running = Array.from(this.processes.entries())
if (running.length === 0) return
log("manager", `shutting down ${running.length} running instance(s)...`);
log('manager', `shutting down ${running.length} running instance(s)...`)
for (const [id, proc] of running) {
try {
proc.kill("SIGTERM");
log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
proc.kill('SIGTERM')
log('manager', `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`)
} catch {
// already dead
}
}
const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
const timeout = new Promise<void>(resolve =>
setTimeout(resolve, SHUTDOWN_TIMEOUT_MS),
)
await Promise.race([
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
timeout,
]);
])
for (const [id, proc] of running) {
try {
proc.kill("SIGKILL");
log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
proc.kill('SIGKILL')
log('manager', `sent SIGKILL to ${id.slice(0, 8)}`)
} catch {
// already dead
}
}
log("manager", "all instances shut down");
log('manager', 'all instances shut down')
}
private parseCommand(command: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
const args: string[] = []
let current = ''
let inQuote: string | null = null
for (const ch of command) {
if (inQuote) {
if (ch === inQuote) {
inQuote = null;
inQuote = null
} else {
current += ch;
current += ch
}
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === " " || ch === "\t") {
inQuote = ch
} else if (ch === ' ' || ch === '\t') {
if (current) {
args.push(current);
current = "";
args.push(current)
current = ''
}
} else {
current += ch;
current += ch
}
}
if (current) args.push(current);
return args;
if (current) args.push(current)
return args
}
private pipeStream(
readable: ReadableStream<Uint8Array>,
instanceId: string,
stream: "stdout" | "stderr",
stream: 'stdout' | 'stderr',
) {
const reader = readable.getReader();
const decoder = new TextDecoder();
let buffer = "";
const reader = readable.getReader()
const decoder = new TextDecoder()
let buffer = ''
const processChunk = () => {
reader
.read()
.then(({ done, value }) => {
if (done) {
if (buffer) this.appendLog(instanceId, buffer, stream);
return;
if (buffer) this.appendLog(instanceId, buffer, stream)
return
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line) this.appendLog(instanceId, line, stream);
if (line) this.appendLog(instanceId, line, stream)
}
processChunk();
processChunk()
})
.catch(() => {
// stream ended or error
});
};
processChunk();
})
}
processChunk()
}
private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
const instance = this.instances.get(instanceId);
if (!instance) return;
private appendLog(
instanceId: string,
text: string,
stream: 'stdout' | 'stderr',
) {
const instance = this.instances.get(instanceId)
if (!instance) return
const entry: LogEntry = { timestamp: Date.now(), stream, text };
instance.logs.push(entry);
const entry: LogEntry = { timestamp: Date.now(), stream, text }
instance.logs.push(entry)
if (instance.logs.length > MAX_LOG_LINES) {
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES);
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES)
}
for (const sub of instance.subscribers) {
try {
sub(entry);
sub(entry)
} catch {
// subscriber error, remove it
instance.subscribers.delete(sub);
instance.subscribers.delete(sub)
}
}
}
@@ -207,14 +219,14 @@ export class ProcessManager {
private notifyStatus(instance: AcpInstance) {
const statusEntry: LogEntry = {
timestamp: Date.now(),
stream: "stderr",
stream: 'stderr',
text: `[${instance.status}] exit code: ${instance.exitCode}`,
};
}
for (const sub of instance.subscribers) {
try {
sub(statusEntry);
sub(statusEntry)
} catch {
instance.subscribers.delete(sub);
instance.subscribers.delete(sub)
}
}
}
@@ -228,6 +240,6 @@ export class ProcessManager {
pid: inst.pid,
startTime: inst.startTime,
exitCode: inst.exitCode,
};
}
}
}

View File

@@ -1,41 +1,41 @@
import { Hono } from "hono";
import type { ProcessManager } from "./manager.js";
import { MANAGER_HTML } from "./html.js";
import { Hono } from 'hono'
import type { ProcessManager } from './manager.js'
import { MANAGER_HTML } from './html.js'
function logReq(method: string, path: string, status?: number) {
const ts = new Date().toISOString();
const suffix = status != null ? ` -> ${status}` : "";
console.log(`[${ts}] [http] ${method} ${path}${suffix}`);
const ts = new Date().toISOString()
const suffix = status != null ? ` -> ${status}` : ''
console.log(`[${ts}] [http] ${method} ${path}${suffix}`)
}
export function createApp(manager: ProcessManager): Hono {
const app = new Hono();
const app = new Hono()
app.get("/", (c) => {
logReq("GET", "/", 200);
return c.html(MANAGER_HTML);
});
app.get('/', c => {
logReq('GET', '/', 200)
return c.html(MANAGER_HTML)
})
app.get("/api/instances", (c) => {
const list = manager.list();
logReq("GET", "/api/instances", 200);
return c.json(list);
});
app.get('/api/instances', c => {
const list = manager.list()
logReq('GET', '/api/instances', 200)
return c.json(list)
})
app.post("/api/instances", async (c) => {
let body: { group?: string; command?: string };
app.post('/api/instances', async c => {
let body: { group?: string; command?: string }
try {
body = await c.req.json<{ group?: string; command?: string }>();
body = await c.req.json<{ group?: string; command?: string }>()
} catch {
logReq("POST", "/api/instances", 400);
return c.json({ error: "invalid JSON body" }, 400);
logReq('POST', '/api/instances', 400)
return c.json({ error: 'invalid JSON body' }, 400)
}
if (!body.group?.trim() || !body.command?.trim()) {
logReq("POST", "/api/instances", 400);
return c.json({ error: "group and command are required" }, 400);
logReq('POST', '/api/instances', 400)
return c.json({ error: 'group and command are required' }, 400)
}
const instance = manager.create(body.group.trim(), body.command.trim());
logReq("POST", `/api/instances group=${body.group}`, 201);
const instance = manager.create(body.group.trim(), body.command.trim())
logReq('POST', `/api/instances group=${body.group}`, 201)
return c.json(
{
id: instance.id,
@@ -47,107 +47,107 @@ export function createApp(manager: ProcessManager): Hono {
exitCode: instance.exitCode,
},
201,
);
});
)
})
app.post("/api/instances/:id/stop", (c) => {
const id = c.req.param("id");
const inst = manager.get(id);
app.post('/api/instances/:id/stop', c => {
const id = c.req.param('id')
const inst = manager.get(id)
if (!inst) {
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404);
return c.json({ error: "not found" }, 404);
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 404)
return c.json({ error: 'not found' }, 404)
}
if (inst.status !== "running") {
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400);
return c.json({ error: "not running" }, 400);
if (inst.status !== 'running') {
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 400)
return c.json({ error: 'not running' }, 400)
}
manager.stop(inst.id);
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200);
return c.json({ ok: true });
});
manager.stop(inst.id)
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 200)
return c.json({ ok: true })
})
app.delete("/api/instances/:id", (c) => {
const id = c.req.param("id");
const inst = manager.get(id);
app.delete('/api/instances/:id', c => {
const id = c.req.param('id')
const inst = manager.get(id)
if (!inst) {
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404);
return c.json({ error: "not found" }, 404);
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 404)
return c.json({ error: 'not found' }, 404)
}
if (inst.status === "running") {
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400);
return c.json({ error: "still running" }, 400);
if (inst.status === 'running') {
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 400)
return c.json({ error: 'still running' }, 400)
}
manager.remove(inst.id);
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200);
return c.json({ ok: true });
});
manager.remove(inst.id)
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 200)
return c.json({ ok: true })
})
app.get("/api/instances/:id/logs", (c) => {
const id = c.req.param("id");
const inst = manager.get(id);
app.get('/api/instances/:id/logs', c => {
const id = c.req.param('id')
const inst = manager.get(id)
if (!inst) {
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404);
return c.json({ error: "not found" }, 404);
logReq('GET', `/api/instances/${id.slice(0, 8)}/logs`, 404)
return c.json({ error: 'not found' }, 404)
}
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`);
logReq('GET', `/api/instances/${id.slice(0, 8)}/logs SSE`)
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const encoder = new TextEncoder()
const send = (data: string) => {
try {
controller.enqueue(encoder.encode(data));
controller.enqueue(encoder.encode(data))
} catch {
// stream closed
}
};
}
// send historical logs
for (const log of inst.logs) {
send(`data: ${JSON.stringify(log)}\n\n`);
send(`data: ${JSON.stringify(log)}\n\n`)
}
// subscribe to new logs
const unsub = manager.subscribe(inst.id, (entry) => {
send(`data: ${JSON.stringify(entry)}\n\n`);
});
const unsub = manager.subscribe(inst.id, entry => {
send(`data: ${JSON.stringify(entry)}\n\n`)
})
// keepalive every 15s
const keepalive = setInterval(() => {
send(": keepalive\n\n");
}, 15000);
send(': keepalive\n\n')
}, 15000)
const cleanup = () => {
unsub();
clearInterval(keepalive);
logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
unsub()
clearInterval(keepalive)
logReq('SSE', `/api/instances/${id.slice(0, 8)}/logs closed`)
try {
controller.close();
controller.close()
} catch {
// already closed
}
};
}
c.req.raw.signal.addEventListener("abort", cleanup, { once: true });
c.req.raw.signal.addEventListener('abort', cleanup, { once: true })
},
});
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
});
})
})
// Catch-all: log unmatched routes for debugging
app.all("*", (c) => {
logReq(c.req.method, c.req.path, 404);
return c.json({ error: "not found", path: c.req.path }, 404);
});
app.all('*', c => {
logReq(c.req.method, c.req.path, 404)
return c.json({ error: 'not found', path: c.req.path }, 404)
})
return app;
return app
}

View File

@@ -1,34 +1,34 @@
export type InstanceStatus = "running" | "stopped" | "failed";
export type InstanceStatus = 'running' | 'stopped' | 'failed'
export interface AcpInstance {
id: string;
group: string;
command: string;
status: InstanceStatus;
pid: number | undefined;
startTime: number;
exitCode: number | null;
logs: LogEntry[];
subscribers: Set<(entry: LogEntry) => void>;
id: string
group: string
command: string
status: InstanceStatus
pid: number | undefined
startTime: number
exitCode: number | null
logs: LogEntry[]
subscribers: Set<(entry: LogEntry) => void>
}
export interface LogEntry {
timestamp: number;
stream: "stdout" | "stderr";
text: string;
timestamp: number
stream: 'stdout' | 'stderr'
text: string
}
export interface CreateInstanceRequest {
group: string;
command: string;
group: string
command: string
}
export interface InstanceSummary {
id: string;
group: string;
command: string;
status: InstanceStatus;
pid: number | undefined;
startTime: number;
exitCode: number | null;
id: string
group: string
command: string
status: InstanceStatus
pid: number | undefined
startTime: number
exitCode: number | null
}

View File

@@ -1,26 +1,26 @@
import { createLogger } from "./logger.js";
import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js";
import { encodeWebSocketAuthProtocol } from "./ws-auth.js";
import { createLogger } from './logger.js'
import { decodeJsonWsMessage, WsPayloadTooLargeError } from './ws-message.js'
import { encodeWebSocketAuthProtocol } from './ws-auth.js'
export interface RcsUpstreamConfig {
rcsUrl: string; // e.g. "http://localhost:3000"
apiToken: string;
agentName: string;
channelGroupId?: string;
capabilities?: Record<string, unknown>;
maxSessions?: number;
rcsUrl: string // e.g. "http://localhost:3000"
apiToken: string
agentName: string
channelGroupId?: string
capabilities?: Record<string, unknown>
maxSessions?: number
}
export function buildRcsWsUrl(rcsUrl: string): string {
let raw = rcsUrl;
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
const url = new URL(raw);
const path = url.pathname.replace(/\/+$/, "");
if (!path || path === "/") {
url.pathname = "/acp/ws";
let raw = rcsUrl
raw = raw.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://')
const url = new URL(raw)
const path = url.pathname.replace(/\/+$/, '')
if (!path || path === '/') {
url.pathname = '/acp/ws'
}
url.searchParams.delete("token");
return url.toString();
url.searchParams.delete('token')
return url.toString()
}
/**
@@ -34,232 +34,272 @@ export function buildRcsWsUrl(rcsUrl: string): string {
* 5. Reconnects with exponential backoff on failure
*/
export class RcsUpstreamClient {
private static log = createLogger("rcs-upstream");
private ws: WebSocket | null = null;
private registered = false;
private reconnectAttempts = 0;
private closed = false;
private readonly maxReconnectDelay = 30_000;
private readonly baseReconnectDelay = 1_000;
private static log = createLogger('rcs-upstream')
private ws: WebSocket | null = null
private registered = false
private reconnectAttempts = 0
private closed = false
private readonly maxReconnectDelay = 30_000
private readonly baseReconnectDelay = 1_000
/** Agent ID obtained from REST registration */
private agentId: string | null = null;
private agentId: string | null = null
/** Session ID from REST registration (ACP agents auto-create a session) */
private sessionId: string | undefined;
private sessionId: string | undefined
/** Handler for incoming ACP messages from RCS relay */
private messageHandler: ((message: Record<string, unknown>) => void) | null = null;
private messageHandler: ((message: Record<string, unknown>) => void) | null =
null
constructor(private config: RcsUpstreamConfig) {}
/** Get the agent ID from REST registration */
getAgentId(): string | null {
return this.agentId;
return this.agentId
}
/** Set handler for incoming ACP messages from RCS relay */
setMessageHandler(handler: (message: Record<string, unknown>) => void): void {
this.messageHandler = handler;
this.messageHandler = handler
}
/** Register via REST API before establishing WS connection */
private async registerViaRest(): Promise<string> {
const baseUrl = this.config.rcsUrl
.replace(/^ws:\/\//, "http://")
.replace(/^wss:\/\//, "https://")
.replace(/\/acp\/ws.*$/, "")
.replace(/\/$/, "");
.replace(/^ws:\/\//, 'http://')
.replace(/^wss:\/\//, 'https://')
.replace(/\/acp\/ws.*$/, '')
.replace(/\/$/, '')
const url = `${baseUrl}/v1/environments/bridge`;
RcsUpstreamClient.log.info({ url }, "REST register");
const url = `${baseUrl}/v1/environments/bridge`
RcsUpstreamClient.log.info({ url }, 'REST register')
const resp = await fetch(url, {
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiToken}`,
},
body: JSON.stringify({
machine_name: this.config.agentName,
worker_type: "acp",
worker_type: 'acp',
bridge_id: this.config.channelGroupId || undefined,
max_sessions: this.config.maxSessions,
capabilities: this.config.capabilities,
}),
});
})
if (!resp.ok) {
const text = await resp.text();
throw new Error(`REST register failed (${resp.status}): ${text}`);
const text = await resp.text()
throw new Error(`REST register failed (${resp.status}): ${text}`)
}
const data = await resp.json() as { environment_id: string; environment_secret: string; status: string; session_id?: string };
this.agentId = data.environment_id;
this.sessionId = data.session_id;
RcsUpstreamClient.log.info({ agentId: this.agentId, sessionId: this.sessionId }, "REST register success");
return data.environment_id;
const data = (await resp.json()) as {
environment_id: string
environment_secret: string
status: string
session_id?: string
}
this.agentId = data.environment_id
this.sessionId = data.session_id
RcsUpstreamClient.log.info(
{ agentId: this.agentId, sessionId: this.sessionId },
'REST register success',
)
return data.environment_id
}
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
private buildWsUrl(): string {
return buildRcsWsUrl(this.config.rcsUrl);
return buildRcsWsUrl(this.config.rcsUrl)
}
/** Open connection to RCS: REST register → WS identify */
async connect(): Promise<void> {
if (this.closed) return;
if (this.closed) return
// Step 1: REST registration
try {
await this.registerViaRest();
await this.registerViaRest()
} catch (err) {
RcsUpstreamClient.log.error({ err }, "REST registration failed");
RcsUpstreamClient.log.error({ err }, 'REST registration failed')
if (!this.closed) {
this.scheduleReconnect();
this.scheduleReconnect()
}
return;
return
}
// Step 2: WebSocket connection with identify
const wsUrl = this.buildWsUrl();
RcsUpstreamClient.log.info({ url: wsUrl }, "connecting WS");
const wsUrl = this.buildWsUrl()
RcsUpstreamClient.log.info({ url: wsUrl }, 'connecting WS')
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(wsUrl, [
encodeWebSocketAuthProtocol(this.config.apiToken),
]);
])
this.ws.onopen = () => {
RcsUpstreamClient.log.debug("ws open — sending identify");
RcsUpstreamClient.log.debug('ws open — sending identify')
this.ws!.send(
JSON.stringify({
type: "identify",
type: 'identify',
agent_id: this.agentId,
}),
);
};
)
}
this.ws.onmessage = (event) => {
let data: Record<string, unknown>;
this.ws.onmessage = event => {
let data: Record<string, unknown>
try {
data = decodeJsonWsMessage(event.data);
data = decodeJsonWsMessage(event.data)
} catch (err) {
if (err instanceof WsPayloadTooLargeError) {
RcsUpstreamClient.log.warn({ error: err.message }, "server message too large");
this.ws?.close(1009, "message too large");
return;
RcsUpstreamClient.log.warn(
{ error: err.message },
'server message too large',
)
this.ws?.close(1009, 'message too large')
return
}
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
return;
RcsUpstreamClient.log.warn(
{ raw: String(event.data).slice(0, 200) },
'invalid JSON from server',
)
return
}
if (data.type === "identified") {
RcsUpstreamClient.log.info({ agent_id: data.agent_id, channel_group_id: data.channel_group_id }, "identified");
this.registered = true;
this.reconnectAttempts = 0;
if (data.type === 'identified') {
RcsUpstreamClient.log.info(
{
agent_id: data.agent_id,
channel_group_id: data.channel_group_id,
},
'identified',
)
this.registered = true
this.reconnectAttempts = 0
const webBase = this.config.rcsUrl
.replace(/^ws:\/\//, "http://")
.replace(/^wss:\/\//, "https://")
.replace(/\/acp\/ws.*$/, "")
.replace(/\/$/, "");
console.log();
console.log(` 🔗 Dashboard: ${webBase}/code/`);
.replace(/^ws:\/\//, 'http://')
.replace(/^wss:\/\//, 'https://')
.replace(/\/acp\/ws.*$/, '')
.replace(/\/$/, '')
console.log()
console.log(` 🔗 Dashboard: ${webBase}/code/`)
if (this.agentId) {
console.log(` Agent ID: ${this.agentId}`);
console.log(` Agent ID: ${this.agentId}`)
}
console.log();
resolve();
} else if (data.type === "registered") {
console.log()
resolve()
} else if (data.type === 'registered') {
// Legacy fallback: server still uses old register flow
RcsUpstreamClient.log.info({ agent_id: data.agent_id }, "registered (legacy)");
this.agentId = (data.agent_id as string) || this.agentId;
this.registered = true;
this.reconnectAttempts = 0;
resolve();
} else if (data.type === "error") {
RcsUpstreamClient.log.error({ message: data.message }, "server error");
RcsUpstreamClient.log.info(
{ agent_id: data.agent_id },
'registered (legacy)',
)
this.agentId = (data.agent_id as string) || this.agentId
this.registered = true
this.reconnectAttempts = 0
resolve()
} else if (data.type === 'error') {
RcsUpstreamClient.log.error(
{ message: data.message },
'server error',
)
if (!this.registered) {
reject(new Error(data.message as string));
reject(new Error(data.message as string))
}
} else if (data.type === "keep_alive") {
} else if (data.type === 'keep_alive') {
// ignore keepalive
} else {
// Forward ACP protocol messages to handler (for RCS relay support)
RcsUpstreamClient.log.debug({ type: data.type }, "forwarding to relay handler");
this.messageHandler?.(data);
RcsUpstreamClient.log.debug(
{ type: data.type },
'forwarding to relay handler',
)
this.messageHandler?.(data)
}
};
}
this.ws.onerror = () => {
// onclose fires after onerror with the actual close code, so we log there
if (!this.registered) {
reject(new Error("WebSocket connection failed"));
reject(new Error('WebSocket connection failed'))
}
};
}
this.ws.onclose = (event) => {
RcsUpstreamClient.log.info({ code: event.code, reason: event.reason || undefined }, "ws closed");
this.registered = false;
this.ws = null;
this.ws.onclose = event => {
RcsUpstreamClient.log.info(
{ code: event.code, reason: event.reason || undefined },
'ws closed',
)
this.registered = false
this.ws = null
if (!this.closed) {
this.scheduleReconnect();
this.scheduleReconnect()
}
};
}
} catch (err) {
RcsUpstreamClient.log.error({ err }, "connect threw");
reject(err);
RcsUpstreamClient.log.error({ err }, 'connect threw')
reject(err)
}
});
})
}
/** Send an ACP message to RCS for broadcast */
send(message: object): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registered) {
return;
return
}
try {
this.ws.send(JSON.stringify(message));
this.ws.send(JSON.stringify(message))
} catch (err) {
RcsUpstreamClient.log.error({ err }, "send failed");
RcsUpstreamClient.log.error({ err }, 'send failed')
}
}
/** Check if registered with RCS */
isRegistered(): boolean {
return this.registered && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
return (
this.registered &&
this.ws !== null &&
this.ws.readyState === WebSocket.OPEN
)
}
/** Close the RCS connection permanently */
async close(): Promise<void> {
this.closed = true;
this.registered = false;
this.closed = true
this.registered = false
if (this.ws) {
this.ws.close(1000, "client shutdown");
this.ws = null;
this.ws.close(1000, 'client shutdown')
this.ws = null
}
RcsUpstreamClient.log.info("closed");
RcsUpstreamClient.log.info('closed')
}
private scheduleReconnect(): void {
if (this.closed) return;
if (this.closed) return
const delay = Math.min(
this.baseReconnectDelay * 2 ** this.reconnectAttempts,
this.maxReconnectDelay,
);
const jitter = delay * Math.random() * 0.2;
const actualDelay = delay + jitter;
this.reconnectAttempts++;
)
const jitter = delay * Math.random() * 0.2
const actualDelay = delay + jitter
this.reconnectAttempts++
RcsUpstreamClient.log.warn({ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, "reconnecting");
RcsUpstreamClient.log.warn(
{ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) },
'reconnecting',
)
setTimeout(async () => {
if (this.closed) return;
if (this.closed) return
try {
await this.connect();
await this.connect()
} catch {
// connect() itself logs the error; nothing to add here
}
}, actualDelay);
}, actualDelay)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,150 +1,150 @@
// JSON-RPC 2.0 Types
export interface JsonRpcRequest {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: unknown;
jsonrpc: '2.0'
id: string | number
method: string
params?: unknown
}
export interface JsonRpcResponse {
jsonrpc: "2.0";
id: string | number;
result?: unknown;
error?: JsonRpcError;
jsonrpc: '2.0'
id: string | number
result?: unknown
error?: JsonRpcError
}
export interface JsonRpcNotification {
jsonrpc: "2.0";
method: string;
params?: unknown;
jsonrpc: '2.0'
method: string
params?: unknown
}
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
code: number
message: string
data?: unknown
}
export type JsonRpcMessage =
| JsonRpcRequest
| JsonRpcResponse
| JsonRpcNotification;
| JsonRpcNotification
// Helper to check message types
export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
return "method" in msg && "id" in msg;
return 'method' in msg && 'id' in msg
}
export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
return "id" in msg && !("method" in msg);
return 'id' in msg && !('method' in msg)
}
export function isNotification(
msg: JsonRpcMessage,
): msg is JsonRpcNotification {
return "method" in msg && !("id" in msg);
return 'method' in msg && !('id' in msg)
}
// ACP Protocol Types
// Client -> Server messages (from extension to proxy)
export interface ProxyConnectParams {
command: string; // Command to launch the agent (e.g., "claude-agent")
args?: string[]; // Optional arguments
cwd?: string; // Working directory for the agent
command: string // Command to launch the agent (e.g., "claude-agent")
args?: string[] // Optional arguments
cwd?: string // Working directory for the agent
}
export interface ProxyMessage {
type: "connect" | "disconnect" | "message";
payload?: ProxyConnectParams | JsonRpcMessage;
type: 'connect' | 'disconnect' | 'message'
payload?: ProxyConnectParams | JsonRpcMessage
}
// Server -> Client messages (from proxy to extension)
export interface ProxyStatus {
type: "status";
connected: boolean;
type: 'status'
connected: boolean
agentInfo?: {
name?: string;
version?: string;
};
error?: string;
name?: string
version?: string
}
error?: string
}
export interface ProxyAgentMessage {
type: "agent_message";
payload: JsonRpcMessage;
type: 'agent_message'
payload: JsonRpcMessage
}
export interface ProxyError {
type: "error";
message: string;
code?: string;
type: 'error'
message: string
code?: string
}
export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError;
export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError
// ACP Initialization
export interface InitializeParams {
protocolVersion: string;
protocolVersion: string
clientInfo: {
name: string;
version: string;
};
capabilities?: ClientCapabilities;
name: string
version: string
}
capabilities?: ClientCapabilities
}
export interface ClientCapabilities {
streaming?: boolean;
toolApproval?: boolean;
streaming?: boolean
toolApproval?: boolean
}
export interface InitializeResult {
protocolVersion: string;
protocolVersion: string
serverInfo: {
name: string;
version: string;
};
capabilities?: ServerCapabilities;
name: string
version: string
}
capabilities?: ServerCapabilities
}
export interface ServerCapabilities {
streaming?: boolean;
tools?: boolean;
streaming?: boolean
tools?: boolean
}
// ACP Session
export interface SessionSetupParams {
sessionId?: string;
context?: SessionContext;
sessionId?: string
context?: SessionContext
}
export interface SessionContext {
workingDirectory?: string;
files?: string[];
workingDirectory?: string
files?: string[]
}
// ACP Prompt
export interface PromptParams {
sessionId: string;
messages: PromptMessage[];
sessionId: string
messages: PromptMessage[]
}
export interface PromptMessage {
role: "user" | "assistant";
content: string | ContentPart[];
role: 'user' | 'assistant'
content: string | ContentPart[]
}
export interface ContentPart {
type: "text" | "image" | "file";
text?: string;
data?: string;
mimeType?: string;
path?: string;
type: 'text' | 'image' | 'file'
text?: string
data?: string
mimeType?: string
path?: string
}
// Content streaming notification
export interface ContentNotification {
sessionId: string;
content: string;
done?: boolean;
sessionId: string
content: string
done?: boolean
}

View File

@@ -1,54 +1,60 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { createHash, timingSafeEqual } from 'node:crypto'
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
const WS_AUTH_PROTOCOL_PREFIX = 'rcs.auth.'
function sha256(value: string): Buffer {
return createHash("sha256").update(value).digest();
return createHash('sha256').update(value).digest()
}
export function encodeWebSocketAuthProtocol(token: string): string {
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, 'utf8').toString('base64url')}`
}
export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
export function decodeWebSocketAuthProtocol(
protocolHeader: string | undefined,
): string | undefined {
if (!protocolHeader) {
return undefined;
return undefined
}
for (const protocol of protocolHeader.split(",")) {
const trimmed = protocol.trim();
for (const protocol of protocolHeader.split(',')) {
const trimmed = protocol.trim()
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
continue;
continue
}
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length)
if (!encoded) {
return undefined;
return undefined
}
try {
const token = Buffer.from(encoded, "base64url").toString("utf8");
return token.length > 0 ? token : undefined;
const token = Buffer.from(encoded, 'base64url').toString('utf8')
return token.length > 0 ? token : undefined
} catch {
return undefined;
return undefined
}
}
return undefined;
return undefined
}
export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
return authorizationHeader?.startsWith("Bearer ")
? authorizationHeader.slice("Bearer ".length)
: undefined;
export function extractBearerToken(
authorizationHeader: string | undefined,
): string | undefined {
return authorizationHeader?.startsWith('Bearer ')
? authorizationHeader.slice('Bearer '.length)
: undefined
}
export function extractWebSocketAuthToken(headers: {
authorization?: string;
protocol?: string;
authorization?: string
protocol?: string
}): string | undefined {
return extractBearerToken(headers.authorization) ??
decodeWebSocketAuthProtocol(headers.protocol);
return (
extractBearerToken(headers.authorization) ??
decodeWebSocketAuthProtocol(headers.protocol)
)
}
export function authTokensEqual(
@@ -56,7 +62,7 @@ export function authTokensEqual(
expectedToken: string | undefined,
): boolean {
if (!providedToken || !expectedToken) {
return false;
return false
}
return timingSafeEqual(sha256(providedToken), sha256(expectedToken));
return timingSafeEqual(sha256(providedToken), sha256(expectedToken))
}

View File

@@ -1,60 +1,63 @@
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024;
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024
export class WsPayloadTooLargeError extends Error {
constructor(byteLength: number) {
super(`WebSocket message too large: ${byteLength} bytes`);
this.name = "WsPayloadTooLargeError";
super(`WebSocket message too large: ${byteLength} bytes`)
this.name = 'WsPayloadTooLargeError'
}
}
export interface JsonWsMessage {
type: string;
payload?: unknown;
[key: string]: unknown;
type: string
payload?: unknown
[key: string]: unknown
}
function assertPayloadSize(byteLength: number): void {
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
throw new WsPayloadTooLargeError(byteLength);
throw new WsPayloadTooLargeError(byteLength)
}
}
function decodeWsText(data: unknown): string {
if (typeof data === "string") {
assertPayloadSize(Buffer.byteLength(data, "utf8"));
return data;
if (typeof data === 'string') {
assertPayloadSize(Buffer.byteLength(data, 'utf8'))
return data
}
if (data instanceof ArrayBuffer) {
assertPayloadSize(data.byteLength);
return new TextDecoder().decode(new Uint8Array(data));
assertPayloadSize(data.byteLength)
return new TextDecoder().decode(new Uint8Array(data))
}
if (ArrayBuffer.isView(data)) {
assertPayloadSize(data.byteLength);
assertPayloadSize(data.byteLength)
return new TextDecoder().decode(
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
);
)
}
if (Array.isArray(data) && data.every(Buffer.isBuffer)) {
const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0);
assertPayloadSize(byteLength);
return Buffer.concat(data, byteLength).toString("utf8");
const byteLength = data.reduce(
(total, chunk) => total + chunk.byteLength,
0,
)
assertPayloadSize(byteLength)
return Buffer.concat(data, byteLength).toString('utf8')
}
throw new Error("Unsupported WebSocket message payload");
throw new Error('Unsupported WebSocket message payload')
}
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
const parsed = JSON.parse(decodeWsText(data)) as unknown;
const parsed = JSON.parse(decodeWsText(data)) as unknown
if (
typeof parsed !== "object" ||
typeof parsed !== 'object' ||
parsed === null ||
!("type" in parsed) ||
typeof parsed.type !== "string"
!('type' in parsed) ||
typeof parsed.type !== 'string'
) {
throw new Error("Invalid WebSocket message payload");
throw new Error('Invalid WebSocket message payload')
}
return parsed as JsonWsMessage;
return parsed as JsonWsMessage
}

View File

@@ -31,7 +31,7 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"types": ["bun"],
"types": ["bun"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/__tests__"]