@dadado/agent-kit-cli 4.8.9 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Cosmetic Mission Kit / BYO share URLs for dashboard-broadcast.
3
+ * Fragment-only payloads (never sent to Hostinger access logs).
4
+ * ADR: .cursor/memory/decisions/2026-08-11_mission-control-broadcast-url-mask.md
5
+ */
6
+
7
+ /** Live Hostinger path uses the `.html` artifact; extensionless `/mc/open` may 404 until a host alias exists. */
8
+ export const DEFAULT_SHARE_BASE = "https://missionkit.io/mc/open.html";
9
+ export const SHARE_BASE_ENV = "MISSION_CONTROL_SHARE_BASE";
10
+ export const SHARE_TTL_ENV = "MISSION_CONTROL_SHARE_TTL_SEC";
11
+ export const SHARE_SHOW_LAN_ENV = "MISSION_CONTROL_SHARE_SHOW_LAN";
12
+ export const DEFAULT_SHARE_TTL_SEC = 86_400;
13
+
14
+ /**
15
+ * True when hostname is loopback, link-local, or RFC1918 private (IPv4) / ULA (IPv6).
16
+ * Used by share-target validation (open redirect harden).
17
+ * @param {string} hostname
18
+ * @returns {boolean}
19
+ */
20
+ export function isPrivateOrLoopbackHostname(hostname) {
21
+ const host = String(hostname || "")
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/^\[|\]$/g, "");
25
+ if (!host) return false;
26
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
27
+ if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
28
+ // IPv4 dotted quad
29
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
30
+ if (m) {
31
+ const a = Number(m[1]);
32
+ const b = Number(m[2]);
33
+ const c = Number(m[3]);
34
+ const d = Number(m[4]);
35
+ if ([a, b, c, d].some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
36
+ if (a === 127) return true; // loopback
37
+ if (a === 10) return true; // 10/8
38
+ if (a === 192 && b === 168) return true; // 192.168/16
39
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
40
+ if (a === 169 && b === 254) return true; // link-local
41
+ return false;
42
+ }
43
+ // IPv6 ULA fc00::/7 and link-local fe80::/10
44
+ if (host.includes(":")) {
45
+ if (host.startsWith("fc") || host.startsWith("fd")) return true;
46
+ if (
47
+ host.startsWith("fe8") ||
48
+ host.startsWith("fe9") ||
49
+ host.startsWith("fea") ||
50
+ host.startsWith("feb")
51
+ ) {
52
+ return true;
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Validate a decoded share target URL (LAN Mission Control with optional ?token=).
60
+ * @param {string} url
61
+ * @returns {{ ok: true, url: string } | { ok: false, error: string }}
62
+ */
63
+ export function validateBroadcastShareTarget(url) {
64
+ const raw = typeof url === "string" ? url.trim() : "";
65
+ if (!raw) return { ok: false, error: "invalid-target" };
66
+ let parsed;
67
+ try {
68
+ parsed = new URL(raw);
69
+ } catch {
70
+ return { ok: false, error: "invalid-target" };
71
+ }
72
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
73
+ return { ok: false, error: "invalid-target" };
74
+ }
75
+ if (!isPrivateOrLoopbackHostname(parsed.hostname)) {
76
+ return { ok: false, error: "non-private-target" };
77
+ }
78
+ return { ok: true, url: raw };
79
+ }
80
+
81
+ /**
82
+ * Normalize and validate a share page base. Rejects non-HTTPS (except loopback http for local preview).
83
+ * @param {string} raw
84
+ * @returns {{ ok: true, base: string } | { ok: false, error: string }}
85
+ */
86
+ export function normalizeShareBase(raw) {
87
+ const trimmed = String(raw || "")
88
+ .trim()
89
+ .replace(/#.*$/, "")
90
+ .replace(/\/$/, "");
91
+ if (!trimmed) return { ok: false, error: "empty-base" };
92
+ let parsed;
93
+ try {
94
+ parsed = new URL(trimmed);
95
+ } catch {
96
+ return { ok: false, error: "invalid-base" };
97
+ }
98
+ const host = parsed.hostname.toLowerCase();
99
+ const loopback =
100
+ host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost");
101
+ if (parsed.protocol === "https:") {
102
+ return { ok: true, base: trimmed };
103
+ }
104
+ if (parsed.protocol === "http:" && loopback) {
105
+ return { ok: true, base: trimmed };
106
+ }
107
+ return { ok: false, error: "non-https-base" };
108
+ }
109
+
110
+ /**
111
+ * GET /open.html and /open are the public cosmetic share-resolver shell under broadcast bind.
112
+ * All other methods/paths keep the normal token gate.
113
+ * @param {string} method
114
+ * @param {string} pathname
115
+ * @returns {boolean}
116
+ */
117
+ export function isPublicBroadcastShareShell(method, pathname) {
118
+ return method === "GET" && (pathname === "/open.html" || pathname === "/open");
119
+ }
120
+
121
+ /**
122
+ * @param {boolean} tokenRequired
123
+ * @param {string} method
124
+ * @param {string} pathname
125
+ * @returns {boolean}
126
+ */
127
+ export function shareShellTokenRequired(tokenRequired, method, pathname) {
128
+ return Boolean(tokenRequired) && !isPublicBroadcastShareShell(method, pathname);
129
+ }
130
+
131
+ /**
132
+ * Resolve share page base URL. Empty / "0" / "off" / "false" disables masking.
133
+ * Non-HTTPS BYO bases (except loopback http) are rejected → masking off (with stderr warn when available).
134
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
135
+ * @returns {string | null}
136
+ */
137
+ export function resolveShareBase(env = process.env) {
138
+ const raw = env?.[SHARE_BASE_ENV];
139
+ if (raw === undefined || raw === null) return DEFAULT_SHARE_BASE;
140
+ const trimmed = String(raw).trim();
141
+ if (!trimmed) return null;
142
+ const lower = trimmed.toLowerCase();
143
+ if (lower === "0" || lower === "off" || lower === "false" || lower === "none") return null;
144
+ const normalized = normalizeShareBase(trimmed);
145
+ if (!normalized.ok) {
146
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
147
+ console.warn(
148
+ `[broadcast-share] ${SHARE_BASE_ENV} rejected (${normalized.error}); printing LAN URL only. Use HTTPS (or loopback http) or set off.`,
149
+ );
150
+ }
151
+ return null;
152
+ }
153
+ return normalized.base;
154
+ }
155
+
156
+ /**
157
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
158
+ * @returns {number}
159
+ */
160
+ export function resolveShareTtlSec(env = process.env) {
161
+ const raw = env?.[SHARE_TTL_ENV];
162
+ if (raw === undefined || raw === null || String(raw).trim() === "") {
163
+ return DEFAULT_SHARE_TTL_SEC;
164
+ }
165
+ const n = Number.parseInt(String(raw), 10);
166
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_SHARE_TTL_SEC;
167
+ return n;
168
+ }
169
+
170
+ /**
171
+ * When false, starter omits secondary LAN/Local URL lines (share + token only).
172
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
173
+ */
174
+ export function resolveShareShowLan(env = process.env) {
175
+ const raw = env?.[SHARE_SHOW_LAN_ENV];
176
+ if (raw === undefined || raw === null || String(raw).trim() === "") return true;
177
+ const lower = String(raw).trim().toLowerCase();
178
+ return !(lower === "0" || lower === "off" || lower === "false" || lower === "no");
179
+ }
180
+
181
+ /**
182
+ * @param {string} lanUrl full http URL including ?token=
183
+ * @param {{ ttlSec?: number, nowSec?: number }} [opts]
184
+ * @returns {string} fragment without leading '#'
185
+ */
186
+ export function encodeBroadcastSharePayload(lanUrl, opts = {}) {
187
+ const url = typeof lanUrl === "string" ? lanUrl.trim() : "";
188
+ const target = validateBroadcastShareTarget(url);
189
+ if (!target.ok) {
190
+ throw new Error(`encodeBroadcastSharePayload: ${target.error}`);
191
+ }
192
+ const ttlSec = opts.ttlSec ?? DEFAULT_SHARE_TTL_SEC;
193
+ /** @type {{ v: number, u: string, e?: number }} */
194
+ const body = { v: 1, u: target.url };
195
+ if (ttlSec > 0) {
196
+ const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
197
+ body.e = now + ttlSec;
198
+ }
199
+ return `v1.${Buffer.from(JSON.stringify(body), "utf8").toString("base64url")}`;
200
+ }
201
+
202
+ /**
203
+ * @param {string} lanUrl
204
+ * @param {{ base?: string | null, ttlSec?: number, nowSec?: number }} [opts]
205
+ * @returns {string | null}
206
+ */
207
+ export function buildBroadcastShareUrl(lanUrl, opts = {}) {
208
+ const base = opts.base === undefined ? DEFAULT_SHARE_BASE : opts.base;
209
+ if (!base) return null;
210
+ const cleaned = String(base).replace(/#.*$/, "").replace(/\/$/, "");
211
+ const frag = encodeBroadcastSharePayload(lanUrl, opts);
212
+ return `${cleaned}#${frag}`;
213
+ }
214
+
215
+ /**
216
+ * @param {string} fragment hash with or without leading '#'
217
+ * @param {{ nowSec?: number }} [opts]
218
+ * @returns
219
+ * | { ok: true, url: string, expiresAt: number | null }
220
+ * | { ok: false, error: string }
221
+ */
222
+ export function decodeBroadcastShareFragment(fragment, opts = {}) {
223
+ const raw = String(fragment || "")
224
+ .replace(/^#/, "")
225
+ .trim();
226
+ if (!raw) return { ok: false, error: "missing-fragment" };
227
+ const m = /^v1\.([A-Za-z0-9_-]+)$/.exec(raw);
228
+ if (!m) return { ok: false, error: "unsupported-version" };
229
+ let parsed;
230
+ try {
231
+ const json = Buffer.from(m[1], "base64url").toString("utf8");
232
+ parsed = JSON.parse(json);
233
+ } catch {
234
+ return { ok: false, error: "invalid-payload" };
235
+ }
236
+ if (!parsed || typeof parsed !== "object" || parsed.v !== 1) {
237
+ return { ok: false, error: "unsupported-version" };
238
+ }
239
+ const url = typeof parsed.u === "string" ? parsed.u.trim() : "";
240
+ const target = validateBroadcastShareTarget(url);
241
+ if (!target.ok) return { ok: false, error: target.error };
242
+ let expiresAt = null;
243
+ if (parsed.e !== undefined && parsed.e !== null) {
244
+ const e = Number(parsed.e);
245
+ if (!Number.isFinite(e)) return { ok: false, error: "invalid-expiry" };
246
+ expiresAt = e;
247
+ const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
248
+ if (now > e) return { ok: false, error: "expired" };
249
+ }
250
+ return { ok: true, url: target.url, expiresAt };
251
+ }
@@ -0,0 +1,10 @@
1
+ /** Ambient types for dashboard/lib/guards.mjs (consumed by CLI TypeScript). */
2
+
3
+ export function resolveContextConfigPath(
4
+ repoRoot: string,
5
+ fsHooks?: {
6
+ existsSync?: (path: string) => boolean;
7
+ realpathSync?: (path: string) => string;
8
+ mkdirSync?: (path: string, opts?: { recursive?: boolean }) => void;
9
+ },
10
+ ): { ok: true; path: string } | { ok: false; error: string };
@@ -445,7 +445,10 @@ export const CONFIG_PERSONA_IDS = Object.freeze(["autopilot", "night-shift", "gh
445
445
  export const CONFIG_PERSONA_MODES = Object.freeze(["continue-plan", "run-plan", "cli-run-plan"]);
446
446
 
447
447
  /** Allowed externalPlanReview.backend values. */
448
- export const CONFIG_REVIEW_BACKENDS = Object.freeze(["claude"]);
448
+ export const CONFIG_REVIEW_BACKENDS = Object.freeze(["auto", "claude", "cursor"]);
449
+
450
+ /** Named reviewer / advisor / implementer model id (no secrets). */
451
+ const REVIEW_MODEL_ID = /^[A-Za-z0-9._:+-]{1,64}$/;
449
452
 
450
453
  /** Allowed externalPlanReview.mode values (audits arming path). */
451
454
  export const CONFIG_REVIEW_MODES = Object.freeze(["paste", "autonomous"]);
@@ -591,6 +594,10 @@ export function validateConfigWriteBody(body) {
591
594
  const eprAllowed = new Set([
592
595
  "enabled",
593
596
  "backend",
597
+ "reviewerModel",
598
+ "advisorModel",
599
+ "waitSliceSeconds",
600
+ "waitTimeoutSeconds",
594
601
  "autoRemediate",
595
602
  "offerOnExhausted",
596
603
  "mode",
@@ -612,10 +619,45 @@ export function validateConfigWriteBody(body) {
612
619
  }
613
620
  if ("backend" in epr) {
614
621
  if (typeof epr.backend !== "string" || !CONFIG_REVIEW_BACKENDS.includes(epr.backend)) {
615
- return { ok: false, error: "externalPlanReview.backend must be a known backend" };
622
+ return { ok: false, error: "externalPlanReview.backend must be auto, claude, or cursor" };
616
623
  }
617
624
  eprPatch.backend = epr.backend;
618
625
  }
626
+ if ("reviewerModel" in epr) {
627
+ if (
628
+ typeof epr.reviewerModel !== "string" ||
629
+ !REVIEW_MODEL_ID.test(epr.reviewerModel.trim())
630
+ ) {
631
+ return { ok: false, error: "externalPlanReview.reviewerModel must be a model id" };
632
+ }
633
+ eprPatch.reviewerModel = epr.reviewerModel.trim();
634
+ }
635
+ if ("advisorModel" in epr) {
636
+ if (typeof epr.advisorModel !== "string" || !REVIEW_MODEL_ID.test(epr.advisorModel.trim())) {
637
+ return { ok: false, error: "externalPlanReview.advisorModel must be a model id" };
638
+ }
639
+ eprPatch.advisorModel = epr.advisorModel.trim();
640
+ }
641
+ if ("waitSliceSeconds" in epr) {
642
+ const n = epr.waitSliceSeconds;
643
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 3600) {
644
+ return {
645
+ ok: false,
646
+ error: "externalPlanReview.waitSliceSeconds must be an integer 1..3600",
647
+ };
648
+ }
649
+ eprPatch.waitSliceSeconds = n;
650
+ }
651
+ if ("waitTimeoutSeconds" in epr) {
652
+ const n = epr.waitTimeoutSeconds;
653
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 86400) {
654
+ return {
655
+ ok: false,
656
+ error: "externalPlanReview.waitTimeoutSeconds must be an integer 1..86400",
657
+ };
658
+ }
659
+ eprPatch.waitTimeoutSeconds = n;
660
+ }
619
661
  if ("autoRemediate" in epr) {
620
662
  if (typeof epr.autoRemediate !== "boolean") {
621
663
  return { ok: false, error: "externalPlanReview.autoRemediate must be boolean" };
@@ -675,12 +717,17 @@ export function validateConfigWriteBody(body) {
675
717
  if (!modes || typeof modes !== "object" || Array.isArray(modes)) {
676
718
  return { ok: false, error: "agentPersona.modes must be an object" };
677
719
  }
678
- /** @type {Record<string, string>} */
720
+ /** @type {Record<string, string | null>} */
679
721
  const modesPatch = {};
680
722
  for (const [mode, persona] of Object.entries(modes)) {
681
723
  if (!CONFIG_PERSONA_MODES.includes(mode)) {
682
724
  return { ok: false, error: `unknown agentPersona.modes key: ${mode}` };
683
725
  }
726
+ // null clears an existing mode override (Inherit default).
727
+ if (persona === null) {
728
+ modesPatch[mode] = null;
729
+ continue;
730
+ }
684
731
  if (typeof persona !== "string" || !CONFIG_PERSONA_IDS.includes(persona)) {
685
732
  return { ok: false, error: `agentPersona.modes.${mode} must be a builtin persona id` };
686
733
  }
@@ -767,7 +814,15 @@ export function mergeConfigAllowlist(existing, patch) {
767
814
  prev.modes && typeof prev.modes === "object" && !Array.isArray(prev.modes)
768
815
  ? { ...prev.modes }
769
816
  : {};
770
- next.modes = { ...prevModes, ...patch.agentPersona.modes };
817
+ const nextModes = { ...prevModes };
818
+ for (const [mode, persona] of Object.entries(patch.agentPersona.modes)) {
819
+ if (persona === null) {
820
+ delete nextModes[mode];
821
+ } else {
822
+ nextModes[mode] = persona;
823
+ }
824
+ }
825
+ next.modes = nextModes;
771
826
  }
772
827
  base.agentPersona = next;
773
828
  }
@@ -834,6 +889,18 @@ export function allowlistConfig(raw) {
834
889
  if (typeof raw.externalPlanReview.preflight === "string") {
835
890
  epr.preflight = truncateStr(raw.externalPlanReview.preflight, 16);
836
891
  }
892
+ if (typeof raw.externalPlanReview.reviewerModel === "string") {
893
+ epr.reviewerModel = truncateStr(raw.externalPlanReview.reviewerModel, 64);
894
+ }
895
+ if (typeof raw.externalPlanReview.advisorModel === "string") {
896
+ epr.advisorModel = truncateStr(raw.externalPlanReview.advisorModel, 64);
897
+ }
898
+ if (typeof raw.externalPlanReview.waitSliceSeconds === "number") {
899
+ epr.waitSliceSeconds = raw.externalPlanReview.waitSliceSeconds;
900
+ }
901
+ if (typeof raw.externalPlanReview.waitTimeoutSeconds === "number") {
902
+ epr.waitTimeoutSeconds = raw.externalPlanReview.waitTimeoutSeconds;
903
+ }
837
904
  summary.externalPlanReview = epr;
838
905
  }
839
906
  if (raw.agentPersona && typeof raw.agentPersona === "object") {
@@ -0,0 +1,31 @@
1
+ import type { spawn, spawnSync } from "node:child_process";
2
+ import type { readFileSync } from "node:fs";
3
+
4
+ export const OS_DEFAULT_TOKENS: Set<string>;
5
+ export function isSafePreferredBrowser(value: string): boolean;
6
+ export function shouldSkipOpen(env?: NodeJS.ProcessEnv): boolean;
7
+ export function normalizePreferredBrowser(value: unknown): string | null;
8
+ export function resolvePreferredBrowser(opts?: {
9
+ env?: NodeJS.ProcessEnv;
10
+ configValue?: unknown;
11
+ }): string | null;
12
+ export function readPreferredBrowserFromConfig(
13
+ configPath: string,
14
+ fsHooks?: { readFileSync?: typeof readFileSync },
15
+ ): unknown;
16
+ export function buildOpenBrowserCommand(opts: {
17
+ url: string;
18
+ preferred?: string | null;
19
+ platform?: NodeJS.Platform;
20
+ }): { command: string; args: string[] } | null;
21
+ export function openBrowser(
22
+ url: string,
23
+ options?: {
24
+ env?: NodeJS.ProcessEnv;
25
+ preferred?: string | null;
26
+ configValue?: unknown;
27
+ platform?: NodeJS.Platform;
28
+ spawnFn?: typeof spawn;
29
+ spawnSyncFn?: typeof spawnSync;
30
+ },
31
+ ): { opened: boolean; reason?: string; command?: string; args?: string[] };