@singbox-iac/cli 0.1.17 → 0.1.19

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 (83) hide show
  1. package/README-en.md +117 -20
  2. package/README.md +54 -2
  3. package/dist/cli/commands/apply.js +3 -0
  4. package/dist/cli/commands/apply.js.map +1 -1
  5. package/dist/cli/commands/build.js +15 -1
  6. package/dist/cli/commands/build.js.map +1 -1
  7. package/dist/cli/commands/diagnose.js +21 -5
  8. package/dist/cli/commands/diagnose.js.map +1 -1
  9. package/dist/cli/commands/proxifier.js +13 -4
  10. package/dist/cli/commands/proxifier.js.map +1 -1
  11. package/dist/cli/commands/reload.js +14 -1
  12. package/dist/cli/commands/reload.js.map +1 -1
  13. package/dist/cli/commands/restart.js +52 -8
  14. package/dist/cli/commands/restart.js.map +1 -1
  15. package/dist/cli/commands/rollback.js +1 -0
  16. package/dist/cli/commands/rollback.js.map +1 -1
  17. package/dist/cli/commands/setup.js +2 -1
  18. package/dist/cli/commands/setup.js.map +1 -1
  19. package/dist/cli/commands/start.js +92 -11
  20. package/dist/cli/commands/start.js.map +1 -1
  21. package/dist/cli/commands/status.js +12 -6
  22. package/dist/cli/commands/status.js.map +1 -1
  23. package/dist/cli/commands/stop.js +24 -8
  24. package/dist/cli/commands/stop.js.map +1 -1
  25. package/dist/cli/commands/verify.js +20 -2
  26. package/dist/cli/commands/verify.js.map +1 -1
  27. package/dist/config/load-config.js +24 -1
  28. package/dist/config/load-config.js.map +1 -1
  29. package/dist/config/schema.d.ts +173 -29
  30. package/dist/config/schema.js +63 -1
  31. package/dist/config/schema.js.map +1 -1
  32. package/dist/domain/dns-plan.d.ts +11 -1
  33. package/dist/domain/intent.d.ts +2 -2
  34. package/dist/domain/verification-plan.d.ts +3 -3
  35. package/dist/modules/authoring/index.js +22 -2
  36. package/dist/modules/authoring/index.js.map +1 -1
  37. package/dist/modules/bundle-registry/index.d.ts +8 -2
  38. package/dist/modules/bundle-registry/index.js +54 -13
  39. package/dist/modules/bundle-registry/index.js.map +1 -1
  40. package/dist/modules/compiler/index.js +79 -12
  41. package/dist/modules/compiler/index.js.map +1 -1
  42. package/dist/modules/desktop-runtime/index.d.ts +50 -0
  43. package/dist/modules/desktop-runtime/index.js +236 -16
  44. package/dist/modules/desktop-runtime/index.js.map +1 -1
  45. package/dist/modules/diagnostics/index.js +93 -0
  46. package/dist/modules/diagnostics/index.js.map +1 -1
  47. package/dist/modules/dns-plan/index.js +73 -12
  48. package/dist/modules/dns-plan/index.js.map +1 -1
  49. package/dist/modules/doctor/index.js +41 -0
  50. package/dist/modules/doctor/index.js.map +1 -1
  51. package/dist/modules/intent/index.js +2 -2
  52. package/dist/modules/intent/index.js.map +1 -1
  53. package/dist/modules/layered-authoring/index.js +1 -1
  54. package/dist/modules/layered-authoring/index.js.map +1 -1
  55. package/dist/modules/manager/index.d.ts +3 -1
  56. package/dist/modules/manager/index.js +37 -2
  57. package/dist/modules/manager/index.js.map +1 -1
  58. package/dist/modules/natural-language/index.d.ts +3 -2
  59. package/dist/modules/natural-language/index.js +83 -35
  60. package/dist/modules/natural-language/index.js.map +1 -1
  61. package/dist/modules/proxifier/index.js +3 -4
  62. package/dist/modules/proxifier/index.js.map +1 -1
  63. package/dist/modules/runtime-mode/index.d.ts +1 -1
  64. package/dist/modules/runtime-mode/index.js +0 -5
  65. package/dist/modules/runtime-mode/index.js.map +1 -1
  66. package/dist/modules/runtime-watchdog/index.d.ts +10 -0
  67. package/dist/modules/runtime-watchdog/index.js +33 -0
  68. package/dist/modules/runtime-watchdog/index.js.map +1 -1
  69. package/dist/modules/status/index.d.ts +13 -0
  70. package/dist/modules/status/index.js +106 -9
  71. package/dist/modules/status/index.js.map +1 -1
  72. package/dist/modules/update/index.js +3 -1
  73. package/dist/modules/update/index.js.map +1 -1
  74. package/dist/modules/verification/index.d.ts +27 -5
  75. package/dist/modules/verification/index.js +542 -20
  76. package/dist/modules/verification/index.js.map +1 -1
  77. package/dist/modules/verification-plan/index.js +16 -6
  78. package/dist/modules/verification-plan/index.js.map +1 -1
  79. package/docs/natural-language-authoring.md +6 -0
  80. package/docs/runtime-modes.md +23 -7
  81. package/docs/runtime-on-macos.md +54 -9
  82. package/docs/sing-box-config-primer.md +10 -1
  83. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { access } from "node:fs/promises";
4
- import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
4
+ import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
5
5
  import net from "node:net";
6
6
  import { tmpdir } from "node:os";
7
7
  import path from "node:path";
8
+ import { isProcessRoot } from "../desktop-runtime/index.js";
8
9
  import { checkConfig, resolveSingBoxBinary } from "../manager/index.js";
9
10
  import { resolveChromeDependency } from "../runtime-dependencies/index.js";
10
11
  export function assertVerificationReportPassed(report) {
@@ -16,6 +17,7 @@ export function assertVerificationReportPassed(report) {
16
17
  .map((scenario) => `- ${scenario.name}\n url: ${scenario.url}\n details: ${scenario.details}`)
17
18
  .join("\n")}`);
18
19
  }
20
+ const nativeProcessProbeUrl = "https://example.com/";
19
21
  export async function verifyConfigRoutes(input) {
20
22
  const singBoxBinary = await resolveSingBoxBinary(input.singBoxBinary);
21
23
  const requestBinary = await resolveCurlBinary();
@@ -90,20 +92,175 @@ export function verifyDnsPlan(plan, dnsPlan) {
90
92
  export function verifyAppPlan(plan, config) {
91
93
  const route = asObject(config.route, "Config is missing route.");
92
94
  const rules = ensureArray(route.rules, "Route is missing rules.");
93
- const proxifierProtected = rules.some((rule) => Array.isArray(rule.inbound) &&
94
- rule.inbound.includes("in-proxifier") &&
95
- rule.action === "route" &&
96
- rule.outbound === "Process-Proxy");
97
95
  return plan.appChecks.map((check) => ({
98
96
  app: check.app,
99
- passed: check.expectedInbound === "in-proxifier" ? proxifierProtected : true,
100
- expectedInbound: check.expectedInbound,
97
+ passed: hasExpectedProcessAwareRoute(rules, check),
98
+ expectedCaptureBackend: check.expectedCaptureBackend,
101
99
  expectedOutboundGroup: check.expectedOutboundGroup,
102
- details: check.expectedInbound === "in-proxifier"
103
- ? "Protected in-proxifier route is present."
104
- : "No special app inbound is required.",
100
+ details: describeProcessAwareRouteResult(rules, check),
105
101
  }));
106
102
  }
103
+ export async function verifyNativeProcessE2E(input) {
104
+ const nativeChecks = input.plan.appChecks.filter((check) => check.expectedCaptureBackend === "native-process");
105
+ if (nativeChecks.length === 0) {
106
+ return [];
107
+ }
108
+ if (!isProcessRoot()) {
109
+ return nativeChecks.map((check) => ({
110
+ app: check.app,
111
+ status: "SKIP",
112
+ expectedOutboundGroup: check.expectedOutboundGroup,
113
+ details: "Native-process e2e requires root. Re-run `sudo singbox-iac verify app -c <builder.config.yaml>`.",
114
+ }));
115
+ }
116
+ const singBoxBinary = await resolveSingBoxBinary(input.singBoxBinary);
117
+ const cCompilerBinary = await resolveCCompilerBinary();
118
+ const rawConfig = JSON.parse(await readFile(input.configPath, "utf8"));
119
+ const results = [];
120
+ for (const check of nativeChecks) {
121
+ const prepared = await prepareVerificationConfig(rawConfig);
122
+ const conflictingOutbound = chooseConflictingOutbound(prepared.config, check.expectedOutboundGroup);
123
+ if (!conflictingOutbound) {
124
+ results.push({
125
+ app: check.app,
126
+ status: "SKIP",
127
+ expectedOutboundGroup: check.expectedOutboundGroup,
128
+ details: "No conflicting fallback outbound is available in the compiled config for native-process e2e.",
129
+ });
130
+ continue;
131
+ }
132
+ injectNativeProcessProbeRule(prepared.config, conflictingOutbound);
133
+ const processName = selectNativeProcessProbeName(prepared.config, check.expectedOutboundGroup);
134
+ if (!processName) {
135
+ results.push({
136
+ app: check.app,
137
+ status: "SKIP",
138
+ expectedOutboundGroup: check.expectedOutboundGroup,
139
+ details: "No concrete process_name matcher was available for native-process e2e. Add a plain processName matcher to the bundle or explicit process policy.",
140
+ });
141
+ continue;
142
+ }
143
+ const runDir = await mkdtemp(path.join(tmpdir(), "singbox-iac-native-process-"));
144
+ const verifyConfigPath = path.join(runDir, "native-process.config.json");
145
+ const logPath = path.join(runDir, "native-process.log");
146
+ const probeBinaryPath = path.join(runDir, processName);
147
+ await compileNativeProcessProbe({
148
+ runDir,
149
+ compilerBinary: cCompilerBinary,
150
+ probeBinaryPath,
151
+ });
152
+ injectNativeProcessPathProbeRule(prepared.config, probeBinaryPath, check.expectedOutboundGroup);
153
+ await writeFile(verifyConfigPath, `${JSON.stringify(prepared.config, null, 2)}\n`, "utf8");
154
+ await checkConfig({ configPath: verifyConfigPath, singBoxBinary });
155
+ const logBuffer = { text: "" };
156
+ const appender = async (chunk) => {
157
+ const text = chunk.toString();
158
+ logBuffer.text += text;
159
+ await writeFile(logPath, text, { encoding: "utf8", flag: "a" });
160
+ };
161
+ const child = spawn(singBoxBinary, ["run", "-c", verifyConfigPath], {
162
+ stdio: ["ignore", "pipe", "pipe"],
163
+ });
164
+ child.stdout.on("data", (chunk) => void appender(chunk));
165
+ child.stderr.on("data", (chunk) => void appender(chunk));
166
+ const exitPromise = new Promise((resolve, reject) => {
167
+ child.on("error", reject);
168
+ child.on("close", (code) => resolve(code ?? 0));
169
+ });
170
+ try {
171
+ await waitForLog(logBuffer, /sing-box started/, 15_000, "Timed out waiting for sing-box startup during native-process verification.");
172
+ const scenario = await verifyNativeProcessRuntimeScenario({
173
+ id: `native-process-${slugifyProcessName(processName)}`,
174
+ name: `native-process e2e for ${check.app}`,
175
+ url: nativeProcessProbeUrl,
176
+ inboundTag: "in-tun",
177
+ proxyPort: prepared.mixedPort,
178
+ expectedOutboundGroup: check.expectedOutboundGroup,
179
+ processName,
180
+ probeBinaryPath,
181
+ }, logBuffer);
182
+ results.push({
183
+ app: check.app,
184
+ status: scenario.passed ? "PASS" : "FAIL",
185
+ expectedOutboundGroup: check.expectedOutboundGroup,
186
+ processName,
187
+ details: scenario.passed
188
+ ? `Probe binary=${probeBinaryPath}; compiled process_name=${processName}; conflicting fallback=${conflictingOutbound}. ${scenario.details}`
189
+ : `Probe binary=${probeBinaryPath}; compiled process_name=${processName}; conflicting fallback=${conflictingOutbound}. ${scenario.details}`,
190
+ });
191
+ }
192
+ catch (error) {
193
+ results.push({
194
+ app: check.app,
195
+ status: "FAIL",
196
+ expectedOutboundGroup: check.expectedOutboundGroup,
197
+ processName,
198
+ details: error instanceof Error ? error.message : String(error),
199
+ });
200
+ }
201
+ finally {
202
+ child.kill("SIGINT");
203
+ await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 2_000))]);
204
+ }
205
+ }
206
+ return results;
207
+ }
208
+ export function assertNativeProcessE2EPassed(results) {
209
+ const failures = results.filter((result) => result.status === "FAIL");
210
+ if (failures.length === 0) {
211
+ return;
212
+ }
213
+ throw new Error(`Native-process e2e failed:\n${failures
214
+ .map((result) => `- ${result.app}${result.processName ? ` (${result.processName})` : ""}: ${result.details}`)
215
+ .join("\n")}`);
216
+ }
217
+ function hasExpectedProcessAwareRoute(rules, check) {
218
+ switch (check.expectedCaptureBackend) {
219
+ case "fallback":
220
+ return true;
221
+ case "proxifier":
222
+ return rules.some((rule) => Array.isArray(rule.inbound) &&
223
+ rule.inbound.includes("in-proxifier") &&
224
+ rule.outbound === check.expectedOutboundGroup);
225
+ case "native-process":
226
+ return rules.some((rule) => rule.outbound === check.expectedOutboundGroup &&
227
+ (Array.isArray(rule.process_name) ||
228
+ Array.isArray(rule.process_path) ||
229
+ Array.isArray(rule.process_path_regex)));
230
+ }
231
+ }
232
+ function describeProcessAwareRouteResult(rules, check) {
233
+ if (check.expectedCaptureBackend === "fallback") {
234
+ return "No capture backend was required for this app check.";
235
+ }
236
+ const matchedRule = rules.find((rule) => {
237
+ if (check.expectedCaptureBackend === "proxifier") {
238
+ return (Array.isArray(rule.inbound) &&
239
+ rule.inbound.includes("in-proxifier") &&
240
+ rule.outbound === check.expectedOutboundGroup);
241
+ }
242
+ return (rule.outbound === check.expectedOutboundGroup &&
243
+ (Array.isArray(rule.process_name) ||
244
+ Array.isArray(rule.process_path) ||
245
+ Array.isArray(rule.process_path_regex)));
246
+ });
247
+ if (!matchedRule) {
248
+ return `Expected ${check.expectedCaptureBackend} rule for ${check.expectedOutboundGroup}, but none was found.`;
249
+ }
250
+ const matchers = [
251
+ Array.isArray(matchedRule.inbound) ? `inbound=${matchedRule.inbound.join(",")}` : undefined,
252
+ Array.isArray(matchedRule.process_name)
253
+ ? `process_name=${matchedRule.process_name.join(",")}`
254
+ : undefined,
255
+ Array.isArray(matchedRule.process_path)
256
+ ? `process_path=${matchedRule.process_path.join(",")}`
257
+ : undefined,
258
+ Array.isArray(matchedRule.process_path_regex)
259
+ ? `process_path_regex=${matchedRule.process_path_regex.join(",")}`
260
+ : undefined,
261
+ ].filter((value) => typeof value === "string" && value.length > 0);
262
+ return `Matched ${check.expectedCaptureBackend} route to ${check.expectedOutboundGroup}${matchers.length > 0 ? ` (${matchers.join("; ")})` : ""}.`;
263
+ }
107
264
  export function verifyProtocolPlan(plan, config) {
108
265
  const route = asObject(config.route, "Config is missing route.");
109
266
  const rules = ensureArray(route.rules, "Route is missing rules.");
@@ -277,6 +434,52 @@ async function verifyRuntimeScenario(scenario, logBuffer, requestBinary) {
277
434
  expectedOutboundTag: scenario.expectedOutboundTag,
278
435
  };
279
436
  }
437
+ async function verifyNativeProcessRuntimeScenario(scenario, logBuffer) {
438
+ const hostname = new URL(scenario.url).hostname;
439
+ const inboundLog = new RegExp(`inbound/mixed\\[${escapeRegExp(scenario.inboundTag)}\\]: inbound connection to ${escapeRegExp(hostname)}:443`);
440
+ const routeLogAlternatives = [
441
+ new RegExp(`router: match\\[\\d+\\].*process_path[^\\n]*${escapeRegExp(scenario.probeBinaryPath)}[^\\n]*=> route\\(${escapeRegExp(scenario.expectedOutboundGroup)}\\)`),
442
+ new RegExp(`router: match\\[\\d+\\].*process_path[^\\n]*${escapeRegExp(path.basename(scenario.probeBinaryPath))}[^\\n]*=> route\\(${escapeRegExp(scenario.expectedOutboundGroup)}\\)`),
443
+ new RegExp(`router: match\\[\\d+\\].*process_name[^\\n]*${escapeRegExp(scenario.processName)}[^\\n]*=> route\\(${escapeRegExp(scenario.expectedOutboundGroup)}\\)`),
444
+ ];
445
+ let lastFailure = `Expected native-process logs were not observed for ${hostname} within the timeout.`;
446
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
447
+ const offset = logBuffer.text.length;
448
+ const requestResult = await runNativeProcessProbeScenario({
449
+ probeBinaryPath: scenario.probeBinaryPath,
450
+ proxyPort: scenario.proxyPort,
451
+ targetHost: hostname,
452
+ targetPort: 443,
453
+ });
454
+ const excerpt = await waitForScenarioLogsWithAlternatives(logBuffer, offset, [inboundLog], routeLogAlternatives, 20_000);
455
+ const requestFailure = detectRequestFailure(requestResult);
456
+ if (excerpt !== undefined) {
457
+ return {
458
+ name: scenario.name,
459
+ passed: true,
460
+ details: requestFailure === undefined
461
+ ? excerpt.trim()
462
+ : `${excerpt.trim()}\nRequest note: ${requestFailure}`,
463
+ url: scenario.url,
464
+ inboundTag: scenario.inboundTag,
465
+ expectedOutboundTag: scenario.expectedOutboundGroup,
466
+ };
467
+ }
468
+ lastFailure =
469
+ requestFailure ?? `Expected native-process logs were not observed for ${hostname} within the timeout.`;
470
+ if (attempt < 2) {
471
+ await sleep(500);
472
+ }
473
+ }
474
+ return {
475
+ name: scenario.name,
476
+ passed: false,
477
+ details: lastFailure,
478
+ url: scenario.url,
479
+ inboundTag: scenario.inboundTag,
480
+ expectedOutboundTag: scenario.expectedOutboundGroup,
481
+ };
482
+ }
280
483
  export function isRouteLevelProxySuccess(scenario, requestResult) {
281
484
  if (scenario.inboundTag !== "in-proxifier") {
282
485
  return false;
@@ -344,18 +547,59 @@ export async function resolveCurlBinary(explicitPath) {
344
547
  }
345
548
  throw new Error("Unable to find a usable curl binary for route verification.");
346
549
  }
550
+ export async function resolveCCompilerBinary(explicitPath) {
551
+ const candidates = [
552
+ explicitPath,
553
+ process.env.CC,
554
+ "/usr/bin/cc",
555
+ "/usr/bin/clang",
556
+ ...resolvePathCandidates("cc"),
557
+ ...resolvePathCandidates("clang"),
558
+ ].filter((candidate) => typeof candidate === "string" && candidate.length > 0);
559
+ for (const candidate of candidates) {
560
+ if (await isExecutable(candidate)) {
561
+ return candidate;
562
+ }
563
+ }
564
+ throw new Error("Unable to find a usable C compiler for native-process verification.");
565
+ }
347
566
  export async function prepareVerificationConfig(config) {
348
567
  const cloned = structuredClone(config);
349
568
  const mixedPort = await findAvailablePort();
350
569
  const proxifierPort = await findAvailablePort();
351
570
  const inbounds = ensureArray(cloned.inbounds, "Config is missing inbounds.");
571
+ let hasMixedInbound = false;
572
+ let hasProxifierInbound = false;
352
573
  for (const inbound of inbounds) {
353
574
  if (inbound.tag === "in-mixed") {
575
+ hasMixedInbound = true;
354
576
  inbound.listen_port = mixedPort;
355
577
  }
356
578
  if (inbound.tag === "in-proxifier") {
579
+ hasProxifierInbound = true;
357
580
  inbound.listen_port = proxifierPort;
358
581
  }
582
+ if (inbound.tag === "in-tun") {
583
+ for (const key of Object.keys(inbound)) {
584
+ delete inbound[key];
585
+ }
586
+ Object.assign(inbound, {
587
+ type: "mixed",
588
+ tag: "in-tun",
589
+ listen: "127.0.0.1",
590
+ listen_port: mixedPort,
591
+ });
592
+ }
593
+ }
594
+ if (!hasMixedInbound &&
595
+ !hasProxifierInbound &&
596
+ !inbounds.some((inbound) => inbound.tag === "in-tun")) {
597
+ inbounds.push({
598
+ type: "mixed",
599
+ tag: "in-mixed",
600
+ listen: "127.0.0.1",
601
+ listen_port: mixedPort,
602
+ });
359
603
  }
360
604
  cloned.log = { level: "debug" };
361
605
  const dns = asObject(cloned.dns, "Config is missing dns.");
@@ -365,9 +609,16 @@ export async function prepareVerificationConfig(config) {
365
609
  tag: "dns-local-verify",
366
610
  },
367
611
  ];
612
+ dns.rules = [];
368
613
  dns.final = "dns-local-verify";
614
+ dns.reverse_mapping = false;
369
615
  const route = asObject(cloned.route, "Config is missing route.");
370
616
  route.default_domain_resolver = "dns-local-verify";
617
+ if (typeof cloned.experimental === "object" && cloned.experimental !== null) {
618
+ const experimental = asObject(cloned.experimental, "Config has an invalid experimental block.");
619
+ const { cache_file: _cacheFile, ...remaining } = experimental;
620
+ cloned.experimental = Object.keys(remaining).length === 0 ? undefined : remaining;
621
+ }
371
622
  const outbounds = ensureArray(cloned.outbounds, "Config is missing outbounds.");
372
623
  const globalIndex = outbounds.findIndex((outbound) => outbound.tag === "Global");
373
624
  if (globalIndex >= 0) {
@@ -389,6 +640,59 @@ export async function prepareVerificationConfig(config) {
389
640
  proxifierPort,
390
641
  };
391
642
  }
643
+ export function chooseConflictingOutbound(config, expectedOutboundGroup) {
644
+ const outbounds = ensureArray(config.outbounds, "Config is missing outbounds.");
645
+ const preferred = ["direct", "Global", "AI-Out", "Dev-Common-Out", "Stitch-Out", "Process-Proxy"];
646
+ for (const tag of preferred) {
647
+ if (tag === expectedOutboundGroup) {
648
+ continue;
649
+ }
650
+ if (outbounds.some((outbound) => outbound.tag === tag)) {
651
+ return tag;
652
+ }
653
+ }
654
+ return outbounds
655
+ .map((outbound) => (typeof outbound.tag === "string" ? outbound.tag : undefined))
656
+ .find((tag) => typeof tag === "string" && tag !== expectedOutboundGroup);
657
+ }
658
+ export function injectNativeProcessProbeRule(config, conflictingOutbound) {
659
+ const route = asObject(config.route, "Config is missing route.");
660
+ const rules = ensureArray(route.rules, "Route is missing rules.");
661
+ const probeHost = new URL(nativeProcessProbeUrl).hostname;
662
+ const insertionIndex = findLastProcessAwareRuleIndex(rules) + 1;
663
+ rules.splice(insertionIndex, 0, {
664
+ domain: [probeHost],
665
+ action: "route",
666
+ outbound: conflictingOutbound,
667
+ });
668
+ }
669
+ export function injectNativeProcessPathProbeRule(config, probeBinaryPath, expectedOutboundGroup) {
670
+ const route = asObject(config.route, "Config is missing route.");
671
+ const rules = ensureArray(route.rules, "Route is missing rules.");
672
+ const insertionIndex = findLastProcessAwareRuleIndex(rules) + 1;
673
+ rules.splice(insertionIndex, 0, {
674
+ process_path: [probeBinaryPath],
675
+ action: "route",
676
+ outbound: expectedOutboundGroup,
677
+ });
678
+ }
679
+ export function selectNativeProcessProbeName(config, expectedOutboundGroup) {
680
+ const route = asObject(config.route, "Config is missing route.");
681
+ const rules = ensureArray(route.rules, "Route is missing rules.");
682
+ for (const rule of rules) {
683
+ if (rule.outbound !== expectedOutboundGroup || !Array.isArray(rule.process_name)) {
684
+ continue;
685
+ }
686
+ const candidate = rule.process_name.find((value) => typeof value === "string" &&
687
+ value.length > 0 &&
688
+ !value.includes("/") &&
689
+ !value.includes("*"));
690
+ if (candidate) {
691
+ return candidate;
692
+ }
693
+ }
694
+ return undefined;
695
+ }
392
696
  export function validateConfigInvariants(config) {
393
697
  const route = asObject(config.route, "Config is missing route.");
394
698
  const rules = ensureArray(route.rules, "Route is missing rules.");
@@ -400,6 +704,10 @@ export function validateConfigInvariants(config) {
400
704
  const proxifierIndex = rules.findIndex((rule) => Array.isArray(rule.inbound) &&
401
705
  rule.inbound.includes("in-proxifier") &&
402
706
  rule.outbound === "Process-Proxy");
707
+ const nativeProcessIndex = rules.findIndex((rule) => rule.outbound === "Process-Proxy" &&
708
+ (Array.isArray(rule.process_name) ||
709
+ Array.isArray(rule.process_path) ||
710
+ Array.isArray(rule.process_path_regex)));
403
711
  const stitchIndex = rules.findIndex((rule) => Array.isArray(rule.domain_suffix) &&
404
712
  rule.domain_suffix.includes("stitch.withgoogle.com") &&
405
713
  rule.outbound === "Stitch-Out");
@@ -418,7 +726,13 @@ export function validateConfigInvariants(config) {
418
726
  const priorityOrder = [
419
727
  { name: "quic", index: quicIndex, required: true },
420
728
  { name: "dns", index: dnsIndex, required: true },
421
- { name: "proxifier", index: proxifierIndex, required: true },
729
+ {
730
+ name: "processAware",
731
+ index: nativeProcessIndex >= 0 && proxifierIndex >= 0
732
+ ? Math.min(nativeProcessIndex, proxifierIndex)
733
+ : Math.max(nativeProcessIndex, proxifierIndex),
734
+ required: false,
735
+ },
422
736
  { name: "stitch", index: stitchIndex, required: true },
423
737
  { name: "explicitAi", index: explicitAiIndex, required: true },
424
738
  { name: "aiRuleSet", index: aiRuleSetIndex, required: false },
@@ -435,16 +749,31 @@ export function validateConfigInvariants(config) {
435
749
  const previous = entries[index - 1];
436
750
  return previous !== undefined && previous.index < entry.index;
437
751
  });
438
- checks.push(makeCheck("route-priority", routePriorityPassed, `Rule order indices: quic=${quicIndex}, dns=${dnsIndex}, proxifier=${proxifierIndex}, stitch=${stitchIndex}, explicitAi=${explicitAiIndex}, aiRuleSet=${aiRuleSetIndex}, devRuleSet=${devRuleSetIndex}, china=${chinaIndex}`));
752
+ checks.push(makeCheck("route-priority", routePriorityPassed, `Rule order indices: quic=${quicIndex}, dns=${dnsIndex}, proxifier=${proxifierIndex}, nativeProcess=${nativeProcessIndex}, stitch=${stitchIndex}, explicitAi=${explicitAiIndex}, aiRuleSet=${aiRuleSetIndex}, devRuleSet=${devRuleSetIndex}, china=${chinaIndex}`));
439
753
  checks.push(makeCheck("default-domain-resolver", typeof route.default_domain_resolver === "string" &&
440
754
  dnsServers.some((server) => server.tag === route.default_domain_resolver), `default_domain_resolver=${String(route.default_domain_resolver)}`));
441
755
  checks.push(makeCheck("dns-shape", dnsServers.some((server) => server.type === "local" && server.tag === "dns-local-default") &&
442
756
  dnsServers.some((server) => (server.type === "tcp" || server.type === "udp") && server.server === "1.1.1.1") &&
443
- dnsServers.some((server) => (server.type === "tcp" || server.type === "udp") && server.server === "223.5.5.5"), `dns servers=${dnsServers
757
+ dnsServers.some((server) => (server.type === "tcp" || server.type === "udp") && server.server === "223.5.5.5") &&
758
+ (!dnsServers.some((server) => server.type === "fakeip") ||
759
+ (dnsServers.some((server) => server.type === "fakeip" && server.tag === "dns-fakeip") &&
760
+ ensureArray(dns.rules ?? [], "DNS rules are missing.").some((rule) => Array.isArray(rule.query_type) &&
761
+ rule.query_type.includes("A") &&
762
+ rule.server === "dns-fakeip"))), `dns servers=${dnsServers
444
763
  .map((server) => `${String(server.tag)}:${String(server.server)}`)
445
764
  .join(", ")}`));
446
765
  return checks;
447
766
  }
767
+ function findLastProcessAwareRuleIndex(rules) {
768
+ return rules.reduce((lastIndex, rule, index) => Array.isArray(rule.process_name) ||
769
+ Array.isArray(rule.process_path) ||
770
+ Array.isArray(rule.process_path_regex)
771
+ ? index
772
+ : lastIndex, -1);
773
+ }
774
+ function slugifyProcessName(processName) {
775
+ return processName.replaceAll(/[^A-Za-z0-9._-]+/g, "-");
776
+ }
448
777
  export function resolveDefaultLeafOutboundTag(config, tag) {
449
778
  const outbounds = ensureArray(config.outbounds, "Config is missing outbounds.");
450
779
  const byTag = new Map();
@@ -481,20 +810,44 @@ export function resolveDefaultLeafOutboundTag(config, tag) {
481
810
  }
482
811
  }
483
812
  function buildRuntimeScenarios(config, mixedPort, proxifierPort, configuredScenarios) {
484
- const sourceScenarios = configuredScenarios && configuredScenarios.length > 0
813
+ const sourceScenarios = normalizeConfiguredVerificationScenarios(config, configuredScenarios && configuredScenarios.length > 0
485
814
  ? configuredScenarios
486
- : defaultConfiguredScenarios;
487
- return sourceScenarios.map((scenario) => ({
815
+ : defaultConfiguredScenarios);
816
+ const fallbackScenarios = sourceScenarios.length > 0
817
+ ? sourceScenarios
818
+ : normalizeConfiguredVerificationScenarios(config, defaultConfiguredScenarios);
819
+ return fallbackScenarios.map((scenario) => ({
488
820
  id: scenario.id,
489
821
  name: scenario.name,
490
822
  url: scenario.url,
491
823
  inboundTag: scenario.inbound,
492
824
  proxyPort: scenario.inbound === "in-proxifier" ? proxifierPort : mixedPort,
493
- expectedOutboundTag: scenario.expectedOutbound === "direct" || scenario.expectedOutbound === "block"
494
- ? scenario.expectedOutbound
495
- : resolveDefaultLeafOutboundTag(config, scenario.expectedOutbound),
825
+ expectedOutboundTag: resolveExpectedVerificationOutboundTag(config, scenario.expectedOutbound),
496
826
  }));
497
827
  }
828
+ export function resolveExpectedVerificationOutboundTag(config, expectedOutboundTag) {
829
+ return expectedOutboundTag === "direct" || expectedOutboundTag === "block"
830
+ ? expectedOutboundTag
831
+ : resolveDefaultLeafOutboundTag(config, expectedOutboundTag);
832
+ }
833
+ export function normalizeConfiguredVerificationScenarios(config, scenarios) {
834
+ const availableInbounds = new Set(ensureArray(config.inbounds, "Config is missing inbounds.")
835
+ .map((inbound) => inbound.tag)
836
+ .filter((tag) => typeof tag === "string" && tag.length > 0));
837
+ const hasTun = availableInbounds.has("in-tun");
838
+ const hasMixed = availableInbounds.has("in-mixed");
839
+ const hasProxifier = availableInbounds.has("in-proxifier");
840
+ const prefersTunFallback = hasTun && !hasMixed && !hasProxifier;
841
+ return scenarios.flatMap((scenario) => {
842
+ if (availableInbounds.has(scenario.inbound)) {
843
+ return [scenario];
844
+ }
845
+ if (scenario.inbound === "in-mixed" && (hasTun || prefersTunFallback)) {
846
+ return [{ ...scenario, inbound: "in-tun" }];
847
+ }
848
+ return [];
849
+ });
850
+ }
498
851
  const defaultConfiguredScenarios = [
499
852
  {
500
853
  id: "stitch",
@@ -551,11 +904,49 @@ async function runProxyRequestScenario(input) {
551
904
  "5",
552
905
  "--proxy",
553
906
  `http://127.0.0.1:${input.proxyPort}`,
907
+ "--noproxy",
908
+ "",
554
909
  input.url,
555
910
  ];
556
911
  return new Promise((resolve, reject) => {
557
912
  const child = spawn(input.requestBinary, args, {
558
913
  stdio: ["ignore", "pipe", "pipe"],
914
+ env: buildVerificationRequestEnv(),
915
+ });
916
+ let stdout = "";
917
+ let stderr = "";
918
+ let timedOut = false;
919
+ const timeout = setTimeout(() => {
920
+ timedOut = true;
921
+ child.kill("SIGKILL");
922
+ }, 12_000);
923
+ child.stdout.on("data", (chunk) => {
924
+ stdout += chunk.toString();
925
+ });
926
+ child.stderr.on("data", (chunk) => {
927
+ stderr += chunk.toString();
928
+ });
929
+ child.on("error", (error) => {
930
+ clearTimeout(timeout);
931
+ reject(error);
932
+ });
933
+ child.on("close", (code) => {
934
+ clearTimeout(timeout);
935
+ resolve({
936
+ exitCode: code ?? 0,
937
+ stdout,
938
+ stderr,
939
+ timedOut,
940
+ });
941
+ });
942
+ });
943
+ }
944
+ async function runNativeProcessProbeScenario(input) {
945
+ const args = ["127.0.0.1", String(input.proxyPort), input.targetHost, String(input.targetPort)];
946
+ return new Promise((resolve, reject) => {
947
+ const child = spawn(input.probeBinaryPath, args, {
948
+ stdio: ["ignore", "pipe", "pipe"],
949
+ env: buildVerificationRequestEnv(),
559
950
  });
560
951
  let stdout = "";
561
952
  let stderr = "";
@@ -600,11 +991,12 @@ async function runJsonRequestScenario(input) {
600
991
  input.url,
601
992
  ];
602
993
  if (typeof input.proxyPort === "number") {
603
- args.splice(args.length - 1, 0, "--proxy", `http://127.0.0.1:${input.proxyPort}`);
994
+ args.splice(args.length - 1, 0, "--proxy", `http://127.0.0.1:${input.proxyPort}`, "--noproxy", "");
604
995
  }
605
996
  return new Promise((resolve, reject) => {
606
997
  const child = spawn(input.requestBinary, args, {
607
998
  stdio: ["ignore", "pipe", "pipe"],
999
+ env: buildVerificationRequestEnv(),
608
1000
  });
609
1001
  let stdout = "";
610
1002
  let stderr = "";
@@ -724,6 +1116,18 @@ async function waitForScenarioLogs(buffer, offset, patterns, timeoutMs) {
724
1116
  }
725
1117
  return undefined;
726
1118
  }
1119
+ async function waitForScenarioLogsWithAlternatives(buffer, offset, requiredPatterns, alternativePatterns, timeoutMs) {
1120
+ const startedAt = Date.now();
1121
+ while (Date.now() - startedAt < timeoutMs) {
1122
+ const excerpt = buffer.text.slice(offset);
1123
+ if (requiredPatterns.every((pattern) => pattern.test(excerpt)) &&
1124
+ alternativePatterns.some((pattern) => pattern.test(excerpt))) {
1125
+ return excerpt;
1126
+ }
1127
+ await sleep(250);
1128
+ }
1129
+ return undefined;
1130
+ }
727
1131
  async function waitForLog(buffer, pattern, timeoutMs, errorMessage) {
728
1132
  const startedAt = Date.now();
729
1133
  while (Date.now() - startedAt < timeoutMs) {
@@ -774,6 +1178,124 @@ function makeCheck(name, passed, details) {
774
1178
  function escapeRegExp(input) {
775
1179
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
776
1180
  }
1181
+ export function buildVerificationRequestEnv(baseEnv = process.env) {
1182
+ const stripped = {};
1183
+ for (const [key, value] of Object.entries(baseEnv)) {
1184
+ if (/^(http_proxy|https_proxy|all_proxy|no_proxy)$/i.test(key)) {
1185
+ continue;
1186
+ }
1187
+ stripped[key] = value;
1188
+ }
1189
+ return stripped;
1190
+ }
1191
+ async function compileNativeProcessProbe(input) {
1192
+ const sourcePath = path.join(input.runDir, "native-process-probe.c");
1193
+ await writeFile(sourcePath, buildNativeProcessProbeSource(), "utf8");
1194
+ await new Promise((resolve, reject) => {
1195
+ const child = spawn(input.compilerBinary, ["-O2", "-Wall", "-Wextra", "-o", input.probeBinaryPath, sourcePath], {
1196
+ stdio: ["ignore", "pipe", "pipe"],
1197
+ });
1198
+ let stderr = "";
1199
+ child.stdout.on("data", () => {
1200
+ // ignore compiler stdout
1201
+ });
1202
+ child.stderr.on("data", (chunk) => {
1203
+ stderr += chunk.toString();
1204
+ });
1205
+ child.on("error", reject);
1206
+ child.on("close", (code) => {
1207
+ if ((code ?? 0) === 0) {
1208
+ resolve();
1209
+ return;
1210
+ }
1211
+ reject(new Error(`Failed to compile native-process probe with ${input.compilerBinary}.\n${stderr.trim()}`));
1212
+ });
1213
+ });
1214
+ await chmod(input.probeBinaryPath, 0o755);
1215
+ }
1216
+ function buildNativeProcessProbeSource() {
1217
+ return `#include <arpa/inet.h>
1218
+ #include <errno.h>
1219
+ #include <netinet/in.h>
1220
+ #include <stdio.h>
1221
+ #include <stdlib.h>
1222
+ #include <stdint.h>
1223
+ #include <string.h>
1224
+ #include <sys/socket.h>
1225
+ #include <sys/time.h>
1226
+ #include <unistd.h>
1227
+
1228
+ int main(int argc, char **argv) {
1229
+ if (argc != 5) {
1230
+ fprintf(stderr, "usage: %s <proxy-host> <proxy-port> <target-host> <target-port>\\n", argv[0]);
1231
+ return 2;
1232
+ }
1233
+
1234
+ const char *proxyHost = argv[1];
1235
+ const char *proxyPort = argv[2];
1236
+ const char *targetHost = argv[3];
1237
+ const char *targetPort = argv[4];
1238
+
1239
+ int socketFd = socket(AF_INET, SOCK_STREAM, 0);
1240
+ if (socketFd < 0) {
1241
+ perror("socket");
1242
+ return 3;
1243
+ }
1244
+
1245
+ struct timeval timeout;
1246
+ timeout.tv_sec = 3;
1247
+ timeout.tv_usec = 0;
1248
+ setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
1249
+ setsockopt(socketFd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
1250
+
1251
+ struct sockaddr_in proxyAddress;
1252
+ memset(&proxyAddress, 0, sizeof(proxyAddress));
1253
+ proxyAddress.sin_family = AF_INET;
1254
+ proxyAddress.sin_port = htons((uint16_t) atoi(proxyPort));
1255
+
1256
+ if (inet_pton(AF_INET, proxyHost, &proxyAddress.sin_addr) != 1) {
1257
+ fprintf(stderr, "invalid proxy host: %s\\n", proxyHost);
1258
+ close(socketFd);
1259
+ return 4;
1260
+ }
1261
+
1262
+ if (connect(socketFd, (struct sockaddr *) &proxyAddress, sizeof(proxyAddress)) != 0) {
1263
+ perror("connect");
1264
+ close(socketFd);
1265
+ return 5;
1266
+ }
1267
+
1268
+ char request[1024];
1269
+ int written = snprintf(
1270
+ request,
1271
+ sizeof(request),
1272
+ "CONNECT %s:%s HTTP/1.1\\r\\nHost: %s:%s\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n",
1273
+ targetHost,
1274
+ targetPort,
1275
+ targetHost,
1276
+ targetPort
1277
+ );
1278
+ if (written < 0 || written >= (int) sizeof(request)) {
1279
+ fprintf(stderr, "failed to encode request\\n");
1280
+ close(socketFd);
1281
+ return 6;
1282
+ }
1283
+
1284
+ ssize_t sent = send(socketFd, request, (size_t) written, 0);
1285
+ if (sent < 0) {
1286
+ perror("send");
1287
+ close(socketFd);
1288
+ return 7;
1289
+ }
1290
+
1291
+ char response[512];
1292
+ recv(socketFd, response, sizeof(response) - 1, 0);
1293
+ usleep(250000);
1294
+ close(socketFd);
1295
+ return 0;
1296
+ }
1297
+ `;
1298
+ }
777
1299
  async function isExecutable(filePath) {
778
1300
  try {
779
1301
  await access(filePath, constants.X_OK);