@wardby/cli 0.7.0 → 0.8.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 +5 -5
- package/README.md +6 -1
- package/deploy/local/docker-compose.quickstart-coding.yml +6 -0
- package/dist/claude-coding-worker/driver.d.ts +12 -0
- package/dist/claude-coding-worker/driver.js +29 -7
- package/dist/claude-coding-worker/main.js +5 -1
- package/dist/claude-coding-worker/sdk.js +17 -1
- package/dist/claude-plugin/command.d.ts +61 -0
- package/dist/claude-plugin/command.js +376 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +2 -1
- package/dist/cli.js +47 -3
- package/dist/coding/code-host.d.ts +16 -0
- package/dist/coding/code-host.js +60 -0
- package/dist/coding/mcp-token.d.ts +12 -0
- package/dist/coding/mcp-token.js +26 -0
- package/dist/coding/protocol.js +2 -0
- package/dist/coding/redact.js +6 -1
- package/dist/coding/transcript/recorder.d.ts +5 -4
- package/dist/coding/transcript/recorder.js +1 -1
- package/dist/coding-worker/debug-trace.d.ts +3 -1
- package/dist/coding-worker/debug-trace.js +4 -2
- package/dist/coding-worker/driver.js +8 -1
- package/dist/coding-worker/main.js +5 -1
- package/dist/coding-worker/mcp-config.d.ts +27 -0
- package/dist/coding-worker/mcp-config.js +32 -0
- package/dist/coding-worker/sdk.d.ts +36 -0
- package/dist/coding-worker/sdk.js +28 -1
- package/dist/coding-worker/types.d.ts +4 -0
- package/dist/config/mcp-call-limit.d.ts +8 -0
- package/dist/config/mcp-call-limit.js +18 -0
- package/dist/config/mcp-servers.d.ts +8 -0
- package/dist/config/mcp-servers.js +12 -0
- package/dist/config/test-simulators.d.ts +10 -0
- package/dist/config/test-simulators.js +28 -0
- package/dist/config/tool-timeout.d.ts +1 -0
- package/dist/config/tool-timeout.js +18 -0
- package/dist/core/coding-mcp.d.ts +36 -0
- package/dist/core/coding-mcp.js +253 -0
- package/dist/core/coding-repository.d.ts +46 -0
- package/dist/core/coding-repository.js +92 -0
- package/dist/core/delegation-slots.d.ts +27 -0
- package/dist/core/delegation-slots.js +44 -0
- package/dist/core/dispatch.d.ts +6 -2
- package/dist/core/dispatch.js +36 -11
- package/dist/core/error-code.js +1 -0
- package/dist/core/grants.d.ts +28 -3
- package/dist/core/grants.js +46 -4
- package/dist/core/host-events.js +7 -3
- package/dist/core/host-status.js +2 -2
- package/dist/core/issue-status.js +4 -3
- package/dist/core/mark-ready.d.ts +1 -1
- package/dist/core/mark-ready.js +1 -1
- package/dist/core/mcp-client/client.d.ts +47 -0
- package/dist/core/mcp-client/client.js +141 -0
- package/dist/core/mcp-client/jsonrpc.d.ts +15 -0
- package/dist/core/mcp-client/jsonrpc.js +54 -0
- package/dist/core/mcp-client/results.d.ts +13 -0
- package/dist/core/mcp-client/results.js +42 -0
- package/dist/core/mcp-servers.d.ts +90 -0
- package/dist/core/mcp-servers.js +230 -0
- package/dist/core/mcp-tools.d.ts +108 -0
- package/dist/core/mcp-tools.js +287 -0
- package/dist/core/merge-order-check.js +38 -19
- package/dist/core/notification-dispatcher.js +2 -1
- package/dist/core/related-pull-requests.js +26 -10
- package/dist/core/review-fix.js +2 -2
- package/dist/core/review-host-tools.js +3 -1
- package/dist/core/runner.d.ts +66 -16
- package/dist/core/runner.js +281 -73
- package/dist/core/secret-broker-config.d.ts +2 -0
- package/dist/core/secret-broker-config.js +1 -1
- package/dist/core/secret-guard.d.ts +5 -2
- package/dist/core/secret-guard.js +22 -7
- package/dist/core/secrets.d.ts +11 -1
- package/dist/core/secrets.js +27 -1
- package/dist/core/serializable-retry.d.ts +18 -0
- package/dist/core/serializable-retry.js +43 -0
- package/dist/core/tool-admin.d.ts +5 -1
- package/dist/core/tool-admin.js +7 -2
- package/dist/core/tool-names.d.ts +13 -0
- package/dist/core/tool-names.js +20 -0
- package/dist/core/webhooks.d.ts +4 -0
- package/dist/core/webhooks.js +68 -49
- package/dist/core/workflow-run-events.js +2 -1
- package/dist/generated/prisma/browser.d.ts +31 -0
- package/dist/generated/prisma/client.d.ts +31 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +50 -50
- package/dist/generated/prisma/internal/class.d.ts +66 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +530 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +75 -2
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +79 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +75 -2
- package/dist/generated/prisma/models/Agent.d.ts +271 -0
- package/dist/generated/prisma/models/AgentMcpServer.d.ts +1412 -0
- package/dist/generated/prisma/models/AgentMcpServer.js +1 -0
- package/dist/generated/prisma/models/AgentRepository.d.ts +40 -1
- package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
- package/dist/generated/prisma/models/CodingAgentProfile.d.ts +0 -7
- package/dist/generated/prisma/models/CodingProxySession.d.ts +119 -2
- package/dist/generated/prisma/models/CodingRun.d.ts +76 -1
- package/dist/generated/prisma/models/HostProvider.d.ts +2176 -0
- package/dist/generated/prisma/models/HostProvider.js +1 -0
- package/dist/generated/prisma/models/HostProviderCredential.d.ts +1285 -0
- package/dist/generated/prisma/models/HostProviderCredential.js +1 -0
- package/dist/generated/prisma/models/HostProviderOperation.d.ts +1470 -0
- package/dist/generated/prisma/models/HostProviderOperation.js +1 -0
- package/dist/generated/prisma/models/HostProviderTrustedAccount.d.ts +1145 -0
- package/dist/generated/prisma/models/HostProviderTrustedAccount.js +1 -0
- package/dist/generated/prisma/models/McpServer.d.ts +1500 -0
- package/dist/generated/prisma/models/McpServer.js +1 -0
- package/dist/generated/prisma/models/NativeGatewayCall.d.ts +39 -1
- package/dist/generated/prisma/models/Principal.d.ts +167 -0
- package/dist/generated/prisma/models/ResourceGrant.d.ts +1 -1
- package/dist/generated/prisma/models/Secret.d.ts +387 -0
- package/dist/generated/prisma/models/Tool.d.ts +223 -1
- package/dist/generated/prisma/models.d.ts +6 -0
- package/dist/help/search.js +12 -3
- package/dist/help-index.json +719 -47
- package/dist/import/create.js +16 -1
- package/dist/import/neutral-schema.d.ts +1 -0
- package/dist/import/neutral-schema.js +2 -0
- package/dist/mcp/auth/grants-cli.d.ts +6 -4
- package/dist/mcp/auth/grants-cli.js +42 -3
- package/dist/mcp/auth/resource-server.d.ts +3 -1
- package/dist/mcp/auth/resource-server.js +17 -2
- package/dist/mcp/context.d.ts +3 -0
- package/dist/mcp/index.js +11 -0
- package/dist/mcp/tools/agents.js +100 -15
- package/dist/mcp/tools/grants.js +66 -28
- package/dist/mcp/tools/host-providers.d.ts +2 -0
- package/dist/mcp/tools/host-providers.js +215 -0
- package/dist/mcp/tools/mcp-servers.d.ts +26 -0
- package/dist/mcp/tools/mcp-servers.js +481 -0
- package/dist/mcp/tools/repositories.js +50 -10
- package/dist/mcp/tools/runs.js +178 -18
- package/dist/mcp/tools/secret-elicitation.d.ts +2 -0
- package/dist/mcp/tools/secret-elicitation.js +25 -2
- package/dist/mcp/tools/secrets.js +38 -4
- package/dist/mcp/tools/tools.d.ts +6 -4
- package/dist/mcp/tools/tools.js +40 -8
- package/dist/mcp/tools/trigger.js +28 -6
- package/dist/mcp/transport/http-limits.d.ts +2 -0
- package/dist/mcp/transport/http-limits.js +6 -2
- package/dist/mcp/webhooks/ingress.js +10 -0
- package/dist/native-worker/gateway-server.d.ts +14 -0
- package/dist/native-worker/gateway-server.js +28 -7
- package/dist/native-worker/gateway.d.ts +2 -0
- package/dist/native-worker/gateway.js +5 -1
- package/dist/native-worker/http-server.d.ts +4 -0
- package/dist/native-worker/http-server.js +51 -0
- package/dist/native-worker/ledger.d.ts +6 -2
- package/dist/native-worker/ledger.js +5 -4
- package/dist/native-worker/protocol.d.ts +1 -0
- package/dist/native-worker/protocol.js +4 -1
- package/dist/providers/coding-proxy/capability-hash.d.ts +2 -0
- package/dist/providers/coding-proxy/capability-hash.js +5 -0
- package/dist/providers/coding-proxy/mcp-route.d.ts +34 -0
- package/dist/providers/coding-proxy/mcp-route.js +166 -0
- package/dist/providers/coding-proxy/memory-ledger.d.ts +2 -0
- package/dist/providers/coding-proxy/memory-ledger.js +15 -0
- package/dist/providers/coding-proxy/prisma-ledger.d.ts +1 -0
- package/dist/providers/coding-proxy/prisma-ledger.js +30 -2
- package/dist/providers/coding-proxy/proxy.d.ts +7 -1
- package/dist/providers/coding-proxy/proxy.js +134 -34
- package/dist/providers/coding-proxy/runtime.d.ts +6 -0
- package/dist/providers/coding-proxy/runtime.js +32 -1
- package/dist/providers/coding-proxy/server.d.ts +3 -0
- package/dist/providers/coding-proxy/server.js +10 -0
- package/dist/providers/coding-proxy/types.d.ts +13 -0
- package/dist/providers/executor/composition.js +5 -0
- package/dist/providers/executor/container.d.ts +33 -1
- package/dist/providers/executor/container.js +76 -7
- package/dist/providers/host-tools/invoke.d.ts +37 -0
- package/dist/providers/host-tools/invoke.js +117 -0
- package/dist/providers/host-tools/operations.d.ts +65 -0
- package/dist/providers/host-tools/operations.js +149 -0
- package/dist/providers/host-tools/registry.d.ts +79 -0
- package/dist/providers/host-tools/registry.js +362 -0
- package/dist/providers/host-tools/request-guard.d.ts +17 -0
- package/dist/providers/host-tools/request-guard.js +99 -0
- package/dist/providers/host-tools/self-test.d.ts +64 -0
- package/dist/providers/host-tools/self-test.js +142 -0
- package/dist/providers/jobs/docker-isolation.d.ts +2 -2
- package/dist/providers/jobs/docker-isolation.js +30 -8
- package/dist/providers/jobs/docker.d.ts +8 -0
- package/dist/providers/jobs/docker.js +33 -9
- package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
- package/dist/providers/jobs/kubernetes-isolation.js +15 -2
- package/dist/providers/jobs/kubernetes.d.ts +5 -0
- package/dist/providers/jobs/kubernetes.js +6 -1
- package/dist/providers/jobs/types.d.ts +6 -0
- package/dist/providers/review-host/types.d.ts +6 -4
- package/dist/providers/review-host/types.js +1 -1
- package/dist/providers/vcs/git.d.ts +6 -2
- package/dist/providers/vcs/git.js +15 -4
- package/dist/providers/vcs/github.d.ts +2 -71
- package/dist/providers/vcs/github.js +8 -11
- package/dist/providers/vcs/index.d.ts +1 -1
- package/dist/providers/vcs/index.js +1 -1
- package/dist/providers/vcs/remote.d.ts +4 -0
- package/dist/providers/vcs/remote.js +7 -1
- package/dist/providers/vcs/routing.d.ts +12 -6
- package/dist/providers/vcs/routing.js +21 -6
- package/dist/providers/vcs/types.d.ts +71 -3
- package/dist/quickstart/index.d.ts +5 -0
- package/dist/quickstart/index.js +21 -0
- package/dist/quickstart-images.json +1 -1
- package/dist/sandbox/eval-core.d.ts +12 -2
- package/dist/sandbox/eval-core.js +20 -4
- package/dist/sandbox/host-functions.d.ts +4 -0
- package/dist/sandbox/host-functions.js +7 -2
- package/dist/sandbox/limits.d.ts +4 -1
- package/dist/sandbox/limits.js +4 -1
- package/dist/sandbox/run-in-sandbox.d.ts +6 -0
- package/dist/sandbox/run-in-sandbox.js +14 -2
- package/dist/sandbox/safe-fetch.d.ts +2 -0
- package/dist/sandbox/safe-fetch.js +1 -1
- package/dist/sandbox/secret-broker.d.ts +1 -0
- package/dist/sandbox/secret-broker.js +1 -1
- package/dist/sandbox/tool-timeout.d.ts +32 -0
- package/dist/sandbox/tool-timeout.js +52 -0
- package/dist/sandbox/user-tool.d.ts +8 -1
- package/dist/sandbox/user-tool.js +9 -3
- package/dist/viewer/graph.js +2 -1
- package/dist/wardby-bin.js +5 -0
- package/docs/README.md +11 -1
- package/docs/agent-recipes.md +37 -4
- package/docs/architecture-runtime.md +1 -1
- package/docs/claude-code-plugin.md +164 -0
- package/docs/code-review-agents.md +6 -0
- package/docs/coding-agent-setup.md +77 -1
- package/docs/coding-worker-byo-images.md +4 -2
- package/docs/coding-worker-isolation.md +78 -7
- package/docs/debugging-agent-runs.md +242 -0
- package/docs/getting-started-gke.md +5 -4
- package/docs/getting-started-identity-provider.md +43 -37
- package/docs/getting-started.md +8 -0
- package/docs/host-providers.md +294 -0
- package/docs/mcp-servers.md +254 -0
- package/docs/native-sandbox.md +25 -9
- package/docs/security-deployment.md +110 -31
- package/docs/tool-secrets.md +3 -0
- package/docs/viewer-api.md +6 -0
- package/docs/viewer-api.openapi.yml +1 -1
- package/help/admin-viewer.md +4 -0
- package/help/agent-recipes.md +33 -2
- package/help/brokered-secrets.md +3 -0
- package/help/build-claude-code-image.md +1 -1
- package/help/claude-code-run-pane.md +40 -0
- package/help/errors/coding-mcp-pins-unavailable.md +35 -0
- package/help/errors/database-auth-invalid.md +1 -1
- package/help/errors/host-provider-error.md +40 -0
- package/help/errors/host-tool-failed.md +36 -0
- package/help/errors/mcp-call-limit.md +22 -0
- package/help/errors/mcp-call-timeout.md +22 -0
- package/help/errors/mcp-server-auth-failed.md +22 -0
- package/help/errors/mcp-server-credentials-unavailable.md +45 -0
- package/help/errors/mcp-server-error.md +28 -0
- package/help/errors/mcp-server-unreachable.md +22 -0
- package/help/errors/mcp-servers-off.md +21 -0
- package/help/errors/mcp-tool-not-allowed.md +26 -0
- package/help/errors/mcp-tool-removed.md +22 -0
- package/help/errors/mcp-unavailable.md +25 -0
- package/help/errors/native-sandbox-capacity.md +1 -1
- package/help/errors/native-sandbox-deadline-exceeded.md +1 -1
- package/help/errors/native-sandbox-docker-failed.md +1 -1
- package/help/errors/native-sandbox-gateway-error.md +1 -1
- package/help/errors/native-sandbox-gateway-unavailable.md +1 -1
- package/help/errors/native-sandbox-image-not-pinned.md +1 -1
- package/help/errors/native-sandbox-isolation-mismatch.md +1 -1
- package/help/errors/native-sandbox-network-unenforced.md +1 -1
- package/help/errors/native-sandbox-requires-catalog.md +1 -1
- package/help/errors/native-sandbox-unavailable.md +1 -1
- package/help/errors/native-sandbox-warm-delivery-failed.md +1 -1
- package/help/errors/native-sandbox-worker-exited.md +1 -1
- package/help/errors/native-sandbox-worker-lost.md +1 -1
- package/help/errors/native-sandbox-worker-unready.md +1 -1
- package/help/errors/repository-not-listed.md +36 -0
- package/help/errors/repository-required.md +36 -0
- package/help/errors/tool-timeout.md +38 -0
- package/help/host-providers.md +40 -0
- package/help/identity-and-access.md +4 -1
- package/help/mcp-servers.md +73 -0
- package/help/mcp.md +7 -2
- package/help/native-capabilities.md +27 -1
- package/help/native-sandbox.md +1 -1
- package/help/operating-agents.md +16 -0
- package/help/related-pull-requests.md +7 -3
- package/help/repo-instructions-and-skills.md +3 -1
- package/help/resume-runs-locally.md +3 -0
- package/help/stop-run.md +1 -1
- package/help/troubleshooting/coding-workers.md +9 -1
- package/help/troubleshooting/debugging-agent-runs.md +29 -0
- package/package.json +2 -1
- package/prisma/migrations/20261102000000_host_providers/migration.sql +56 -0
- package/prisma/migrations/20261104000000_coding_agent_repository_list/migration.sql +5 -0
- package/prisma/migrations/20261106000000_tool_timeout/migration.sql +4 -0
- package/prisma/migrations/20261107000000_mcp_servers/migration.sql +33 -0
- package/prisma/migrations/20261108000000_coding_mcp/migration.sql +5 -0
- package/prisma/migrations/20261109000000_task_run_index/migration.sql +3 -0
- package/prisma/schema.prisma +159 -14
- package/scripts/git-askpass.sh +1 -1
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* gets 404, not another principal's final text/cost.
|
|
30
30
|
*/
|
|
31
31
|
import { dispatchRun } from "../../core/dispatch.js";
|
|
32
|
+
import { RepositoryChoiceRefusedError, resolveCodingRepository } from "../../core/coding-repository.js";
|
|
32
33
|
import { AttributionError, explicitAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET, } from "../../core/attribution.js";
|
|
33
34
|
import { CodingBaseRefSchema, CodingTaskOverrideSchema, isLocalRepository } from "../../coding/protocol.js";
|
|
34
35
|
import { loadLocalRepoRoots, resolveLocalRepository, LocalRepoError } from "../../coding/local-repo.js";
|
|
@@ -46,6 +47,7 @@ const TriggerAgentSchema = z
|
|
|
46
47
|
agentId: z.string().trim().min(1).max(128),
|
|
47
48
|
task: CodingTaskOverrideSchema.optional(),
|
|
48
49
|
baseRef: CodingBaseRefSchema.optional(),
|
|
50
|
+
repository: z.string().min(1).max(300).optional(),
|
|
49
51
|
issue: z.unknown().optional(),
|
|
50
52
|
review: z
|
|
51
53
|
.object({
|
|
@@ -127,10 +129,10 @@ function assertOverrideAllowed(principalId, agent, args) {
|
|
|
127
129
|
// Strictly the owner: the stdio operator's owner-level access doesn't make
|
|
128
130
|
// it the owner whose repository authority the task steers (review M8).
|
|
129
131
|
const isOwner = agent.ownerId !== null && agent.ownerId === principalId;
|
|
130
|
-
if (isOwner || (args.task === undefined && args.baseRef === undefined))
|
|
132
|
+
if (isOwner || (args.task === undefined && args.baseRef === undefined && args.repository === undefined))
|
|
131
133
|
return;
|
|
132
134
|
if (!agent.codingProfile?.allowWebhookTaskOverride) {
|
|
133
|
-
throw new McpError(403, `Agent "${agent.id}": only its owner can pass a task or
|
|
135
|
+
throw new McpError(403, `Agent "${agent.id}": only its owner can pass a task, baseRef or repository, unless the owner allows it (codingProfile.allowWebhookTaskOverride).`);
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
/** execute on the agent, plus the override rule; `db` is the transaction when re-checking. */
|
|
@@ -150,6 +152,10 @@ export function registerTriggerTool(mcp) {
|
|
|
150
152
|
agentId: { type: "string" },
|
|
151
153
|
task: { type: "string" },
|
|
152
154
|
baseRef: { type: "string" },
|
|
155
|
+
repository: {
|
|
156
|
+
type: "string",
|
|
157
|
+
description: "Coding agents only: a repository on the agent's list (see get_agent). Omit to use codingProfile.repository.",
|
|
158
|
+
},
|
|
153
159
|
review: {
|
|
154
160
|
type: "object",
|
|
155
161
|
additionalProperties: false,
|
|
@@ -189,13 +195,28 @@ export function registerTriggerTool(mcp) {
|
|
|
189
195
|
}
|
|
190
196
|
review = await resolveLocalReview(ctx, agent.id, args.review);
|
|
191
197
|
}
|
|
192
|
-
if (agent.kind !== "coding" &&
|
|
193
|
-
|
|
198
|
+
if (agent.kind !== "coding" &&
|
|
199
|
+
(args.task !== undefined || args.baseRef !== undefined || args.repository !== undefined)) {
|
|
200
|
+
throw new McpError(400, "Task, baseRef and repository overrides are only valid for coding agents.");
|
|
194
201
|
}
|
|
195
202
|
// A local repository is re-checked against the roots as they are now
|
|
196
203
|
// (they can narrow after the agent was saved), before any run exists.
|
|
197
204
|
let warnings = [];
|
|
198
|
-
|
|
205
|
+
// The resolver's refusal lists the agent's repositories, so it runs only
|
|
206
|
+
// after requireTriggerable (execute on the agent) and never echoes the
|
|
207
|
+
// requested value.
|
|
208
|
+
const refused = (err) => {
|
|
209
|
+
if (err instanceof RepositoryChoiceRefusedError)
|
|
210
|
+
throw new McpError(400, `${err.code}: ${err.message}`);
|
|
211
|
+
throw err;
|
|
212
|
+
};
|
|
213
|
+
const repository = agent.kind === "coding" && agent.codingProfile
|
|
214
|
+
? (await resolveCodingRepository(ctx.db, {
|
|
215
|
+
agentId: agent.id,
|
|
216
|
+
profile: agent.codingProfile,
|
|
217
|
+
requested: args.repository,
|
|
218
|
+
}).catch(refused)).repository
|
|
219
|
+
: undefined;
|
|
199
220
|
if (repository !== undefined && isLocalRepository(repository)) {
|
|
200
221
|
try {
|
|
201
222
|
const resolved = await resolveLocalRepository(repository, loadLocalRepoRoots(process.env).roots);
|
|
@@ -238,6 +259,7 @@ export function registerTriggerTool(mcp) {
|
|
|
238
259
|
trigger: "manual",
|
|
239
260
|
codingTask: args.task,
|
|
240
261
|
codingBaseRef: args.baseRef,
|
|
262
|
+
codingRepository: args.repository,
|
|
241
263
|
attribution,
|
|
242
264
|
task: ctx.clientSupportsTasks ? { principalId: ctx.principal.id, ttlMs: DEFAULT_TASK_TTL_MS } : undefined,
|
|
243
265
|
triggeredById: ctx.principal.id,
|
|
@@ -262,7 +284,7 @@ export function registerTriggerTool(mcp) {
|
|
|
262
284
|
catch (err) {
|
|
263
285
|
if (pullRequest)
|
|
264
286
|
await ctx.db.localPullRequest.deleteMany({ where: { id: pullRequest.id } }).catch(() => { });
|
|
265
|
-
|
|
287
|
+
return refused(err);
|
|
266
288
|
}
|
|
267
289
|
if (ctx.clientSupportsTasks) {
|
|
268
290
|
if (!dispatched.task)
|
|
@@ -14,4 +14,6 @@ export declare class PayloadTooLargeError extends HttpBoundaryError {
|
|
|
14
14
|
}
|
|
15
15
|
export declare function canonicalUrl(value: string): URL;
|
|
16
16
|
export declare function readBody(req: IncomingMessage, maxBytes: number, signal: AbortSignal): Promise<string>;
|
|
17
|
+
/** readBody's bytes, undecoded (for a route that forwards the body unchanged). */
|
|
18
|
+
export declare function readBodyBytes(req: IncomingMessage, maxBytes: number, signal: AbortSignal): Promise<Buffer>;
|
|
17
19
|
export declare function parseBody(raw: string, contentType: string | undefined): unknown;
|
|
@@ -23,7 +23,11 @@ export function canonicalUrl(value) {
|
|
|
23
23
|
}
|
|
24
24
|
return url;
|
|
25
25
|
}
|
|
26
|
-
export function readBody(req, maxBytes, signal) {
|
|
26
|
+
export async function readBody(req, maxBytes, signal) {
|
|
27
|
+
return (await readBodyBytes(req, maxBytes, signal)).toString("utf8");
|
|
28
|
+
}
|
|
29
|
+
/** readBody's bytes, undecoded (for a route that forwards the body unchanged). */
|
|
30
|
+
export function readBodyBytes(req, maxBytes, signal) {
|
|
27
31
|
const length = req.headers["content-length"];
|
|
28
32
|
if (length && (!/^\d+$/.test(length) || Number(length) > maxBytes))
|
|
29
33
|
return Promise.reject(new PayloadTooLargeError());
|
|
@@ -55,7 +59,7 @@ export function readBody(req, maxBytes, signal) {
|
|
|
55
59
|
};
|
|
56
60
|
const end = () => {
|
|
57
61
|
clean();
|
|
58
|
-
resolve(Buffer.concat(chunks, size)
|
|
62
|
+
resolve(Buffer.concat(chunks, size));
|
|
59
63
|
};
|
|
60
64
|
req.on("data", data);
|
|
61
65
|
req.once("end", end);
|
|
@@ -18,11 +18,19 @@ export async function handleWebhookIngress(webhookId, req, db, executor, issueTr
|
|
|
18
18
|
if (req.body.task !== undefined && codingTask === undefined) {
|
|
19
19
|
return { status: 400, body: { error: "invalid_task" } };
|
|
20
20
|
}
|
|
21
|
+
// `wardbyRepository`, not `repository`, for the same reason: a forwarded GitHub
|
|
22
|
+
// payload carries its own top-level `repository` object.
|
|
23
|
+
const repository = req.body.wardbyRepository;
|
|
24
|
+
if (repository !== undefined &&
|
|
25
|
+
(typeof repository !== "string" || repository.length === 0 || repository.length > 300)) {
|
|
26
|
+
return { status: 400, body: { error: "invalid_repository" } };
|
|
27
|
+
}
|
|
21
28
|
// `wardbyIssue`, not `issue`: forwarded third-party payloads (GitHub, Jira)
|
|
22
29
|
// carry their own top-level `issue` object, which is not ours to read.
|
|
23
30
|
const result = await resolveWebhookRun(webhookId, secret, db, executor, codingTask, {
|
|
24
31
|
issue: req.body.wardbyIssue,
|
|
25
32
|
issueTrackers,
|
|
33
|
+
repository,
|
|
26
34
|
});
|
|
27
35
|
if (result.ok) {
|
|
28
36
|
return { status: 202, body: { runId: result.runId } };
|
|
@@ -30,6 +38,8 @@ export async function handleWebhookIngress(webhookId, req, db, executor, issueTr
|
|
|
30
38
|
switch (result.reason) {
|
|
31
39
|
case "invalid_issue":
|
|
32
40
|
return { status: 400, body: { error: "invalid_issue", error_description: result.message } };
|
|
41
|
+
case "invalid_repository":
|
|
42
|
+
return { status: 400, body: { error: "invalid_repository" } };
|
|
33
43
|
case "not_found":
|
|
34
44
|
return { status: 404, body: { error: "not_found" } };
|
|
35
45
|
case "invalid_secret":
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* reported usage; the worker's own usage numbers are never read.
|
|
12
12
|
*/
|
|
13
13
|
import { createNativeRunTools, durableDelegate, type NativeRunFinishContext, type NativeRunProviders, type RunnerDb, type SandboxSessionSnapshot } from "../core/runner.js";
|
|
14
|
+
import type { CodingMcpDeps } from "../core/coding-mcp.js";
|
|
14
15
|
import { ReviewReadLedger } from "../core/review-reads.js";
|
|
15
16
|
import type { LlmProvider, LlmStreamEvent } from "../providers/llm/types.js";
|
|
16
17
|
import { PrismaGatewayLedger, type GatewaySessionRecord } from "./ledger.js";
|
|
@@ -26,6 +27,8 @@ export interface GatewayServerDeps {
|
|
|
26
27
|
providers: NativeRunProviders;
|
|
27
28
|
/** Delegation long-poll window; tests shorten it. */
|
|
28
29
|
pollWindowMs?: number;
|
|
30
|
+
/** Serves POST /coding-mcp on the same listener (remote MCP servers for coding runs); unset → 404. */
|
|
31
|
+
codingMcp?: CodingMcpDeps;
|
|
29
32
|
}
|
|
30
33
|
/** What a request gets back: one JSON value, or a stream of model events. */
|
|
31
34
|
export type GatewayResponse = {
|
|
@@ -72,6 +75,17 @@ export declare function streamModel(ctx: RebuiltRun, ledger: PrismaGatewayLedger
|
|
|
72
75
|
export declare const CONSOLE_WITHHELD_MESSAGE = "sandbox tool console line withheld: the tool's secret values could not be read";
|
|
73
76
|
/** Serves one privileged bridge call for a worker (exported for tests). */
|
|
74
77
|
export declare function hostCall(ctx: RebuiltRun, ledger: PrismaGatewayLedger, callId: string, params: GatewayParams<"host.call">): Promise<unknown>;
|
|
78
|
+
/** Added to a tool's limit for one host call: the worker's own wall time ends the call first. */
|
|
79
|
+
export declare const GATEWAY_HOST_CALL_MARGIN_MS = 20000;
|
|
80
|
+
/** How long one host call of `tool` may take: its limit plus the margin, cut to the run's deadline. */
|
|
81
|
+
export declare function hostCallTimeoutMs(ctx: {
|
|
82
|
+
session: {
|
|
83
|
+
deadlineAt: Date;
|
|
84
|
+
};
|
|
85
|
+
tools: {
|
|
86
|
+
toolTimeoutMs: (name: string) => number | undefined;
|
|
87
|
+
};
|
|
88
|
+
}, tool: string, now?: number): number;
|
|
75
89
|
/** The worker's terminal call: the run's one terminal write (masked by finishNativeRun), then the session ends. */
|
|
76
90
|
export declare function finish(ctx: RebuiltRun, ledger: PrismaGatewayLedger, callId: string, params: GatewayParams<"finish">): Promise<null>;
|
|
77
91
|
export {};
|
|
@@ -16,6 +16,7 @@ import { DELEGATE_TOOL_PREFIX } from "../core/tool-names.js";
|
|
|
16
16
|
import { relayedOutputTokens } from "../providers/coding-proxy/interrupted.js";
|
|
17
17
|
import { RoutingLlmProvider } from "../providers/llm/routing.js";
|
|
18
18
|
import { isPrivilegedBridgeName } from "../sandbox/host-functions.js";
|
|
19
|
+
import { DEFAULT_TOOL_TIMEOUT_MS } from "../sandbox/tool-timeout.js";
|
|
19
20
|
import { forWorker, gatewayLog } from "./gateway.js";
|
|
20
21
|
import { PrismaGatewayLedger } from "./ledger.js";
|
|
21
22
|
import { GatewayError, GatewayRequestSchema, parseParams, } from "./protocol.js";
|
|
@@ -65,6 +66,7 @@ export function runSecretValues(ctx, capability) {
|
|
|
65
66
|
return async () => [
|
|
66
67
|
capability,
|
|
67
68
|
...(await userToolSecretValues(ctx.tools, Object.keys(ctx.snapshot.input.userTools))),
|
|
69
|
+
...(await ctx.tools.mcpSecretValues()),
|
|
68
70
|
];
|
|
69
71
|
}
|
|
70
72
|
/**
|
|
@@ -178,6 +180,7 @@ async function rebuild(deps, session) {
|
|
|
178
180
|
repoAccess,
|
|
179
181
|
issueTrackers,
|
|
180
182
|
reviewReads,
|
|
183
|
+
deadlineAt: session.deadlineAt,
|
|
181
184
|
}),
|
|
182
185
|
finishContext: { runId: session.runId, db, providers, reviewHosts, repoAccess, issueTrackers },
|
|
183
186
|
delegation: {
|
|
@@ -335,9 +338,15 @@ export async function hostCall(ctx, ledger, callId, params) {
|
|
|
335
338
|
}
|
|
336
339
|
redact = values;
|
|
337
340
|
}
|
|
338
|
-
|
|
339
|
-
if
|
|
341
|
+
// The tool's own limit plus a margin (30 s for a default tool), never past the run's deadline,
|
|
342
|
+
// and aborted if the run is stopped meanwhile.
|
|
343
|
+
const stop = ctx.tools.watchStop();
|
|
344
|
+
const signal = AbortSignal.any([AbortSignal.timeout(hostCallTimeoutMs(ctx, params.tool)), stop.signal]);
|
|
345
|
+
const host = ctx.tools.privilegedHostFor(params.tool, signal, redact);
|
|
346
|
+
if (!host) {
|
|
347
|
+
stop.done();
|
|
340
348
|
throw new GatewayError("not_allowed", `"${params.tool}" is not a user tool of this run.`);
|
|
349
|
+
}
|
|
341
350
|
const bridge = host[params.bridge];
|
|
342
351
|
const run = async () => {
|
|
343
352
|
try {
|
|
@@ -348,11 +357,23 @@ export async function hostCall(ctx, ledger, callId, params) {
|
|
|
348
357
|
throw new GatewayError("bridge_error", err instanceof Error ? err.message.slice(0, 1024) : "Host function failed.");
|
|
349
358
|
}
|
|
350
359
|
};
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
360
|
+
try {
|
|
361
|
+
// Console lines are not worth a ledger row, and a secret value must never be stored in one
|
|
362
|
+
// (a secrets.get is a side-effect-free read, so a retried callId simply reads it again).
|
|
363
|
+
if (params.bridge === "__bridge_console" || params.bridge === "__bridge_secretsGet")
|
|
364
|
+
return await run();
|
|
365
|
+
return await replayable(ledger, ctx.session.id, callId, "host.call", run);
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
stop.done();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Added to a tool's limit for one host call: the worker's own wall time ends the call first. */
|
|
372
|
+
export const GATEWAY_HOST_CALL_MARGIN_MS = 20_000;
|
|
373
|
+
/** How long one host call of `tool` may take: its limit plus the margin, cut to the run's deadline. */
|
|
374
|
+
export function hostCallTimeoutMs(ctx, tool, now = Date.now()) {
|
|
375
|
+
const limitMs = (ctx.tools.toolTimeoutMs(tool) ?? DEFAULT_TOOL_TIMEOUT_MS) + GATEWAY_HOST_CALL_MARGIN_MS;
|
|
376
|
+
return Math.max(0, Math.min(limitMs, ctx.session.deadlineAt.getTime() - now));
|
|
356
377
|
}
|
|
357
378
|
/** Runs a one-shot call once per callId; a retry replays its recorded result. */
|
|
358
379
|
async function replayable(ledger, sessionId, callId, method, run) {
|
|
@@ -63,6 +63,8 @@ export interface SandboxedEngineOptions {
|
|
|
63
63
|
* the run's values is not sent to the worker; without this, no cause code is sent.
|
|
64
64
|
*/
|
|
65
65
|
readableSecretValues?: (tool: string) => Promise<string[]>;
|
|
66
|
+
/** Every secret value of the run's attached MCP servers, checked the same way. */
|
|
67
|
+
mcpSecretValues?: () => Promise<string[]>;
|
|
66
68
|
drivability: () => Promise<RunDrivability>;
|
|
67
69
|
launcher: WorkerLauncher;
|
|
68
70
|
}
|
|
@@ -116,8 +116,12 @@ export async function runSandboxedEngine(options) {
|
|
|
116
116
|
};
|
|
117
117
|
/** The run's readable secret values, for checking a failure's cause code before the worker sees it. */
|
|
118
118
|
const readable = options.readableSecretValues;
|
|
119
|
+
const mcpValues = options.mcpSecretValues;
|
|
119
120
|
const runSecretValues = readable
|
|
120
|
-
? async () =>
|
|
121
|
+
? async () => [
|
|
122
|
+
...(await Promise.all(Object.keys(input.userTools).map((tool) => readable(tool)))).flat(),
|
|
123
|
+
...(mcpValues ? await mcpValues() : []),
|
|
124
|
+
]
|
|
121
125
|
: undefined;
|
|
122
126
|
const served = {
|
|
123
127
|
async *stream(raw) {
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* POST /native-gateway/v1/call body: GatewayRequest
|
|
8
8
|
* → application/json { ok: true, result } | { ok: false, error: { code, message } }
|
|
9
9
|
* → application/x-ndjson (llm.stream) { event } … then { end: true } or { error: { code, message, causeCode? } }
|
|
10
|
+
*
|
|
11
|
+
* POST /coding-mcp a coding run's MCP calls, forwarded by the coding proxy's /mcp route
|
|
12
|
+
* (src/core/coding-mcp.ts); its own auth: the run's MCP token, not a
|
|
13
|
+
* gateway capability. 404 when the listener was started without it.
|
|
10
14
|
*/
|
|
11
15
|
import { type Server } from "node:http";
|
|
12
16
|
import { type GatewayServerDeps } from "./gateway-server.js";
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
* POST /native-gateway/v1/call body: GatewayRequest
|
|
8
8
|
* → application/json { ok: true, result } | { ok: false, error: { code, message } }
|
|
9
9
|
* → application/x-ndjson (llm.stream) { event } … then { end: true } or { error: { code, message, causeCode? } }
|
|
10
|
+
*
|
|
11
|
+
* POST /coding-mcp a coding run's MCP calls, forwarded by the coding proxy's /mcp route
|
|
12
|
+
* (src/core/coding-mcp.ts); its own auth: the run's MCP token, not a
|
|
13
|
+
* gateway capability. 404 when the listener was started without it.
|
|
10
14
|
*/
|
|
11
15
|
import { createServer } from "node:http";
|
|
16
|
+
import { CODING_MCP_PATH, handleCodingMcpRequest } from "../core/coding-mcp.js";
|
|
12
17
|
import { logger } from "../core/logger.js";
|
|
18
|
+
import { HTTP_LIMITS, HttpBoundaryError, readBody as readLimitedBody } from "../mcp/transport/http-limits.js";
|
|
13
19
|
import { logUnexpectedError } from "./gateway.js";
|
|
14
20
|
import { serveGatewayRequest } from "./gateway-server.js";
|
|
15
21
|
import { GatewayError, MAX_MESSAGE_BYTES, toGatewayError } from "./protocol.js";
|
|
@@ -58,6 +64,10 @@ export function createGatewayServer(deps) {
|
|
|
58
64
|
send(healthy ? 200 : 503, { ok: healthy });
|
|
59
65
|
return;
|
|
60
66
|
}
|
|
67
|
+
if (new URL(req.url ?? "/", "http://gateway").pathname === CODING_MCP_PATH && deps.codingMcp) {
|
|
68
|
+
await serveCodingMcp(req, res, deps.codingMcp);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
61
71
|
if (req.method !== "POST" || req.url !== NATIVE_GATEWAY_PATH) {
|
|
62
72
|
send(404, { ok: false, error: { code: "not_found", message: "not found" } });
|
|
63
73
|
return;
|
|
@@ -101,6 +111,47 @@ export function createGatewayServer(deps) {
|
|
|
101
111
|
});
|
|
102
112
|
});
|
|
103
113
|
}
|
|
114
|
+
/** /coding-mcp: one JSON-RPC message per POST, answered as one JSON body (or 202 with none). */
|
|
115
|
+
async function serveCodingMcp(req, res, deps) {
|
|
116
|
+
if (req.method !== "POST") {
|
|
117
|
+
res.setHeader("allow", "POST");
|
|
118
|
+
res.setHeader("content-type", "application/json");
|
|
119
|
+
res.writeHead(405);
|
|
120
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Use POST." } }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let body;
|
|
124
|
+
try {
|
|
125
|
+
body = await readLimitedBody(req, HTTP_LIMITS.json, AbortSignal.timeout(HTTP_LIMITS.requestMs));
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
const status = err instanceof HttpBoundaryError ? err.status : 400;
|
|
129
|
+
res.setHeader("content-type", "application/json");
|
|
130
|
+
res.writeHead(status);
|
|
131
|
+
res.end(JSON.stringify({
|
|
132
|
+
jsonrpc: "2.0",
|
|
133
|
+
id: null,
|
|
134
|
+
error: { code: -32600, message: "The request body could not be read." },
|
|
135
|
+
}));
|
|
136
|
+
// A body left unread would otherwise keep the socket busy.
|
|
137
|
+
if (status === 413)
|
|
138
|
+
req.resume();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// The proxy gives up on a call at its forward limit (or its worker goes away): the closed response
|
|
142
|
+
// aborts the call in flight rather than leave it running against the MCP server.
|
|
143
|
+
const closed = new AbortController();
|
|
144
|
+
res.on("close", () => {
|
|
145
|
+
if (!res.writableFinished)
|
|
146
|
+
closed.abort();
|
|
147
|
+
});
|
|
148
|
+
const response = await handleCodingMcpRequest(req.headers.authorization, body, deps, closed.signal);
|
|
149
|
+
if (closed.signal.aborted)
|
|
150
|
+
return;
|
|
151
|
+
res.setHeader("content-type", response.contentType);
|
|
152
|
+
res.writeHead(response.status);
|
|
153
|
+
res.end(response.body);
|
|
154
|
+
}
|
|
104
155
|
/** Starts the listener; resolves once it is accepting connections. */
|
|
105
156
|
export async function startGatewayServer(listen, deps) {
|
|
106
157
|
const server = createGatewayServer(deps);
|
|
@@ -115,11 +115,15 @@ export declare class PrismaGatewayLedger {
|
|
|
115
115
|
setDelegation(sessionId: string, callId: string, state: {
|
|
116
116
|
status: "pending" | "waiting_budget";
|
|
117
117
|
childAgentId: string;
|
|
118
|
+
childRepository?: string;
|
|
118
119
|
childRunId?: string;
|
|
119
120
|
}): Promise<void>;
|
|
120
121
|
delegation(sessionId: string, callId: string): Promise<DelegationState | null>;
|
|
121
|
-
/** The sub-agents other delegations of this run
|
|
122
|
-
|
|
122
|
+
/** The sub-agents (and repositories, null = the child's default) other delegations of this run wait on budget to start. */
|
|
123
|
+
waitingDelegations(sessionId: string, exceptCallId: string): Promise<{
|
|
124
|
+
childAgentId: string;
|
|
125
|
+
childRepository: string | null;
|
|
126
|
+
}[]>;
|
|
123
127
|
/** Delegations of this session whose child run is started and not yet reported. */
|
|
124
128
|
openDelegationChildren(sessionId: string): Promise<string[]>;
|
|
125
129
|
}
|
|
@@ -202,6 +202,7 @@ export class PrismaGatewayLedger {
|
|
|
202
202
|
data: {
|
|
203
203
|
status: state.status,
|
|
204
204
|
childAgentId: state.childAgentId,
|
|
205
|
+
...(state.childRepository ? { childRepository: state.childRepository } : {}),
|
|
205
206
|
...(state.childRunId ? { childRunId: state.childRunId } : {}),
|
|
206
207
|
},
|
|
207
208
|
});
|
|
@@ -213,13 +214,13 @@ export class PrismaGatewayLedger {
|
|
|
213
214
|
const status = row.status === "waiting_budget" ? "waiting_budget" : row.status === "completed" ? "completed" : "pending";
|
|
214
215
|
return { status, childRunId: row.childRunId, childAgentId: row.childAgentId, createdAt: row.createdAt };
|
|
215
216
|
}
|
|
216
|
-
/** The sub-agents other delegations of this run
|
|
217
|
-
async
|
|
217
|
+
/** The sub-agents (and repositories, null = the child's default) other delegations of this run wait on budget to start. */
|
|
218
|
+
async waitingDelegations(sessionId, exceptCallId) {
|
|
218
219
|
const rows = await this.db.nativeGatewayCall.findMany({
|
|
219
220
|
where: { sessionId, status: "waiting_budget", callId: { not: exceptCallId }, childAgentId: { not: null } },
|
|
220
|
-
select: { childAgentId: true },
|
|
221
|
+
select: { childAgentId: true, childRepository: true },
|
|
221
222
|
});
|
|
222
|
-
return rows.map((row) => row.childAgentId);
|
|
223
|
+
return rows.map((row) => ({ childAgentId: row.childAgentId, childRepository: row.childRepository }));
|
|
223
224
|
}
|
|
224
225
|
/** Delegations of this session whose child run is started and not yet reported. */
|
|
225
226
|
async openDelegationChildren(sessionId) {
|
|
@@ -145,6 +145,7 @@ export declare const WorkerInputSchema: z.ZodObject<{
|
|
|
145
145
|
userTools: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
146
146
|
code: z.ZodString;
|
|
147
147
|
paramsZod: z.ZodString;
|
|
148
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
148
149
|
}, z.core.$strict>>;
|
|
149
150
|
runsConcurrently: z.ZodArray<z.ZodString>;
|
|
150
151
|
pricing: z.ZodObject<{
|
|
@@ -125,7 +125,10 @@ const LoadedToolSchema = z
|
|
|
125
125
|
.object({ name: z.string(), description: z.string(), jsonSchema: z.record(z.string(), z.unknown()) })
|
|
126
126
|
.strict();
|
|
127
127
|
/** A user tool as the worker runs it: code and schema only. Capabilities stay on the gateway. */
|
|
128
|
-
|
|
128
|
+
/** `timeoutMs`: the tool's time limit for this run, already capped by the server; absent = the default. */
|
|
129
|
+
const WorkerUserToolSchema = z
|
|
130
|
+
.object({ code: z.string(), paramsZod: z.string(), timeoutMs: z.number().int().positive().optional() })
|
|
131
|
+
.strict();
|
|
129
132
|
const PricingSchema = z
|
|
130
133
|
.object({
|
|
131
134
|
provider: z.string(),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coding proxy's /mcp route (remote MCP servers, phase 2): the worker's MCP server URL. It
|
|
3
|
+
* checks the run's MCP token (`rmt_…`, WARDBY_MCP_TOKEN in the worker) against the proxy session,
|
|
4
|
+
* then forwards the JSON-RPC body unchanged, with the same Authorization, to the native gateway's
|
|
5
|
+
* /coding-mcp route (WARDBY_MCP_GATEWAY_URL), which checks the token again and runs the call. The
|
|
6
|
+
* proxy never sees an MCP server URL or secret; the gateway redacts results before they get here.
|
|
7
|
+
*
|
|
8
|
+
* Secrets: the token is only hashed and forwarded, never logged or echoed. The route's log line
|
|
9
|
+
* carries the session id, a sanitized JSON-RPC method, the status and a fixed reason code only:
|
|
10
|
+
* never the body, the token or an error's text. Every body this route writes is fixed text or the
|
|
11
|
+
* gateway's answer, unchanged.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
|
+
import type { ProxyLedger } from "./types.js";
|
|
15
|
+
export declare const MCP_ROUTE_PATH = "/mcp";
|
|
16
|
+
/** The longest a forwarded call may take, when the session's deadline is later. */
|
|
17
|
+
export declare const MCP_FORWARD_MAX_MS: number;
|
|
18
|
+
export declare const MCP_UNAVAILABLE_MESSAGE = "MCP tools aren't available on this server.";
|
|
19
|
+
/**
|
|
20
|
+
* A JSON-RPC method as the log may carry it: its first 64 characters when they are letters, `/`
|
|
21
|
+
* and `_` only, else "other". Anything that starts like an MCP token is "other" too.
|
|
22
|
+
*/
|
|
23
|
+
export declare function mcpLogMethod(value: unknown): string;
|
|
24
|
+
export interface McpRouteOptions {
|
|
25
|
+
ledger: Pick<ProxyLedger, "findSessionByMcpTokenHash">;
|
|
26
|
+
/** The native gateway's base URL; without one, /mcp answers 503 mcp_unavailable. */
|
|
27
|
+
gatewayUrl: string | undefined;
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
29
|
+
now?: () => Date;
|
|
30
|
+
log?: {
|
|
31
|
+
info(fields: Record<string, unknown>, message: string): void;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export declare function createMcpRoute(options: McpRouteOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { isMcpToken, mcpTokenHash, MCP_TOKEN_REQUIRED_MESSAGE } from "../../coding/mcp-token.js";
|
|
2
|
+
import { logger } from "../../core/logger.js";
|
|
3
|
+
import { HTTP_LIMITS, HttpBoundaryError, readBodyBytes } from "../../mcp/transport/http-limits.js";
|
|
4
|
+
export const MCP_ROUTE_PATH = "/mcp";
|
|
5
|
+
/** The gateway's route this one forwards to. */
|
|
6
|
+
const GATEWAY_PATH = "/coding-mcp";
|
|
7
|
+
/** The longest a forwarded call may take, when the session's deadline is later. */
|
|
8
|
+
export const MCP_FORWARD_MAX_MS = 5 * 60_000;
|
|
9
|
+
/** The largest gateway answer relayed (a tool result is text the model reads; same as a model request). */
|
|
10
|
+
const MAX_GATEWAY_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
export const MCP_UNAVAILABLE_MESSAGE = "MCP tools aren't available on this server.";
|
|
12
|
+
const MCP_UNREACHABLE_MESSAGE = "MCP tools can't be reached right now.";
|
|
13
|
+
const LOG_METHOD = /^[A-Za-z/_]+$/;
|
|
14
|
+
/**
|
|
15
|
+
* A JSON-RPC method as the log may carry it: its first 64 characters when they are letters, `/`
|
|
16
|
+
* and `_` only, else "other". Anything that starts like an MCP token is "other" too.
|
|
17
|
+
*/
|
|
18
|
+
export function mcpLogMethod(value) {
|
|
19
|
+
if (typeof value !== "string")
|
|
20
|
+
return "other";
|
|
21
|
+
const method = value.slice(0, 64);
|
|
22
|
+
return LOG_METHOD.test(method) && !method.includes("rmt_") ? method : "other";
|
|
23
|
+
}
|
|
24
|
+
function methodOf(body) {
|
|
25
|
+
try {
|
|
26
|
+
const message = JSON.parse(body.toString("utf8"));
|
|
27
|
+
return mcpLogMethod(message?.method);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "other";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
class GatewayError extends Error {
|
|
34
|
+
reason;
|
|
35
|
+
constructor(reason) {
|
|
36
|
+
super(reason);
|
|
37
|
+
this.reason = reason;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const JSON_HEADERS = { "content-type": "application/json", "cache-control": "no-store" };
|
|
41
|
+
const rpcError = (code, message) => JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message } });
|
|
42
|
+
async function readGatewayBody(response) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
if (!response.body)
|
|
46
|
+
return Buffer.alloc(0);
|
|
47
|
+
for await (const chunk of response.body) {
|
|
48
|
+
size += chunk.byteLength;
|
|
49
|
+
if (size > MAX_GATEWAY_RESPONSE_BYTES) {
|
|
50
|
+
void response.body.cancel().catch(() => undefined);
|
|
51
|
+
throw new GatewayError("gateway_response_too_large");
|
|
52
|
+
}
|
|
53
|
+
chunks.push(Buffer.from(chunk));
|
|
54
|
+
}
|
|
55
|
+
return Buffer.concat(chunks, size);
|
|
56
|
+
}
|
|
57
|
+
export function createMcpRoute(options) {
|
|
58
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
59
|
+
const now = options.now ?? (() => new Date());
|
|
60
|
+
const log = options.log ?? logger.child({ module: "coding-proxy" });
|
|
61
|
+
const gatewayUrl = options.gatewayUrl?.replace(/\/+$/, "");
|
|
62
|
+
return async (req, res) => {
|
|
63
|
+
const startedAt = performance.now();
|
|
64
|
+
const seen = {};
|
|
65
|
+
/** Refused before the body was read: close the connection rather than read what is still coming. */
|
|
66
|
+
const closeIfBodyUnread = () => {
|
|
67
|
+
if (!req.complete && !res.headersSent)
|
|
68
|
+
res.setHeader("connection", "close");
|
|
69
|
+
};
|
|
70
|
+
const send = (status, body, headers = JSON_HEADERS) => {
|
|
71
|
+
res.writeHead(status, headers).end(body);
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
if (req.method !== "POST") {
|
|
75
|
+
closeIfBodyUnread();
|
|
76
|
+
res.setHeader("allow", "POST");
|
|
77
|
+
if (req.method === "HEAD")
|
|
78
|
+
res.writeHead(405, JSON_HEADERS).end();
|
|
79
|
+
else
|
|
80
|
+
send(405, rpcError(-32600, "Use POST."));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!gatewayUrl) {
|
|
84
|
+
closeIfBodyUnread();
|
|
85
|
+
send(503, JSON.stringify({ error: "mcp_unavailable", message: MCP_UNAVAILABLE_MESSAGE }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const token = /^Bearer (\S+)$/.exec(req.headers.authorization ?? "")?.[1];
|
|
89
|
+
const session = isMcpToken(token)
|
|
90
|
+
? await options.ledger.findSessionByMcpTokenHash(mcpTokenHash(token))
|
|
91
|
+
: undefined;
|
|
92
|
+
const at = now().getTime();
|
|
93
|
+
if (!session || session.status !== "active" || at >= session.deadlineAt.getTime()) {
|
|
94
|
+
// No WWW-Authenticate: a client must not start OAuth discovery against this route.
|
|
95
|
+
closeIfBodyUnread();
|
|
96
|
+
send(401, rpcError(-32001, MCP_TOKEN_REQUIRED_MESSAGE));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
seen.sessionId = session.id;
|
|
100
|
+
if (req.headers["content-type"]?.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
|
|
101
|
+
closeIfBodyUnread();
|
|
102
|
+
send(415, rpcError(-32600, "Send one JSON-RPC message as application/json."));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let body;
|
|
106
|
+
try {
|
|
107
|
+
body = await readBodyBytes(req, HTTP_LIMITS.json, AbortSignal.timeout(HTTP_LIMITS.requestMs));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
closeIfBodyUnread();
|
|
111
|
+
send(error instanceof HttpBoundaryError ? error.status : 400, rpcError(-32600, "The request body could not be read."));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
seen.method = methodOf(body);
|
|
115
|
+
// The call may take as long as it needs, up to the run's deadline or MCP_FORWARD_MAX_MS. The
|
|
116
|
+
// server's requestTimeout covers receiving the request only, which is over by now.
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const timer = setTimeout(() => controller.abort(new GatewayError("gateway_timeout")), Math.min(MCP_FORWARD_MAX_MS, session.deadlineAt.getTime() - at));
|
|
119
|
+
const clientGone = () => {
|
|
120
|
+
if (!res.writableFinished)
|
|
121
|
+
controller.abort(new GatewayError("client_disconnected"));
|
|
122
|
+
};
|
|
123
|
+
res.once("close", clientGone);
|
|
124
|
+
try {
|
|
125
|
+
const upstream = await fetchImpl(`${gatewayUrl}${GATEWAY_PATH}`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { authorization: req.headers.authorization, "content-type": "application/json" },
|
|
128
|
+
body: new Uint8Array(body),
|
|
129
|
+
redirect: "error",
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
});
|
|
132
|
+
const answer = await readGatewayBody(upstream);
|
|
133
|
+
send(upstream.status, answer, {
|
|
134
|
+
"content-type": upstream.headers.get("content-type") ?? "application/json",
|
|
135
|
+
"cache-control": "no-store",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const reason = controller.signal.reason instanceof GatewayError ? controller.signal.reason.reason : undefined;
|
|
140
|
+
throw error instanceof GatewayError ? error : new GatewayError(reason ?? "gateway_unreachable");
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
res.off("close", clientGone);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
// Nothing of the error's text leaves or is logged: only a fixed reason code.
|
|
149
|
+
seen.reason = error instanceof GatewayError ? error.reason : "gateway_unreachable";
|
|
150
|
+
if (!res.headersSent)
|
|
151
|
+
send(502, JSON.stringify({ error: "mcp_unavailable", message: MCP_UNREACHABLE_MESSAGE }));
|
|
152
|
+
else
|
|
153
|
+
res.destroy();
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
log.info({
|
|
157
|
+
event: "mcp.request",
|
|
158
|
+
...(seen.sessionId ? { sessionId: seen.sessionId } : {}),
|
|
159
|
+
...(seen.method ? { method: seen.method } : {}),
|
|
160
|
+
status: res.statusCode,
|
|
161
|
+
...(seen.reason ? { reason: seen.reason } : {}),
|
|
162
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
163
|
+
}, "coding proxy mcp request");
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|