@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.
Files changed (102) hide show
  1. package/dist/src/execution/cli.js +0 -0
  2. package/package.json +3 -2
  3. package/dist/api/router.d.ts +0 -24
  4. package/dist/api/router.js +0 -44
  5. package/dist/api/server.d.ts +0 -13
  6. package/dist/api/server.js +0 -280
  7. package/dist/api/wire-types.d.ts +0 -46
  8. package/dist/api/wire-types.js +0 -5
  9. package/dist/config/db-path.d.ts +0 -1
  10. package/dist/config/db-path.js +0 -1
  11. package/dist/config/exporter.d.ts +0 -3
  12. package/dist/config/exporter.js +0 -87
  13. package/dist/config/index.d.ts +0 -4
  14. package/dist/config/index.js +0 -4
  15. package/dist/config/seed-loader.d.ts +0 -10
  16. package/dist/config/seed-loader.js +0 -108
  17. package/dist/config/zod-schemas.d.ts +0 -165
  18. package/dist/config/zod-schemas.js +0 -283
  19. package/dist/cors.d.ts +0 -8
  20. package/dist/cors.js +0 -21
  21. package/dist/engine/constants.d.ts +0 -1
  22. package/dist/engine/constants.js +0 -1
  23. package/dist/engine/engine.d.ts +0 -69
  24. package/dist/engine/engine.js +0 -485
  25. package/dist/engine/event-emitter.d.ts +0 -9
  26. package/dist/engine/event-emitter.js +0 -19
  27. package/dist/engine/event-types.d.ts +0 -105
  28. package/dist/engine/event-types.js +0 -1
  29. package/dist/engine/flow-spawner.d.ts +0 -8
  30. package/dist/engine/flow-spawner.js +0 -28
  31. package/dist/engine/gate-command-validator.d.ts +0 -6
  32. package/dist/engine/gate-command-validator.js +0 -46
  33. package/dist/engine/gate-evaluator.d.ts +0 -12
  34. package/dist/engine/gate-evaluator.js +0 -233
  35. package/dist/engine/handlebars.d.ts +0 -9
  36. package/dist/engine/handlebars.js +0 -51
  37. package/dist/engine/index.d.ts +0 -12
  38. package/dist/engine/index.js +0 -7
  39. package/dist/engine/invocation-builder.d.ts +0 -18
  40. package/dist/engine/invocation-builder.js +0 -58
  41. package/dist/engine/on-enter.d.ts +0 -8
  42. package/dist/engine/on-enter.js +0 -102
  43. package/dist/engine/ssrf-guard.d.ts +0 -22
  44. package/dist/engine/ssrf-guard.js +0 -159
  45. package/dist/engine/state-machine.d.ts +0 -12
  46. package/dist/engine/state-machine.js +0 -74
  47. package/dist/execution/active-runner.d.ts +0 -45
  48. package/dist/execution/active-runner.js +0 -165
  49. package/dist/execution/admin-schemas.d.ts +0 -116
  50. package/dist/execution/admin-schemas.js +0 -125
  51. package/dist/execution/cli.d.ts +0 -57
  52. package/dist/execution/cli.js +0 -498
  53. package/dist/execution/handlers/admin.d.ts +0 -67
  54. package/dist/execution/handlers/admin.js +0 -200
  55. package/dist/execution/handlers/flow.d.ts +0 -25
  56. package/dist/execution/handlers/flow.js +0 -289
  57. package/dist/execution/handlers/query.d.ts +0 -31
  58. package/dist/execution/handlers/query.js +0 -64
  59. package/dist/execution/index.d.ts +0 -4
  60. package/dist/execution/index.js +0 -3
  61. package/dist/execution/mcp-helpers.d.ts +0 -42
  62. package/dist/execution/mcp-helpers.js +0 -23
  63. package/dist/execution/mcp-server.d.ts +0 -33
  64. package/dist/execution/mcp-server.js +0 -1020
  65. package/dist/execution/provision-worktree.d.ts +0 -16
  66. package/dist/execution/provision-worktree.js +0 -123
  67. package/dist/execution/tool-schemas.d.ts +0 -40
  68. package/dist/execution/tool-schemas.js +0 -44
  69. package/dist/logger.d.ts +0 -8
  70. package/dist/logger.js +0 -12
  71. package/dist/main.d.ts +0 -14
  72. package/dist/main.js +0 -28
  73. package/dist/repositories/drizzle/entity.repo.d.ts +0 -27
  74. package/dist/repositories/drizzle/entity.repo.js +0 -190
  75. package/dist/repositories/drizzle/event.repo.d.ts +0 -12
  76. package/dist/repositories/drizzle/event.repo.js +0 -24
  77. package/dist/repositories/drizzle/flow.repo.d.ts +0 -22
  78. package/dist/repositories/drizzle/flow.repo.js +0 -364
  79. package/dist/repositories/drizzle/gate.repo.d.ts +0 -16
  80. package/dist/repositories/drizzle/gate.repo.js +0 -98
  81. package/dist/repositories/drizzle/index.d.ts +0 -6
  82. package/dist/repositories/drizzle/index.js +0 -7
  83. package/dist/repositories/drizzle/invocation.repo.d.ts +0 -23
  84. package/dist/repositories/drizzle/invocation.repo.js +0 -199
  85. package/dist/repositories/drizzle/schema.d.ts +0 -1932
  86. package/dist/repositories/drizzle/schema.js +0 -155
  87. package/dist/repositories/drizzle/transition-log.repo.d.ts +0 -11
  88. package/dist/repositories/drizzle/transition-log.repo.js +0 -42
  89. package/dist/repositories/interfaces.d.ts +0 -321
  90. package/dist/repositories/interfaces.js +0 -2
  91. package/dist/utils/redact.d.ts +0 -2
  92. package/dist/utils/redact.js +0 -62
  93. package/gates/blocking-graph.d.ts +0 -26
  94. package/gates/blocking-graph.js +0 -102
  95. package/gates/test/bad-return-gate.d.ts +0 -1
  96. package/gates/test/bad-return-gate.js +0 -4
  97. package/gates/test/passing-gate.d.ts +0 -2
  98. package/gates/test/passing-gate.js +0 -3
  99. package/gates/test/slow-gate.d.ts +0 -2
  100. package/gates/test/slow-gate.js +0 -5
  101. package/gates/test/throwing-gate.d.ts +0 -1
  102. 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
- });
@@ -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;
@@ -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
- }