@dobby.ai/dobby 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 (174) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +267 -0
  3. package/README.md +382 -0
  4. package/ROADMAP.md +34 -0
  5. package/config/cron.example.json +9 -0
  6. package/config/gateway.example.json +128 -0
  7. package/config/models.custom.example.json +27 -0
  8. package/dist/src/agent/event-forwarder.js +341 -0
  9. package/dist/src/agent/tests/event-forwarder.test.js +113 -0
  10. package/dist/src/cli/commands/config.js +243 -0
  11. package/dist/src/cli/commands/configure.js +61 -0
  12. package/dist/src/cli/commands/cron.js +288 -0
  13. package/dist/src/cli/commands/doctor.js +189 -0
  14. package/dist/src/cli/commands/extension.js +151 -0
  15. package/dist/src/cli/commands/init.js +286 -0
  16. package/dist/src/cli/commands/start.js +177 -0
  17. package/dist/src/cli/commands/topology.js +254 -0
  18. package/dist/src/cli/index.js +8 -0
  19. package/dist/src/cli/program.js +386 -0
  20. package/dist/src/cli/shared/config-io.js +223 -0
  21. package/dist/src/cli/shared/config-mutators.js +345 -0
  22. package/dist/src/cli/shared/config-path.js +207 -0
  23. package/dist/src/cli/shared/config-schema.js +159 -0
  24. package/dist/src/cli/shared/config-types.js +1 -0
  25. package/dist/src/cli/shared/configure-sections.js +429 -0
  26. package/dist/src/cli/shared/discord-config.js +12 -0
  27. package/dist/src/cli/shared/init-catalog.js +115 -0
  28. package/dist/src/cli/shared/init-models-file.js +65 -0
  29. package/dist/src/cli/shared/presets.js +86 -0
  30. package/dist/src/cli/shared/runtime.js +29 -0
  31. package/dist/src/cli/shared/schema-prompts.js +325 -0
  32. package/dist/src/cli/tests/config-command.test.js +42 -0
  33. package/dist/src/cli/tests/config-io.test.js +64 -0
  34. package/dist/src/cli/tests/config-mutators.test.js +47 -0
  35. package/dist/src/cli/tests/config-path.test.js +21 -0
  36. package/dist/src/cli/tests/discord-config.test.js +23 -0
  37. package/dist/src/cli/tests/doctor.test.js +107 -0
  38. package/dist/src/cli/tests/init-catalog.test.js +87 -0
  39. package/dist/src/cli/tests/presets.test.js +41 -0
  40. package/dist/src/cli/tests/program-options.test.js +92 -0
  41. package/dist/src/cli/tests/routing-config.test.js +199 -0
  42. package/dist/src/cli/tests/routing-legacy.test.js +191 -0
  43. package/dist/src/core/control-command.js +12 -0
  44. package/dist/src/core/dedup-store.js +92 -0
  45. package/dist/src/core/gateway.js +432 -0
  46. package/dist/src/core/routing.js +306 -0
  47. package/dist/src/core/runtime-registry.js +119 -0
  48. package/dist/src/core/tests/control-command.test.js +17 -0
  49. package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
  50. package/dist/src/core/tests/runtime-registry.test.js +116 -0
  51. package/dist/src/core/tests/typing-controller.test.js +103 -0
  52. package/dist/src/core/types.js +1 -0
  53. package/dist/src/core/typing-controller.js +88 -0
  54. package/dist/src/cron/config.js +114 -0
  55. package/dist/src/cron/schedule.js +49 -0
  56. package/dist/src/cron/service.js +196 -0
  57. package/dist/src/cron/store.js +142 -0
  58. package/dist/src/cron/types.js +1 -0
  59. package/dist/src/extension/loader.js +97 -0
  60. package/dist/src/extension/manager.js +269 -0
  61. package/dist/src/extension/manifest.js +21 -0
  62. package/dist/src/extension/registry.js +137 -0
  63. package/dist/src/main.js +6 -0
  64. package/dist/src/sandbox/executor.js +1 -0
  65. package/dist/src/sandbox/host-executor.js +111 -0
  66. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
  67. package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
  68. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
  69. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
  70. package/docs/MVP.md +135 -0
  71. package/docs/RUNBOOK.md +242 -0
  72. package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
  73. package/package.json +43 -0
  74. package/plugins/connector-discord/dobby.manifest.json +18 -0
  75. package/plugins/connector-discord/index.js +1 -0
  76. package/plugins/connector-discord/package-lock.json +360 -0
  77. package/plugins/connector-discord/package.json +38 -0
  78. package/plugins/connector-discord/src/connector.ts +350 -0
  79. package/plugins/connector-discord/src/contribution.ts +21 -0
  80. package/plugins/connector-discord/src/mapper.ts +102 -0
  81. package/plugins/connector-discord/tsconfig.json +19 -0
  82. package/plugins/connector-feishu/dobby.manifest.json +18 -0
  83. package/plugins/connector-feishu/index.js +1 -0
  84. package/plugins/connector-feishu/package-lock.json +618 -0
  85. package/plugins/connector-feishu/package.json +38 -0
  86. package/plugins/connector-feishu/src/connector.ts +343 -0
  87. package/plugins/connector-feishu/src/contribution.ts +26 -0
  88. package/plugins/connector-feishu/src/mapper.ts +401 -0
  89. package/plugins/connector-feishu/tsconfig.json +19 -0
  90. package/plugins/plugin-sdk/index.d.ts +261 -0
  91. package/plugins/plugin-sdk/index.js +1 -0
  92. package/plugins/plugin-sdk/package-lock.json +12 -0
  93. package/plugins/plugin-sdk/package.json +22 -0
  94. package/plugins/provider-claude/dobby.manifest.json +17 -0
  95. package/plugins/provider-claude/index.js +1 -0
  96. package/plugins/provider-claude/package-lock.json +3398 -0
  97. package/plugins/provider-claude/package.json +39 -0
  98. package/plugins/provider-claude/src/contribution.ts +1018 -0
  99. package/plugins/provider-claude/tsconfig.json +19 -0
  100. package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
  101. package/plugins/provider-claude-cli/index.js +1 -0
  102. package/plugins/provider-claude-cli/package-lock.json +2898 -0
  103. package/plugins/provider-claude-cli/package.json +38 -0
  104. package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
  105. package/plugins/provider-claude-cli/tsconfig.json +19 -0
  106. package/plugins/provider-pi/dobby.manifest.json +17 -0
  107. package/plugins/provider-pi/index.js +1 -0
  108. package/plugins/provider-pi/package-lock.json +3877 -0
  109. package/plugins/provider-pi/package.json +40 -0
  110. package/plugins/provider-pi/src/contribution.ts +476 -0
  111. package/plugins/provider-pi/tsconfig.json +19 -0
  112. package/plugins/sandbox-core/boxlite.js +1 -0
  113. package/plugins/sandbox-core/dobby.manifest.json +17 -0
  114. package/plugins/sandbox-core/docker.js +1 -0
  115. package/plugins/sandbox-core/package-lock.json +136 -0
  116. package/plugins/sandbox-core/package.json +39 -0
  117. package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
  118. package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
  119. package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
  120. package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
  121. package/plugins/sandbox-core/src/docker-executor.ts +217 -0
  122. package/plugins/sandbox-core/tsconfig.json +19 -0
  123. package/scripts/local-extensions.mjs +168 -0
  124. package/src/agent/event-forwarder.ts +414 -0
  125. package/src/cli/commands/config.ts +328 -0
  126. package/src/cli/commands/configure.ts +92 -0
  127. package/src/cli/commands/cron.ts +410 -0
  128. package/src/cli/commands/doctor.ts +230 -0
  129. package/src/cli/commands/extension.ts +205 -0
  130. package/src/cli/commands/init.ts +396 -0
  131. package/src/cli/commands/start.ts +223 -0
  132. package/src/cli/commands/topology.ts +383 -0
  133. package/src/cli/index.ts +9 -0
  134. package/src/cli/program.ts +465 -0
  135. package/src/cli/shared/config-io.ts +277 -0
  136. package/src/cli/shared/config-mutators.ts +440 -0
  137. package/src/cli/shared/config-schema.ts +228 -0
  138. package/src/cli/shared/config-types.ts +121 -0
  139. package/src/cli/shared/configure-sections.ts +551 -0
  140. package/src/cli/shared/discord-config.ts +14 -0
  141. package/src/cli/shared/init-catalog.ts +189 -0
  142. package/src/cli/shared/init-models-file.ts +77 -0
  143. package/src/cli/shared/runtime.ts +33 -0
  144. package/src/cli/shared/schema-prompts.ts +414 -0
  145. package/src/cli/tests/config-command.test.ts +56 -0
  146. package/src/cli/tests/config-io.test.ts +92 -0
  147. package/src/cli/tests/config-mutators.test.ts +59 -0
  148. package/src/cli/tests/doctor.test.ts +120 -0
  149. package/src/cli/tests/init-catalog.test.ts +96 -0
  150. package/src/cli/tests/program-options.test.ts +113 -0
  151. package/src/cli/tests/routing-config.test.ts +209 -0
  152. package/src/core/control-command.ts +12 -0
  153. package/src/core/dedup-store.ts +103 -0
  154. package/src/core/gateway.ts +607 -0
  155. package/src/core/routing.ts +379 -0
  156. package/src/core/runtime-registry.ts +141 -0
  157. package/src/core/tests/control-command.test.ts +20 -0
  158. package/src/core/tests/runtime-registry.test.ts +140 -0
  159. package/src/core/tests/typing-controller.test.ts +129 -0
  160. package/src/core/types.ts +318 -0
  161. package/src/core/typing-controller.ts +119 -0
  162. package/src/cron/config.ts +154 -0
  163. package/src/cron/schedule.ts +61 -0
  164. package/src/cron/service.ts +249 -0
  165. package/src/cron/store.ts +155 -0
  166. package/src/cron/types.ts +60 -0
  167. package/src/extension/loader.ts +145 -0
  168. package/src/extension/manager.ts +355 -0
  169. package/src/extension/manifest.ts +26 -0
  170. package/src/extension/registry.ts +229 -0
  171. package/src/main.ts +8 -0
  172. package/src/sandbox/executor.ts +44 -0
  173. package/src/sandbox/host-executor.ts +118 -0
  174. package/tsconfig.json +18 -0
@@ -0,0 +1,277 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { loadGatewayConfig } from "../../core/routing.js";
6
+ import type { RawGatewayConfig } from "./config-types.js";
7
+
8
+ /**
9
+ * Default config file path used by all CLI commands.
10
+ */
11
+ export const DEFAULT_CONFIG_PATH = resolve(homedir(), ".dobby", "gateway.json");
12
+
13
+ /**
14
+ * Expands "~" prefixed paths to the current user's home directory.
15
+ */
16
+ function expandHome(value: string): string {
17
+ if (value === "~") {
18
+ return homedir();
19
+ }
20
+
21
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
22
+ return resolve(homedir(), value.slice(2));
23
+ }
24
+
25
+ return value;
26
+ }
27
+
28
+ type ConfigPathSource = "env" | "repo" | "default";
29
+
30
+ interface ConfigPathResolutionInput {
31
+ cwd?: string;
32
+ env?: NodeJS.ProcessEnv;
33
+ }
34
+
35
+ interface ResolvedConfigPathInfo {
36
+ path: string;
37
+ source: ConfigPathSource;
38
+ }
39
+
40
+ /**
41
+ * Returns true when a directory looks like the dobby repository root.
42
+ */
43
+ function isDobbyRepoRoot(candidateDir: string): boolean {
44
+ const packageJsonPath = resolve(candidateDir, "package.json");
45
+ const repoConfigPath = resolve(candidateDir, "config", "gateway.json");
46
+ const localExtensionsScriptPath = resolve(candidateDir, "scripts", "local-extensions.mjs");
47
+
48
+ if (!existsSync(packageJsonPath) || !existsSync(repoConfigPath) || !existsSync(localExtensionsScriptPath)) {
49
+ return false;
50
+ }
51
+
52
+ try {
53
+ const packageJsonRaw = readFileSync(packageJsonPath, "utf-8");
54
+ const parsed = JSON.parse(packageJsonRaw) as { name?: unknown };
55
+ return parsed.name === "dobby";
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function resolveConfigBaseDir(configPath: string): string {
62
+ const absoluteConfigPath = resolve(configPath);
63
+ const configDir = dirname(absoluteConfigPath);
64
+ const repoRoot = dirname(configDir);
65
+
66
+ if (absoluteConfigPath === resolve(repoRoot, "config", "gateway.json") && isDobbyRepoRoot(repoRoot)) {
67
+ return repoRoot;
68
+ }
69
+
70
+ return configDir;
71
+ }
72
+
73
+ /**
74
+ * Scans current directory and ancestors to find a local dobby repo config path.
75
+ */
76
+ function findDobbyRepoConfigPath(startDir: string): string | null {
77
+ let currentDir = resolve(startDir);
78
+
79
+ while (true) {
80
+ if (isDobbyRepoRoot(currentDir)) {
81
+ return resolve(currentDir, "config", "gateway.json");
82
+ }
83
+
84
+ const parentDir = dirname(currentDir);
85
+ if (parentDir === currentDir) {
86
+ return null;
87
+ }
88
+ currentDir = parentDir;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Resolves config path source by priority: env override -> local repo -> default home path.
94
+ */
95
+ function resolveConfigPathInfo(input?: ConfigPathResolutionInput): ResolvedConfigPathInfo {
96
+ const env = input?.env ?? process.env;
97
+ const cwd = input?.cwd ?? process.cwd();
98
+ const rawOverride = env.DOBBY_CONFIG_PATH;
99
+ const override = typeof rawOverride === "string" ? rawOverride.trim() : "";
100
+
101
+ if (override.length > 0) {
102
+ const expanded = expandHome(override);
103
+ return {
104
+ path: isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded),
105
+ source: "env",
106
+ };
107
+ }
108
+
109
+ const localRepoConfigPath = findDobbyRepoConfigPath(cwd);
110
+ if (localRepoConfigPath) {
111
+ return {
112
+ path: localRepoConfigPath,
113
+ source: "repo",
114
+ };
115
+ }
116
+
117
+ return {
118
+ path: DEFAULT_CONFIG_PATH,
119
+ source: "default",
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Resolves config path with dev-friendly detection and env override support.
125
+ */
126
+ export function resolveConfigPath(input?: ConfigPathResolutionInput): string {
127
+ return resolveConfigPathInfo(input).path;
128
+ }
129
+
130
+ /**
131
+ * Formats a user-facing init command hint for missing-config errors.
132
+ */
133
+ function initCommandHint(): string {
134
+ return "dobby init";
135
+ }
136
+
137
+ /**
138
+ * Resolves data.rootDir into an absolute path using config-file-relative semantics.
139
+ */
140
+ export function resolveDataRootDir(configPath: string, rawConfig: RawGatewayConfig): string {
141
+ const absoluteConfigPath = resolve(configPath);
142
+ const baseDir = resolveConfigBaseDir(absoluteConfigPath);
143
+ const rawRootDir = typeof rawConfig.data?.rootDir === "string" && rawConfig.data.rootDir.trim().length > 0
144
+ ? rawConfig.data.rootDir
145
+ : "./data";
146
+
147
+ const expanded = expandHome(rawRootDir);
148
+ if (isAbsolute(expanded)) {
149
+ return resolve(expanded);
150
+ }
151
+
152
+ return resolve(baseDir, expanded);
153
+ }
154
+
155
+ /**
156
+ * Checks whether a file exists without throwing for missing paths.
157
+ */
158
+ async function fileExists(path: string): Promise<boolean> {
159
+ try {
160
+ await access(path);
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Reads and parses the raw JSON config file.
169
+ */
170
+ export async function readRawConfig(configPath: string): Promise<RawGatewayConfig | null> {
171
+ const absolutePath = resolve(configPath);
172
+ if (!(await fileExists(absolutePath))) {
173
+ return null;
174
+ }
175
+
176
+ const raw = await readFile(absolutePath, "utf-8");
177
+ const parsed = JSON.parse(raw) as unknown;
178
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
179
+ throw new Error(`Config at '${absolutePath}' must be a JSON object`);
180
+ }
181
+
182
+ return parsed as RawGatewayConfig;
183
+ }
184
+
185
+ /**
186
+ * Writes config content atomically via temp file + rename.
187
+ */
188
+ async function writeAtomic(configPath: string, content: string): Promise<void> {
189
+ const absolutePath = resolve(configPath);
190
+ const configDir = dirname(absolutePath);
191
+ await mkdir(configDir, { recursive: true });
192
+
193
+ const tempPath = `${absolutePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
194
+ await writeFile(tempPath, content, "utf-8");
195
+ await rename(tempPath, absolutePath);
196
+ }
197
+
198
+ /**
199
+ * Serializes and writes the config file with normalized JSON formatting.
200
+ */
201
+ export async function writeRawConfig(configPath: string, rawConfig: RawGatewayConfig): Promise<void> {
202
+ const payload = `${JSON.stringify(rawConfig, null, 2)}\n`;
203
+ await writeAtomic(configPath, payload);
204
+ }
205
+
206
+ /**
207
+ * Creates a timestamped backup beside the target config file.
208
+ */
209
+ export async function backupConfig(configPath: string): Promise<string | null> {
210
+ const absolutePath = resolve(configPath);
211
+ if (!(await fileExists(absolutePath))) {
212
+ return null;
213
+ }
214
+
215
+ const content = await readFile(absolutePath, "utf-8");
216
+ const backupPath = `${absolutePath}.bak-${Date.now()}`;
217
+ await writeFile(backupPath, content, "utf-8");
218
+ return backupPath;
219
+ }
220
+
221
+ /**
222
+ * Writes config, validates with loadGatewayConfig, and rolls back on any failure.
223
+ */
224
+ export async function writeConfigWithValidation(
225
+ configPath: string,
226
+ rawConfig: RawGatewayConfig,
227
+ options?: {
228
+ validate?: boolean;
229
+ createBackup?: boolean;
230
+ },
231
+ ): Promise<{ backupPath: string | null }> {
232
+ const absolutePath = resolve(configPath);
233
+ const validate = options?.validate !== false;
234
+
235
+ const previousExists = await fileExists(absolutePath);
236
+ const previousContent = previousExists ? await readFile(absolutePath, "utf-8") : null;
237
+ const backupPath = options?.createBackup ? await backupConfig(absolutePath) : null;
238
+
239
+ try {
240
+ await writeRawConfig(absolutePath, rawConfig);
241
+ if (!validate) {
242
+ return { backupPath };
243
+ }
244
+
245
+ await loadGatewayConfig(absolutePath);
246
+ return { backupPath };
247
+ } catch (error) {
248
+ if (previousContent === null) {
249
+ await rm(absolutePath, { force: true });
250
+ } else {
251
+ await writeAtomic(absolutePath, previousContent);
252
+ }
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Reads config and throws a user-oriented error when the file is missing.
259
+ */
260
+ export async function requireRawConfig(configPath: string): Promise<RawGatewayConfig> {
261
+ const raw = await readRawConfig(configPath);
262
+ if (!raw) {
263
+ const resolvedConfigPath = resolve(configPath);
264
+ const currentResolution = resolveConfigPathInfo();
265
+ let sourceHint = "";
266
+ if (resolvedConfigPath === currentResolution.path && currentResolution.source === "env") {
267
+ sourceHint = ` Source: DOBBY_CONFIG_PATH='${process.env.DOBBY_CONFIG_PATH ?? ""}'.`;
268
+ } else if (resolvedConfigPath === currentResolution.path && currentResolution.source === "repo") {
269
+ sourceHint = " Source: detected dobby repo, using ./config/gateway.json.";
270
+ }
271
+
272
+ throw new Error(
273
+ `Config '${resolvedConfigPath}' does not exist.${sourceHint} Run '${initCommandHint()}' first.`,
274
+ );
275
+ }
276
+ return raw;
277
+ }
@@ -0,0 +1,440 @@
1
+ import type { ExtensionContributionManifest } from "../../core/types.js";
2
+ import type {
3
+ ContributionInstanceTemplate,
4
+ ContributionTemplatesByKind,
5
+ NormalizedGatewayConfig,
6
+ RawBindingConfig,
7
+ RawExtensionItemConfig,
8
+ RawGatewayConfig,
9
+ RawRouteDefaults,
10
+ RawRouteProfile,
11
+ } from "./config-types.js";
12
+
13
+ const DEFAULT_DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1000;
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+
19
+ function asItemMap(value: unknown): Record<string, RawExtensionItemConfig> {
20
+ if (!isRecord(value)) {
21
+ return {};
22
+ }
23
+
24
+ const result: Record<string, RawExtensionItemConfig> = {};
25
+ for (const [instanceId, raw] of Object.entries(value)) {
26
+ if (!isRecord(raw) || typeof raw.type !== "string" || raw.type.trim().length === 0) {
27
+ continue;
28
+ }
29
+
30
+ result[instanceId] = {
31
+ ...raw,
32
+ type: raw.type,
33
+ };
34
+ }
35
+
36
+ return result;
37
+ }
38
+
39
+ function asAllowList(value: unknown): Array<{ package: string; enabled: boolean }> {
40
+ if (!Array.isArray(value)) {
41
+ return [];
42
+ }
43
+
44
+ const normalized: Array<{ package: string; enabled: boolean }> = [];
45
+ for (const item of value) {
46
+ if (!isRecord(item) || typeof item.package !== "string" || item.package.trim().length === 0) {
47
+ continue;
48
+ }
49
+
50
+ normalized.push({
51
+ package: item.package,
52
+ enabled: item.enabled !== false,
53
+ });
54
+ }
55
+
56
+ return normalized;
57
+ }
58
+
59
+ function asRouteDefaults(value: unknown): RawRouteDefaults {
60
+ if (!isRecord(value)) {
61
+ return {
62
+ tools: "full",
63
+ mentions: "required",
64
+ };
65
+ }
66
+
67
+ return {
68
+ ...(typeof value.provider === "string" && value.provider.trim().length > 0 ? { provider: value.provider } : {}),
69
+ ...(typeof value.sandbox === "string" && value.sandbox.trim().length > 0 ? { sandbox: value.sandbox } : {}),
70
+ tools: value.tools === "readonly" ? "readonly" : "full",
71
+ mentions: value.mentions === "optional" ? "optional" : "required",
72
+ };
73
+ }
74
+
75
+ function asRoutes(value: unknown): Record<string, RawRouteProfile> {
76
+ if (!isRecord(value)) {
77
+ return {};
78
+ }
79
+
80
+ const normalized: Record<string, RawRouteProfile> = {};
81
+ for (const [routeId, route] of Object.entries(value)) {
82
+ if (!isRecord(route) || typeof route.projectRoot !== "string" || route.projectRoot.trim().length === 0) {
83
+ continue;
84
+ }
85
+
86
+ normalized[routeId] = {
87
+ ...route,
88
+ projectRoot: route.projectRoot,
89
+ ...(route.tools === "readonly" ? { tools: "readonly" as const } : {}),
90
+ ...(route.mentions === "optional" ? { mentions: "optional" as const } : {}),
91
+ ...(typeof route.provider === "string" && route.provider.trim().length > 0 ? { provider: route.provider } : {}),
92
+ ...(typeof route.sandbox === "string" && route.sandbox.trim().length > 0 ? { sandbox: route.sandbox } : {}),
93
+ ...(typeof route.systemPromptFile === "string" ? { systemPromptFile: route.systemPromptFile } : {}),
94
+ };
95
+ }
96
+
97
+ return normalized;
98
+ }
99
+
100
+ function asBindings(value: unknown): Record<string, RawBindingConfig> {
101
+ if (!isRecord(value)) {
102
+ return {};
103
+ }
104
+
105
+ const normalized: Record<string, RawBindingConfig> = {};
106
+ for (const [bindingId, binding] of Object.entries(value)) {
107
+ if (!isRecord(binding)) {
108
+ continue;
109
+ }
110
+
111
+ const rawSource = binding.source;
112
+ if (
113
+ typeof binding.connector !== "string"
114
+ || binding.connector.trim().length === 0
115
+ || typeof binding.route !== "string"
116
+ || binding.route.trim().length === 0
117
+ || !isRecord(rawSource)
118
+ || (rawSource.type !== "channel" && rawSource.type !== "chat")
119
+ || typeof rawSource.id !== "string"
120
+ || rawSource.id.trim().length === 0
121
+ ) {
122
+ continue;
123
+ }
124
+
125
+ normalized[bindingId] = {
126
+ ...binding,
127
+ connector: binding.connector,
128
+ route: binding.route,
129
+ source: {
130
+ ...rawSource,
131
+ type: rawSource.type,
132
+ id: rawSource.id,
133
+ },
134
+ };
135
+ }
136
+
137
+ return normalized;
138
+ }
139
+
140
+ export function ensureGatewayConfigShape(config: RawGatewayConfig): NormalizedGatewayConfig {
141
+ const normalizedProvidersDefault =
142
+ typeof config.providers?.default === "string" && config.providers.default.trim().length > 0
143
+ ? config.providers.default
144
+ : "";
145
+ const normalizedSandboxesDefault =
146
+ typeof config.sandboxes?.default === "string" && config.sandboxes.default.trim().length > 0
147
+ ? config.sandboxes.default
148
+ : "host.builtin";
149
+
150
+ const routeDefaults = asRouteDefaults(config.routes?.defaults);
151
+ if (!routeDefaults.provider && normalizedProvidersDefault) {
152
+ routeDefaults.provider = normalizedProvidersDefault;
153
+ }
154
+ if (!routeDefaults.sandbox && normalizedSandboxesDefault) {
155
+ routeDefaults.sandbox = normalizedSandboxesDefault;
156
+ }
157
+
158
+ return {
159
+ ...config,
160
+ extensions: {
161
+ ...((isRecord(config.extensions) ? config.extensions : {}) as Record<string, unknown>),
162
+ allowList: asAllowList(config.extensions?.allowList),
163
+ },
164
+ providers: {
165
+ ...((isRecord(config.providers) ? config.providers : {}) as Record<string, unknown>),
166
+ default: normalizedProvidersDefault,
167
+ items: asItemMap(config.providers?.items),
168
+ },
169
+ connectors: {
170
+ ...((isRecord(config.connectors) ? config.connectors : {}) as Record<string, unknown>),
171
+ items: asItemMap(config.connectors?.items),
172
+ },
173
+ sandboxes: {
174
+ ...((isRecord(config.sandboxes) ? config.sandboxes : {}) as Record<string, unknown>),
175
+ default: normalizedSandboxesDefault,
176
+ items: asItemMap(config.sandboxes?.items),
177
+ },
178
+ routes: {
179
+ ...((isRecord(config.routes) ? config.routes : {}) as Record<string, unknown>),
180
+ defaults: routeDefaults,
181
+ items: asRoutes(config.routes?.items),
182
+ },
183
+ bindings: {
184
+ ...((isRecord(config.bindings) ? config.bindings : {}) as Record<string, unknown>),
185
+ items: asBindings(config.bindings?.items),
186
+ },
187
+ data: {
188
+ ...((isRecord(config.data) ? config.data : {}) as Record<string, unknown>),
189
+ rootDir: typeof config.data?.rootDir === "string" && config.data.rootDir.trim().length > 0 ? config.data.rootDir : "./data",
190
+ dedupTtlMs:
191
+ typeof config.data?.dedupTtlMs === "number" && Number.isFinite(config.data.dedupTtlMs) && config.data.dedupTtlMs > 0
192
+ ? config.data.dedupTtlMs
193
+ : DEFAULT_DEDUP_TTL_MS,
194
+ },
195
+ };
196
+ }
197
+
198
+ export function upsertAllowListPackage(config: RawGatewayConfig, packageName: string, enabled = true): void {
199
+ const next = ensureGatewayConfigShape(config);
200
+ const allowList = next.extensions.allowList;
201
+ const existing = allowList.find((item) => item.package === packageName);
202
+ if (existing) {
203
+ existing.enabled = enabled;
204
+ config.extensions = next.extensions;
205
+ return;
206
+ }
207
+
208
+ allowList.push({ package: packageName, enabled });
209
+ config.extensions = {
210
+ ...next.extensions,
211
+ allowList,
212
+ };
213
+ }
214
+
215
+ function buildTemplateInstanceId(contributionId: string): string {
216
+ const segments = contributionId.split(".");
217
+ const suffix = segments.length > 1 ? segments.slice(1).join("-") : contributionId;
218
+ return `${suffix}.main`;
219
+ }
220
+
221
+ export function buildContributionTemplates(contributions: ExtensionContributionManifest[]): ContributionTemplatesByKind {
222
+ const templates: ContributionTemplatesByKind = {
223
+ providers: [],
224
+ connectors: [],
225
+ sandboxes: [],
226
+ };
227
+
228
+ for (const contribution of contributions) {
229
+ const template: ContributionInstanceTemplate = {
230
+ id: buildTemplateInstanceId(contribution.id),
231
+ type: contribution.id,
232
+ config: {},
233
+ };
234
+
235
+ if (contribution.kind === "provider") {
236
+ templates.providers.push(template);
237
+ continue;
238
+ }
239
+
240
+ if (contribution.kind === "connector") {
241
+ templates.connectors.push(template);
242
+ continue;
243
+ }
244
+
245
+ templates.sandboxes.push(template);
246
+ }
247
+
248
+ return templates;
249
+ }
250
+
251
+ function upsertTemplateInstances(
252
+ items: Record<string, RawExtensionItemConfig>,
253
+ templates: ContributionInstanceTemplate[],
254
+ ): string[] {
255
+ const byType = new Set(Object.values(items).map((instance) => instance.type));
256
+ const addedIds: string[] = [];
257
+
258
+ for (const template of templates) {
259
+ if (byType.has(template.type)) {
260
+ continue;
261
+ }
262
+
263
+ let candidateId = template.id;
264
+ let suffix = 2;
265
+ while (items[candidateId]) {
266
+ candidateId = `${template.id}-${suffix}`;
267
+ suffix += 1;
268
+ }
269
+
270
+ items[candidateId] = {
271
+ type: template.type,
272
+ ...structuredClone(template.config),
273
+ };
274
+ byType.add(template.type);
275
+ addedIds.push(candidateId);
276
+ }
277
+
278
+ return addedIds;
279
+ }
280
+
281
+ export function applyContributionTemplates(config: RawGatewayConfig, templates: ContributionTemplatesByKind): {
282
+ providers: string[];
283
+ connectors: string[];
284
+ sandboxes: string[];
285
+ } {
286
+ const next = ensureGatewayConfigShape(config);
287
+ const providerItems = next.providers.items;
288
+ const connectorItems = next.connectors.items;
289
+ const sandboxItems = next.sandboxes.items;
290
+
291
+ const added = {
292
+ providers: upsertTemplateInstances(providerItems, templates.providers),
293
+ connectors: upsertTemplateInstances(connectorItems, templates.connectors),
294
+ sandboxes: upsertTemplateInstances(sandboxItems, templates.sandboxes),
295
+ };
296
+
297
+ config.providers = {
298
+ ...next.providers,
299
+ items: providerItems,
300
+ };
301
+ config.connectors = {
302
+ ...next.connectors,
303
+ items: connectorItems,
304
+ };
305
+ config.sandboxes = {
306
+ ...next.sandboxes,
307
+ items: sandboxItems,
308
+ };
309
+
310
+ return added;
311
+ }
312
+
313
+ export function upsertProviderInstance(
314
+ config: RawGatewayConfig,
315
+ instanceId: string,
316
+ type: string,
317
+ instanceConfig: Record<string, unknown>,
318
+ ): void {
319
+ const next = ensureGatewayConfigShape(config);
320
+ next.providers.items[instanceId] = {
321
+ type,
322
+ ...structuredClone(instanceConfig),
323
+ };
324
+ config.providers = {
325
+ ...next.providers,
326
+ items: next.providers.items,
327
+ };
328
+ }
329
+
330
+ export function upsertConnectorInstance(
331
+ config: RawGatewayConfig,
332
+ instanceId: string,
333
+ type: string,
334
+ instanceConfig: Record<string, unknown>,
335
+ ): void {
336
+ const next = ensureGatewayConfigShape(config);
337
+ next.connectors.items[instanceId] = {
338
+ type,
339
+ ...structuredClone(instanceConfig),
340
+ };
341
+ config.connectors = {
342
+ ...next.connectors,
343
+ items: next.connectors.items,
344
+ };
345
+ }
346
+
347
+ export function upsertSandboxInstance(
348
+ config: RawGatewayConfig,
349
+ instanceId: string,
350
+ type: string,
351
+ instanceConfig: Record<string, unknown>,
352
+ ): void {
353
+ const next = ensureGatewayConfigShape(config);
354
+ next.sandboxes.items[instanceId] = {
355
+ type,
356
+ ...structuredClone(instanceConfig),
357
+ };
358
+ config.sandboxes = {
359
+ ...next.sandboxes,
360
+ items: next.sandboxes.items,
361
+ };
362
+ }
363
+
364
+ export function setDefaultProviderIfMissingOrInvalid(config: RawGatewayConfig): void {
365
+ const next = ensureGatewayConfigShape(config);
366
+ const items = next.providers.items;
367
+ const defaultProvider = next.providers.default;
368
+
369
+ if (defaultProvider && items[defaultProvider]) {
370
+ config.providers = next.providers;
371
+ if (!next.routes.defaults.provider) {
372
+ config.routes = {
373
+ ...next.routes,
374
+ defaults: {
375
+ ...next.routes.defaults,
376
+ provider: defaultProvider,
377
+ },
378
+ };
379
+ }
380
+ return;
381
+ }
382
+
383
+ const candidates = Object.keys(items).sort((a, b) => a.localeCompare(b));
384
+ if (candidates.length === 0) {
385
+ config.providers = next.providers;
386
+ return;
387
+ }
388
+
389
+ config.providers = {
390
+ ...next.providers,
391
+ default: candidates[0]!,
392
+ items,
393
+ };
394
+ config.routes = {
395
+ ...next.routes,
396
+ defaults: {
397
+ ...next.routes.defaults,
398
+ provider: candidates[0]!,
399
+ },
400
+ };
401
+ }
402
+
403
+ export function upsertRoute(config: RawGatewayConfig, routeId: string, profile: RawRouteProfile): void {
404
+ const next = ensureGatewayConfigShape(config);
405
+ next.routes.items[routeId] = {
406
+ projectRoot: profile.projectRoot,
407
+ ...(profile.tools ? { tools: profile.tools } : {}),
408
+ ...(profile.mentions ? { mentions: profile.mentions } : {}),
409
+ ...(profile.provider ? { provider: profile.provider } : {}),
410
+ ...(profile.sandbox ? { sandbox: profile.sandbox } : {}),
411
+ ...(typeof profile.systemPromptFile === "string" ? { systemPromptFile: profile.systemPromptFile } : {}),
412
+ };
413
+ config.routes = {
414
+ ...next.routes,
415
+ items: next.routes.items,
416
+ };
417
+ }
418
+
419
+ export function upsertBinding(config: RawGatewayConfig, bindingId: string, binding: RawBindingConfig): void {
420
+ const next = ensureGatewayConfigShape(config);
421
+ next.bindings.items[bindingId] = structuredClone(binding);
422
+ config.bindings = {
423
+ ...next.bindings,
424
+ items: next.bindings.items,
425
+ };
426
+ }
427
+
428
+ export function listContributionIds(config: RawGatewayConfig): {
429
+ providers: string[];
430
+ connectors: string[];
431
+ sandboxes: string[];
432
+ } {
433
+ const next = ensureGatewayConfigShape(config);
434
+
435
+ return {
436
+ providers: Object.values(next.providers.items).map((instance) => instance.type),
437
+ connectors: Object.values(next.connectors.items).map((instance) => instance.type),
438
+ sandboxes: Object.values(next.sandboxes.items).map((instance) => instance.type),
439
+ };
440
+ }