@wopr-network/defcon 0.2.0 → 0.2.2
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/dist/src/execution/cli.js +0 -0
- package/package.json +3 -2
- package/dist/api/router.d.ts +0 -24
- package/dist/api/router.js +0 -44
- package/dist/api/server.d.ts +0 -13
- package/dist/api/server.js +0 -280
- package/dist/api/wire-types.d.ts +0 -46
- package/dist/api/wire-types.js +0 -5
- package/dist/config/db-path.d.ts +0 -1
- package/dist/config/db-path.js +0 -1
- package/dist/config/exporter.d.ts +0 -3
- package/dist/config/exporter.js +0 -87
- package/dist/config/index.d.ts +0 -4
- package/dist/config/index.js +0 -4
- package/dist/config/seed-loader.d.ts +0 -10
- package/dist/config/seed-loader.js +0 -108
- package/dist/config/zod-schemas.d.ts +0 -165
- package/dist/config/zod-schemas.js +0 -283
- package/dist/cors.d.ts +0 -8
- package/dist/cors.js +0 -21
- package/dist/engine/constants.d.ts +0 -1
- package/dist/engine/constants.js +0 -1
- package/dist/engine/engine.d.ts +0 -69
- package/dist/engine/engine.js +0 -485
- package/dist/engine/event-emitter.d.ts +0 -9
- package/dist/engine/event-emitter.js +0 -19
- package/dist/engine/event-types.d.ts +0 -105
- package/dist/engine/event-types.js +0 -1
- package/dist/engine/flow-spawner.d.ts +0 -8
- package/dist/engine/flow-spawner.js +0 -28
- package/dist/engine/gate-command-validator.d.ts +0 -6
- package/dist/engine/gate-command-validator.js +0 -46
- package/dist/engine/gate-evaluator.d.ts +0 -12
- package/dist/engine/gate-evaluator.js +0 -233
- package/dist/engine/handlebars.d.ts +0 -9
- package/dist/engine/handlebars.js +0 -51
- package/dist/engine/index.d.ts +0 -12
- package/dist/engine/index.js +0 -7
- package/dist/engine/invocation-builder.d.ts +0 -18
- package/dist/engine/invocation-builder.js +0 -58
- package/dist/engine/on-enter.d.ts +0 -8
- package/dist/engine/on-enter.js +0 -102
- package/dist/engine/ssrf-guard.d.ts +0 -22
- package/dist/engine/ssrf-guard.js +0 -159
- package/dist/engine/state-machine.d.ts +0 -12
- package/dist/engine/state-machine.js +0 -74
- package/dist/execution/active-runner.d.ts +0 -45
- package/dist/execution/active-runner.js +0 -165
- package/dist/execution/admin-schemas.d.ts +0 -116
- package/dist/execution/admin-schemas.js +0 -125
- package/dist/execution/cli.d.ts +0 -57
- package/dist/execution/cli.js +0 -498
- package/dist/execution/handlers/admin.d.ts +0 -67
- package/dist/execution/handlers/admin.js +0 -200
- package/dist/execution/handlers/flow.d.ts +0 -25
- package/dist/execution/handlers/flow.js +0 -289
- package/dist/execution/handlers/query.d.ts +0 -31
- package/dist/execution/handlers/query.js +0 -64
- package/dist/execution/index.d.ts +0 -4
- package/dist/execution/index.js +0 -3
- package/dist/execution/mcp-helpers.d.ts +0 -42
- package/dist/execution/mcp-helpers.js +0 -23
- package/dist/execution/mcp-server.d.ts +0 -33
- package/dist/execution/mcp-server.js +0 -1020
- package/dist/execution/provision-worktree.d.ts +0 -16
- package/dist/execution/provision-worktree.js +0 -123
- package/dist/execution/tool-schemas.d.ts +0 -40
- package/dist/execution/tool-schemas.js +0 -44
- package/dist/logger.d.ts +0 -8
- package/dist/logger.js +0 -12
- package/dist/main.d.ts +0 -14
- package/dist/main.js +0 -28
- package/dist/repositories/drizzle/entity.repo.d.ts +0 -27
- package/dist/repositories/drizzle/entity.repo.js +0 -190
- package/dist/repositories/drizzle/event.repo.d.ts +0 -12
- package/dist/repositories/drizzle/event.repo.js +0 -24
- package/dist/repositories/drizzle/flow.repo.d.ts +0 -22
- package/dist/repositories/drizzle/flow.repo.js +0 -364
- package/dist/repositories/drizzle/gate.repo.d.ts +0 -16
- package/dist/repositories/drizzle/gate.repo.js +0 -98
- package/dist/repositories/drizzle/index.d.ts +0 -6
- package/dist/repositories/drizzle/index.js +0 -7
- package/dist/repositories/drizzle/invocation.repo.d.ts +0 -23
- package/dist/repositories/drizzle/invocation.repo.js +0 -199
- package/dist/repositories/drizzle/schema.d.ts +0 -1932
- package/dist/repositories/drizzle/schema.js +0 -155
- package/dist/repositories/drizzle/transition-log.repo.d.ts +0 -11
- package/dist/repositories/drizzle/transition-log.repo.js +0 -42
- package/dist/repositories/interfaces.d.ts +0 -321
- package/dist/repositories/interfaces.js +0 -2
- package/dist/utils/redact.d.ts +0 -2
- package/dist/utils/redact.js +0 -62
- package/gates/blocking-graph.d.ts +0 -26
- package/gates/blocking-graph.js +0 -102
- package/gates/test/bad-return-gate.d.ts +0 -1
- package/gates/test/bad-return-gate.js +0 -4
- package/gates/test/passing-gate.d.ts +0 -2
- package/gates/test/passing-gate.js +0 -3
- package/gates/test/slow-gate.d.ts +0 -2
- package/gates/test/slow-gate.js +0 -5
- package/gates/test/throwing-gate.d.ts +0 -1
- package/gates/test/throwing-gate.js +0 -3
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { z } from "zod/v4";
|
|
2
|
-
import { validateGateCommand } from "../engine/gate-command-validator.js";
|
|
3
|
-
import { validateTemplate } from "../engine/handlebars.js";
|
|
4
|
-
const safeTemplate = z.string().refine(validateTemplate, {
|
|
5
|
-
message: "Template contains unsafe patterns (lookup, @root, __proto__, constructor)",
|
|
6
|
-
});
|
|
7
|
-
const AdminStateInlineSchema = z.object({
|
|
8
|
-
name: z.string().min(1),
|
|
9
|
-
modelTier: z.string().optional(),
|
|
10
|
-
mode: z.enum(["passive", "active"]).optional(),
|
|
11
|
-
promptTemplate: safeTemplate.optional(),
|
|
12
|
-
constraints: z.record(z.string(), z.unknown()).optional(),
|
|
13
|
-
});
|
|
14
|
-
export const AdminFlowCreateSchema = z.object({
|
|
15
|
-
name: z.string().min(1),
|
|
16
|
-
initialState: z.string().min(1),
|
|
17
|
-
discipline: z.string().min(1).optional(),
|
|
18
|
-
defaultModelTier: z.string().min(1).optional(),
|
|
19
|
-
description: z.string().optional(),
|
|
20
|
-
entitySchema: z.record(z.string(), z.unknown()).optional(),
|
|
21
|
-
maxConcurrent: z.number().int().min(0).optional(),
|
|
22
|
-
maxConcurrentPerRepo: z.number().int().min(0).optional(),
|
|
23
|
-
affinityWindowMs: z.number().int().min(0).optional(),
|
|
24
|
-
gateTimeoutMs: z.number().int().min(0).optional(),
|
|
25
|
-
createdBy: z.string().optional(),
|
|
26
|
-
timeoutPrompt: safeTemplate.min(1).optional(),
|
|
27
|
-
states: z.array(AdminStateInlineSchema).min(1, "Flow must have at least one state definition").optional(),
|
|
28
|
-
});
|
|
29
|
-
export const AdminFlowUpdateSchema = z.object({
|
|
30
|
-
flow_name: z.string().min(1),
|
|
31
|
-
description: z.string().optional(),
|
|
32
|
-
discipline: z.string().min(1).nullable().optional(),
|
|
33
|
-
defaultModelTier: z.string().min(1).nullable().optional(),
|
|
34
|
-
maxConcurrent: z.number().int().min(0).optional(),
|
|
35
|
-
maxConcurrentPerRepo: z.number().int().min(0).optional(),
|
|
36
|
-
affinityWindowMs: z.number().int().min(0).optional(),
|
|
37
|
-
gateTimeoutMs: z.number().int().min(0).optional(),
|
|
38
|
-
initialState: z.string().min(1).optional(),
|
|
39
|
-
timeoutPrompt: safeTemplate.min(1).nullable().optional(),
|
|
40
|
-
});
|
|
41
|
-
export const AdminStateCreateSchema = z.object({
|
|
42
|
-
flow_name: z.string().min(1),
|
|
43
|
-
name: z.string().min(1),
|
|
44
|
-
modelTier: z.string().optional(),
|
|
45
|
-
mode: z.enum(["passive", "active"]).optional(),
|
|
46
|
-
promptTemplate: safeTemplate.optional(),
|
|
47
|
-
constraints: z.record(z.string(), z.unknown()).optional(),
|
|
48
|
-
});
|
|
49
|
-
export const AdminStateUpdateSchema = z.object({
|
|
50
|
-
flow_name: z.string().min(1),
|
|
51
|
-
state_name: z.string().min(1),
|
|
52
|
-
modelTier: z.string().optional(),
|
|
53
|
-
mode: z.enum(["passive", "active"]).optional(),
|
|
54
|
-
promptTemplate: safeTemplate.optional(),
|
|
55
|
-
constraints: z.record(z.string(), z.unknown()).optional(),
|
|
56
|
-
});
|
|
57
|
-
export const AdminTransitionCreateSchema = z.object({
|
|
58
|
-
flow_name: z.string().min(1),
|
|
59
|
-
fromState: z.string().min(1),
|
|
60
|
-
toState: z.string().min(1),
|
|
61
|
-
trigger: z.string().min(1),
|
|
62
|
-
gateName: z.string().min(1).optional(),
|
|
63
|
-
condition: safeTemplate.optional(),
|
|
64
|
-
priority: z.number().int().min(0).optional(),
|
|
65
|
-
spawnFlow: z.string().optional(),
|
|
66
|
-
spawnTemplate: safeTemplate.optional(),
|
|
67
|
-
});
|
|
68
|
-
export const AdminTransitionUpdateSchema = z.object({
|
|
69
|
-
flow_name: z.string().min(1),
|
|
70
|
-
transition_id: z.string().min(1),
|
|
71
|
-
fromState: z.string().min(1).optional(),
|
|
72
|
-
toState: z.string().min(1).optional(),
|
|
73
|
-
trigger: z.string().min(1).optional(),
|
|
74
|
-
gateName: z.string().min(1).optional(),
|
|
75
|
-
condition: safeTemplate.nullable().optional(),
|
|
76
|
-
priority: z.number().int().min(0).nullable().optional(),
|
|
77
|
-
spawnFlow: z.string().nullable().optional(),
|
|
78
|
-
spawnTemplate: safeTemplate.nullable().optional(),
|
|
79
|
-
});
|
|
80
|
-
export const AdminGateCreateSchema = z.discriminatedUnion("type", [
|
|
81
|
-
z.object({
|
|
82
|
-
name: z.string().min(1),
|
|
83
|
-
type: z.literal("command"),
|
|
84
|
-
command: z
|
|
85
|
-
.string()
|
|
86
|
-
.min(1)
|
|
87
|
-
.superRefine((cmd, ctx) => {
|
|
88
|
-
const result = validateGateCommand(cmd);
|
|
89
|
-
if (!result.valid) {
|
|
90
|
-
ctx.addIssue({ code: "custom", message: result.error ?? "Gate command not allowed" });
|
|
91
|
-
}
|
|
92
|
-
}),
|
|
93
|
-
timeoutMs: z.number().int().min(0).optional(),
|
|
94
|
-
failurePrompt: z.string().optional(),
|
|
95
|
-
timeoutPrompt: z.string().optional(),
|
|
96
|
-
}),
|
|
97
|
-
z.object({
|
|
98
|
-
name: z.string().min(1),
|
|
99
|
-
type: z.literal("function"),
|
|
100
|
-
functionRef: z.string().regex(/^[^:]+:[^:]+$/, "functionRef must be in 'path:exportName' format"),
|
|
101
|
-
timeoutMs: z.number().int().min(0).optional(),
|
|
102
|
-
failurePrompt: z.string().optional(),
|
|
103
|
-
timeoutPrompt: z.string().optional(),
|
|
104
|
-
}),
|
|
105
|
-
z.object({
|
|
106
|
-
name: z.string().min(1),
|
|
107
|
-
type: z.literal("api"),
|
|
108
|
-
apiConfig: z.record(z.string(), z.unknown()),
|
|
109
|
-
timeoutMs: z.number().int().min(0).optional(),
|
|
110
|
-
failurePrompt: z.string().optional(),
|
|
111
|
-
timeoutPrompt: z.string().optional(),
|
|
112
|
-
}),
|
|
113
|
-
]);
|
|
114
|
-
export const AdminGateAttachSchema = z.object({
|
|
115
|
-
flow_name: z.string().min(1),
|
|
116
|
-
transition_id: z.string().min(1),
|
|
117
|
-
gate_name: z.string().min(1),
|
|
118
|
-
});
|
|
119
|
-
export const AdminFlowSnapshotSchema = z.object({
|
|
120
|
-
flow_name: z.string().min(1),
|
|
121
|
-
});
|
|
122
|
-
export const AdminFlowRestoreSchema = z.object({
|
|
123
|
-
flow_name: z.string().min(1),
|
|
124
|
-
version: z.number().int().min(1),
|
|
125
|
-
});
|
package/dist/execution/cli.d.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Validates that DEFCON_ADMIN_TOKEN is set when network transports are active.
|
|
4
|
-
* Throws if a network transport (HTTP or SSE) is started without a token.
|
|
5
|
-
* Stdio-only mode is exempt (local, single-user).
|
|
6
|
-
*/
|
|
7
|
-
export declare function validateAdminToken(opts: {
|
|
8
|
-
adminToken: string | undefined;
|
|
9
|
-
startHttp: boolean;
|
|
10
|
-
transport: string;
|
|
11
|
-
}): void;
|
|
12
|
-
/**
|
|
13
|
-
* Validates that DEFCON_WORKER_TOKEN is set when network transports are active.
|
|
14
|
-
* Throws if a network transport (HTTP or SSE) is started without a token.
|
|
15
|
-
* Stdio-only mode is exempt (local, single-user).
|
|
16
|
-
*/
|
|
17
|
-
export declare function validateWorkerToken(opts: {
|
|
18
|
-
workerToken: string | undefined;
|
|
19
|
-
startHttp: boolean;
|
|
20
|
-
transport: string;
|
|
21
|
-
}): void;
|
|
22
|
-
/**
|
|
23
|
-
* Verifies that the token on an incoming POST /messages request matches the
|
|
24
|
-
* token that was presented at SSE handshake time.
|
|
25
|
-
*
|
|
26
|
-
* Rules:
|
|
27
|
-
* - If no token was stored at handshake (unauthenticated connection), any
|
|
28
|
-
* incoming token (or lack thereof) is accepted — the session itself was
|
|
29
|
-
* already established without auth.
|
|
30
|
-
* - If a token WAS stored at handshake, the incoming request must supply the
|
|
31
|
-
* same token (timing-safe comparison). Missing or mismatched → reject.
|
|
32
|
-
*
|
|
33
|
-
* Exported for unit testing.
|
|
34
|
-
*/
|
|
35
|
-
export declare function verifySessionToken(storedTokenHash: string | undefined, incomingToken: string | undefined): boolean;
|
|
36
|
-
/**
|
|
37
|
-
* Builds a shutdown handler that calls stopReaper() with a timeout guard,
|
|
38
|
-
* then closes resources and exits. Exported for unit testing.
|
|
39
|
-
*/
|
|
40
|
-
export declare function makeShutdownHandler(opts: {
|
|
41
|
-
stopReaper: () => Promise<void>;
|
|
42
|
-
closeables: Array<{
|
|
43
|
-
close: () => void;
|
|
44
|
-
}>;
|
|
45
|
-
stopReaperTimeoutMs?: number;
|
|
46
|
-
}): () => void;
|
|
47
|
-
export declare function extractBearerToken(header: string | undefined): string | undefined;
|
|
48
|
-
/**
|
|
49
|
-
* Resolves the MCP session ID for POST /messages routing.
|
|
50
|
-
*
|
|
51
|
-
* Prefers the X-Session-Id request header over the ?sessionId= query parameter.
|
|
52
|
-
* Using a header prevents the session ID from appearing in nginx/ALB/CloudTrail
|
|
53
|
-
* access logs, which would enable session hijacking.
|
|
54
|
-
*
|
|
55
|
-
* Exported for unit testing.
|
|
56
|
-
*/
|
|
57
|
-
export declare function resolveSessionId(headers: Record<string, string | string[] | undefined>, searchParams: URLSearchParams): string;
|
package/dist/execution/cli.js
DELETED
|
@@ -1,498 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { writeFileSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
6
|
-
import { pathToFileURL } from "node:url";
|
|
7
|
-
import Database from "better-sqlite3";
|
|
8
|
-
import { Command } from "commander";
|
|
9
|
-
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
10
|
-
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
11
|
-
import { createHttpServer } from "../api/server.js";
|
|
12
|
-
import { exportSeed } from "../config/exporter.js";
|
|
13
|
-
import { loadSeed } from "../config/seed-loader.js";
|
|
14
|
-
import { resolveCorsOrigin } from "../cors.js";
|
|
15
|
-
import { Engine } from "../engine/engine.js";
|
|
16
|
-
import { EventEmitter } from "../engine/event-emitter.js";
|
|
17
|
-
import { DrizzleEntityRepository } from "../repositories/drizzle/entity.repo.js";
|
|
18
|
-
import { DrizzleEventRepository } from "../repositories/drizzle/event.repo.js";
|
|
19
|
-
import { DrizzleFlowRepository } from "../repositories/drizzle/flow.repo.js";
|
|
20
|
-
import { DrizzleGateRepository } from "../repositories/drizzle/gate.repo.js";
|
|
21
|
-
import { DrizzleInvocationRepository } from "../repositories/drizzle/invocation.repo.js";
|
|
22
|
-
import * as schema from "../repositories/drizzle/schema.js";
|
|
23
|
-
import { entities, entityHistory, flowDefinitions, flowVersions, gateDefinitions, gateResults, invocations, stateDefinitions, transitionRules, } from "../repositories/drizzle/schema.js";
|
|
24
|
-
import { DrizzleTransitionLogRepository } from "../repositories/drizzle/transition-log.repo.js";
|
|
25
|
-
import { createMcpServer, startStdioServer } from "./mcp-server.js";
|
|
26
|
-
import { provisionWorktree } from "./provision-worktree.js";
|
|
27
|
-
const DB_DEFAULT = process.env.AGENTIC_DB_PATH ?? "./agentic-flow.db";
|
|
28
|
-
/**
|
|
29
|
-
* Validates that DEFCON_ADMIN_TOKEN is set when network transports are active.
|
|
30
|
-
* Throws if a network transport (HTTP or SSE) is started without a token.
|
|
31
|
-
* Stdio-only mode is exempt (local, single-user).
|
|
32
|
-
*/
|
|
33
|
-
export function validateAdminToken(opts) {
|
|
34
|
-
const transport = opts.transport.toLowerCase().trim();
|
|
35
|
-
const token = opts.adminToken?.trim();
|
|
36
|
-
const networkActive = opts.startHttp || transport === "sse";
|
|
37
|
-
if (networkActive && !token) {
|
|
38
|
-
throw new Error("DEFCON_ADMIN_TOKEN must be set when using HTTP or SSE transport. " +
|
|
39
|
-
"Admin tools are accessible over the network and require authentication. " +
|
|
40
|
-
"Set DEFCON_ADMIN_TOKEN in your environment or use stdio transport for local-only access.");
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Validates that DEFCON_WORKER_TOKEN is set when network transports are active.
|
|
45
|
-
* Throws if a network transport (HTTP or SSE) is started without a token.
|
|
46
|
-
* Stdio-only mode is exempt (local, single-user).
|
|
47
|
-
*/
|
|
48
|
-
export function validateWorkerToken(opts) {
|
|
49
|
-
const transport = opts.transport.toLowerCase().trim();
|
|
50
|
-
const token = opts.workerToken?.trim();
|
|
51
|
-
const networkActive = opts.startHttp || transport === "sse";
|
|
52
|
-
if (networkActive && !token) {
|
|
53
|
-
throw new Error("DEFCON_WORKER_TOKEN must be set when using HTTP or SSE transport. " +
|
|
54
|
-
"Worker tools (flow.*) are accessible over the network and require authentication. " +
|
|
55
|
-
"Set DEFCON_WORKER_TOKEN in your environment or use stdio transport for local-only access.");
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const MIGRATIONS_FOLDER = new URL("../../drizzle", import.meta.url).pathname;
|
|
59
|
-
const REAPER_INTERVAL_DEFAULT = "30000"; // 30s
|
|
60
|
-
const CLAIM_TTL_DEFAULT = "300000"; // 5min
|
|
61
|
-
function openDb(dbPath) {
|
|
62
|
-
const sqlite = new Database(dbPath);
|
|
63
|
-
sqlite.pragma("journal_mode = WAL");
|
|
64
|
-
sqlite.pragma("foreign_keys = ON");
|
|
65
|
-
const db = drizzle(sqlite, { schema });
|
|
66
|
-
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
67
|
-
return { db, sqlite };
|
|
68
|
-
}
|
|
69
|
-
const program = new Command();
|
|
70
|
-
program.name("defcon").version("0.1.0");
|
|
71
|
-
// ─── init ───
|
|
72
|
-
program
|
|
73
|
-
.command("init")
|
|
74
|
-
.option("--seed <path>", "Path to seed JSON file")
|
|
75
|
-
.option("--force", "Drop existing data before loading")
|
|
76
|
-
.option("--db <path>", "Database path", DB_DEFAULT)
|
|
77
|
-
.action(async (opts) => {
|
|
78
|
-
const seedPath = opts.seed;
|
|
79
|
-
if (typeof seedPath !== "string") {
|
|
80
|
-
console.log("Usage: defcon init --seed <path> [--force]");
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
const { db, sqlite } = openDb(opts.db);
|
|
84
|
-
const flowRepo = new DrizzleFlowRepository(db);
|
|
85
|
-
const gateRepo = new DrizzleGateRepository(db);
|
|
86
|
-
if (opts.force) {
|
|
87
|
-
db.delete(gateResults).run();
|
|
88
|
-
db.delete(entityHistory).run();
|
|
89
|
-
db.delete(invocations).run();
|
|
90
|
-
db.delete(entities).run();
|
|
91
|
-
db.delete(transitionRules).run();
|
|
92
|
-
db.delete(stateDefinitions).run();
|
|
93
|
-
db.delete(flowVersions).run();
|
|
94
|
-
db.delete(gateDefinitions).run();
|
|
95
|
-
db.delete(flowDefinitions).run();
|
|
96
|
-
}
|
|
97
|
-
const seedRoot = process.env.DEFCON_SEED_ROOT;
|
|
98
|
-
const result = await loadSeed(resolve(seedPath), flowRepo, gateRepo, sqlite, seedRoot ? { allowedRoot: seedRoot } : undefined);
|
|
99
|
-
console.log(`Loaded seed: flows: ${result.flows}, gates: ${result.gates}`);
|
|
100
|
-
sqlite.close();
|
|
101
|
-
});
|
|
102
|
-
// ─── export ───
|
|
103
|
-
program
|
|
104
|
-
.command("export")
|
|
105
|
-
.option("--out <path>", "Output file path (defaults to stdout)")
|
|
106
|
-
.option("--db <path>", "Database path", DB_DEFAULT)
|
|
107
|
-
.action(async (opts) => {
|
|
108
|
-
const { db, sqlite } = openDb(opts.db);
|
|
109
|
-
const flowRepo = new DrizzleFlowRepository(db);
|
|
110
|
-
const gateRepo = new DrizzleGateRepository(db);
|
|
111
|
-
const seed = await exportSeed(flowRepo, gateRepo);
|
|
112
|
-
const json = JSON.stringify(seed, null, 2);
|
|
113
|
-
if (opts.out) {
|
|
114
|
-
writeFileSync(resolve(opts.out), json);
|
|
115
|
-
console.log(`Exported to ${opts.out}`);
|
|
116
|
-
}
|
|
117
|
-
else {
|
|
118
|
-
console.log(json);
|
|
119
|
-
}
|
|
120
|
-
sqlite.close();
|
|
121
|
-
});
|
|
122
|
-
// ─── serve ───
|
|
123
|
-
program
|
|
124
|
-
.command("serve")
|
|
125
|
-
.description("Start MCP server")
|
|
126
|
-
.option("--transport <type>", "Transport: stdio or sse", "stdio")
|
|
127
|
-
.option("--port <number>", "Port for SSE transport", "3001")
|
|
128
|
-
.option("--host <address>", "Host address to bind to (default: 127.0.0.1, use 0.0.0.0 for network access)", "127.0.0.1")
|
|
129
|
-
.option("--db <path>", "Database path", DB_DEFAULT)
|
|
130
|
-
.option("--reaper-interval <ms>", "Reaper poll interval in milliseconds", REAPER_INTERVAL_DEFAULT)
|
|
131
|
-
.option("--claim-ttl <ms>", "Claim TTL in milliseconds", CLAIM_TTL_DEFAULT)
|
|
132
|
-
.option("--http-only", "Start HTTP REST server only (no MCP stdio)")
|
|
133
|
-
.option("--mcp-only", "Start MCP stdio only (no HTTP REST server)")
|
|
134
|
-
.option("--http-port <number>", "Port for HTTP REST API", "3000")
|
|
135
|
-
.option("--http-host <address>", "Host for HTTP REST API", "127.0.0.1")
|
|
136
|
-
.action(async (opts) => {
|
|
137
|
-
const { db, sqlite } = openDb(opts.db);
|
|
138
|
-
const entityRepo = new DrizzleEntityRepository(db);
|
|
139
|
-
const flowRepo = new DrizzleFlowRepository(db);
|
|
140
|
-
const invocationRepo = new DrizzleInvocationRepository(db);
|
|
141
|
-
const gateRepo = new DrizzleGateRepository(db);
|
|
142
|
-
const transitionLogRepo = new DrizzleTransitionLogRepository(db);
|
|
143
|
-
const eventEmitter = new EventEmitter();
|
|
144
|
-
eventEmitter.register({
|
|
145
|
-
emit: async (event) => {
|
|
146
|
-
process.stderr.write(`[event] ${event.type} ${JSON.stringify(event)}\n`);
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
const engine = new Engine({
|
|
150
|
-
entityRepo,
|
|
151
|
-
flowRepo,
|
|
152
|
-
invocationRepo,
|
|
153
|
-
gateRepo,
|
|
154
|
-
transitionLogRepo,
|
|
155
|
-
adapters: new Map(),
|
|
156
|
-
eventEmitter,
|
|
157
|
-
});
|
|
158
|
-
const deps = {
|
|
159
|
-
entities: entityRepo,
|
|
160
|
-
flows: flowRepo,
|
|
161
|
-
invocations: invocationRepo,
|
|
162
|
-
gates: gateRepo,
|
|
163
|
-
transitions: transitionLogRepo,
|
|
164
|
-
eventRepo: new DrizzleEventRepository(db),
|
|
165
|
-
engine,
|
|
166
|
-
};
|
|
167
|
-
const reaperInterval = parseInt(opts.reaperInterval, 10);
|
|
168
|
-
if (Number.isNaN(reaperInterval) || reaperInterval < 1000) {
|
|
169
|
-
console.error("--reaper-interval must be a number >= 1000ms");
|
|
170
|
-
sqlite.close();
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
173
|
-
const claimTtl = parseInt(opts.claimTtl, 10);
|
|
174
|
-
if (Number.isNaN(claimTtl) || claimTtl < 5000) {
|
|
175
|
-
console.error("--claim-ttl must be a number >= 5000ms");
|
|
176
|
-
sqlite.close();
|
|
177
|
-
process.exit(1);
|
|
178
|
-
}
|
|
179
|
-
const stopReaper = engine.startReaper(reaperInterval, claimTtl);
|
|
180
|
-
if (opts.httpOnly && opts.mcpOnly) {
|
|
181
|
-
console.error("Cannot use --http-only and --mcp-only together");
|
|
182
|
-
await stopReaper();
|
|
183
|
-
sqlite.close();
|
|
184
|
-
process.exit(1);
|
|
185
|
-
}
|
|
186
|
-
const adminToken = process.env.DEFCON_ADMIN_TOKEN || undefined;
|
|
187
|
-
const workerToken = process.env.DEFCON_WORKER_TOKEN || undefined;
|
|
188
|
-
const startHttp = !opts.mcpOnly;
|
|
189
|
-
const startMcp = !opts.httpOnly;
|
|
190
|
-
try {
|
|
191
|
-
validateAdminToken({ adminToken, startHttp, transport: opts.transport });
|
|
192
|
-
}
|
|
193
|
-
catch (err) {
|
|
194
|
-
console.error(err.message);
|
|
195
|
-
await stopReaper();
|
|
196
|
-
sqlite.close();
|
|
197
|
-
process.exit(1);
|
|
198
|
-
}
|
|
199
|
-
try {
|
|
200
|
-
validateWorkerToken({ workerToken, startHttp, transport: opts.transport });
|
|
201
|
-
}
|
|
202
|
-
catch (err) {
|
|
203
|
-
console.error(err.message);
|
|
204
|
-
await stopReaper();
|
|
205
|
-
sqlite.close();
|
|
206
|
-
process.exit(1);
|
|
207
|
-
}
|
|
208
|
-
let restHttpServer;
|
|
209
|
-
if (startHttp) {
|
|
210
|
-
const httpPort = parseInt(opts.httpPort, 10);
|
|
211
|
-
const httpHost = opts.httpHost;
|
|
212
|
-
let restCorsResult;
|
|
213
|
-
try {
|
|
214
|
-
restCorsResult = resolveCorsOrigin({ host: httpHost, corsEnv: process.env.DEFCON_CORS_ORIGIN });
|
|
215
|
-
}
|
|
216
|
-
catch (err) {
|
|
217
|
-
console.error(err.message);
|
|
218
|
-
await stopReaper();
|
|
219
|
-
sqlite.close();
|
|
220
|
-
process.exit(1);
|
|
221
|
-
}
|
|
222
|
-
restHttpServer = createHttpServer({
|
|
223
|
-
engine,
|
|
224
|
-
mcpDeps: deps,
|
|
225
|
-
adminToken,
|
|
226
|
-
workerToken,
|
|
227
|
-
corsOrigin: restCorsResult.origin ?? undefined,
|
|
228
|
-
});
|
|
229
|
-
restHttpServer.listen(httpPort, httpHost, () => {
|
|
230
|
-
const addr = restHttpServer?.address();
|
|
231
|
-
const boundPort = addr && typeof addr === "object" ? addr.port : httpPort;
|
|
232
|
-
console.error(`HTTP REST API listening on ${httpHost}:${boundPort}`);
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
if (opts.transport === "sse" && startMcp) {
|
|
236
|
-
const { SSEServerTransport } = await import("@modelcontextprotocol/sdk/server/sse.js");
|
|
237
|
-
const http = await import("node:http");
|
|
238
|
-
const port = parseInt(opts.port, 10);
|
|
239
|
-
// Map session IDs to transports for POST routing
|
|
240
|
-
const transports = new Map();
|
|
241
|
-
// Map session IDs to the SHA-256 hash of the bearer token used at SSE handshake
|
|
242
|
-
const sessionTokens = new Map();
|
|
243
|
-
const host = opts.host;
|
|
244
|
-
let corsResult;
|
|
245
|
-
try {
|
|
246
|
-
corsResult = resolveCorsOrigin({ host, corsEnv: process.env.DEFCON_CORS_ORIGIN });
|
|
247
|
-
}
|
|
248
|
-
catch (err) {
|
|
249
|
-
console.error(err.message);
|
|
250
|
-
await stopReaper();
|
|
251
|
-
sqlite.close();
|
|
252
|
-
process.exit(1);
|
|
253
|
-
}
|
|
254
|
-
const allowedOriginPattern = corsResult.origin
|
|
255
|
-
? corsResult.origin // exact string match
|
|
256
|
-
: /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; // loopback default
|
|
257
|
-
const httpServer = http.createServer(async (req, res) => {
|
|
258
|
-
// CORS: restrict to localhost origins when bound to loopback; require DEFCON_CORS_ORIGIN when bound to non-loopback
|
|
259
|
-
const origin = req.headers.origin;
|
|
260
|
-
if (origin) {
|
|
261
|
-
const originAllowed = typeof allowedOriginPattern === "string"
|
|
262
|
-
? origin === allowedOriginPattern
|
|
263
|
-
: allowedOriginPattern.test(origin);
|
|
264
|
-
if (originAllowed) {
|
|
265
|
-
res.setHeader("Vary", "Origin");
|
|
266
|
-
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
267
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
268
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id");
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
// Handle CORS preflight — scoped to SSE-related routes only
|
|
272
|
-
if (req.method === "OPTIONS" && (req.url === "/sse" || req.url?.startsWith("/messages"))) {
|
|
273
|
-
res.writeHead(204).end();
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
if (req.url === "/sse" && req.method === "GET") {
|
|
277
|
-
const callerToken = extractBearerToken(req.headers.authorization);
|
|
278
|
-
const transport = new SSEServerTransport("/messages", res);
|
|
279
|
-
transports.set(transport.sessionId, transport);
|
|
280
|
-
sessionTokens.set(transport.sessionId, callerToken != null ? createHash("sha256").update(callerToken).digest("hex") : undefined);
|
|
281
|
-
res.on("close", () => {
|
|
282
|
-
transports.delete(transport.sessionId);
|
|
283
|
-
sessionTokens.delete(transport.sessionId);
|
|
284
|
-
});
|
|
285
|
-
const mcpOpts = {
|
|
286
|
-
adminToken,
|
|
287
|
-
workerToken,
|
|
288
|
-
callerToken,
|
|
289
|
-
};
|
|
290
|
-
const server = createMcpServer(deps, mcpOpts);
|
|
291
|
-
await server.connect(transport);
|
|
292
|
-
}
|
|
293
|
-
else if (req.url?.startsWith("/messages") && req.method === "POST") {
|
|
294
|
-
const url = new URL(req.url, `http://localhost:${port}`);
|
|
295
|
-
const sessionId = resolveSessionId(req.headers, url.searchParams);
|
|
296
|
-
const transport = transports.get(sessionId);
|
|
297
|
-
if (!transport) {
|
|
298
|
-
res.writeHead(404).end("Session not found");
|
|
299
|
-
}
|
|
300
|
-
else if (!verifySessionToken(sessionTokens.get(sessionId), extractBearerToken(req.headers.authorization))) {
|
|
301
|
-
res.writeHead(401).end("Unauthorized");
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
await transport.handlePostMessage(req, res);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
res.writeHead(404).end();
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
httpServer.listen(port, host, () => {
|
|
312
|
-
const addr = httpServer.address();
|
|
313
|
-
const boundPort = addr && typeof addr === "object" ? addr.port : port;
|
|
314
|
-
console.log(`MCP SSE server listening on ${host}:${boundPort}`);
|
|
315
|
-
});
|
|
316
|
-
const shutdown = makeShutdownHandler({
|
|
317
|
-
stopReaper,
|
|
318
|
-
closeables: [{ close: () => restHttpServer?.close() }, httpServer, sqlite],
|
|
319
|
-
});
|
|
320
|
-
process.once("SIGINT", shutdown);
|
|
321
|
-
process.once("SIGTERM", shutdown);
|
|
322
|
-
}
|
|
323
|
-
else if (startMcp) {
|
|
324
|
-
// stdio (default)
|
|
325
|
-
console.error("Starting MCP server on stdio...");
|
|
326
|
-
const cleanup = makeShutdownHandler({ stopReaper, closeables: [sqlite] });
|
|
327
|
-
process.once("SIGINT", cleanup);
|
|
328
|
-
process.once("SIGTERM", cleanup);
|
|
329
|
-
const mcpOpts = { adminToken, workerToken, stdioTrusted: true };
|
|
330
|
-
await startStdioServer(deps, mcpOpts);
|
|
331
|
-
}
|
|
332
|
-
else {
|
|
333
|
-
// HTTP-only mode — keep process alive
|
|
334
|
-
const cleanup = makeShutdownHandler({
|
|
335
|
-
stopReaper,
|
|
336
|
-
closeables: [{ close: () => restHttpServer?.close() }, sqlite],
|
|
337
|
-
});
|
|
338
|
-
process.once("SIGINT", cleanup);
|
|
339
|
-
process.once("SIGTERM", cleanup);
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
// ─── status ───
|
|
343
|
-
program
|
|
344
|
-
.command("status")
|
|
345
|
-
.description("Print pipeline status")
|
|
346
|
-
.option("--flow <name>", "Filter by flow name")
|
|
347
|
-
.option("--state <name>", "Filter by state")
|
|
348
|
-
.option("--json", "Output as JSON")
|
|
349
|
-
.option("--db <path>", "Database path", DB_DEFAULT)
|
|
350
|
-
.action(async (opts) => {
|
|
351
|
-
const { db, sqlite } = openDb(opts.db);
|
|
352
|
-
const flowRepo = new DrizzleFlowRepository(db);
|
|
353
|
-
const entityRepo = new DrizzleEntityRepository(db);
|
|
354
|
-
const invocationRepo = new DrizzleInvocationRepository(db);
|
|
355
|
-
const allFlows = await flowRepo.listAll();
|
|
356
|
-
const targetFlows = opts.flow ? allFlows.filter((f) => f.name === opts.flow) : allFlows;
|
|
357
|
-
if (targetFlows.length === 0 && opts.flow) {
|
|
358
|
-
console.error(`Flow not found: ${opts.flow}`);
|
|
359
|
-
sqlite.close();
|
|
360
|
-
process.exit(1);
|
|
361
|
-
}
|
|
362
|
-
const statusData = {};
|
|
363
|
-
let activeInvocations = 0;
|
|
364
|
-
let pendingClaims = 0;
|
|
365
|
-
for (const flow of targetFlows) {
|
|
366
|
-
const flowStatus = {};
|
|
367
|
-
for (const state of flow.states) {
|
|
368
|
-
if (opts.state && state.name !== opts.state)
|
|
369
|
-
continue;
|
|
370
|
-
const entitiesInState = await entityRepo.findByFlowAndState(flow.id, state.name);
|
|
371
|
-
flowStatus[state.name] = entitiesInState.length;
|
|
372
|
-
}
|
|
373
|
-
statusData[flow.name] = flowStatus;
|
|
374
|
-
const flowInvocations = await invocationRepo.findByFlow(flow.id);
|
|
375
|
-
activeInvocations += flowInvocations.filter((i) => i.claimedAt !== null && !i.completedAt && !i.failedAt).length;
|
|
376
|
-
pendingClaims += flowInvocations.filter((i) => !i.claimedAt && !i.completedAt && !i.failedAt).length;
|
|
377
|
-
}
|
|
378
|
-
if (opts.json) {
|
|
379
|
-
console.log(JSON.stringify({ flows: statusData, activeInvocations, pendingClaims }));
|
|
380
|
-
}
|
|
381
|
-
else {
|
|
382
|
-
console.log("Pipeline Status");
|
|
383
|
-
console.log("================");
|
|
384
|
-
for (const [flowName, states] of Object.entries(statusData)) {
|
|
385
|
-
console.log(`\nFlow: ${flowName}`);
|
|
386
|
-
for (const [stateName, count] of Object.entries(states)) {
|
|
387
|
-
console.log(` ${stateName}: ${count} entities`);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
console.log(`\nActive invocations: ${activeInvocations}`);
|
|
391
|
-
console.log(`Pending claims: ${pendingClaims}`);
|
|
392
|
-
}
|
|
393
|
-
sqlite.close();
|
|
394
|
-
});
|
|
395
|
-
// ─── provision-worktree ───
|
|
396
|
-
program
|
|
397
|
-
.command("provision-worktree")
|
|
398
|
-
.description("Provision a git worktree and branch for an issue")
|
|
399
|
-
.argument("<repo>", "GitHub repo (e.g. wopr-network/defcon)")
|
|
400
|
-
.argument("<issue-key>", "Issue key (e.g. WOP-392)")
|
|
401
|
-
.option("--base-path <path>", "Worktree base directory", join(homedir(), "worktrees"))
|
|
402
|
-
.option("--clone-root <path>", "Directory where repos are cloned", homedir())
|
|
403
|
-
.action((repo, issueKey, opts) => {
|
|
404
|
-
try {
|
|
405
|
-
const result = provisionWorktree({
|
|
406
|
-
repo,
|
|
407
|
-
issueKey,
|
|
408
|
-
basePath: opts.basePath,
|
|
409
|
-
cloneRoot: opts.cloneRoot,
|
|
410
|
-
});
|
|
411
|
-
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
412
|
-
}
|
|
413
|
-
catch (err) {
|
|
414
|
-
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
415
|
-
process.exit(1);
|
|
416
|
-
}
|
|
417
|
-
});
|
|
418
|
-
/**
|
|
419
|
-
* Verifies that the token on an incoming POST /messages request matches the
|
|
420
|
-
* token that was presented at SSE handshake time.
|
|
421
|
-
*
|
|
422
|
-
* Rules:
|
|
423
|
-
* - If no token was stored at handshake (unauthenticated connection), any
|
|
424
|
-
* incoming token (or lack thereof) is accepted — the session itself was
|
|
425
|
-
* already established without auth.
|
|
426
|
-
* - If a token WAS stored at handshake, the incoming request must supply the
|
|
427
|
-
* same token (timing-safe comparison). Missing or mismatched → reject.
|
|
428
|
-
*
|
|
429
|
-
* Exported for unit testing.
|
|
430
|
-
*/
|
|
431
|
-
export function verifySessionToken(storedTokenHash, incomingToken) {
|
|
432
|
-
if (!storedTokenHash) {
|
|
433
|
-
// Session was established without a token; no per-request check needed.
|
|
434
|
-
return true;
|
|
435
|
-
}
|
|
436
|
-
if (!incomingToken) {
|
|
437
|
-
return false;
|
|
438
|
-
}
|
|
439
|
-
const hashIncoming = createHash("sha256").update(incomingToken).digest("hex");
|
|
440
|
-
const storedBuf = Buffer.from(storedTokenHash, "hex");
|
|
441
|
-
const incomingBuf = Buffer.from(hashIncoming, "hex");
|
|
442
|
-
if (storedBuf.length !== incomingBuf.length)
|
|
443
|
-
return false;
|
|
444
|
-
return timingSafeEqual(storedBuf, incomingBuf);
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* Builds a shutdown handler that calls stopReaper() with a timeout guard,
|
|
448
|
-
* then closes resources and exits. Exported for unit testing.
|
|
449
|
-
*/
|
|
450
|
-
export function makeShutdownHandler(opts) {
|
|
451
|
-
const { stopReaper, closeables, stopReaperTimeoutMs = 5000 } = opts;
|
|
452
|
-
return () => {
|
|
453
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("stopReaper timed out")), stopReaperTimeoutMs));
|
|
454
|
-
let exitCode = 0;
|
|
455
|
-
Promise.race([stopReaper(), timeoutPromise])
|
|
456
|
-
.catch((err) => {
|
|
457
|
-
console.error("[shutdown] stopReaper failed:", err);
|
|
458
|
-
exitCode = 1;
|
|
459
|
-
})
|
|
460
|
-
.finally(() => {
|
|
461
|
-
for (const c of closeables)
|
|
462
|
-
c.close();
|
|
463
|
-
process.exit(exitCode);
|
|
464
|
-
});
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
export function extractBearerToken(header) {
|
|
468
|
-
if (!header)
|
|
469
|
-
return undefined;
|
|
470
|
-
const lower = header.toLowerCase();
|
|
471
|
-
if (!lower.startsWith("bearer "))
|
|
472
|
-
return undefined;
|
|
473
|
-
return header.slice(7).trim() || undefined;
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* Resolves the MCP session ID for POST /messages routing.
|
|
477
|
-
*
|
|
478
|
-
* Prefers the X-Session-Id request header over the ?sessionId= query parameter.
|
|
479
|
-
* Using a header prevents the session ID from appearing in nginx/ALB/CloudTrail
|
|
480
|
-
* access logs, which would enable session hijacking.
|
|
481
|
-
*
|
|
482
|
-
* Exported for unit testing.
|
|
483
|
-
*/
|
|
484
|
-
export function resolveSessionId(headers, searchParams) {
|
|
485
|
-
const header = headers["x-session-id"];
|
|
486
|
-
if (header) {
|
|
487
|
-
return Array.isArray(header) ? header[0] : header;
|
|
488
|
-
}
|
|
489
|
-
return searchParams.get("sessionId") ?? "";
|
|
490
|
-
}
|
|
491
|
-
// Only run when invoked as the main entry point, not when imported as a module
|
|
492
|
-
const isMain = process.argv[1] != null && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
493
|
-
if (isMain) {
|
|
494
|
-
program.parseAsync(process.argv).catch((err) => {
|
|
495
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
496
|
-
process.exit(1);
|
|
497
|
-
});
|
|
498
|
-
}
|