comber 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +732 -0
  3. package/action.yml +279 -0
  4. package/comber.config.example.json +64 -0
  5. package/dist/api/classify.js +99 -0
  6. package/dist/api/classify.js.map +1 -0
  7. package/dist/api/driver.js +235 -0
  8. package/dist/api/driver.js.map +1 -0
  9. package/dist/api/graphql-driver.js +150 -0
  10. package/dist/api/graphql-driver.js.map +1 -0
  11. package/dist/api/graphql.js +115 -0
  12. package/dist/api/graphql.js.map +1 -0
  13. package/dist/api/har-driver.js +167 -0
  14. package/dist/api/har-driver.js.map +1 -0
  15. package/dist/api/har.js +100 -0
  16. package/dist/api/har.js.map +1 -0
  17. package/dist/api/load.js +83 -0
  18. package/dist/api/load.js.map +1 -0
  19. package/dist/api/openapi.js +159 -0
  20. package/dist/api/openapi.js.map +1 -0
  21. package/dist/api/request.js +141 -0
  22. package/dist/api/request.js.map +1 -0
  23. package/dist/api/schema.js +126 -0
  24. package/dist/api/schema.js.map +1 -0
  25. package/dist/cli.js +352 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/comment.js +164 -0
  28. package/dist/comment.js.map +1 -0
  29. package/dist/config.js +664 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/connectors/builtin/discord.js +55 -0
  32. package/dist/connectors/builtin/github-checks.js +53 -0
  33. package/dist/connectors/builtin/junit.js +70 -0
  34. package/dist/connectors/builtin/linear.js +58 -0
  35. package/dist/connectors/builtin/slack.js +60 -0
  36. package/dist/connectors/builtin/webhook.js +39 -0
  37. package/dist/connectors/index.js +3 -0
  38. package/dist/connectors/loader.js +72 -0
  39. package/dist/connectors/registry.js +28 -0
  40. package/dist/connectors/types.js +23 -0
  41. package/dist/core/action-gate.js +48 -0
  42. package/dist/core/baseline.js +85 -0
  43. package/dist/core/baseline.js.map +1 -0
  44. package/dist/core/blind-spots.js +22 -0
  45. package/dist/core/blind-spots.js.map +1 -0
  46. package/dist/core/contract-extras.js +1 -0
  47. package/dist/core/contract-extras.js.map +1 -0
  48. package/dist/core/contract.js +1 -0
  49. package/dist/core/contract.js.map +1 -0
  50. package/dist/core/coverage.js +70 -0
  51. package/dist/core/coverage.js.map +1 -0
  52. package/dist/core/crawl.js +229 -0
  53. package/dist/core/crawl.js.map +1 -0
  54. package/dist/core/egress.js +128 -0
  55. package/dist/core/egress.js.map +1 -0
  56. package/dist/core/ledger.js +41 -0
  57. package/dist/core/ledger.js.map +1 -0
  58. package/dist/core/net.js +39 -0
  59. package/dist/core/pipeline.js +191 -0
  60. package/dist/core/pipeline.js.map +1 -0
  61. package/dist/core/plan.js +121 -0
  62. package/dist/core/plan.js.map +1 -0
  63. package/dist/core/redact.js +240 -0
  64. package/dist/core/redact.js.map +1 -0
  65. package/dist/core/replay.js +263 -0
  66. package/dist/core/replay.js.map +1 -0
  67. package/dist/core/report.js +253 -0
  68. package/dist/core/report.js.map +1 -0
  69. package/dist/core/safety-judge.js +116 -0
  70. package/dist/core/safety.js +92 -0
  71. package/dist/core/safety.js.map +1 -0
  72. package/dist/core/signal-normalize.js +190 -0
  73. package/dist/core/signal-normalize.js.map +1 -0
  74. package/dist/core/signals.js +5 -0
  75. package/dist/core/signals.js.map +1 -0
  76. package/dist/core/triage.js +104 -0
  77. package/dist/core/triage.js.map +1 -0
  78. package/dist/core/types.js +1 -0
  79. package/dist/core/types.js.map +1 -0
  80. package/dist/desktop/desktop-profile.js +6 -0
  81. package/dist/desktop/desktop-profile.js.map +1 -0
  82. package/dist/desktop/driver.js +433 -0
  83. package/dist/desktop/driver.js.map +1 -0
  84. package/dist/desktop/evidence.js +206 -0
  85. package/dist/desktop/evidence.js.map +1 -0
  86. package/dist/desktop/macos/accessibility-runner.js +118 -0
  87. package/dist/desktop/macos/accessibility-runner.js.map +1 -0
  88. package/dist/desktop/macos/types.js +1 -0
  89. package/dist/desktop/macos/types.js.map +1 -0
  90. package/dist/desktop/preflight.js +107 -0
  91. package/dist/desktop/preflight.js.map +1 -0
  92. package/dist/desktop/proxy-proof.js +185 -0
  93. package/dist/desktop/proxy-proof.js.map +1 -0
  94. package/dist/desktop/view.js +348 -0
  95. package/dist/desktop/view.js.map +1 -0
  96. package/dist/desktop/windows/types.js +1 -0
  97. package/dist/desktop/windows/types.js.map +1 -0
  98. package/dist/desktop/windows/uia-runner.js +127 -0
  99. package/dist/desktop/windows/uia-runner.js.map +1 -0
  100. package/dist/native/apple.js +14 -0
  101. package/dist/native/apple.js.map +1 -0
  102. package/dist/native/device-profile.js +6 -0
  103. package/dist/native/device-profile.js.map +1 -0
  104. package/dist/native/driver.js +416 -0
  105. package/dist/native/driver.js.map +1 -0
  106. package/dist/native/evidence.js +164 -0
  107. package/dist/native/evidence.js.map +1 -0
  108. package/dist/native/load.js +9 -0
  109. package/dist/native/load.js.map +1 -0
  110. package/dist/native/maestro.js +153 -0
  111. package/dist/native/maestro.js.map +1 -0
  112. package/dist/native/preflight.js +72 -0
  113. package/dist/native/preflight.js.map +1 -0
  114. package/dist/native/proxy-proof.js +182 -0
  115. package/dist/native/proxy-proof.js.map +1 -0
  116. package/dist/native/samsung.js +34 -0
  117. package/dist/native/samsung.js.map +1 -0
  118. package/dist/native/signals.js +50 -0
  119. package/dist/native/signals.js.map +1 -0
  120. package/dist/native/view.js +139 -0
  121. package/dist/native/view.js.map +1 -0
  122. package/dist/profile.js +237 -0
  123. package/dist/profile.js.map +1 -0
  124. package/dist/sink.js +134 -0
  125. package/dist/sink.js.map +1 -0
  126. package/dist/sinks/file.js +49 -0
  127. package/dist/sinks/file.js.map +1 -0
  128. package/dist/sinks/http.js +38 -0
  129. package/dist/sinks/http.js.map +1 -0
  130. package/dist/sinks/index.js +5 -0
  131. package/dist/sinks/index.js.map +1 -0
  132. package/dist/sinks/multi.js +40 -0
  133. package/dist/sinks/multi.js.map +1 -0
  134. package/dist/sinks/paths.js +32 -0
  135. package/dist/sinks/paths.js.map +1 -0
  136. package/dist/sinks/record.js +58 -0
  137. package/dist/sinks/record.js.map +1 -0
  138. package/dist/sinks/types.js +1 -0
  139. package/dist/sinks/types.js.map +1 -0
  140. package/dist/web/breakage.js +210 -0
  141. package/dist/web/breakage.js.map +1 -0
  142. package/dist/web/controls.js +102 -0
  143. package/dist/web/controls.js.map +1 -0
  144. package/dist/web/driver.js +803 -0
  145. package/dist/web/driver.js.map +1 -0
  146. package/dist/web/elements.js +151 -0
  147. package/dist/web/elements.js.map +1 -0
  148. package/dist/web/fingerprint.js +13 -0
  149. package/dist/web/fingerprint.js.map +1 -0
  150. package/dist/web/forms.js +74 -0
  151. package/dist/web/forms.js.map +1 -0
  152. package/dist/web/links.js +194 -0
  153. package/dist/web/links.js.map +1 -0
  154. package/dist/web/types.js +1 -0
  155. package/dist/web/types.js.map +1 -0
  156. package/package.json +80 -0
  157. package/runtime/macos-accessibility-runner.js +275 -0
  158. package/runtime/windows-uia-runner.ps1 +539 -0
package/dist/config.js ADDED
@@ -0,0 +1,664 @@
1
+ import { config as loadEnv } from "dotenv";
2
+ import { loadNativeEgressProxyEvidenceFromProof } from "./native/proxy-proof.js";
3
+ import { loadDesktopEgressProxyEvidenceFromProof } from "./desktop/proxy-proof.js";
4
+ loadEnv();
5
+ /**
6
+ * Model IDs are pinned here (authoritative values from the runtime environment,
7
+ * 2026-06). Change in ONE place if they move. Traversal is deterministic + heuristic
8
+ * (`orderForExhaustion`); the ONE model call is the post-crawl Opus judge that triages
9
+ * ambiguous findings. (A Haiku action-picker is a planned enhancement — not wired yet, so
10
+ * it is deliberately NOT listed here or claimed in the CLI banner.)
11
+ */
12
+ export const MODELS = {
13
+ /** Breakage triage / "is this a genuine defect?" judge (non-adversarial quality call). */
14
+ judge: "claude-opus-4-8",
15
+ /** ADVERSARIAL "is this ACTION safe?" gate. Fable 5 — best judgment + dual-use safety
16
+ * training; this is the one gate a malicious page actively tries to fool, so robustness
17
+ * beats cost. PINNED here now; CONSUMED by a later increment that wires the LLM safety-judge
18
+ * onto the action-floor's `escalate` verdict (`core/action-gate.ts`). It fires only off the
19
+ * hot path, at low/medium effort, and must fail closed on a `refusal` (Comber is
20
+ * security-adjacent, so the cyber classifier will occasionally decline → server-side fallback
21
+ * to the judge model). See claude-brain PROJECTS/RADLAB-QA-AGENT/HOSTED_THREAT_MODEL.md §3a. */
22
+ safetyJudge: "claude-fable-5",
23
+ };
24
+ /**
25
+ * Destructive-action denylist. Any interactive element whose accessible name
26
+ * matches this is OBSERVED, never clicked — the non-negotiable guard against a
27
+ * synthetic run nuking data or charging a card. Tune per-app via config.
28
+ */
29
+ export const DESTRUCTIVE_DENYLIST = /\b(delete|remove|destroy|cancel|unsubscribe|pay|payment|purchase|buy|checkout|charge|refund|transfer|withdraw|wire|send ?money|place ?order|confirm ?order|submit ?payment|archive|deactivate|close account|sign ?out|log ?out)\b/i;
30
+ /** Destructive intent in a link/form target URL — gated even if the name looks benign. */
31
+ export const DESTRUCTIVE_HREF_DENYLIST = /(\/logout|\/signout|\/delete|\/destroy|\/cancel|\/checkout|\/billing|\/pay|\/purchase|\/unsubscribe|\/deactivate)\b/i;
32
+ /**
33
+ * Network-egress URL denylist — ANY request (XHR/form/nav) to a clearly-destructive or
34
+ * money endpoint is aborted at the wire regardless of element name. Always on (the real
35
+ * safety backstop, "an HTTP verb does not lie"). `--block-writes` additionally aborts ALL
36
+ * mutating methods (POST/PUT/PATCH/DELETE) — the paranoid "only GET/HEAD/OPTIONS" mode —
37
+ * but that's opt-in because many SPAs legitimately POST for reads (GraphQL) and blocking
38
+ * those makes the app look broken to the crawler.
39
+ */
40
+ export const EGRESS_URL_DENYLIST = /(\/checkout|\/billing|\/subscribe|\/logout|\/signout|\/account\/delete|\/api\/.*\/(delete|cancel|charge|refund|transfer)|stripe\.com\/v1|api\.paypal\.com|js\.stripe\.com\/v1\/(tokens|charges|sources))/i;
41
+ const VALUE_FLAGS = new Set([
42
+ "api-base",
43
+ "hosted",
44
+ "app",
45
+ "app-id",
46
+ "app-path",
47
+ "automation-backend",
48
+ "baseline",
49
+ "bundle-id",
50
+ "config",
51
+ "deeplink",
52
+ "device",
53
+ "device-class",
54
+ "device-model",
55
+ "egress-proxy-artifact",
56
+ "egress-proxy-command",
57
+ "egress-proxy-provider",
58
+ "egress-proxy-proof",
59
+ "evidence-accessibility-tree",
60
+ "evidence-automation-tree",
61
+ "evidence-crash-log",
62
+ "evidence-log",
63
+ "evidence-screenshot",
64
+ "evidence-video",
65
+ "graphql",
66
+ "har",
67
+ "api-timeout",
68
+ "app-version",
69
+ "android-version",
70
+ "build-fingerprint",
71
+ "link-concurrency",
72
+ "link-timeout",
73
+ "max-actions",
74
+ "max-external-links",
75
+ "max-ms",
76
+ "max-per-state",
77
+ "max-regotos",
78
+ "max-states",
79
+ "max-tokens",
80
+ "max-visits-per-route",
81
+ "near-dup",
82
+ "openapi",
83
+ "out",
84
+ "os-version",
85
+ "platform",
86
+ "provider",
87
+ "provider-run-id",
88
+ "pr-comment",
89
+ "profile",
90
+ "process-id",
91
+ "replay",
92
+ "replay-out",
93
+ "seed",
94
+ "storage",
95
+ "one-ui",
96
+ "process-name",
97
+ "window-title",
98
+ "launch-command",
99
+ "verification-command",
100
+ ]);
101
+ /** Every boolean (no-value) flag Comber accepts. Kept next to VALUE_FLAGS so the two
102
+ * together are the authoritative known-flag set the unknown-flag guard validates against. */
103
+ const BOOLEAN_FLAGS = new Set([
104
+ "allow-private-target",
105
+ "allow-writes",
106
+ "block-writes",
107
+ "cross-origin",
108
+ "deterministic",
109
+ "dry-run",
110
+ "external-links",
111
+ "no-external-links",
112
+ "headed",
113
+ "llm",
114
+ "no-llm",
115
+ "no-screenshots",
116
+ "samsung",
117
+ "egress-proxy-verified",
118
+ "accessibility-permission-verified",
119
+ "strict-gate", // fail-closed action-floor for untrusted/hosted targets
120
+ "strict", // consumed in cli.ts, listed here so it is not flagged as unknown
121
+ "help",
122
+ "version",
123
+ "h",
124
+ "V",
125
+ ]);
126
+ /**
127
+ * Reject an unknown/misspelled `--flag`. Without this a typo like `--max-state 5` (missing
128
+ * the trailing `s`) is silently ignored: the value becomes a positional, Comber crawls the
129
+ * literal "5", visits 0 states, and — worst of all — a `--strick` typo silently disables CI
130
+ * gating while the job stays green. A one-character typo must fail loudly, not fail open.
131
+ */
132
+ function rejectUnknownFlags(argv) {
133
+ for (const arg of argv) {
134
+ if (!arg.startsWith("--") || arg === "--")
135
+ continue;
136
+ const name = arg.slice(2).split("=", 1)[0] ?? "";
137
+ if (name === "")
138
+ continue;
139
+ if (!VALUE_FLAGS.has(name) && !BOOLEAN_FLAGS.has(name)) {
140
+ throw new Error(`unknown flag --${name}. Run \`comber --help\` for the full flag list.`);
141
+ }
142
+ }
143
+ }
144
+ function positionalArgs(argv) {
145
+ const out = [];
146
+ for (let i = 0; i < argv.length; i++) {
147
+ const arg = argv[i];
148
+ if (!arg.startsWith("--")) {
149
+ out.push(arg);
150
+ continue;
151
+ }
152
+ const flagName = arg.slice(2).split("=", 1)[0] ?? "";
153
+ if (!arg.includes("=") && VALUE_FLAGS.has(flagName))
154
+ i++;
155
+ }
156
+ return out;
157
+ }
158
+ function combineRegex(base, additions, name) {
159
+ if (!additions || additions.length === 0)
160
+ return base;
161
+ const sources = [base.source];
162
+ for (const raw of additions) {
163
+ try {
164
+ sources.push(new RegExp(raw, base.flags).source);
165
+ }
166
+ catch {
167
+ throw new Error(`${name} contains an invalid regex: ${raw}`);
168
+ }
169
+ }
170
+ return new RegExp(sources.join("|"), base.flags);
171
+ }
172
+ function originOf(url) {
173
+ try {
174
+ return new URL(url).origin;
175
+ }
176
+ catch {
177
+ return undefined;
178
+ }
179
+ }
180
+ function parseDeviceClass(value) {
181
+ if (value === undefined)
182
+ return undefined;
183
+ if (value === "phone" || value === "tablet" || value === "foldable" || value === "emulator" || value === "simulator" || value === "unknown") {
184
+ return value;
185
+ }
186
+ throw new Error(`--device-class must be phone|tablet|foldable|emulator|simulator|unknown (got ${value})`);
187
+ }
188
+ function artifactsForKind(kind, paths) {
189
+ const out = [];
190
+ for (const path of paths ?? []) {
191
+ out.push({ kind, path });
192
+ }
193
+ return out;
194
+ }
195
+ function desktopArtifactsForKind(kind, paths) {
196
+ const out = [];
197
+ for (const path of paths ?? []) {
198
+ out.push({ kind, path });
199
+ }
200
+ return out;
201
+ }
202
+ function isDesktopAutomationBackend(value) {
203
+ return value === "accessibility" || value === "xctest" || value === "uia" || value === "unknown";
204
+ }
205
+ /**
206
+ * Resolve the multi-role auth set (role → token) for the IDOR / broken-auth harness. Merges the
207
+ * config-file map with the $COMBER_API_AUTH_PROFILES env JSON (env WINS — tokens belong in env,
208
+ * never a committed config and never a CLI flag). Empty tokens are dropped. Returns undefined
209
+ * when no profiles are configured (the harness is opt-in).
210
+ */
211
+ function resolveAuthProfiles(configMap) {
212
+ const merged = { ...(configMap ?? {}) };
213
+ const env = process.env.COMBER_API_AUTH_PROFILES;
214
+ if (env) {
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(env);
218
+ }
219
+ catch {
220
+ throw new Error("COMBER_API_AUTH_PROFILES must be a JSON object of { role: token }");
221
+ }
222
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
223
+ throw new Error("COMBER_API_AUTH_PROFILES must be a JSON object of { role: token }");
224
+ }
225
+ for (const [role, token] of Object.entries(parsed)) {
226
+ if (typeof token === "string")
227
+ merged[role] = token;
228
+ }
229
+ }
230
+ const profiles = Object.entries(merged)
231
+ .filter(([, token]) => typeof token === "string" && token.length > 0)
232
+ .map(([role, token]) => ({ role, token }));
233
+ return profiles.length ? profiles : undefined;
234
+ }
235
+ /** Reject `..` traversal in a config-controlled WRITE path (out dir / PR comment).
236
+ * A committed comber.config.json must not be able to direct a write outside the
237
+ * tree via `../../`. Absolute paths are allowed (explicit operator intent). */
238
+ function rejectTraversal(p, name) {
239
+ if (p === undefined)
240
+ return undefined;
241
+ if (p.replace(/\\/g, "/").split("/").includes("..")) {
242
+ throw new Error(`${name} must not contain '..' path traversal: ${p}`);
243
+ }
244
+ return p;
245
+ }
246
+ export function loadConfig(argv, profile = {}) {
247
+ rejectUnknownFlags(argv);
248
+ const positional = positionalArgs(argv);
249
+ const flag = (name) => {
250
+ const hit = argv.find((a) => a.startsWith(`--${name}=`));
251
+ if (hit)
252
+ return hit.slice(name.length + 3);
253
+ const idx = argv.indexOf(`--${name}`);
254
+ if (idx >= 0) {
255
+ const next = argv[idx + 1];
256
+ if (next && !next.startsWith("--"))
257
+ return next;
258
+ }
259
+ return undefined;
260
+ };
261
+ const has = (name) => argv.includes(`--${name}`);
262
+ // Validate the RESOLVED value (CLI flag OR the profile-supplied fallback), not
263
+ // just the CLI raw — otherwise a profile typo like `"maxStates": -3` slips past
264
+ // and silently yields a 0-state "clean" run. parseProfile only checks finiteness.
265
+ const intFlag = (name, fallback, min = 1) => {
266
+ const raw = flag(name);
267
+ const value = raw === undefined ? fallback : Number(raw);
268
+ if (!Number.isInteger(value) || value < min) {
269
+ throw new Error(`--${name} (or its profile value) must be an integer >= ${min} (got ${value})`);
270
+ }
271
+ return value;
272
+ };
273
+ const numberFlag = (name, fallback, min, max) => {
274
+ const raw = flag(name);
275
+ const value = raw === undefined ? fallback : Number(raw);
276
+ if (!Number.isFinite(value) || value < min || value > max) {
277
+ throw new Error(`--${name} (or its profile value) must be a number between ${min} and ${max} (got ${value})`);
278
+ }
279
+ return value;
280
+ };
281
+ const optionalIntFlag = (name, fallback, min = 1) => {
282
+ const raw = flag(name);
283
+ const rawValue = raw === undefined ? fallback : raw;
284
+ if (rawValue === undefined)
285
+ return undefined;
286
+ const value = typeof rawValue === "number" ? rawValue : Number(rawValue);
287
+ if (!Number.isInteger(value) || value < min) {
288
+ throw new Error(`--${name} (or its profile value) must be an integer >= ${min} (got ${rawValue})`);
289
+ }
290
+ return value;
291
+ };
292
+ const strFlag = (name, fallback) => flag(name) ?? fallback;
293
+ /** Collect EVERY occurrence of a repeatable flag (`--deeplink a --deeplink b`). */
294
+ const multiFlag = (name) => {
295
+ const out = [];
296
+ for (let i = 0; i < argv.length; i++) {
297
+ const a = argv[i];
298
+ if (a.startsWith(`--${name}=`))
299
+ out.push(a.slice(name.length + 3));
300
+ else if (a === `--${name}`) {
301
+ const next = argv[i + 1];
302
+ if (next && !next.startsWith("--"))
303
+ out.push(next);
304
+ }
305
+ }
306
+ return out;
307
+ };
308
+ const repeatableFlag = (name, fallback) => {
309
+ const values = multiFlag(name);
310
+ return values.length ? values : fallback ?? [];
311
+ };
312
+ // -- driver selection: the composition root picks WebDriver (positional URL, the existing
313
+ // behavior) vs ApiDriver (`api` / `--openapi`) vs NativeDriver (`native` / `--app`)
314
+ // vs DesktopDriver (`desktop` / `--app-path` or `--process-name`).
315
+ const desktopMode = positional[0] === "desktop" ||
316
+ flag("app-path") !== undefined ||
317
+ flag("process-name") !== undefined ||
318
+ flag("process-id") !== undefined ||
319
+ profile.target === "desktop" ||
320
+ profile.appPath !== undefined ||
321
+ profile.processName !== undefined ||
322
+ profile.processId !== undefined;
323
+ const nativeMode = !desktopMode &&
324
+ (positional[0] === "native" || flag("app") !== undefined || profile.target === "native" || profile.app !== undefined);
325
+ const graphqlSel = strFlag("graphql", profile.graphql);
326
+ const harSel = strFlag("har", profile.har);
327
+ const apiMode = !desktopMode &&
328
+ !nativeMode &&
329
+ (positional[0] === "api" ||
330
+ flag("openapi") !== undefined ||
331
+ graphqlSel !== undefined ||
332
+ harSel !== undefined ||
333
+ profile.target === "api" ||
334
+ profile.openapi !== undefined);
335
+ const target = desktopMode ? "desktop" : nativeMode ? "native" : apiMode ? "api" : "web";
336
+ let startUrl;
337
+ let apiSourceKind;
338
+ let openapiSource;
339
+ let graphqlUrl;
340
+ let harPath;
341
+ let apiBase;
342
+ let apiSeedDataPath;
343
+ let apiRoleSeedPath;
344
+ let apiOwnerScoped;
345
+ let apiToken;
346
+ let apiAuthProfiles;
347
+ let desktopPlatform;
348
+ let desktopAppId;
349
+ let desktopAppPath;
350
+ let desktopProcessName;
351
+ let desktopProcessId;
352
+ let desktopWindowTitle;
353
+ let desktopAppProfile;
354
+ let nativeApp;
355
+ let nativePlatform;
356
+ let nativeDevice;
357
+ let nativeDeeplinks;
358
+ let nativeDeviceProfile;
359
+ if (desktopMode) {
360
+ const platformRaw = strFlag("platform", profile.platform);
361
+ const resolvedPlatform = platformRaw ?? "windows";
362
+ if (resolvedPlatform !== "windows" && resolvedPlatform !== "macos") {
363
+ throw new Error(`desktop platform must be windows or macos (got ${resolvedPlatform})`);
364
+ }
365
+ desktopPlatform = resolvedPlatform;
366
+ desktopAppId = strFlag("bundle-id", strFlag("app-id", profile.app));
367
+ desktopAppPath = rejectTraversal(strFlag("app-path", profile.appPath), "--app-path / appPath");
368
+ desktopProcessName = strFlag("process-name", profile.processName);
369
+ desktopProcessId = optionalIntFlag("process-id", profile.processId);
370
+ desktopWindowTitle = strFlag("window-title", profile.windowTitle);
371
+ if (desktopPlatform !== "windows" && desktopProcessId !== undefined) {
372
+ throw new Error("desktop --process-id is only supported for the Windows UIA backend");
373
+ }
374
+ if (!desktopAppId && !desktopAppPath && !desktopProcessName && desktopProcessId === undefined) {
375
+ throw new Error("usage: comber desktop --platform windows|macos (--app-path PATH | --process-name NAME | --app-id ID | --process-id PID)");
376
+ }
377
+ const backendRaw = strFlag("automation-backend", profile.automationBackend) ?? (desktopPlatform === "windows" ? "uia" : "accessibility");
378
+ if (!isDesktopAutomationBackend(backendRaw)) {
379
+ throw new Error(`desktop automation backend must be accessibility, xctest, uia, or unknown (got ${backendRaw})`);
380
+ }
381
+ const backend = backendRaw;
382
+ if (desktopPlatform === "windows" && backend !== "uia") {
383
+ throw new Error(`desktop Windows automation backend must be uia (got ${backendRaw})`);
384
+ }
385
+ if (desktopPlatform === "macos" && backend !== "accessibility" && backend !== "xctest") {
386
+ throw new Error(`desktop macOS automation backend must be accessibility or xctest (got ${backendRaw})`);
387
+ }
388
+ const provider = strFlag("provider", profile.provider);
389
+ const providerRunId = strFlag("provider-run-id", profile.providerRunId);
390
+ const launchCommand = strFlag("launch-command", profile.launchCommand) ??
391
+ (desktopPlatform === "windows"
392
+ ? desktopAppPath
393
+ ? `Start-Process ${desktopAppPath}`
394
+ : desktopProcessId !== undefined
395
+ ? `Attach-Process ${desktopProcessId}`
396
+ : `Attach-Process ${desktopProcessName ?? desktopAppId}`
397
+ : desktopAppPath
398
+ ? `open -n ${desktopAppPath}`
399
+ : desktopAppId
400
+ ? `open -nb ${desktopAppId}`
401
+ : `open -na ${desktopProcessName}`);
402
+ const verificationCommand = strFlag("verification-command", profile.verificationCommand);
403
+ const evidenceArtifacts = [
404
+ ...(profile.desktopEvidenceArtifacts ?? []),
405
+ ...desktopArtifactsForKind("screenshot", repeatableFlag("evidence-screenshot", profile.evidenceScreenshots)),
406
+ ...desktopArtifactsForKind("video", repeatableFlag("evidence-video", profile.evidenceVideos)),
407
+ ...desktopArtifactsForKind("log", repeatableFlag("evidence-log", profile.evidenceLogs)),
408
+ ...desktopArtifactsForKind("crash-log", repeatableFlag("evidence-crash-log", profile.evidenceCrashLogs)),
409
+ ...desktopArtifactsForKind("automation-tree", repeatableFlag("evidence-automation-tree", profile.evidenceAutomationTrees)),
410
+ ...desktopArtifactsForKind("accessibility-tree", repeatableFlag("evidence-accessibility-tree", profile.evidenceAccessibilityTrees)),
411
+ ];
412
+ const egressProxyVerified = has("egress-proxy-verified") || profile.egressProxyVerified === true || profile.egressProxy?.verified === true;
413
+ const egressProxyProofPath = rejectTraversal(strFlag("egress-proxy-proof", profile.egressProxyProof), "--egress-proxy-proof / egressProxyProof");
414
+ const egressProxyProof = egressProxyProofPath !== undefined ? loadDesktopEgressProxyEvidenceFromProof(egressProxyProofPath) : undefined;
415
+ const desktopEgressProxyVerified = egressProxyVerified || egressProxyProof?.verified === true;
416
+ const egressProxyProvider = strFlag("egress-proxy-provider", profile.egressProxyProvider ?? profile.egressProxy?.provider ?? egressProxyProof?.provider);
417
+ const egressProxyCommand = strFlag("egress-proxy-command", profile.egressProxyCommand ?? profile.egressProxy?.command ?? egressProxyProof?.command);
418
+ const egressProxyArtifact = strFlag("egress-proxy-artifact", profile.egressProxyArtifact ?? profile.egressProxy?.artifactPath ?? egressProxyProof?.artifactPath);
419
+ const includeEgressProxy = desktopEgressProxyVerified ||
420
+ egressProxyProvider !== undefined ||
421
+ egressProxyCommand !== undefined ||
422
+ egressProxyArtifact !== undefined ||
423
+ egressProxyProof !== undefined;
424
+ const accessibilityPermissionVerified = has("accessibility-permission-verified") || profile.accessibilityPermissionVerified === true;
425
+ desktopAppProfile = {
426
+ platform: desktopPlatform,
427
+ ...(desktopAppId !== undefined ? { appId: desktopAppId } : {}),
428
+ ...(desktopAppPath !== undefined ? { appPath: desktopAppPath } : {}),
429
+ ...(desktopProcessName !== undefined ? { processName: desktopProcessName } : {}),
430
+ ...(desktopProcessId !== undefined ? { processId: desktopProcessId } : {}),
431
+ ...(desktopWindowTitle !== undefined ? { windowTitle: desktopWindowTitle } : {}),
432
+ automationBackend: backend,
433
+ ...(provider !== undefined ? { provider } : {}),
434
+ ...(providerRunId !== undefined ? { providerRunId } : {}),
435
+ launchCommand,
436
+ ...(verificationCommand !== undefined ? { verificationCommand } : {}),
437
+ ...(evidenceArtifacts.length > 0 ? { evidenceArtifacts } : {}),
438
+ ...(desktopPlatform === "windows"
439
+ ? { uiAutomationTreeAvailable: evidenceArtifacts.some((artifact) => artifact.kind === "automation-tree") }
440
+ : { accessibilityPermissionVerified }),
441
+ ...(includeEgressProxy
442
+ ? {
443
+ egressProxy: {
444
+ verified: desktopEgressProxyVerified,
445
+ ...(egressProxyProvider !== undefined ? { provider: egressProxyProvider } : {}),
446
+ ...(egressProxyCommand !== undefined ? { command: egressProxyCommand } : {}),
447
+ ...(egressProxyArtifact !== undefined ? { artifactPath: egressProxyArtifact } : {}),
448
+ },
449
+ }
450
+ : {}),
451
+ };
452
+ startUrl = desktopAppPath ?? (desktopProcessId !== undefined ? `pid:${desktopProcessId}` : desktopProcessName ?? desktopAppId);
453
+ }
454
+ else if (nativeMode) {
455
+ nativeApp = strFlag("app", profile.app);
456
+ if (!nativeApp) {
457
+ throw new Error("usage: comber native --app <appId|apk|path> [--platform android|ios] [--device ID] [--deeplink URI]…");
458
+ }
459
+ const platformRaw = strFlag("platform", profile.platform) ?? "android";
460
+ if (platformRaw !== "android" && platformRaw !== "ios") {
461
+ throw new Error(`--platform must be "android" or "ios" (got ${platformRaw})`);
462
+ }
463
+ nativePlatform = platformRaw;
464
+ nativeDevice = strFlag("device", profile.device);
465
+ const cliDeeplinks = multiFlag("deeplink");
466
+ nativeDeeplinks = cliDeeplinks.length ? cliDeeplinks : profile.deeplinks ?? [];
467
+ const samsungRequested = has("samsung") || profile.samsung === true;
468
+ const deviceClass = parseDeviceClass(strFlag("device-class", profile.deviceClass));
469
+ const deviceModel = strFlag("device-model", profile.deviceModel);
470
+ const osVersion = strFlag("os-version", profile.osVersion);
471
+ const androidVersion = strFlag("android-version", profile.androidVersion);
472
+ const appVersion = strFlag("app-version", profile.appVersion);
473
+ const provider = strFlag("provider", profile.provider);
474
+ const providerRunId = strFlag("provider-run-id", profile.providerRunId);
475
+ const verificationCommand = strFlag("verification-command", profile.verificationCommand);
476
+ const oneUiVersion = strFlag("one-ui", profile.oneUiVersion);
477
+ const buildFingerprint = strFlag("build-fingerprint", profile.buildFingerprint);
478
+ const includeSamsungProfile = samsungRequested || oneUiVersion !== undefined || buildFingerprint !== undefined;
479
+ const evidenceArtifacts = [
480
+ ...(profile.evidenceArtifacts ?? []),
481
+ ...artifactsForKind("screenshot", repeatableFlag("evidence-screenshot", profile.evidenceScreenshots)),
482
+ ...artifactsForKind("video", repeatableFlag("evidence-video", profile.evidenceVideos)),
483
+ ...artifactsForKind("log", repeatableFlag("evidence-log", profile.evidenceLogs)),
484
+ ...artifactsForKind("crash-log", repeatableFlag("evidence-crash-log", profile.evidenceCrashLogs)),
485
+ ];
486
+ const egressProxyProofPath = rejectTraversal(strFlag("egress-proxy-proof", profile.egressProxyProof), "--egress-proxy-proof / egressProxyProof");
487
+ const egressProxyProof = egressProxyProofPath !== undefined ? loadNativeEgressProxyEvidenceFromProof(egressProxyProofPath) : undefined;
488
+ const egressProxyVerified = has("egress-proxy-verified") ||
489
+ profile.egressProxyVerified === true ||
490
+ profile.egressProxy?.verified === true ||
491
+ egressProxyProof?.verified === true;
492
+ const egressProxyProvider = strFlag("egress-proxy-provider", profile.egressProxyProvider ?? profile.egressProxy?.provider ?? egressProxyProof?.provider);
493
+ const egressProxyCommand = strFlag("egress-proxy-command", profile.egressProxyCommand ?? profile.egressProxy?.command ?? egressProxyProof?.command);
494
+ const egressProxyArtifact = strFlag("egress-proxy-artifact", profile.egressProxyArtifact ?? profile.egressProxy?.artifactPath ?? egressProxyProof?.artifactPath);
495
+ const includeEgressProxy = egressProxyVerified ||
496
+ egressProxyProvider !== undefined ||
497
+ egressProxyCommand !== undefined ||
498
+ egressProxyArtifact !== undefined ||
499
+ egressProxyProof !== undefined;
500
+ nativeDeviceProfile = {
501
+ platform: nativePlatform,
502
+ appId: nativeApp,
503
+ ...(nativeDevice !== undefined ? { deviceId: nativeDevice } : {}),
504
+ ...(deviceClass !== undefined ? { deviceClass } : {}),
505
+ ...(deviceModel !== undefined ? { model: deviceModel } : {}),
506
+ ...(osVersion !== undefined ? { osVersion } : {}),
507
+ ...(androidVersion !== undefined ? { androidVersion } : {}),
508
+ ...(appVersion !== undefined ? { appVersion } : {}),
509
+ ...(provider !== undefined ? { provider } : {}),
510
+ ...(providerRunId !== undefined ? { providerRunId } : {}),
511
+ ...(verificationCommand !== undefined ? { verificationCommand } : {}),
512
+ ...(evidenceArtifacts.length > 0 ? { evidenceArtifacts } : {}),
513
+ ...(includeEgressProxy
514
+ ? {
515
+ egressProxy: {
516
+ verified: egressProxyVerified,
517
+ ...(egressProxyProvider !== undefined ? { provider: egressProxyProvider } : {}),
518
+ ...(egressProxyCommand !== undefined ? { command: egressProxyCommand } : {}),
519
+ ...(egressProxyArtifact !== undefined ? { artifactPath: egressProxyArtifact } : {}),
520
+ },
521
+ }
522
+ : {}),
523
+ ...(includeSamsungProfile
524
+ ? {
525
+ samsung: {
526
+ requested: samsungRequested,
527
+ ...(deviceModel !== undefined ? { model: deviceModel } : {}),
528
+ ...(oneUiVersion !== undefined ? { oneUiVersion } : {}),
529
+ ...(buildFingerprint !== undefined ? { buildFingerprint } : {}),
530
+ },
531
+ }
532
+ : {}),
533
+ };
534
+ startUrl = nativeApp; // reuse startUrl for redaction/sink/result identity (the app under test)
535
+ }
536
+ else if (apiMode) {
537
+ // Pick the spec source: --graphql <url> | --har <path> | (default) --openapi <path|url>.
538
+ if (graphqlSel !== undefined) {
539
+ apiSourceKind = "graphql";
540
+ graphqlUrl = graphqlSel;
541
+ startUrl = graphqlUrl; // the endpoint IS the identity (no separate base)
542
+ }
543
+ else if (harSel !== undefined) {
544
+ apiSourceKind = "har";
545
+ harPath = harSel;
546
+ // HAR entries carry absolute URLs; --api-base is optional and only an identity hint.
547
+ startUrl = strFlag("api-base", profile.apiBase) ?? harPath;
548
+ }
549
+ else {
550
+ openapiSource = strFlag("openapi", profile.openapi);
551
+ apiBase = strFlag("api-base", profile.apiBase);
552
+ if (!openapiSource) {
553
+ throw new Error("usage: comber api --openapi <path|url> --api-base <url> [--allow-writes] [--seed PATH]\n" +
554
+ " or: comber api --graphql <url> | comber api --har <path>");
555
+ }
556
+ if (!apiBase) {
557
+ throw new Error("api mode requires --api-base <url> (no default base — point Comber at a base you own)");
558
+ }
559
+ apiSourceKind = /\.ya?ml$/i.test(openapiSource) ? "openapi-yaml" : "openapi-json";
560
+ startUrl = apiBase; // reuse startUrl for origin/redaction/sink/result identity
561
+ }
562
+ apiSeedDataPath = strFlag("seed", profile.seed);
563
+ apiRoleSeedPath = profile.roleSeed;
564
+ apiOwnerScoped = profile.ownerScoped;
565
+ apiToken = process.env.COMBER_API_TOKEN || undefined;
566
+ apiAuthProfiles = resolveAuthProfiles(profile.apiAuthProfiles);
567
+ }
568
+ else {
569
+ const url = positional[0] ?? profile.startUrl;
570
+ if (!url) {
571
+ throw new Error("usage: comber <url> [--profile NAME] [--config PATH] [--max-states N] [--max-actions N] [--headed] [--llm]\n" +
572
+ " or: comber api --openapi <path|url> --api-base <url> [--allow-writes] [--seed PATH]");
573
+ }
574
+ startUrl = url;
575
+ }
576
+ // Hosted metered inference: --hosted <broker-url> (or $COMBER_HOSTED_URL) points the LLM at our
577
+ // broker instead of api.anthropic.com. In hosted mode the credential is the Comber LICENSE (the
578
+ // broker swaps in the funded key); BYO mode uses the Anthropic key directly. Either is sent by the
579
+ // SDK as x-api-key, so a single `anthropicKey` slot carries whichever applies.
580
+ const hostedInferenceUrl = strFlag("hosted", process.env.COMBER_HOSTED_URL) || undefined;
581
+ const anthropicKey = hostedInferenceUrl
582
+ ? process.env.COMBER_LICENSE || process.env.ANTHROPIC_API_KEY || undefined
583
+ : process.env.ANTHROPIC_API_KEY || undefined;
584
+ const cliLlm = has("llm");
585
+ const noLlm = has("no-llm");
586
+ if (cliLlm && noLlm) {
587
+ throw new Error("--llm and --no-llm cannot be used together");
588
+ }
589
+ const llmRequested = !noLlm && (cliLlm || profile.llm === true);
590
+ if (llmRequested && !anthropicKey) {
591
+ throw new Error(hostedInferenceUrl
592
+ ? "--llm with --hosted requires COMBER_LICENSE (or ANTHROPIC_API_KEY)"
593
+ : "--llm requires ANTHROPIC_API_KEY");
594
+ }
595
+ const startOrigin = originOf(startUrl);
596
+ const allowedOrigins = new Set();
597
+ if (startOrigin)
598
+ allowedOrigins.add(startOrigin);
599
+ for (const origin of profile.allowedOrigins ?? []) {
600
+ allowedOrigins.add(origin);
601
+ }
602
+ return {
603
+ target,
604
+ startUrl,
605
+ apiSourceKind,
606
+ openapiSource,
607
+ graphqlUrl,
608
+ harPath,
609
+ apiBase,
610
+ apiSeedDataPath,
611
+ apiRoleSeedPath,
612
+ apiOwnerScoped,
613
+ apiToken,
614
+ apiAuthProfiles,
615
+ apiAllowPrivateTarget: has("allow-private-target") || profile.allowPrivateTarget === true,
616
+ apiTimeoutMs: intFlag("api-timeout", profile.apiTimeoutMs ?? 15_000),
617
+ nativeApp,
618
+ nativePlatform,
619
+ nativeDevice,
620
+ nativeDeeplinks,
621
+ nativeDeviceProfile,
622
+ desktopPlatform,
623
+ desktopAppId,
624
+ desktopAppPath,
625
+ desktopProcessName,
626
+ desktopProcessId,
627
+ desktopWindowTitle,
628
+ desktopAppProfile,
629
+ profileName: profile.name,
630
+ allowedOrigins: [...allowedOrigins],
631
+ maxStates: intFlag("max-states", profile.maxStates ?? 40),
632
+ maxActions: intFlag("max-actions", profile.maxActions ?? 400),
633
+ maxWallClockMs: intFlag("max-ms", profile.maxWallClockMs ?? 5 * 60_000),
634
+ maxTokenBudget: intFlag("max-tokens", profile.maxTokenBudget ?? 200_000),
635
+ maxActionsPerState: intFlag("max-per-state", profile.maxActionsPerState ?? 40),
636
+ maxRegotosPerState: intFlag("max-regotos", profile.maxRegotosPerState ?? 8, 0),
637
+ maxVisitsPerRoute: intFlag("max-visits-per-route", profile.maxVisitsPerRoute ?? 3),
638
+ nearDupThreshold: numberFlag("near-dup", profile.nearDupThreshold ?? 0.92, 0, 1),
639
+ checkExternalLinks: (has("external-links") || profile.externalLinks === true) && !has("no-external-links"),
640
+ maxExternalLinks: intFlag("max-external-links", profile.maxExternalLinks ?? 300, 0),
641
+ linkTimeoutMs: intFlag("link-timeout", profile.linkTimeoutMs ?? 10_000),
642
+ linkConcurrency: intFlag("link-concurrency", profile.linkConcurrency ?? 8),
643
+ sameOriginOnly: !(has("cross-origin") || profile.crossOrigin === true),
644
+ screenshots: !has("no-screenshots") && profile.screenshots !== false,
645
+ useLLM: llmRequested && Boolean(anthropicKey),
646
+ deterministic: has("deterministic") || profile.deterministic === true,
647
+ dryRun: has("dry-run") || profile.dryRun === true,
648
+ blockWrites: has("block-writes") || (!has("allow-writes") && profile.allowWrites !== true),
649
+ actionGateMode: has("strict-gate") || profile.strictGate === true ? "strict" : "advisory",
650
+ egressUrlDenylist: EGRESS_URL_DENYLIST,
651
+ anthropicKey,
652
+ hostedInferenceUrl,
653
+ storageStatePath: process.env.COMBER_STORAGE_STATE || strFlag("storage", profile.storageStatePath),
654
+ baselinePath: strFlag("baseline", profile.baselinePath),
655
+ prCommentPath: rejectTraversal(strFlag("pr-comment", profile.prCommentPath), "--pr-comment / prComment"),
656
+ headless: has("headed") ? false : profile.headless ?? true,
657
+ outDir: rejectTraversal(strFlag("out", profile.outDir), "--out / outDir") ?? "runs",
658
+ denylist: combineRegex(DESTRUCTIVE_DENYLIST, profile.denylist, "denylist"),
659
+ destructiveHrefDenylist: combineRegex(DESTRUCTIVE_HREF_DENYLIST, profile.destructiveHrefDenylist, "destructiveHrefDenylist"),
660
+ replayPlanPath: strFlag("replay", profile.replay),
661
+ replayOutPath: rejectTraversal(strFlag("replay-out", profile.replayOut), "--replay-out / replayOut"),
662
+ connectors: profile.connectors,
663
+ };
664
+ }