mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 08:45:50 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -342,4 +342,4 @@ fetchInstances();
|
||||
setInterval(fetchInstances, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
</html>`
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"types": ["bun"],
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||
|
||||
Reference in New Issue
Block a user