@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.
- package/.env.example +9 -0
- package/AGENTS.md +267 -0
- package/README.md +382 -0
- package/ROADMAP.md +34 -0
- package/config/cron.example.json +9 -0
- package/config/gateway.example.json +128 -0
- package/config/models.custom.example.json +27 -0
- package/dist/src/agent/event-forwarder.js +341 -0
- package/dist/src/agent/tests/event-forwarder.test.js +113 -0
- package/dist/src/cli/commands/config.js +243 -0
- package/dist/src/cli/commands/configure.js +61 -0
- package/dist/src/cli/commands/cron.js +288 -0
- package/dist/src/cli/commands/doctor.js +189 -0
- package/dist/src/cli/commands/extension.js +151 -0
- package/dist/src/cli/commands/init.js +286 -0
- package/dist/src/cli/commands/start.js +177 -0
- package/dist/src/cli/commands/topology.js +254 -0
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/program.js +386 -0
- package/dist/src/cli/shared/config-io.js +223 -0
- package/dist/src/cli/shared/config-mutators.js +345 -0
- package/dist/src/cli/shared/config-path.js +207 -0
- package/dist/src/cli/shared/config-schema.js +159 -0
- package/dist/src/cli/shared/config-types.js +1 -0
- package/dist/src/cli/shared/configure-sections.js +429 -0
- package/dist/src/cli/shared/discord-config.js +12 -0
- package/dist/src/cli/shared/init-catalog.js +115 -0
- package/dist/src/cli/shared/init-models-file.js +65 -0
- package/dist/src/cli/shared/presets.js +86 -0
- package/dist/src/cli/shared/runtime.js +29 -0
- package/dist/src/cli/shared/schema-prompts.js +325 -0
- package/dist/src/cli/tests/config-command.test.js +42 -0
- package/dist/src/cli/tests/config-io.test.js +64 -0
- package/dist/src/cli/tests/config-mutators.test.js +47 -0
- package/dist/src/cli/tests/config-path.test.js +21 -0
- package/dist/src/cli/tests/discord-config.test.js +23 -0
- package/dist/src/cli/tests/doctor.test.js +107 -0
- package/dist/src/cli/tests/init-catalog.test.js +87 -0
- package/dist/src/cli/tests/presets.test.js +41 -0
- package/dist/src/cli/tests/program-options.test.js +92 -0
- package/dist/src/cli/tests/routing-config.test.js +199 -0
- package/dist/src/cli/tests/routing-legacy.test.js +191 -0
- package/dist/src/core/control-command.js +12 -0
- package/dist/src/core/dedup-store.js +92 -0
- package/dist/src/core/gateway.js +432 -0
- package/dist/src/core/routing.js +306 -0
- package/dist/src/core/runtime-registry.js +119 -0
- package/dist/src/core/tests/control-command.test.js +17 -0
- package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
- package/dist/src/core/tests/runtime-registry.test.js +116 -0
- package/dist/src/core/tests/typing-controller.test.js +103 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/typing-controller.js +88 -0
- package/dist/src/cron/config.js +114 -0
- package/dist/src/cron/schedule.js +49 -0
- package/dist/src/cron/service.js +196 -0
- package/dist/src/cron/store.js +142 -0
- package/dist/src/cron/types.js +1 -0
- package/dist/src/extension/loader.js +97 -0
- package/dist/src/extension/manager.js +269 -0
- package/dist/src/extension/manifest.js +21 -0
- package/dist/src/extension/registry.js +137 -0
- package/dist/src/main.js +6 -0
- package/dist/src/sandbox/executor.js +1 -0
- package/dist/src/sandbox/host-executor.js +111 -0
- package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
- package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
- package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
- package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
- package/docs/MVP.md +135 -0
- package/docs/RUNBOOK.md +242 -0
- package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
- package/package.json +43 -0
- package/plugins/connector-discord/dobby.manifest.json +18 -0
- package/plugins/connector-discord/index.js +1 -0
- package/plugins/connector-discord/package-lock.json +360 -0
- package/plugins/connector-discord/package.json +38 -0
- package/plugins/connector-discord/src/connector.ts +350 -0
- package/plugins/connector-discord/src/contribution.ts +21 -0
- package/plugins/connector-discord/src/mapper.ts +102 -0
- package/plugins/connector-discord/tsconfig.json +19 -0
- package/plugins/connector-feishu/dobby.manifest.json +18 -0
- package/plugins/connector-feishu/index.js +1 -0
- package/plugins/connector-feishu/package-lock.json +618 -0
- package/plugins/connector-feishu/package.json +38 -0
- package/plugins/connector-feishu/src/connector.ts +343 -0
- package/plugins/connector-feishu/src/contribution.ts +26 -0
- package/plugins/connector-feishu/src/mapper.ts +401 -0
- package/plugins/connector-feishu/tsconfig.json +19 -0
- package/plugins/plugin-sdk/index.d.ts +261 -0
- package/plugins/plugin-sdk/index.js +1 -0
- package/plugins/plugin-sdk/package-lock.json +12 -0
- package/plugins/plugin-sdk/package.json +22 -0
- package/plugins/provider-claude/dobby.manifest.json +17 -0
- package/plugins/provider-claude/index.js +1 -0
- package/plugins/provider-claude/package-lock.json +3398 -0
- package/plugins/provider-claude/package.json +39 -0
- package/plugins/provider-claude/src/contribution.ts +1018 -0
- package/plugins/provider-claude/tsconfig.json +19 -0
- package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
- package/plugins/provider-claude-cli/index.js +1 -0
- package/plugins/provider-claude-cli/package-lock.json +2898 -0
- package/plugins/provider-claude-cli/package.json +38 -0
- package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
- package/plugins/provider-claude-cli/tsconfig.json +19 -0
- package/plugins/provider-pi/dobby.manifest.json +17 -0
- package/plugins/provider-pi/index.js +1 -0
- package/plugins/provider-pi/package-lock.json +3877 -0
- package/plugins/provider-pi/package.json +40 -0
- package/plugins/provider-pi/src/contribution.ts +476 -0
- package/plugins/provider-pi/tsconfig.json +19 -0
- package/plugins/sandbox-core/boxlite.js +1 -0
- package/plugins/sandbox-core/dobby.manifest.json +17 -0
- package/plugins/sandbox-core/docker.js +1 -0
- package/plugins/sandbox-core/package-lock.json +136 -0
- package/plugins/sandbox-core/package.json +39 -0
- package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
- package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
- package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
- package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
- package/plugins/sandbox-core/src/docker-executor.ts +217 -0
- package/plugins/sandbox-core/tsconfig.json +19 -0
- package/scripts/local-extensions.mjs +168 -0
- package/src/agent/event-forwarder.ts +414 -0
- package/src/cli/commands/config.ts +328 -0
- package/src/cli/commands/configure.ts +92 -0
- package/src/cli/commands/cron.ts +410 -0
- package/src/cli/commands/doctor.ts +230 -0
- package/src/cli/commands/extension.ts +205 -0
- package/src/cli/commands/init.ts +396 -0
- package/src/cli/commands/start.ts +223 -0
- package/src/cli/commands/topology.ts +383 -0
- package/src/cli/index.ts +9 -0
- package/src/cli/program.ts +465 -0
- package/src/cli/shared/config-io.ts +277 -0
- package/src/cli/shared/config-mutators.ts +440 -0
- package/src/cli/shared/config-schema.ts +228 -0
- package/src/cli/shared/config-types.ts +121 -0
- package/src/cli/shared/configure-sections.ts +551 -0
- package/src/cli/shared/discord-config.ts +14 -0
- package/src/cli/shared/init-catalog.ts +189 -0
- package/src/cli/shared/init-models-file.ts +77 -0
- package/src/cli/shared/runtime.ts +33 -0
- package/src/cli/shared/schema-prompts.ts +414 -0
- package/src/cli/tests/config-command.test.ts +56 -0
- package/src/cli/tests/config-io.test.ts +92 -0
- package/src/cli/tests/config-mutators.test.ts +59 -0
- package/src/cli/tests/doctor.test.ts +120 -0
- package/src/cli/tests/init-catalog.test.ts +96 -0
- package/src/cli/tests/program-options.test.ts +113 -0
- package/src/cli/tests/routing-config.test.ts +209 -0
- package/src/core/control-command.ts +12 -0
- package/src/core/dedup-store.ts +103 -0
- package/src/core/gateway.ts +607 -0
- package/src/core/routing.ts +379 -0
- package/src/core/runtime-registry.ts +141 -0
- package/src/core/tests/control-command.test.ts +20 -0
- package/src/core/tests/runtime-registry.test.ts +140 -0
- package/src/core/tests/typing-controller.test.ts +129 -0
- package/src/core/types.ts +318 -0
- package/src/core/typing-controller.ts +119 -0
- package/src/cron/config.ts +154 -0
- package/src/cron/schedule.ts +61 -0
- package/src/cron/service.ts +249 -0
- package/src/cron/store.ts +155 -0
- package/src/cron/types.ts +60 -0
- package/src/extension/loader.ts +145 -0
- package/src/extension/manager.ts +355 -0
- package/src/extension/manifest.ts +26 -0
- package/src/extension/registry.ts +229 -0
- package/src/main.ts +8 -0
- package/src/sandbox/executor.ts +44 -0
- package/src/sandbox/host-executor.ts +118 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
const DEFAULT_DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function asItemMap(value) {
|
|
6
|
+
if (!isRecord(value)) {
|
|
7
|
+
return {};
|
|
8
|
+
}
|
|
9
|
+
const result = {};
|
|
10
|
+
for (const [instanceId, raw] of Object.entries(value)) {
|
|
11
|
+
if (!isRecord(raw) || typeof raw.type !== "string" || raw.type.trim().length === 0) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
result[instanceId] = {
|
|
15
|
+
...raw,
|
|
16
|
+
type: raw.type,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function asAllowList(value) {
|
|
22
|
+
if (!Array.isArray(value)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const normalized = [];
|
|
26
|
+
for (const item of value) {
|
|
27
|
+
if (!isRecord(item) || typeof item.package !== "string" || item.package.trim().length === 0) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
normalized.push({
|
|
31
|
+
package: item.package,
|
|
32
|
+
enabled: item.enabled !== false,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
function asRouteDefaults(value) {
|
|
38
|
+
if (!isRecord(value)) {
|
|
39
|
+
return {
|
|
40
|
+
tools: "full",
|
|
41
|
+
mentions: "required",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
...(typeof value.provider === "string" && value.provider.trim().length > 0 ? { provider: value.provider } : {}),
|
|
46
|
+
...(typeof value.sandbox === "string" && value.sandbox.trim().length > 0 ? { sandbox: value.sandbox } : {}),
|
|
47
|
+
tools: value.tools === "readonly" ? "readonly" : "full",
|
|
48
|
+
mentions: value.mentions === "optional" ? "optional" : "required",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function asRoutes(value) {
|
|
52
|
+
if (!isRecord(value)) {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
const normalized = {};
|
|
56
|
+
for (const [routeId, route] of Object.entries(value)) {
|
|
57
|
+
if (!isRecord(route) || typeof route.projectRoot !== "string" || route.projectRoot.trim().length === 0) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
normalized[routeId] = {
|
|
61
|
+
...route,
|
|
62
|
+
projectRoot: route.projectRoot,
|
|
63
|
+
...(route.tools === "readonly" ? { tools: "readonly" } : {}),
|
|
64
|
+
...(route.mentions === "optional" ? { mentions: "optional" } : {}),
|
|
65
|
+
...(typeof route.provider === "string" && route.provider.trim().length > 0 ? { provider: route.provider } : {}),
|
|
66
|
+
...(typeof route.sandbox === "string" && route.sandbox.trim().length > 0 ? { sandbox: route.sandbox } : {}),
|
|
67
|
+
...(typeof route.systemPromptFile === "string" ? { systemPromptFile: route.systemPromptFile } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
function asBindings(value) {
|
|
73
|
+
if (!isRecord(value)) {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
const normalized = {};
|
|
77
|
+
for (const [bindingId, binding] of Object.entries(value)) {
|
|
78
|
+
if (!isRecord(binding)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const rawSource = binding.source;
|
|
82
|
+
if (typeof binding.connector !== "string"
|
|
83
|
+
|| binding.connector.trim().length === 0
|
|
84
|
+
|| typeof binding.route !== "string"
|
|
85
|
+
|| binding.route.trim().length === 0
|
|
86
|
+
|| !isRecord(rawSource)
|
|
87
|
+
|| (rawSource.type !== "channel" && rawSource.type !== "chat")
|
|
88
|
+
|| typeof rawSource.id !== "string"
|
|
89
|
+
|| rawSource.id.trim().length === 0) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
normalized[bindingId] = {
|
|
93
|
+
...binding,
|
|
94
|
+
connector: binding.connector,
|
|
95
|
+
route: binding.route,
|
|
96
|
+
source: {
|
|
97
|
+
...rawSource,
|
|
98
|
+
type: rawSource.type,
|
|
99
|
+
id: rawSource.id,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
export function ensureGatewayConfigShape(config) {
|
|
106
|
+
const normalizedProvidersDefault = typeof config.providers?.default === "string" && config.providers.default.trim().length > 0
|
|
107
|
+
? config.providers.default
|
|
108
|
+
: "";
|
|
109
|
+
const normalizedSandboxesDefault = typeof config.sandboxes?.default === "string" && config.sandboxes.default.trim().length > 0
|
|
110
|
+
? config.sandboxes.default
|
|
111
|
+
: "host.builtin";
|
|
112
|
+
const routeDefaults = asRouteDefaults(config.routes?.defaults);
|
|
113
|
+
if (!routeDefaults.provider && normalizedProvidersDefault) {
|
|
114
|
+
routeDefaults.provider = normalizedProvidersDefault;
|
|
115
|
+
}
|
|
116
|
+
if (!routeDefaults.sandbox && normalizedSandboxesDefault) {
|
|
117
|
+
routeDefaults.sandbox = normalizedSandboxesDefault;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...config,
|
|
121
|
+
extensions: {
|
|
122
|
+
...(isRecord(config.extensions) ? config.extensions : {}),
|
|
123
|
+
allowList: asAllowList(config.extensions?.allowList),
|
|
124
|
+
},
|
|
125
|
+
providers: {
|
|
126
|
+
...(isRecord(config.providers) ? config.providers : {}),
|
|
127
|
+
default: normalizedProvidersDefault,
|
|
128
|
+
items: asItemMap(config.providers?.items),
|
|
129
|
+
},
|
|
130
|
+
connectors: {
|
|
131
|
+
...(isRecord(config.connectors) ? config.connectors : {}),
|
|
132
|
+
items: asItemMap(config.connectors?.items),
|
|
133
|
+
},
|
|
134
|
+
sandboxes: {
|
|
135
|
+
...(isRecord(config.sandboxes) ? config.sandboxes : {}),
|
|
136
|
+
default: normalizedSandboxesDefault,
|
|
137
|
+
items: asItemMap(config.sandboxes?.items),
|
|
138
|
+
},
|
|
139
|
+
routes: {
|
|
140
|
+
...(isRecord(config.routes) ? config.routes : {}),
|
|
141
|
+
defaults: routeDefaults,
|
|
142
|
+
items: asRoutes(config.routes?.items),
|
|
143
|
+
},
|
|
144
|
+
bindings: {
|
|
145
|
+
...(isRecord(config.bindings) ? config.bindings : {}),
|
|
146
|
+
items: asBindings(config.bindings?.items),
|
|
147
|
+
},
|
|
148
|
+
data: {
|
|
149
|
+
...(isRecord(config.data) ? config.data : {}),
|
|
150
|
+
rootDir: typeof config.data?.rootDir === "string" && config.data.rootDir.trim().length > 0 ? config.data.rootDir : "./data",
|
|
151
|
+
dedupTtlMs: typeof config.data?.dedupTtlMs === "number" && Number.isFinite(config.data.dedupTtlMs) && config.data.dedupTtlMs > 0
|
|
152
|
+
? config.data.dedupTtlMs
|
|
153
|
+
: DEFAULT_DEDUP_TTL_MS,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function upsertAllowListPackage(config, packageName, enabled = true) {
|
|
158
|
+
const next = ensureGatewayConfigShape(config);
|
|
159
|
+
const allowList = next.extensions.allowList;
|
|
160
|
+
const existing = allowList.find((item) => item.package === packageName);
|
|
161
|
+
if (existing) {
|
|
162
|
+
existing.enabled = enabled;
|
|
163
|
+
config.extensions = next.extensions;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
allowList.push({ package: packageName, enabled });
|
|
167
|
+
config.extensions = {
|
|
168
|
+
...next.extensions,
|
|
169
|
+
allowList,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function buildTemplateInstanceId(contributionId) {
|
|
173
|
+
const segments = contributionId.split(".");
|
|
174
|
+
const suffix = segments.length > 1 ? segments.slice(1).join("-") : contributionId;
|
|
175
|
+
return `${suffix}.main`;
|
|
176
|
+
}
|
|
177
|
+
export function buildContributionTemplates(contributions) {
|
|
178
|
+
const templates = {
|
|
179
|
+
providers: [],
|
|
180
|
+
connectors: [],
|
|
181
|
+
sandboxes: [],
|
|
182
|
+
};
|
|
183
|
+
for (const contribution of contributions) {
|
|
184
|
+
const template = {
|
|
185
|
+
id: buildTemplateInstanceId(contribution.id),
|
|
186
|
+
type: contribution.id,
|
|
187
|
+
config: {},
|
|
188
|
+
};
|
|
189
|
+
if (contribution.kind === "provider") {
|
|
190
|
+
templates.providers.push(template);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (contribution.kind === "connector") {
|
|
194
|
+
templates.connectors.push(template);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
templates.sandboxes.push(template);
|
|
198
|
+
}
|
|
199
|
+
return templates;
|
|
200
|
+
}
|
|
201
|
+
function upsertTemplateInstances(items, templates) {
|
|
202
|
+
const byType = new Set(Object.values(items).map((instance) => instance.type));
|
|
203
|
+
const addedIds = [];
|
|
204
|
+
for (const template of templates) {
|
|
205
|
+
if (byType.has(template.type)) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
let candidateId = template.id;
|
|
209
|
+
let suffix = 2;
|
|
210
|
+
while (items[candidateId]) {
|
|
211
|
+
candidateId = `${template.id}-${suffix}`;
|
|
212
|
+
suffix += 1;
|
|
213
|
+
}
|
|
214
|
+
items[candidateId] = {
|
|
215
|
+
type: template.type,
|
|
216
|
+
...structuredClone(template.config),
|
|
217
|
+
};
|
|
218
|
+
byType.add(template.type);
|
|
219
|
+
addedIds.push(candidateId);
|
|
220
|
+
}
|
|
221
|
+
return addedIds;
|
|
222
|
+
}
|
|
223
|
+
export function applyContributionTemplates(config, templates) {
|
|
224
|
+
const next = ensureGatewayConfigShape(config);
|
|
225
|
+
const providerItems = next.providers.items;
|
|
226
|
+
const connectorItems = next.connectors.items;
|
|
227
|
+
const sandboxItems = next.sandboxes.items;
|
|
228
|
+
const added = {
|
|
229
|
+
providers: upsertTemplateInstances(providerItems, templates.providers),
|
|
230
|
+
connectors: upsertTemplateInstances(connectorItems, templates.connectors),
|
|
231
|
+
sandboxes: upsertTemplateInstances(sandboxItems, templates.sandboxes),
|
|
232
|
+
};
|
|
233
|
+
config.providers = {
|
|
234
|
+
...next.providers,
|
|
235
|
+
items: providerItems,
|
|
236
|
+
};
|
|
237
|
+
config.connectors = {
|
|
238
|
+
...next.connectors,
|
|
239
|
+
items: connectorItems,
|
|
240
|
+
};
|
|
241
|
+
config.sandboxes = {
|
|
242
|
+
...next.sandboxes,
|
|
243
|
+
items: sandboxItems,
|
|
244
|
+
};
|
|
245
|
+
return added;
|
|
246
|
+
}
|
|
247
|
+
export function upsertProviderInstance(config, instanceId, type, instanceConfig) {
|
|
248
|
+
const next = ensureGatewayConfigShape(config);
|
|
249
|
+
next.providers.items[instanceId] = {
|
|
250
|
+
type,
|
|
251
|
+
...structuredClone(instanceConfig),
|
|
252
|
+
};
|
|
253
|
+
config.providers = {
|
|
254
|
+
...next.providers,
|
|
255
|
+
items: next.providers.items,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export function upsertConnectorInstance(config, instanceId, type, instanceConfig) {
|
|
259
|
+
const next = ensureGatewayConfigShape(config);
|
|
260
|
+
next.connectors.items[instanceId] = {
|
|
261
|
+
type,
|
|
262
|
+
...structuredClone(instanceConfig),
|
|
263
|
+
};
|
|
264
|
+
config.connectors = {
|
|
265
|
+
...next.connectors,
|
|
266
|
+
items: next.connectors.items,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export function upsertSandboxInstance(config, instanceId, type, instanceConfig) {
|
|
270
|
+
const next = ensureGatewayConfigShape(config);
|
|
271
|
+
next.sandboxes.items[instanceId] = {
|
|
272
|
+
type,
|
|
273
|
+
...structuredClone(instanceConfig),
|
|
274
|
+
};
|
|
275
|
+
config.sandboxes = {
|
|
276
|
+
...next.sandboxes,
|
|
277
|
+
items: next.sandboxes.items,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
export function setDefaultProviderIfMissingOrInvalid(config) {
|
|
281
|
+
const next = ensureGatewayConfigShape(config);
|
|
282
|
+
const items = next.providers.items;
|
|
283
|
+
const defaultProvider = next.providers.default;
|
|
284
|
+
if (defaultProvider && items[defaultProvider]) {
|
|
285
|
+
config.providers = next.providers;
|
|
286
|
+
if (!next.routes.defaults.provider) {
|
|
287
|
+
config.routes = {
|
|
288
|
+
...next.routes,
|
|
289
|
+
defaults: {
|
|
290
|
+
...next.routes.defaults,
|
|
291
|
+
provider: defaultProvider,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const candidates = Object.keys(items).sort((a, b) => a.localeCompare(b));
|
|
298
|
+
if (candidates.length === 0) {
|
|
299
|
+
config.providers = next.providers;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
config.providers = {
|
|
303
|
+
...next.providers,
|
|
304
|
+
default: candidates[0],
|
|
305
|
+
items,
|
|
306
|
+
};
|
|
307
|
+
config.routes = {
|
|
308
|
+
...next.routes,
|
|
309
|
+
defaults: {
|
|
310
|
+
...next.routes.defaults,
|
|
311
|
+
provider: candidates[0],
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
export function upsertRoute(config, routeId, profile) {
|
|
316
|
+
const next = ensureGatewayConfigShape(config);
|
|
317
|
+
next.routes.items[routeId] = {
|
|
318
|
+
projectRoot: profile.projectRoot,
|
|
319
|
+
...(profile.tools ? { tools: profile.tools } : {}),
|
|
320
|
+
...(profile.mentions ? { mentions: profile.mentions } : {}),
|
|
321
|
+
...(profile.provider ? { provider: profile.provider } : {}),
|
|
322
|
+
...(profile.sandbox ? { sandbox: profile.sandbox } : {}),
|
|
323
|
+
...(typeof profile.systemPromptFile === "string" ? { systemPromptFile: profile.systemPromptFile } : {}),
|
|
324
|
+
};
|
|
325
|
+
config.routes = {
|
|
326
|
+
...next.routes,
|
|
327
|
+
items: next.routes.items,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
export function upsertBinding(config, bindingId, binding) {
|
|
331
|
+
const next = ensureGatewayConfigShape(config);
|
|
332
|
+
next.bindings.items[bindingId] = structuredClone(binding);
|
|
333
|
+
config.bindings = {
|
|
334
|
+
...next.bindings,
|
|
335
|
+
items: next.bindings.items,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export function listContributionIds(config) {
|
|
339
|
+
const next = ensureGatewayConfigShape(config);
|
|
340
|
+
return {
|
|
341
|
+
providers: Object.values(next.providers.items).map((instance) => instance.type),
|
|
342
|
+
connectors: Object.values(next.connectors.items).map((instance) => instance.type),
|
|
343
|
+
sandboxes: Object.values(next.sandboxes.items).map((instance) => instance.type),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
2
|
+
/**
|
|
3
|
+
* Returns true when a path segment represents an array index.
|
|
4
|
+
*/
|
|
5
|
+
function isIndexSegment(raw) {
|
|
6
|
+
return /^[0-9]+$/.test(raw);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Rejects dangerous object keys that could enable prototype pollution.
|
|
10
|
+
*/
|
|
11
|
+
function validatePathSegments(path) {
|
|
12
|
+
for (const segment of path) {
|
|
13
|
+
if (!isIndexSegment(segment) && BLOCKED_OBJECT_KEYS.has(segment)) {
|
|
14
|
+
throw new Error(`Invalid path segment: ${segment}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses dot/bracket path syntax into normalized path segments.
|
|
20
|
+
*/
|
|
21
|
+
export function parsePath(rawPath) {
|
|
22
|
+
const trimmed = rawPath.trim();
|
|
23
|
+
if (!trimmed) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const segments = [];
|
|
27
|
+
let current = "";
|
|
28
|
+
let index = 0;
|
|
29
|
+
while (index < trimmed.length) {
|
|
30
|
+
const char = trimmed[index];
|
|
31
|
+
if (char === "\\") {
|
|
32
|
+
const next = trimmed[index + 1];
|
|
33
|
+
if (next) {
|
|
34
|
+
current += next;
|
|
35
|
+
}
|
|
36
|
+
index += 2;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (char === ".") {
|
|
40
|
+
if (current) {
|
|
41
|
+
segments.push(current);
|
|
42
|
+
}
|
|
43
|
+
current = "";
|
|
44
|
+
index += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (char === "[") {
|
|
48
|
+
if (current) {
|
|
49
|
+
segments.push(current);
|
|
50
|
+
}
|
|
51
|
+
current = "";
|
|
52
|
+
const closeIndex = trimmed.indexOf("]", index);
|
|
53
|
+
if (closeIndex === -1) {
|
|
54
|
+
throw new Error(`Invalid path (missing ']'): ${rawPath}`);
|
|
55
|
+
}
|
|
56
|
+
const inside = trimmed.slice(index + 1, closeIndex).trim();
|
|
57
|
+
if (!inside) {
|
|
58
|
+
throw new Error(`Invalid path (empty '[]'): ${rawPath}`);
|
|
59
|
+
}
|
|
60
|
+
segments.push(inside);
|
|
61
|
+
index = closeIndex + 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
current += char;
|
|
65
|
+
index += 1;
|
|
66
|
+
}
|
|
67
|
+
if (current) {
|
|
68
|
+
segments.push(current);
|
|
69
|
+
}
|
|
70
|
+
const normalized = segments.map((segment) => segment.trim()).filter(Boolean);
|
|
71
|
+
validatePathSegments(normalized);
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Safe own-property check wrapper.
|
|
76
|
+
*/
|
|
77
|
+
function hasOwnKey(value, key) {
|
|
78
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reads a value from object/array structures by parsed path segments.
|
|
82
|
+
*/
|
|
83
|
+
export function getAtPath(root, path) {
|
|
84
|
+
let current = root;
|
|
85
|
+
for (const segment of path) {
|
|
86
|
+
if (!current || typeof current !== "object") {
|
|
87
|
+
return { found: false };
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(current)) {
|
|
90
|
+
if (!isIndexSegment(segment)) {
|
|
91
|
+
return { found: false };
|
|
92
|
+
}
|
|
93
|
+
const index = Number.parseInt(segment, 10);
|
|
94
|
+
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
|
95
|
+
return { found: false };
|
|
96
|
+
}
|
|
97
|
+
current = current[index];
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const record = current;
|
|
101
|
+
if (!hasOwnKey(record, segment)) {
|
|
102
|
+
return { found: false };
|
|
103
|
+
}
|
|
104
|
+
current = record[segment];
|
|
105
|
+
}
|
|
106
|
+
return { found: true, value: current };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Sets a value at path, creating intermediate objects/arrays as needed.
|
|
110
|
+
*/
|
|
111
|
+
export function setAtPath(root, path, value) {
|
|
112
|
+
if (path.length === 0) {
|
|
113
|
+
throw new Error("Path is empty.");
|
|
114
|
+
}
|
|
115
|
+
let current = root;
|
|
116
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
117
|
+
const segment = path[i];
|
|
118
|
+
const next = path[i + 1];
|
|
119
|
+
const nextIsIndex = Boolean(next && isIndexSegment(next));
|
|
120
|
+
if (Array.isArray(current)) {
|
|
121
|
+
if (!isIndexSegment(segment)) {
|
|
122
|
+
throw new Error(`Expected numeric index for array segment '${segment}'`);
|
|
123
|
+
}
|
|
124
|
+
const index = Number.parseInt(segment, 10);
|
|
125
|
+
const existing = current[index];
|
|
126
|
+
if (!existing || typeof existing !== "object") {
|
|
127
|
+
current[index] = nextIsIndex ? [] : {};
|
|
128
|
+
}
|
|
129
|
+
current = current[index];
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!current || typeof current !== "object") {
|
|
133
|
+
throw new Error(`Cannot traverse into '${segment}' (not an object)`);
|
|
134
|
+
}
|
|
135
|
+
const record = current;
|
|
136
|
+
const existing = hasOwnKey(record, segment) ? record[segment] : undefined;
|
|
137
|
+
if (!existing || typeof existing !== "object") {
|
|
138
|
+
record[segment] = nextIsIndex ? [] : {};
|
|
139
|
+
}
|
|
140
|
+
current = record[segment];
|
|
141
|
+
}
|
|
142
|
+
const tail = path[path.length - 1];
|
|
143
|
+
if (Array.isArray(current)) {
|
|
144
|
+
if (!isIndexSegment(tail)) {
|
|
145
|
+
throw new Error(`Expected numeric index for array segment '${tail}'`);
|
|
146
|
+
}
|
|
147
|
+
const index = Number.parseInt(tail, 10);
|
|
148
|
+
current[index] = value;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!current || typeof current !== "object") {
|
|
152
|
+
throw new Error(`Cannot set '${tail}' (parent is not an object)`);
|
|
153
|
+
}
|
|
154
|
+
current[tail] = value;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Removes a value at path and returns whether the target existed.
|
|
158
|
+
*/
|
|
159
|
+
export function unsetAtPath(root, path) {
|
|
160
|
+
if (path.length === 0) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
let current = root;
|
|
164
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
165
|
+
const segment = path[i];
|
|
166
|
+
if (!current || typeof current !== "object") {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (Array.isArray(current)) {
|
|
170
|
+
if (!isIndexSegment(segment)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const index = Number.parseInt(segment, 10);
|
|
174
|
+
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
current = current[index];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const record = current;
|
|
181
|
+
if (!hasOwnKey(record, segment)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
current = record[segment];
|
|
185
|
+
}
|
|
186
|
+
const tail = path[path.length - 1];
|
|
187
|
+
if (Array.isArray(current)) {
|
|
188
|
+
if (!isIndexSegment(tail)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const index = Number.parseInt(tail, 10);
|
|
192
|
+
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
current.splice(index, 1);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
if (!current || typeof current !== "object") {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
const record = current;
|
|
202
|
+
if (!hasOwnKey(record, tail)) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
delete record[tail];
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import pino from "pino";
|
|
4
|
+
import { ExtensionLoader } from "../../extension/loader.js";
|
|
5
|
+
import { ExtensionRegistry } from "../../extension/registry.js";
|
|
6
|
+
import { ensureGatewayConfigShape } from "./config-mutators.js";
|
|
7
|
+
import { resolveDataRootDir } from "./config-io.js";
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function decodeJsonPointerSegment(value) {
|
|
12
|
+
return value.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
13
|
+
}
|
|
14
|
+
function normalizeSchemaForValidation(schema) {
|
|
15
|
+
const cloned = structuredClone(schema);
|
|
16
|
+
if (isRecord(cloned) && typeof cloned.$schema === "string") {
|
|
17
|
+
delete cloned.$schema;
|
|
18
|
+
}
|
|
19
|
+
return cloned;
|
|
20
|
+
}
|
|
21
|
+
function formatErrorPath(instancePath) {
|
|
22
|
+
if (!instancePath) {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
const segments = instancePath
|
|
26
|
+
.split("/")
|
|
27
|
+
.slice(1)
|
|
28
|
+
.map((segment) => decodeJsonPointerSegment(segment))
|
|
29
|
+
.filter((segment) => segment.length > 0);
|
|
30
|
+
if (segments.length === 0) {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
let formatted = "";
|
|
34
|
+
for (const segment of segments) {
|
|
35
|
+
if (/^\d+$/.test(segment)) {
|
|
36
|
+
formatted += `[${segment}]`;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(segment)) {
|
|
40
|
+
formatted += `.${segment}`;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
formatted += `['${segment.replaceAll("'", "\\'")}']`;
|
|
44
|
+
}
|
|
45
|
+
return formatted;
|
|
46
|
+
}
|
|
47
|
+
function buildValidationErrorMessage(task, contributionId, errors) {
|
|
48
|
+
const details = (errors ?? [])
|
|
49
|
+
.slice(0, 5)
|
|
50
|
+
.map((error) => {
|
|
51
|
+
const suffix = formatErrorPath(error.instancePath);
|
|
52
|
+
return `${task.section}.items['${task.instanceId}']${suffix}: ${error.message ?? "invalid"}`;
|
|
53
|
+
})
|
|
54
|
+
.join("; ");
|
|
55
|
+
return (`Invalid config for instance '${task.instanceId}' (contribution '${contributionId}'). `
|
|
56
|
+
+ (details.length > 0 ? details : "Schema validation failed."));
|
|
57
|
+
}
|
|
58
|
+
async function loadRegistryForConfig(configPath, rawConfig) {
|
|
59
|
+
const normalized = ensureGatewayConfigShape(structuredClone(rawConfig));
|
|
60
|
+
const rootDir = resolveDataRootDir(configPath, normalized);
|
|
61
|
+
const loader = new ExtensionLoader(pino({ name: "dobby.config-schema", level: "silent" }), {
|
|
62
|
+
extensionsDir: join(rootDir, "extensions"),
|
|
63
|
+
});
|
|
64
|
+
const loadedPackages = await loader.loadAllowList(normalized.extensions.allowList);
|
|
65
|
+
const registry = new ExtensionRegistry();
|
|
66
|
+
registry.registerPackages(loadedPackages);
|
|
67
|
+
return registry;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Loads contribution-level JSON Schema catalog from installed/allow-listed extensions.
|
|
71
|
+
*/
|
|
72
|
+
export async function loadContributionSchemaCatalog(configPath, rawConfig) {
|
|
73
|
+
const registry = await loadRegistryForConfig(configPath, rawConfig);
|
|
74
|
+
return registry.listContributionSchemas();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Lists available contribution schemas with lightweight flags for CLI display.
|
|
78
|
+
*/
|
|
79
|
+
export async function listContributionSchemas(configPath, rawConfig) {
|
|
80
|
+
const catalog = await loadContributionSchemaCatalog(configPath, rawConfig);
|
|
81
|
+
return catalog.map((item) => ({
|
|
82
|
+
contributionId: item.contributionId,
|
|
83
|
+
packageName: item.packageName,
|
|
84
|
+
kind: item.kind,
|
|
85
|
+
hasSchema: Boolean(item.configSchema),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Returns one contribution schema entry, or null when not found.
|
|
90
|
+
*/
|
|
91
|
+
export async function getContributionSchema(configPath, rawConfig, contributionId) {
|
|
92
|
+
const catalog = await loadContributionSchemaCatalog(configPath, rawConfig);
|
|
93
|
+
return catalog.find((item) => item.contributionId === contributionId) ?? null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Applies extension config defaults and validates provider/connector/sandbox instance configs with Ajv.
|
|
97
|
+
*/
|
|
98
|
+
export async function applyAndValidateContributionSchemas(configPath, rawConfig) {
|
|
99
|
+
const next = ensureGatewayConfigShape(structuredClone(rawConfig));
|
|
100
|
+
const catalog = await loadContributionSchemaCatalog(configPath, next);
|
|
101
|
+
const schemaByContribution = new Map();
|
|
102
|
+
for (const entry of catalog) {
|
|
103
|
+
if (!entry.configSchema) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
schemaByContribution.set(entry.contributionId, normalizeSchemaForValidation(entry.configSchema));
|
|
107
|
+
}
|
|
108
|
+
const ajv = new Ajv({
|
|
109
|
+
allErrors: true,
|
|
110
|
+
strict: false,
|
|
111
|
+
useDefaults: true,
|
|
112
|
+
});
|
|
113
|
+
const validators = new Map();
|
|
114
|
+
const tasks = [
|
|
115
|
+
...Object.entries(next.providers.items).map(([instanceId, instance]) => ({
|
|
116
|
+
section: "providers",
|
|
117
|
+
instanceId,
|
|
118
|
+
instance,
|
|
119
|
+
})),
|
|
120
|
+
...Object.entries(next.connectors.items).map(([instanceId, instance]) => ({
|
|
121
|
+
section: "connectors",
|
|
122
|
+
instanceId,
|
|
123
|
+
instance,
|
|
124
|
+
})),
|
|
125
|
+
...Object.entries(next.sandboxes.items).map(([instanceId, instance]) => ({
|
|
126
|
+
section: "sandboxes",
|
|
127
|
+
instanceId,
|
|
128
|
+
instance,
|
|
129
|
+
})),
|
|
130
|
+
];
|
|
131
|
+
for (const task of tasks) {
|
|
132
|
+
const contributionId = task.instance.type;
|
|
133
|
+
const schema = schemaByContribution.get(contributionId);
|
|
134
|
+
if (!schema) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
let validate = validators.get(contributionId);
|
|
138
|
+
if (!validate) {
|
|
139
|
+
const compiled = ajv.compile(schema);
|
|
140
|
+
validators.set(contributionId, compiled);
|
|
141
|
+
validate = compiled;
|
|
142
|
+
}
|
|
143
|
+
const { type: _type, ...instanceConfig } = task.instance;
|
|
144
|
+
const valid = validate(instanceConfig);
|
|
145
|
+
if (!valid) {
|
|
146
|
+
throw new Error(buildValidationErrorMessage(task, contributionId, validate.errors));
|
|
147
|
+
}
|
|
148
|
+
for (const key of Object.keys(task.instance)) {
|
|
149
|
+
if (key !== "type") {
|
|
150
|
+
delete task.instance[key];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
Object.assign(task.instance, {
|
|
154
|
+
type: contributionId,
|
|
155
|
+
...instanceConfig,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
}
|