copilot-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/apps/agent-engine/.env.example +41 -0
  4. package/apps/agent-engine/LICENSE +21 -0
  5. package/apps/agent-engine/README.md +57 -0
  6. package/apps/agent-engine/bot-registry.example.json +28 -0
  7. package/apps/agent-engine/capabilities/example/index.js +3 -0
  8. package/apps/agent-engine/capabilities/example/manifest.json +14 -0
  9. package/apps/agent-engine/dist/agent-worker.js +241 -0
  10. package/apps/agent-engine/dist/config.js +225 -0
  11. package/apps/agent-engine/dist/index.js +352 -0
  12. package/apps/agent-engine/dist/test/project-fingerprint.test.js +40 -0
  13. package/apps/agent-engine/dist/test/thread-id.test.js +12 -0
  14. package/apps/agent-engine/package.json +28 -0
  15. package/apps/control-plane/.env.example +25 -0
  16. package/apps/control-plane/README.md +35 -0
  17. package/apps/control-plane/bot-registry.example.json +40 -0
  18. package/apps/control-plane/capabilities/example/index.js +3 -0
  19. package/apps/control-plane/capabilities/example/manifest.json +14 -0
  20. package/apps/control-plane/dist/agent-worker.js +243 -0
  21. package/apps/control-plane/dist/channels/channel-factory.js +21 -0
  22. package/apps/control-plane/dist/channels/hub-ops-commands.js +752 -0
  23. package/apps/control-plane/dist/channels/telegram-channel.js +743 -0
  24. package/apps/control-plane/dist/channels/whatsapp-channel.js +35 -0
  25. package/apps/control-plane/dist/config.js +230 -0
  26. package/apps/control-plane/dist/copilot-hub.js +138 -0
  27. package/apps/control-plane/dist/index.js +349 -0
  28. package/apps/control-plane/dist/kernel/admin-contract.js +51 -0
  29. package/apps/control-plane/dist/test/project-fingerprint.test.js +40 -0
  30. package/apps/control-plane/dist/test/thread-id.test.js +12 -0
  31. package/apps/control-plane/package.json +27 -0
  32. package/package.json +89 -0
  33. package/packages/contracts/README.md +10 -0
  34. package/packages/contracts/dist/control-plane.d.ts +24 -0
  35. package/packages/contracts/dist/control-plane.js +37 -0
  36. package/packages/contracts/dist/control-plane.js.map +1 -0
  37. package/packages/contracts/dist/index.d.ts +1 -0
  38. package/packages/contracts/dist/index.js +2 -0
  39. package/packages/contracts/dist/index.js.map +1 -0
  40. package/packages/contracts/package.json +27 -0
  41. package/packages/core/README.md +33 -0
  42. package/packages/core/dist/agent-supervisor.d.ts +39 -0
  43. package/packages/core/dist/agent-supervisor.js +552 -0
  44. package/packages/core/dist/agent-supervisor.js.map +1 -0
  45. package/packages/core/dist/bot-manager.d.ts +66 -0
  46. package/packages/core/dist/bot-manager.js +333 -0
  47. package/packages/core/dist/bot-manager.js.map +1 -0
  48. package/packages/core/dist/bot-registry.d.ts +60 -0
  49. package/packages/core/dist/bot-registry.js +381 -0
  50. package/packages/core/dist/bot-registry.js.map +1 -0
  51. package/packages/core/dist/bot-runtime.d.ts +135 -0
  52. package/packages/core/dist/bot-runtime.js +349 -0
  53. package/packages/core/dist/bot-runtime.js.map +1 -0
  54. package/packages/core/dist/bridge-service.d.ts +39 -0
  55. package/packages/core/dist/bridge-service.js +272 -0
  56. package/packages/core/dist/bridge-service.js.map +1 -0
  57. package/packages/core/dist/capability-manager.d.ts +18 -0
  58. package/packages/core/dist/capability-manager.js +335 -0
  59. package/packages/core/dist/capability-manager.js.map +1 -0
  60. package/packages/core/dist/capability-scaffold.d.ts +26 -0
  61. package/packages/core/dist/capability-scaffold.js +118 -0
  62. package/packages/core/dist/capability-scaffold.js.map +1 -0
  63. package/packages/core/dist/channel-factory.d.ts +6 -0
  64. package/packages/core/dist/channel-factory.js +22 -0
  65. package/packages/core/dist/channel-factory.js.map +1 -0
  66. package/packages/core/dist/codex-app-client.d.ts +56 -0
  67. package/packages/core/dist/codex-app-client.js +762 -0
  68. package/packages/core/dist/codex-app-client.js.map +1 -0
  69. package/packages/core/dist/codex-provider.d.ts +31 -0
  70. package/packages/core/dist/codex-provider.js +64 -0
  71. package/packages/core/dist/codex-provider.js.map +1 -0
  72. package/packages/core/dist/control-permission.d.ts +19 -0
  73. package/packages/core/dist/control-permission.js +106 -0
  74. package/packages/core/dist/control-permission.js.map +1 -0
  75. package/packages/core/dist/control-plane-actions.d.ts +1 -0
  76. package/packages/core/dist/control-plane-actions.js +2 -0
  77. package/packages/core/dist/control-plane-actions.js.map +1 -0
  78. package/packages/core/dist/example-capability.d.ts +17 -0
  79. package/packages/core/dist/example-capability.js +22 -0
  80. package/packages/core/dist/example-capability.js.map +1 -0
  81. package/packages/core/dist/extension-contract.d.ts +22 -0
  82. package/packages/core/dist/extension-contract.js +28 -0
  83. package/packages/core/dist/extension-contract.js.map +1 -0
  84. package/packages/core/dist/index.d.ts +26 -0
  85. package/packages/core/dist/index.js +27 -0
  86. package/packages/core/dist/index.js.map +1 -0
  87. package/packages/core/dist/instance-lock.d.ts +9 -0
  88. package/packages/core/dist/instance-lock.js +74 -0
  89. package/packages/core/dist/instance-lock.js.map +1 -0
  90. package/packages/core/dist/kernel-control-plane.d.ts +16 -0
  91. package/packages/core/dist/kernel-control-plane.js +500 -0
  92. package/packages/core/dist/kernel-control-plane.js.map +1 -0
  93. package/packages/core/dist/kernel-version.d.ts +1 -0
  94. package/packages/core/dist/kernel-version.js +2 -0
  95. package/packages/core/dist/kernel-version.js.map +1 -0
  96. package/packages/core/dist/project-fingerprint.d.ts +11 -0
  97. package/packages/core/dist/project-fingerprint.js +33 -0
  98. package/packages/core/dist/project-fingerprint.js.map +1 -0
  99. package/packages/core/dist/provider-factory.d.ts +7 -0
  100. package/packages/core/dist/provider-factory.js +21 -0
  101. package/packages/core/dist/provider-factory.js.map +1 -0
  102. package/packages/core/dist/secret-store.d.ts +18 -0
  103. package/packages/core/dist/secret-store.js +110 -0
  104. package/packages/core/dist/secret-store.js.map +1 -0
  105. package/packages/core/dist/state-store.d.ts +50 -0
  106. package/packages/core/dist/state-store.js +324 -0
  107. package/packages/core/dist/state-store.js.map +1 -0
  108. package/packages/core/dist/telegram-channel.d.ts +27 -0
  109. package/packages/core/dist/telegram-channel.js +951 -0
  110. package/packages/core/dist/telegram-channel.js.map +1 -0
  111. package/packages/core/dist/thread-id.d.ts +1 -0
  112. package/packages/core/dist/thread-id.js +12 -0
  113. package/packages/core/dist/thread-id.js.map +1 -0
  114. package/packages/core/dist/whatsapp-channel.d.ts +26 -0
  115. package/packages/core/dist/whatsapp-channel.js +36 -0
  116. package/packages/core/dist/whatsapp-channel.js.map +1 -0
  117. package/packages/core/dist/workspace-paths.d.ts +5 -0
  118. package/packages/core/dist/workspace-paths.js +77 -0
  119. package/packages/core/dist/workspace-paths.js.map +1 -0
  120. package/packages/core/dist/workspace-policy.d.ts +30 -0
  121. package/packages/core/dist/workspace-policy.js +104 -0
  122. package/packages/core/dist/workspace-policy.js.map +1 -0
  123. package/packages/core/package.json +126 -0
  124. package/scripts/cli.mjs +537 -0
  125. package/scripts/configure.mjs +254 -0
  126. package/scripts/ensure-shared-build.mjs +96 -0
  127. package/scripts/run-node-tests.mjs +52 -0
  128. package/scripts/supervisor.mjs +332 -0
@@ -0,0 +1,225 @@
1
+ // @ts-nocheck
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import dotenv from "dotenv";
5
+ import { createWorkspaceBoundaryPolicy, assertWorkspaceAllowed, parseWorkspaceAllowedRoots, } from "@copilot-hub/core/workspace-policy";
6
+ import { getDefaultExternalWorkspaceBasePath, getKernelRootPath, } from "@copilot-hub/core/workspace-paths";
7
+ dotenv.config();
8
+ const kernelRootPath = getKernelRootPath();
9
+ const configuredDefaultWorkspaceRoot = String(process.env.DEFAULT_WORKSPACE_ROOT ?? "").trim();
10
+ const defaultWorkspaceRoot = resolveWorkspaceRoot(configuredDefaultWorkspaceRoot || getDefaultExternalWorkspaceBasePath(kernelRootPath));
11
+ const configuredProjectsBaseDir = String(process.env.PROJECTS_BASE_DIR ?? "").trim();
12
+ const projectsBaseDir = path.resolve(configuredProjectsBaseDir || defaultWorkspaceRoot);
13
+ const workspaceStrictMode = parseBoolean(process.env.WORKSPACE_STRICT_MODE ?? "true");
14
+ const workspaceAllowedRoots = parseWorkspaceAllowedRoots(process.env.WORKSPACE_ALLOWED_ROOTS ?? "", {
15
+ cwd: process.cwd(),
16
+ });
17
+ const workspacePolicy = createWorkspaceBoundaryPolicy({
18
+ kernelRootPath,
19
+ defaultWorkspaceRoot,
20
+ projectsBaseDir,
21
+ strictMode: workspaceStrictMode,
22
+ additionalAllowedRoots: workspaceAllowedRoots,
23
+ });
24
+ assertWorkspaceAllowed({
25
+ workspaceRoot: defaultWorkspaceRoot,
26
+ policy: workspacePolicy,
27
+ label: "DEFAULT_WORKSPACE_ROOT",
28
+ });
29
+ const dataDir = path.resolve(process.env.BOT_DATA_DIR ?? path.join(process.cwd(), "data"));
30
+ const botRegistryFilePath = path.resolve(process.env.BOT_REGISTRY_FILE ?? path.join(dataDir, "bot-registry.json"));
31
+ const secretStoreFilePath = path.resolve(process.env.SECRET_STORE_FILE ?? path.join(dataDir, "secrets.json"));
32
+ const instanceLockEnabled = parseBoolean(process.env.INSTANCE_LOCK_ENABLED ?? "true");
33
+ const instanceLockFilePath = path.resolve(process.env.INSTANCE_LOCK_FILE ?? path.join(dataDir, "runtime.lock"));
34
+ const bootstrapTelegramToken = String(process.env.TELEGRAM_BOT_TOKEN ?? "").trim();
35
+ const defaultProviderKind = normalizeProviderKind(process.env.DEFAULT_PROVIDER_KIND ?? "codex");
36
+ const codexBin = resolveCodexBin(process.env.CODEX_BIN);
37
+ const codexHomeDir = resolveOptionalPath(process.env.CODEX_HOME_DIR);
38
+ const codexSandbox = normalizeCodexSandbox(process.env.CODEX_SANDBOX ?? "danger-full-access");
39
+ const codexApprovalPolicy = normalizeApprovalPolicy(process.env.CODEX_APPROVAL_POLICY ?? "never");
40
+ const turnActivityTimeoutMs = Number.parseInt(process.env.TURN_ACTIVITY_TIMEOUT_MS ?? "3600000", 10);
41
+ if (!Number.isFinite(turnActivityTimeoutMs) || turnActivityTimeoutMs < 10000) {
42
+ throw new Error("TURN_ACTIVITY_TIMEOUT_MS must be an integer >= 10000.");
43
+ }
44
+ const maxMessages = Number.parseInt(process.env.MAX_THREAD_MESSAGES ?? "200", 10);
45
+ if (!Number.isFinite(maxMessages) || maxMessages < 20) {
46
+ throw new Error("MAX_THREAD_MESSAGES must be an integer >= 20.");
47
+ }
48
+ const webHost = (process.env.WEB_HOST ?? "127.0.0.1").trim() || "127.0.0.1";
49
+ const webPort = Number.parseInt(process.env.WEB_PORT ?? "8787", 10);
50
+ if (!Number.isFinite(webPort) || webPort < 1 || webPort > 65535) {
51
+ throw new Error("WEB_PORT must be an integer in range 1..65535.");
52
+ }
53
+ const webPublicBaseUrl = (process.env.WEB_PUBLIC_BASE_URL ?? `http://localhost:${webPort}`).trim();
54
+ const webPublicBaseUrlExplicit = Boolean((process.env.WEB_PUBLIC_BASE_URL ?? "").trim());
55
+ const webPortAutoIncrement = parseBoolean(process.env.WEB_PORT_AUTO_INCREMENT ?? "true");
56
+ const webPortSearchMax = Number.parseInt(process.env.WEB_PORT_SEARCH_MAX ?? "30", 10);
57
+ if (!Number.isFinite(webPortSearchMax) || webPortSearchMax < 1 || webPortSearchMax > 1000) {
58
+ throw new Error("WEB_PORT_SEARCH_MAX must be an integer in range 1..1000.");
59
+ }
60
+ const agentHeartbeatEnabled = parseBoolean(process.env.AGENT_HEARTBEAT_ENABLED ?? "true");
61
+ const agentHeartbeatIntervalMs = Number.parseInt(process.env.AGENT_HEARTBEAT_INTERVAL_MS ?? "5000", 10);
62
+ if (!Number.isFinite(agentHeartbeatIntervalMs) ||
63
+ agentHeartbeatIntervalMs < 1000 ||
64
+ agentHeartbeatIntervalMs > 600000) {
65
+ throw new Error("AGENT_HEARTBEAT_INTERVAL_MS must be an integer in range 1000..600000.");
66
+ }
67
+ const agentHeartbeatTimeoutMs = Number.parseInt(process.env.AGENT_HEARTBEAT_TIMEOUT_MS ?? "4000", 10);
68
+ if (!Number.isFinite(agentHeartbeatTimeoutMs) ||
69
+ agentHeartbeatTimeoutMs < 500 ||
70
+ agentHeartbeatTimeoutMs > 60000) {
71
+ throw new Error("AGENT_HEARTBEAT_TIMEOUT_MS must be an integer in range 500..60000.");
72
+ }
73
+ const defaultThreadMode = normalizeThreadMode(process.env.THREAD_MODE);
74
+ const defaultSharedThreadId = String(process.env.SHARED_THREAD_ID ?? "shared-main").trim();
75
+ if (!/^[A-Za-z0-9:_-]{1,120}$/.test(defaultSharedThreadId)) {
76
+ throw new Error("SHARED_THREAD_ID has invalid format.");
77
+ }
78
+ const defaultAllowedChatIds = new Set((process.env.TELEGRAM_ALLOWED_CHAT_IDS ?? "")
79
+ .split(",")
80
+ .map((value) => value.trim())
81
+ .filter(Boolean));
82
+ fs.mkdirSync(dataDir, { recursive: true });
83
+ export const config = {
84
+ defaultProviderKind,
85
+ providerDefaults: {
86
+ defaultKind: defaultProviderKind,
87
+ codexBin,
88
+ codexHomeDir,
89
+ codexSandbox,
90
+ codexApprovalPolicy,
91
+ },
92
+ codexBin,
93
+ codexHomeDir,
94
+ codexSandbox,
95
+ codexApprovalPolicy,
96
+ kernelRootPath,
97
+ workspaceStrictMode,
98
+ workspaceAllowedRoots,
99
+ workspacePolicy,
100
+ defaultWorkspaceRoot,
101
+ projectsBaseDir,
102
+ dataDir,
103
+ botRegistryFilePath,
104
+ secretStoreFilePath,
105
+ instanceLockEnabled,
106
+ instanceLockFilePath,
107
+ bootstrapTelegramToken,
108
+ turnActivityTimeoutMs,
109
+ maxMessages,
110
+ webHost,
111
+ webPort,
112
+ webPublicBaseUrl,
113
+ webPublicBaseUrlExplicit,
114
+ webPortAutoIncrement,
115
+ webPortSearchMax,
116
+ agentHeartbeatEnabled,
117
+ agentHeartbeatIntervalMs,
118
+ agentHeartbeatTimeoutMs,
119
+ defaultThreadMode,
120
+ defaultSharedThreadId,
121
+ defaultAllowedChatIds,
122
+ };
123
+ function resolveCodexBin(rawValue) {
124
+ const value = (rawValue ?? "").trim();
125
+ const normalized = value.toLowerCase();
126
+ if (value && normalized !== "codex") {
127
+ return value;
128
+ }
129
+ if (process.platform === "win32") {
130
+ const vscodeCodex = findVscodeCodexExe();
131
+ if (vscodeCodex) {
132
+ return vscodeCodex;
133
+ }
134
+ }
135
+ return value || "codex";
136
+ }
137
+ function findVscodeCodexExe() {
138
+ const userProfile = process.env.USERPROFILE;
139
+ if (!userProfile) {
140
+ return null;
141
+ }
142
+ const extensionsDir = path.join(userProfile, ".vscode", "extensions");
143
+ if (!fs.existsSync(extensionsDir)) {
144
+ return null;
145
+ }
146
+ const candidates = fs
147
+ .readdirSync(extensionsDir, { withFileTypes: true })
148
+ .filter((entry) => entry.isDirectory())
149
+ .map((entry) => entry.name)
150
+ .filter((name) => name.startsWith("openai.chatgpt-"))
151
+ .sort()
152
+ .reverse();
153
+ for (const folder of candidates) {
154
+ const exePath = path.join(extensionsDir, folder, "bin", "windows-x86_64", "codex.exe");
155
+ if (fs.existsSync(exePath)) {
156
+ return exePath;
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+ function normalizeThreadMode(value) {
162
+ const mode = String(value ?? "single")
163
+ .trim()
164
+ .toLowerCase();
165
+ if (mode === "single" || mode === "per_chat") {
166
+ return mode;
167
+ }
168
+ throw new Error("THREAD_MODE must be either 'single' or 'per_chat'.");
169
+ }
170
+ function parseBoolean(value) {
171
+ const normalized = String(value ?? "")
172
+ .trim()
173
+ .toLowerCase();
174
+ if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
175
+ return true;
176
+ }
177
+ if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
178
+ return false;
179
+ }
180
+ throw new Error("Invalid boolean value in environment.");
181
+ }
182
+ function resolveOptionalPath(value) {
183
+ const raw = String(value ?? "").trim();
184
+ if (!raw) {
185
+ return null;
186
+ }
187
+ return path.resolve(raw);
188
+ }
189
+ function normalizeCodexSandbox(value) {
190
+ const mode = String(value ?? "")
191
+ .trim()
192
+ .toLowerCase();
193
+ if (mode === "read-only" || mode === "workspace-write" || mode === "danger-full-access") {
194
+ return mode;
195
+ }
196
+ throw new Error("CODEX_SANDBOX must be one of: read-only, workspace-write, danger-full-access.");
197
+ }
198
+ function normalizeApprovalPolicy(value) {
199
+ const mode = String(value ?? "")
200
+ .trim()
201
+ .toLowerCase();
202
+ if (mode === "untrusted" || mode === "on-failure" || mode === "on-request" || mode === "never") {
203
+ return mode;
204
+ }
205
+ throw new Error("CODEX_APPROVAL_POLICY must be one of: untrusted, on-failure, on-request, never.");
206
+ }
207
+ function normalizeProviderKind(value) {
208
+ const kind = String(value ?? "")
209
+ .trim()
210
+ .toLowerCase();
211
+ if (!kind) {
212
+ return "codex";
213
+ }
214
+ if (kind === "codex") {
215
+ return kind;
216
+ }
217
+ throw new Error("DEFAULT_PROVIDER_KIND must currently be 'codex'.");
218
+ }
219
+ function resolveWorkspaceRoot(value) {
220
+ const raw = String(value ?? "").trim();
221
+ if (!raw) {
222
+ throw new Error("DEFAULT_WORKSPACE_ROOT must not be empty.");
223
+ }
224
+ return path.resolve(raw);
225
+ }
@@ -0,0 +1,352 @@
1
+ // @ts-nocheck
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import express from "express";
5
+ import { BotManager } from "@copilot-hub/core/bot-manager";
6
+ import { loadBotRegistry } from "@copilot-hub/core/bot-registry";
7
+ import { config } from "./config.js";
8
+ import { InstanceLock } from "@copilot-hub/core/instance-lock";
9
+ import { KernelControlPlane } from "@copilot-hub/core/kernel-control-plane";
10
+ import { CONTROL_ACTIONS } from "@copilot-hub/core/control-plane-actions";
11
+ import { KernelSecretStore } from "@copilot-hub/core/secret-store";
12
+ let activeWebPort = config.webPort;
13
+ let runtimeWebPublicBaseUrl = config.webPublicBaseUrl;
14
+ let shuttingDown = false;
15
+ let server = null;
16
+ let botManager = null;
17
+ let instanceLock = null;
18
+ let controlPlane = null;
19
+ let secretStore = null;
20
+ const workerScriptPath = fileURLToPath(new URL("./agent-worker.js", import.meta.url));
21
+ await bootstrap();
22
+ async function bootstrap() {
23
+ try {
24
+ if (config.instanceLockEnabled) {
25
+ instanceLock = new InstanceLock(config.instanceLockFilePath);
26
+ await instanceLock.acquire();
27
+ }
28
+ secretStore = new KernelSecretStore(config.secretStoreFilePath);
29
+ await secretStore.init();
30
+ const registry = await loadBotRegistry({
31
+ filePath: config.botRegistryFilePath,
32
+ dataDir: config.dataDir,
33
+ defaultWorkspaceRoot: config.defaultWorkspaceRoot,
34
+ defaultThreadMode: config.defaultThreadMode,
35
+ defaultSharedThreadId: config.defaultSharedThreadId,
36
+ defaultAllowedChatIds: config.defaultAllowedChatIds,
37
+ bootstrapTelegramToken: config.bootstrapTelegramToken,
38
+ defaultProviderKind: config.defaultProviderKind,
39
+ workspacePolicy: config.workspacePolicy,
40
+ resolveSecret: (name) => secretStore.getSecret(name),
41
+ });
42
+ const runtimeBots = registry.bots.filter((bot) => bot.enabled !== false);
43
+ botManager = new BotManager({
44
+ botDefinitions: runtimeBots,
45
+ providerDefaults: config.providerDefaults,
46
+ turnActivityTimeoutMs: config.turnActivityTimeoutMs,
47
+ maxMessages: config.maxMessages,
48
+ webPublicBaseUrl: runtimeWebPublicBaseUrl,
49
+ projectsBaseDir: config.projectsBaseDir,
50
+ workerScriptPath,
51
+ botDataRootDir: path.join(config.dataDir, "bots"),
52
+ heartbeatEnabled: config.agentHeartbeatEnabled,
53
+ heartbeatIntervalMs: config.agentHeartbeatIntervalMs,
54
+ heartbeatTimeoutMs: config.agentHeartbeatTimeoutMs,
55
+ });
56
+ controlPlane = new KernelControlPlane({
57
+ botManager,
58
+ registryFilePath: registry.filePath,
59
+ secretStore,
60
+ registryLoadOptions: {
61
+ dataDir: config.dataDir,
62
+ defaultWorkspaceRoot: config.defaultWorkspaceRoot,
63
+ defaultThreadMode: config.defaultThreadMode,
64
+ defaultSharedThreadId: config.defaultSharedThreadId,
65
+ defaultAllowedChatIds: config.defaultAllowedChatIds,
66
+ bootstrapTelegramToken: config.bootstrapTelegramToken,
67
+ defaultProviderKind: config.defaultProviderKind,
68
+ workspacePolicy: config.workspacePolicy,
69
+ resolveSecret: (name) => secretStore.getSecret(name),
70
+ },
71
+ });
72
+ botManager.setKernelActionHandler((request) => controlPlane.handleAgentAction(request));
73
+ const app = buildApiApp({
74
+ botManager,
75
+ controlPlane,
76
+ registryFilePath: registry.filePath,
77
+ });
78
+ const started = await startWebServer({
79
+ app,
80
+ host: config.webHost,
81
+ basePort: config.webPort,
82
+ autoIncrement: config.webPortAutoIncrement,
83
+ maxAttempts: config.webPortSearchMax,
84
+ });
85
+ server = started.server;
86
+ activeWebPort = started.port;
87
+ runtimeWebPublicBaseUrl = resolveRuntimeWebPublicBaseUrl({
88
+ explicit: config.webPublicBaseUrlExplicit,
89
+ configuredBaseUrl: config.webPublicBaseUrl,
90
+ host: config.webHost,
91
+ port: activeWebPort,
92
+ });
93
+ botManager.setWebPublicBaseUrl(runtimeWebPublicBaseUrl);
94
+ await botManager.startAutoBots();
95
+ registerSignals();
96
+ console.log(`HTTP API listening on http://${config.webHost}:${activeWebPort}`);
97
+ console.log(`Bot registry loaded: ${registry.filePath}`);
98
+ if (instanceLock) {
99
+ console.log(`Instance lock acquired: ${config.instanceLockFilePath}`);
100
+ }
101
+ }
102
+ catch (error) {
103
+ const message = sanitizeError(error);
104
+ console.error(message);
105
+ await cleanupBeforeExit();
106
+ process.exit(1);
107
+ }
108
+ }
109
+ function buildApiApp({ botManager, controlPlane, registryFilePath }) {
110
+ const app = express();
111
+ app.use(express.json({ limit: "1mb" }));
112
+ app.use((req, res, next) => {
113
+ res.setHeader("Cache-Control", "no-store");
114
+ next();
115
+ });
116
+ app.get("/api/health", (req, res) => {
117
+ res.json({
118
+ ok: true,
119
+ service: "runtime_kernel",
120
+ providerDefault: config.defaultProviderKind,
121
+ webPort: activeWebPort,
122
+ webPublicBaseUrl: runtimeWebPublicBaseUrl,
123
+ botCount: botManager.getBotCount(),
124
+ turnActivityTimeoutMs: config.turnActivityTimeoutMs,
125
+ heartbeatEnabled: config.agentHeartbeatEnabled,
126
+ heartbeatIntervalMs: config.agentHeartbeatIntervalMs,
127
+ heartbeatTimeoutMs: config.agentHeartbeatTimeoutMs,
128
+ registryFile: registryFilePath,
129
+ secretStoreFile: config.secretStoreFilePath,
130
+ });
131
+ });
132
+ app.get("/api/extensions/contract", wrapAsync(async (req, res) => {
133
+ const contract = await controlPlane.runSystemAction(CONTROL_ACTIONS.EXTENSIONS_CONTRACT_GET, {});
134
+ res.json(contract);
135
+ }));
136
+ app.get("/api/bots", wrapAsync(async (req, res) => {
137
+ const bots = await botManager.listBotsLive();
138
+ res.json({ bots });
139
+ }));
140
+ app.post("/api/bots/create", wrapAsync(async (req, res) => {
141
+ const result = await controlPlane.runSystemAction(CONTROL_ACTIONS.BOTS_CREATE, {
142
+ agent: req.body?.agent,
143
+ startIfEnabled: req.body?.startIfEnabled !== false,
144
+ });
145
+ res.json(result);
146
+ }));
147
+ app.post("/api/bots/:botId/delete", wrapAsync(async (req, res) => {
148
+ const botId = String(req.params.botId ?? "").trim();
149
+ const deleteMode = parseDeleteModeFromRequest(req.body);
150
+ const deleted = await controlPlane.runSystemAction(CONTROL_ACTIONS.BOTS_DELETE, {
151
+ botId,
152
+ deleteMode,
153
+ });
154
+ res.json(deleted);
155
+ }));
156
+ app.post("/api/bots/:botId/project", wrapAsync(async (req, res) => {
157
+ const botId = String(req.params.botId ?? "").trim();
158
+ const projectName = String(req.body?.projectName ?? "").trim();
159
+ if (!projectName) {
160
+ res.status(400).json({ error: "Field 'projectName' is required." });
161
+ return;
162
+ }
163
+ const result = await controlPlane.runSystemAction(CONTROL_ACTIONS.BOTS_SET_PROJECT, {
164
+ botId,
165
+ projectName,
166
+ });
167
+ res.json(result);
168
+ }));
169
+ app.post("/api/bots/:botId/policy", wrapAsync(async (req, res) => {
170
+ const botId = String(req.params.botId ?? "").trim();
171
+ const sandboxMode = String(req.body?.sandboxMode ?? "")
172
+ .trim()
173
+ .toLowerCase();
174
+ const approvalPolicy = String(req.body?.approvalPolicy ?? "")
175
+ .trim()
176
+ .toLowerCase();
177
+ if (!sandboxMode) {
178
+ res.status(400).json({ error: "Field 'sandboxMode' is required." });
179
+ return;
180
+ }
181
+ if (!approvalPolicy) {
182
+ res.status(400).json({ error: "Field 'approvalPolicy' is required." });
183
+ return;
184
+ }
185
+ const result = await controlPlane.runSystemAction(CONTROL_ACTIONS.BOTS_SET_POLICY, {
186
+ botId,
187
+ sandboxMode,
188
+ approvalPolicy,
189
+ });
190
+ res.json(result);
191
+ }));
192
+ app.get("/api/projects", wrapAsync(async (req, res) => {
193
+ const projects = await controlPlane.runSystemAction(CONTROL_ACTIONS.PROJECTS_LIST, {});
194
+ res.json(projects);
195
+ }));
196
+ app.post("/api/projects/create", wrapAsync(async (req, res) => {
197
+ const name = String(req.body?.name ?? "").trim();
198
+ if (!name) {
199
+ res.status(400).json({ error: "Field 'name' is required." });
200
+ return;
201
+ }
202
+ const created = await controlPlane.runSystemAction(CONTROL_ACTIONS.PROJECTS_CREATE, { name });
203
+ res.json(created);
204
+ }));
205
+ app.post("/api/bots/:botId/reset", wrapAsync(async (req, res) => {
206
+ const botId = String(req.params.botId ?? "").trim();
207
+ await botManager.resetWebThread(botId);
208
+ const bot = await botManager.getBotStatus(botId);
209
+ res.json({
210
+ bot,
211
+ reset: true,
212
+ });
213
+ }));
214
+ app.get("/api/bots/:botId/approvals", wrapAsync(async (req, res) => {
215
+ const botId = String(req.params.botId ?? "").trim();
216
+ const threadId = String(req.query.threadId ?? "").trim() || undefined;
217
+ const approvals = await botManager.listBotApprovals(botId, threadId);
218
+ res.json({ approvals });
219
+ }));
220
+ app.post("/api/bots/:botId/approvals/:approvalId", wrapAsync(async (req, res) => {
221
+ const botId = String(req.params.botId ?? "").trim();
222
+ const approvalId = String(req.params.approvalId ?? "").trim();
223
+ const threadId = String(req.body?.threadId ?? "").trim();
224
+ const decision = String(req.body?.decision ?? "").trim();
225
+ if (!threadId) {
226
+ res.status(400).json({ error: "Field 'threadId' is required." });
227
+ return;
228
+ }
229
+ if (!decision) {
230
+ res.status(400).json({ error: "Field 'decision' is required." });
231
+ return;
232
+ }
233
+ const resolved = await botManager.resolveBotApproval(botId, {
234
+ threadId,
235
+ approvalId,
236
+ decision,
237
+ });
238
+ res.json({ approval: resolved });
239
+ }));
240
+ app.get("/api/bots/:botId/capabilities", wrapAsync(async (req, res) => {
241
+ const botId = String(req.params.botId ?? "").trim();
242
+ const result = await controlPlane.runSystemAction(CONTROL_ACTIONS.BOTS_CAPABILITIES_LIST, {
243
+ botId,
244
+ });
245
+ res.json(result);
246
+ }));
247
+ app.post("/api/bots/:botId/capabilities/reload", wrapAsync(async (req, res) => {
248
+ const botId = String(req.params.botId ?? "").trim();
249
+ const result = await controlPlane.runSystemAction(CONTROL_ACTIONS.BOTS_CAPABILITIES_RELOAD, {
250
+ botId,
251
+ });
252
+ res.json(result);
253
+ }));
254
+ app.post("/api/bots/:botId/capabilities/scaffold", wrapAsync(async (req, res) => {
255
+ const botId = String(req.params.botId ?? "").trim();
256
+ const capabilityId = String(req.body?.capabilityId ?? "").trim();
257
+ const capabilityName = String(req.body?.capabilityName ?? "").trim();
258
+ if (!capabilityId) {
259
+ res.status(400).json({ error: "Field 'capabilityId' is required." });
260
+ return;
261
+ }
262
+ const result = await controlPlane.runSystemAction(CONTROL_ACTIONS.BOTS_CAPABILITIES_SCAFFOLD, {
263
+ botId,
264
+ capabilityId,
265
+ capabilityName: capabilityName || undefined,
266
+ });
267
+ res.json(result);
268
+ }));
269
+ app.use((error, req, res, _next) => {
270
+ const message = sanitizeError(error);
271
+ res.status(400).json({ error: message });
272
+ });
273
+ return app;
274
+ }
275
+ function registerSignals() {
276
+ process.on("SIGINT", () => {
277
+ void shutdown(0);
278
+ });
279
+ process.on("SIGTERM", () => {
280
+ void shutdown(0);
281
+ });
282
+ }
283
+ function wrapAsync(handler) {
284
+ return (req, res, next) => {
285
+ Promise.resolve(handler(req, res, next)).catch(next);
286
+ };
287
+ }
288
+ function resolveRuntimeWebPublicBaseUrl({ explicit, configuredBaseUrl, host, port }) {
289
+ if (explicit) {
290
+ return configuredBaseUrl;
291
+ }
292
+ const exposedHost = host === "0.0.0.0" ? "127.0.0.1" : host;
293
+ return `http://${exposedHost}:${port}`;
294
+ }
295
+ async function startWebServer({ app, host, basePort, autoIncrement, maxAttempts }) {
296
+ let port = basePort;
297
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
298
+ try {
299
+ const startedServer = await listenOnce({ app, host, port });
300
+ return { server: startedServer, port };
301
+ }
302
+ catch (error) {
303
+ const occupied = error && typeof error === "object" && error.code === "EADDRINUSE";
304
+ if (!occupied || !autoIncrement || port >= 65535) {
305
+ throw error;
306
+ }
307
+ port += 1;
308
+ }
309
+ }
310
+ throw new Error(`Could not find a free web port after ${maxAttempts} attempts starting from ${basePort}.`);
311
+ }
312
+ function listenOnce({ app, host, port }) {
313
+ return new Promise((resolve, reject) => {
314
+ const candidate = app.listen(port, host);
315
+ candidate.once("listening", () => resolve(candidate));
316
+ candidate.once("error", (error) => reject(error));
317
+ });
318
+ }
319
+ async function shutdown(exitCode) {
320
+ if (shuttingDown) {
321
+ return;
322
+ }
323
+ shuttingDown = true;
324
+ await cleanupBeforeExit();
325
+ process.exit(exitCode);
326
+ }
327
+ async function cleanupBeforeExit() {
328
+ if (botManager) {
329
+ await botManager.shutdownAll();
330
+ }
331
+ if (server) {
332
+ await new Promise((resolve) => {
333
+ server.close(() => resolve());
334
+ });
335
+ }
336
+ if (instanceLock) {
337
+ await instanceLock.release();
338
+ }
339
+ }
340
+ function sanitizeError(error) {
341
+ const raw = error instanceof Error ? error.message : String(error);
342
+ return raw.split(/\r?\n/).slice(0, 12).join("\n");
343
+ }
344
+ function parseDeleteModeFromRequest(body) {
345
+ const value = String(body?.deleteMode ?? "")
346
+ .trim()
347
+ .toLowerCase();
348
+ if (value === "soft" || value === "purge_data" || value === "purge_all") {
349
+ return value;
350
+ }
351
+ return "soft";
352
+ }
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createProjectFingerprint } from "@copilot-hub/core/project-fingerprint";
4
+ test("createProjectFingerprint is stable and order-independent for channels", () => {
5
+ const a = createProjectFingerprint({
6
+ runtimeId: "agent-a",
7
+ workspaceRoot: "./workspace-a",
8
+ providerKind: "CODEX",
9
+ channels: [
10
+ { kind: "telegram", id: "123" },
11
+ { kind: "whatsapp", id: "abc" },
12
+ ],
13
+ });
14
+ const b = createProjectFingerprint({
15
+ runtimeId: "agent-a",
16
+ workspaceRoot: "./workspace-a",
17
+ providerKind: "codex",
18
+ channels: [
19
+ { kind: "whatsapp", id: "abc" },
20
+ { kind: "telegram", id: "123" },
21
+ ],
22
+ });
23
+ assert.equal(a, b);
24
+ assert.match(a, /^[a-f0-9]{24}$/);
25
+ });
26
+ test("createProjectFingerprint changes when stable inputs differ", () => {
27
+ const base = createProjectFingerprint({
28
+ runtimeId: "agent-a",
29
+ workspaceRoot: "./workspace-a",
30
+ providerKind: "codex",
31
+ channels: [{ kind: "telegram", id: "123" }],
32
+ });
33
+ const changedProvider = createProjectFingerprint({
34
+ runtimeId: "agent-a",
35
+ workspaceRoot: "./workspace-a",
36
+ providerKind: "other",
37
+ channels: [{ kind: "telegram", id: "123" }],
38
+ });
39
+ assert.notEqual(base, changedProvider);
40
+ });
@@ -0,0 +1,12 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { normalizeThreadId } from "@copilot-hub/core/thread-id";
4
+ test("normalizeThreadId trims and accepts valid thread ids", () => {
5
+ assert.equal(normalizeThreadId(" shared-main "), "shared-main");
6
+ assert.equal(normalizeThreadId("chat:123_ABC-1"), "chat:123_ABC-1");
7
+ });
8
+ test("normalizeThreadId rejects empty and invalid values", () => {
9
+ assert.throws(() => normalizeThreadId(""), /threadId is required/);
10
+ assert.throws(() => normalizeThreadId("thread id with spaces"), /Invalid threadId/);
11
+ assert.throws(() => normalizeThreadId("chat/123"), /Invalid threadId/);
12
+ });
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@copilot-hub/agent-engine",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Copilot Hub execution plane (agent engine)",
6
+ "type": "module",
7
+ "scripts": {
8
+ "setup": "node scripts/setup-runtime.mjs",
9
+ "build": "tsc -p tsconfig.json",
10
+ "build:test": "tsc -p tsconfig.test.json",
11
+ "prestart": "node ../../scripts/ensure-shared-build.mjs && npm run build",
12
+ "start": "node dist/index.js",
13
+ "predev": "node ../../scripts/ensure-shared-build.mjs && npm run build",
14
+ "dev": "node --watch dist/index.js",
15
+ "pretest": "node ../../scripts/ensure-shared-build.mjs && npm run build && npm run build:test",
16
+ "test": "node ../../scripts/run-node-tests.mjs dist/test"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "dependencies": {
22
+ "@copilot-hub/contracts": "0.1.0",
23
+ "@copilot-hub/core": "0.1.0",
24
+ "dotenv": "^16.4.7",
25
+ "express": "^4.21.2",
26
+ "grammy": "^1.38.3"
27
+ }
28
+ }
@@ -0,0 +1,25 @@
1
+ HUB_TELEGRAM_TOKEN_ENV=HUB_TELEGRAM_TOKEN
2
+ HUB_TELEGRAM_TOKEN=123456:replace_me
3
+ HUB_ID=copilot_hub
4
+ HUB_NAME=Copilot Hub
5
+ # Optional. If empty, defaults to Desktop/copilot_workspaces (Windows/macOS/Linux)
6
+ HUB_WORKSPACE_ROOT=
7
+ HUB_IMMUTABLE_CORE=true
8
+ HUB_DATA_DIR=./data/copilot_hub
9
+ HUB_THREAD_MODE=per_chat
10
+ HUB_SHARED_THREAD_ID=shared-copilot-hub
11
+ HUB_ALLOWED_CHAT_IDS=
12
+ HUB_ENGINE_BASE_URL=http://127.0.0.1:8787
13
+ HUB_CODEX_SANDBOX=workspace-write
14
+ HUB_CODEX_APPROVAL_POLICY=on-failure
15
+
16
+ DEFAULT_WORKSPACE_ROOT=
17
+ PROJECTS_BASE_DIR=
18
+ WORKSPACE_STRICT_MODE=true
19
+ WORKSPACE_ALLOWED_ROOTS=
20
+ DEFAULT_PROVIDER_KIND=codex
21
+ CODEX_SANDBOX=danger-full-access
22
+ CODEX_APPROVAL_POLICY=never
23
+ TURN_ACTIVITY_TIMEOUT_MS=3600000
24
+ MAX_THREAD_MESSAGES=200
25
+