Repository navigation
Expand file tree
/
Copy pathmain.c
More file actions
3440 lines (3269 loc) · 151 KB
/
Copy pathmain.c
File metadata and controls
3440 lines (3269 loc) · 151 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* main.c — Entry point for codebase-memory-mcp.
*
* Modes:
* (default) Run as MCP server on stdin/stdout (JSON-RPC 2.0)
* cli <tool> <json> Run a single tool call and print result
* --version Print version and exit
* --help Print usage and exit
* cli --quiet Show errors only; disable automatic terminal progress
* cli --verbose Include informational logs for one-shot commands
* --ui=true/false Enable/disable HTTP UI server (persisted)
* --port=N Set HTTP UI port (persisted, default 9749)
* --tool-profile=analysis|scout Expose a restricted agent tool surface
*
* Long-lived MCP and hook frontends are thin clients of one mandatory
* per-account daemon. One-shot CLI tool calls run in an isolated local server
* and never create or retain a daemon generation.
*/
#ifdef _WIN32
/* winsock2 must precede every project header that can transitively include
* windows.h, otherwise the legacy winsock declarations conflict. */
#include <winsock2.h>
#include <ws2tcpip.h>
#endif
#include "cbm.h"
#include "store/store.h" // cbm_alloc_init — bind 3rd-party allocators to mimalloc before any sqlite/git init
#include "daemon/application.h"
#include "daemon/bootstrap.h"
#include "daemon/frontend.h"
#include "daemon/host.h"
#include "daemon/ipc.h"
#include "daemon/project_lock.h"
#include "daemon/version_cohort.h"
#include "mcp/mcp.h"
#include "mcp/index_supervisor.h"
#include "mcp/test_impact_engine.h"
#include "cli/cli.h"
#include "cli/runtime_settings.h"
#include "foundation/platform.h"
#include "cli/progress_sink.h"
#include "foundation/constants.h"
enum {
MAIN_MIN_ARGC = 1,
MAIN_CLI_ARGC = 2,
MAIN_FLAG_OFF = 5, /* strlen("--ui=") */
MAIN_PORT_OFF = 7, /* strlen("--port=") */
MAIN_MAX_PORT = 65536,
MAIN_PATH_CAP = 4096,
MAIN_CONNECT_TIMEOUT_MS = 1000,
/* #2277: the conflict-remedy STATUS probe runs only after a refusal, so it
* gets the same budget as `daemon status`: the daemon hashes the peer's
* (~300 MB) image before answering, which can exceed a 1 s connect budget. */
MAIN_CONFLICT_STATUS_PROBE_TIMEOUT_MS = 3000,
MAIN_STARTUP_TIMEOUT_MS = 10000,
/* Backstop for waiting out a held startup transition — see
* main_local_transition_acquire. Not a budget for healthy contention: a busy
* lock always resolves, either because the live holder finishes or because
* the OS finishes reclaiming a dead holder's lock. This only bounds a peer
* that never finishes, so a command cannot hang indefinitely. */
MAIN_STARTUP_CONTENTION_CEILING_MS = 120000,
MAIN_MCP_STARTUP_TIMEOUT_MS = 30000,
MAIN_STARTUP_TIMEOUT_MIN_MS = 1000,
MAIN_STARTUP_TIMEOUT_MAX_MS = 600000,
MAIN_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000,
MAIN_HOOK_CONNECT_TIMEOUT_MS = 250,
MAIN_HOOK_REQUEST_TIMEOUT_MS = 1500,
MAIN_HOOK_CLOSE_TIMEOUT_MS = 250,
MAIN_HOOK_NOTICE_INTERVAL_SECONDS = 900,
MAIN_CLOSE_TIMEOUT_MS = 5000,
MAIN_COORDINATION_CLEANUP_MS = 500,
/* The watchdog only polls, so the STACK it needs is tiny — but on glibc the
* thread's static TLS block is carved out of this same allocation, and this
* image's TLS is ~50 KB. At the old 64 KB the request was almost entirely
* TLS, and when TLS grew past it the thread stopped being creatable at all:
* the worker then refused to index without containment and SIGKILLed its own
* group, reported everywhere as a bare "killed (signal 9)" (PR #2233).
* Sized to hold the TLS block AND a real stack; tests/
* test_thread_stack_tls_contract.sh keeps the two from converging again. */
PARENT_WATCHDOG_STACK_SIZE = 256 * CBM_SZ_1K,
};
/* Daemon startup budget, overridable for loaded hosts: a fixed 30 s races
* whatever else the machine is doing (parallel test waves, CI legs), and a
* cold daemon on a starved host loses through no fault of its own.
* CBM_STARTUP_TIMEOUT_MS, clamped to [1s, 10min]; malformed -> default. */
static uint32_t main_startup_timeout_ms(void) {
const char *raw = cbm_runtime_getenv("CBM_STARTUP_TIMEOUT_MS");
if (!raw || !raw[0])
return MAIN_MCP_STARTUP_TIMEOUT_MS;
char *end = NULL;
long value = strtol(raw, &end, 10);
if (!end || *end != '\0' || value <= 0)
return MAIN_MCP_STARTUP_TIMEOUT_MS;
if (value < MAIN_STARTUP_TIMEOUT_MIN_MS)
return MAIN_STARTUP_TIMEOUT_MIN_MS;
if (value > MAIN_STARTUP_TIMEOUT_MAX_MS)
return MAIN_STARTUP_TIMEOUT_MAX_MS;
return (uint32_t)value;
}
#define SLEN(s) (sizeof(s) - 1)
#include "foundation/log.h"
#include "foundation/diagnostics.h"
#include "foundation/platform.h"
#include "foundation/workspace.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/compat_thread.h"
#include "foundation/mem.h"
#include "foundation/mem_events.h"
#include "foundation/profile.h"
#include "foundation/sha256.h"
#include "foundation/secure_random.h"
#include "foundation/win_utf8.h" /* cbm_wide_to_utf8 — Windows UTF-8 argv (#423/#20); no-op on POSIX */
#ifdef _WIN32
#include <shellapi.h> /* CommandLineToArgvW — not pulled in by windows.h under WIN32_LEAN_AND_MEAN */
#include <io.h>
#include <tlhelp32.h> /* CreateToolhelp32Snapshot — parent PID discovery for the parent-death watchdog */
#endif
#include "ui/http_server.h"
#include "ui/embedded_assets.h"
#include "ui/config.h"
#include <yyjson/yyjson.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <stdatomic.h>
#ifndef _WIN32
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
#ifndef CBM_VERSION
#define CBM_VERSION "dev"
#endif
/* ── Globals for signal handling ────────────────────────────────── */
static atomic_int g_shutdown = 0;
static cbm_daemon_runtime_client_t *g_daemon_client = NULL;
static uint64_t main_deadline_after(uint32_t timeout_ms);
static bool main_session_context(const char *preferred_root, char root_out[MAIN_PATH_CAP],
char allowed_out[MAIN_PATH_CAP], const char **allowed_out_ptr);
typedef struct main_local_cli_lease main_local_cli_lease_t;
struct main_local_cli_lease {
char *project;
cbm_project_lock_lease_t *lease;
main_local_cli_lease_t *next;
};
typedef struct {
cbm_project_lock_manager_t *manager;
main_local_cli_lease_t *leases;
FILE *feedback;
bool index_worker;
bool waiting_reported;
} main_local_cli_mutation_t;
typedef struct {
cbm_mutex_t mutex;
cbm_mcp_server_t *server;
bool maintenance_cancelled;
} main_local_maintenance_context_t;
static void main_local_maintenance_context_init(main_local_maintenance_context_t *context) {
memset(context, 0, sizeof(*context));
cbm_mutex_init(&context->mutex);
}
static void main_local_maintenance_context_destroy(main_local_maintenance_context_t *context) {
cbm_mutex_destroy(&context->mutex);
memset(context, 0, sizeof(*context));
}
static void main_local_maintenance_server_bind(main_local_maintenance_context_t *context,
cbm_mcp_server_t *server) {
if (!context) {
return;
}
cbm_mutex_lock(&context->mutex);
context->server = server;
cbm_mutex_unlock(&context->mutex);
}
static bool main_local_command_cancel(void *opaque) {
main_local_maintenance_context_t *context = opaque;
if (!context) {
return false;
}
cbm_mutex_lock(&context->mutex);
bool cancelled = context->server && cbm_mcp_server_cancel_active(context->server);
context->maintenance_cancelled = context->maintenance_cancelled || cancelled;
cbm_mutex_unlock(&context->mutex);
return cancelled;
}
static bool main_local_maintenance_was_cancelled(main_local_maintenance_context_t *context) {
if (!context) {
return false;
}
cbm_mutex_lock(&context->mutex);
bool cancelled = context->maintenance_cancelled;
cbm_mutex_unlock(&context->mutex);
return cancelled;
}
static void main_local_maintenance_finish(cbm_daemon_maintenance_monitor_t **monitor,
main_local_maintenance_context_t *context,
bool context_initialized, const char *participant) {
if (monitor && *monitor && !cbm_daemon_maintenance_monitor_stop(monitor)) {
/* The observer still borrows context (and may be inside cancellation).
* Freeing command/server/manager memory would be a cross-thread UAF. */
cbm_log_error("participant.maintenance_join_failed", "participant", participant, "action",
"process_exit");
(void)fflush(stdout);
(void)fflush(stderr);
_Exit(EXIT_FAILURE);
}
if (context_initialized) {
main_local_maintenance_context_destroy(context);
}
}
static _Noreturn void main_coordination_cleanup_fail_stop(const char *component) {
cbm_log_error("coordination.cleanup_timeout", "component", component, "action", "process_exit");
(void)fprintf(stderr,
"codebase-memory-mcp: coordination cleanup timed out (%s); "
"terminating so the OS releases retained claims\n",
component ? component : "unknown");
(void)fflush(stdout);
(void)fflush(stderr);
_Exit(EXIT_FAILURE);
}
static void main_project_lock_release_fully(cbm_project_lock_lease_t **lease) {
uint64_t deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS);
while (lease && *lease) {
(void)cbm_project_lock_lease_release(lease);
if (!*lease) {
return;
}
if (cbm_now_ms() >= deadline) {
main_coordination_cleanup_fail_stop("project_lock_cleanup");
}
cbm_usleep(1000);
}
}
/* Test-only ownership proof consumed by the POSIX worker-lease contract tests.
* The environment variable is otherwise inert, and only a supervised physical
* worker may publish it. Publication occurs after the native project lease is
* acquired, so a marker from the worker also proves that its polling supervisor
* did not retain the same exclusive lease.
*
* COMPILED OUT of ordinary builds alongside the watchdog probe above. This one
* is benign in isolation (an O_EXCL|O_NOFOLLOW PID file), but it is still
* test-only code reachable through a caller-supplied path in a shipped binary,
* and its consumers all build with TEST_SEAMS=1. The crash/hang injectors in
* internal/cbm/cbm.c are governed by the same boundary. The narrowly validated
* Windows PATH smoke redirect remains in release artifacts so artifact tests
* never mutate the runner's live user PATH. */
#ifdef CBM_ENABLE_TEST_SEAMS
static bool main_test_worker_project_lock_marker(const main_local_cli_mutation_t *mutation) {
#ifdef _WIN32
(void)mutation;
return true;
#else
if (!mutation || !mutation->index_worker) {
return true;
}
char marker_path[MAIN_PATH_CAP] = {0};
if (!cbm_safe_getenv("CBM_TEST_WORKER_PROJECT_LOCK_PID_FILE", marker_path, sizeof(marker_path),
NULL) ||
!marker_path[0]) {
return true;
}
int flags = O_WRONLY | O_CREAT | O_EXCL;
#ifdef O_CLOEXEC
flags |= O_CLOEXEC;
#endif
#ifdef O_NOFOLLOW
flags |= O_NOFOLLOW;
#endif
int marker = open(marker_path, flags, 0600);
if (marker < 0) {
return false;
}
char identity[96];
int length = snprintf(identity, sizeof(identity), "%ld %ld\n", (long)getpid(), (long)getpgrp());
bool written = length > 0 && length < (int)sizeof(identity) &&
write(marker, identity, (size_t)length) == (ssize_t)length;
return close(marker) == 0 && written;
#endif
}
#endif
static bool main_local_cli_mutation_begin_internal(void *context, const char *project, bool wait) {
main_local_cli_mutation_t *mutation = context;
if (!mutation || !mutation->manager || !project || !project[0]) {
return false;
}
for (;;) {
cbm_project_lock_lease_t *lease = NULL;
cbm_private_file_lock_status_t status;
if (wait) {
uint64_t deadline = cbm_project_lock_cli_attempt_deadline(cbm_now_ms());
status = cbm_project_lock_acquire(mutation->manager, project, deadline, NULL, &lease);
} else {
status = cbm_project_lock_try_acquire(mutation->manager, project, &lease);
}
if (status == CBM_PRIVATE_FILE_LOCK_OK && lease) {
main_local_cli_lease_t *held = calloc(1, sizeof(*held));
if (held) {
held->project = cbm_strdup(project);
}
if (!held || !held->project) {
free(held);
main_project_lock_release_fully(&lease);
return false;
}
held->lease = lease;
held->next = mutation->leases;
mutation->leases = held;
#ifdef CBM_ENABLE_TEST_SEAMS
if (!main_test_worker_project_lock_marker(mutation)) {
mutation->leases = held->next;
main_project_lock_release_fully(&held->lease);
free(held->project);
free(held);
return false;
}
#endif
return true;
}
main_project_lock_release_fully(&lease);
if (status != CBM_PRIVATE_FILE_LOCK_BUSY) {
cbm_log_error("cli.project_lock_failed", "project", project, "action",
"refuse_mutation");
return false;
}
if (!wait) {
return false;
}
if (mutation->feedback && !mutation->waiting_reported) {
(void)fprintf(mutation->feedback, "Waiting for another CBM mutation of %s...\n",
project);
(void)fflush(mutation->feedback);
mutation->waiting_reported = true;
}
}
}
static bool main_local_cli_mutation_begin(void *context, const char *project) {
return main_local_cli_mutation_begin_internal(context, project, true);
}
static bool main_local_cli_mutation_try_begin(void *context, const char *project) {
return main_local_cli_mutation_begin_internal(context, project, false);
}
static void main_local_cli_mutation_end(void *context, const char *project) {
main_local_cli_mutation_t *mutation = context;
if (!mutation || !project) {
return;
}
main_local_cli_lease_t **cursor = &mutation->leases;
while (*cursor && strcmp((*cursor)->project, project) != 0) {
cursor = &(*cursor)->next;
}
main_local_cli_lease_t *held = *cursor;
if (held) {
*cursor = held->next;
main_project_lock_release_fully(&held->lease);
free(held->project);
free(held);
}
}
static void main_local_cli_mutation_release_all(main_local_cli_mutation_t *mutation) {
while (mutation && mutation->leases) {
main_local_cli_lease_t *held = mutation->leases;
mutation->leases = held->next;
main_project_lock_release_fully(&held->lease);
free(held->project);
free(held);
}
}
/* Signal handlers only publish intent and close stdin. The daemon host observes
* the atomic; an MCP thin client unblocks its reader and closes its authenticated
* daemon connection from normal thread context. */
static void request_shutdown(void) {
if (atomic_exchange(&g_shutdown, 1)) {
return; /* already shutting down */
}
#ifdef _WIN32
(void)_close(_fileno(stdin));
#else
(void)close(STDIN_FILENO);
#endif
}
static void signal_handler(int sig) {
(void)sig;
request_shutdown();
}
/* ── Parent-process watchdog ────────────────────────────────────── */
/* parent-death watchdog — distilled from #407 (fixes #406, thanks @nvt-pankajsharma).
*
* When this stdio MCP server is launched by an agent that later dies without a
* clean SIGTERM (e.g. the editor is force-killed), the orphaned server would
* otherwise linger forever blocked on stdin. POSIX has no portable "notify on
* parent death" primitive (PR_SET_PDEATHSIG is Linux-only), so we poll getppid:
* once the parent dies the process is reparented (ppid changes, typically to 1)
* and we shut down.
*
* Windows used to be excluded here on the assumption that job objects cover it
* (#914 proved they do not): the KILL_ON_JOB_CLOSE job in subprocess.c only
* wraps processes CBM itself spawns. An MCP stdio server is spawned BY the
* client as its child, and Windows does not propagate parent termination to
* children, so the orphan lingers holding SQLite WAL read locks. The Windows
* branch below waits on a handle to the parent process instead — a signaled
* handle is exact (no PID-reuse window), so no polling of the PID is needed. */
#ifndef _WIN32
typedef struct {
pid_t initial_ppid;
bool kill_worker_group;
bool exit_on_parent_death;
} parent_watchdog_config_t;
static void *parent_watchdog_thread(void *arg) {
parent_watchdog_config_t config = *(parent_watchdog_config_t *)arg;
const unsigned int poll_interval_us = 500000; /* 500ms */
while (!atomic_load(&g_shutdown)) {
cbm_usleep(poll_interval_us);
if (atomic_load(&g_shutdown)) {
break;
}
/* initial_ppid > 1 guards against an already-orphaned start (ppid==1),
* where a changing ppid carries no signal. */
if (config.initial_ppid > 1 && getppid() != config.initial_ppid) {
static const char msg[] = "level=warn msg=parent.exited reason=ppid_changed\n";
(void)write(STDERR_FILENO, msg, sizeof(msg) - 1);
if (config.kill_worker_group) {
/* Valid workers establish pgid == pid before any stateful work.
* SIGKILL is deliberate: no non-escaped descendant may continue
* after the owning daemon disappears. */
(void)kill(-getpid(), SIGKILL);
}
if (config.exit_on_parent_death) {
/* Kernel EOF on every inherited daemon socket is the most
* reliable cancellation signal when an agent disappears. */
_exit(0);
}
request_shutdown();
break;
}
}
return NULL;
}
static bool worker_prepare_process_group(void) {
pid_t process_id = getpid();
return (setpgid(0, 0) == 0 || getpgrp() == process_id) && getpgrp() == process_id;
}
/* A worker that cannot contain its own process tree must not index: on failure
* it would keep running as an orphan with no supervisor able to reap it. Shared
* by the ordered containment steps in the worker path so all of them fail
* identically — write() and _exit() rather than fprintf/exit, because this runs
* after fork-sensitive setup and must not touch stdio locks or atexit handlers.
*/
static void worker_containment_unavailable(const char *why) {
/* WHICH of the containment conditions failed, and the pids behind it. The
* bare sentence cost a full CI cycle and two emulated builds to attribute
* (PR #2233): every venue reported the same line, and the supervisor only
* ever sees `killed (signal 9)` because the refusal SIGKILLs its own group.
* snprintf into a local buffer touches no stdio lock and no atexit handler,
* so the fork-sensitivity contract above still holds. */
char message[256];
int n = snprintf(message, sizeof(message),
"CBM index worker could not start: process-tree containment unavailable "
"(%s; pid=%ld pgrp=%ld ppid=%ld)\n",
why ? why : "unspecified", (long)getpid(), (long)getpgrp(), (long)getppid());
if (n > 0) {
(void)write(STDERR_FILENO, message,
(size_t)n < sizeof(message) ? (size_t)n : sizeof(message) - 1);
}
(void)kill(-getpid(), SIGKILL);
_exit(EXIT_FAILURE);
}
/* Test-only crash-orphan probe used by tests/test_worker_watchdog.sh. It is
* created before the watchdog thread so fork never occurs in a multithreaded
* worker, and inherits the worker's isolated process group.
*
* COMPILED OUT of ordinary builds (see TEST_SEAMS in Makefile.cbm). "Fork a
* child that ignores SIGTERM and loops forever, then write its PID to a path
* the caller chose" is a fine test probe and an appalling thing to find in a
* shipped executable — it is precisely the shape a generic malware classifier
* is built to notice, and it has no production caller. Seams are OPT-IN so the
* failure mode of forgetting the flag is a clean binary, not a leaky one; the
* suites that need it build with TEST_SEAMS=1, and
* scripts/ci/check-binary-composition.sh fails the release if the marker
* string ever reappears in an artifact. */
#ifdef CBM_ENABLE_TEST_SEAMS
static bool worker_start_watchdog_test_descendant(void) {
char pid_path[CBM_SZ_4K] = {0};
if (!cbm_safe_getenv("CBM_TEST_WORKER_DESCENDANT_PID_FILE", pid_path, sizeof(pid_path), NULL) ||
!pid_path[0]) {
return true;
}
pid_t descendant = fork();
if (descendant < 0) {
return false;
}
if (descendant == 0) {
(void)signal(SIGTERM, SIG_IGN);
for (;;) {
cbm_usleep(100000);
}
}
int open_flags = O_WRONLY | O_CREAT | O_EXCL;
#ifdef O_NOFOLLOW
open_flags |= O_NOFOLLOW;
#endif
int pid_file = open(pid_path, open_flags, 0600);
if (pid_file < 0) {
(void)kill(descendant, SIGKILL);
(void)waitpid(descendant, NULL, 0);
return false;
}
char pid_text[32];
int pid_length = snprintf(pid_text, sizeof(pid_text), "%ld\n", (long)descendant);
bool written = pid_length > 0 && pid_length < (int)sizeof(pid_text) &&
write(pid_file, pid_text, (size_t)pid_length) == (ssize_t)pid_length;
written = close(pid_file) == 0 && written;
if (!written) {
(void)unlink(pid_path);
(void)kill(descendant, SIGKILL);
(void)waitpid(descendant, NULL, 0);
}
return written;
}
#endif
static bool worker_start_parent_watchdog(pid_t initial_ppid) {
static parent_watchdog_config_t worker_config;
worker_config.initial_ppid = initial_ppid;
worker_config.kill_worker_group = true;
worker_config.exit_on_parent_death = true;
cbm_thread_t worker_watchdog_tid;
if (cbm_thread_create(&worker_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, parent_watchdog_thread,
&worker_config) != 0) {
return false;
}
return cbm_thread_detach(&worker_watchdog_tid) == 0;
}
static bool client_start_parent_watchdog(pid_t initial_ppid) {
if (initial_ppid <= 1) {
return true;
}
static parent_watchdog_config_t client_config;
client_config.initial_ppid = initial_ppid;
client_config.kill_worker_group = false;
client_config.exit_on_parent_death = true;
cbm_thread_t watchdog;
if (cbm_thread_create(&watchdog, PARENT_WATCHDOG_STACK_SIZE, parent_watchdog_thread,
&client_config) != 0) {
return false;
}
if (cbm_thread_detach(&watchdog) != 0) {
atomic_store(&g_shutdown, 1);
(void)cbm_thread_join(&watchdog);
return false;
}
return true;
}
#else /* _WIN32 */
/* Windows parent-death watchdog — the #914 half of the story.
*
* The KILL_ON_JOB_CLOSE job in subprocess.c only contains processes CBM
* spawns itself; a stdio MCP server is the CLIENT's child, and Windows never
* propagates a parent's termination to its children, so a force-killed client
* leaves the server lingering on stdin while pinning SQLite WAL read locks.
* There is no reparenting to poll for either — instead we open a handle to
* the parent at startup and wait on it: the kernel signals a process handle
* exactly once, when the process terminates, and the held handle pins the
* process object, so PID reuse cannot fool the wait the way re-reading a ppid
* could. The 500 ms timeout exists only to re-check g_shutdown, mirroring the
* POSIX poll cadence.
*
* The worker path keeps its POSIX-only guard: workers are spawned by CBM's own
* subprocess layer inside a kill-on-close job, so containment there is already
* the job object's job. */
typedef struct {
HANDLE parent_process;
bool exit_on_parent_death;
} parent_watchdog_config_t;
static void *parent_watchdog_thread(void *arg) {
parent_watchdog_config_t config = *(parent_watchdog_config_t *)arg;
while (!atomic_load(&g_shutdown)) {
DWORD wait_status = WaitForSingleObject(config.parent_process, 500);
if (wait_status == WAIT_OBJECT_0) {
static const char msg[] = "level=warn msg=parent.exited reason=handle_signaled\n";
(void)_write(_fileno(stderr), msg, sizeof(msg) - 1);
if (config.exit_on_parent_death) {
/* Same deliberate hard stop as the POSIX branch: a lingering
* orphan must release its daemon connection, file locks and
* WAL read lock through kernel handle reclamation, and no
* atexit cleanup is trustworthy after the owning client is
* gone. */
_exit(0);
}
request_shutdown();
break;
}
if (wait_status != WAIT_TIMEOUT) {
break; /* handle became unwaitable — stop watching, never spin */
}
}
return NULL;
}
/* Toolhelp is the documented way to learn one's own parent PID on Windows;
* the PEB value is not exposed through any public API. Returns 0 when the
* lookup itself fails; callers treat that as "no parent signal available". */
static DWORD win_parent_pid_from_snapshot(void) {
DWORD parent_pid = 0;
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) {
return 0;
}
PROCESSENTRY32W entry;
ZeroMemory(&entry, sizeof(entry));
entry.dwSize = sizeof(entry);
DWORD self_pid = GetCurrentProcessId();
if (Process32FirstW(snapshot, &entry)) {
do {
if (entry.th32ProcessID == self_pid) {
parent_pid = entry.th32ParentProcessID;
break;
}
} while (Process32NextW(snapshot, &entry));
}
CloseHandle(snapshot);
return parent_pid;
}
static bool client_start_parent_watchdog(DWORD initial_ppid) {
/* Mirrors the POSIX initial_ppid <= 1 bail-out: when no trustworthy parent
* signal exists at startup — snapshot failed, reserved PID, or the parent
* already exited so the handle cannot be opened — keep running and rely on
* the stdin EOF path instead of watching nothing or dying on a false
* alarm. A watchdog thread creation failure, by contrast, is fatal: the
* same fail-closed choice as POSIX. */
if (initial_ppid == 0) {
return true;
}
HANDLE parent_process = OpenProcess(SYNCHRONIZE, FALSE, initial_ppid);
if (!parent_process) {
return true;
}
static parent_watchdog_config_t client_config;
client_config.parent_process = parent_process;
client_config.exit_on_parent_death = true;
cbm_thread_t watchdog;
if (cbm_thread_create(&watchdog, PARENT_WATCHDOG_STACK_SIZE, parent_watchdog_thread,
&client_config) != 0) {
CloseHandle(parent_process);
return false;
}
if (cbm_thread_detach(&watchdog) != 0) {
atomic_store(&g_shutdown, 1);
(void)cbm_thread_join(&watchdog);
CloseHandle(parent_process);
return false;
}
return true;
}
#endif
/* ── CLI mode ───────────────────────────────────────────────────── */
#define CLI_USAGE CBM_CLI_USAGE /* `cli --help`; wording lives in cli.h (#2102) */
/* Extract text content from MCP tool result envelope and print it.
* MCP results: {"content":[{"type":"text","text":"..."}],"isError":...}
* Returns 1 if the result was an error, 0 otherwise. */
static int cli_print_mcp_result(const char *result) {
yyjson_doc *doc = yyjson_read(result, strlen(result), 0);
if (!doc) {
printf("%s\n", result);
return 0;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *err_val = yyjson_obj_get(root, "isError");
bool is_error = err_val && yyjson_get_bool(err_val);
const char *text = NULL;
yyjson_val *content = yyjson_obj_get(root, "content");
if (yyjson_is_arr(content) && yyjson_arr_size(content) > 0) {
yyjson_val *tv = yyjson_obj_get(yyjson_arr_get_first(content), "text");
text = tv ? yyjson_get_str(tv) : NULL;
}
if (text) {
(void)fprintf(is_error ? stderr : stdout, "%s\n", text);
} else {
printf("%s\n", result);
}
yyjson_doc_free(doc);
return is_error ? SKIP_ONE : 0;
}
/* Strip a flag from argv, returning true if found. */
static bool cli_strip_flag(int *argc, char **argv, const char *flag) {
for (int i = 0; i < *argc; i++) {
if (strcmp(argv[i], flag) != 0) {
continue;
}
for (int j = i; j < *argc - SKIP_ONE; j++) {
argv[j] = argv[j + SKIP_ONE];
}
(*argc)--;
return true;
}
return false;
}
/* Strip a flag AND its following value from argv, returning the value (a pointer
* into the original argv strings, valid for the process lifetime) or NULL if the
* flag is absent. */
static const char *cli_strip_flag_value(int *argc, char **argv, const char *flag) {
for (int i = 0; i < *argc; i++) {
if (strcmp(argv[i], flag) != 0) {
continue;
}
const char *value = (i + SKIP_ONE < *argc) ? argv[i + SKIP_ONE] : NULL;
int remove_count = value ? 2 : 1;
for (int j = i; j < *argc - remove_count; j++) {
argv[j] = argv[j + remove_count];
}
*argc -= remove_count;
return value;
}
return NULL;
}
/* Portable "is fd a terminal?" — _isatty on Windows, isatty on POSIX. */
#ifdef _WIN32
#define cli_isatty(fd) _isatty(fd)
#else
#define cli_isatty(fd) isatty(fd)
#endif
enum { CLI_SLURP_CHUNK = 4096 };
/* Read an open stream fully into a heap, NUL-terminated string. Caller frees.
* Returns NULL on allocation failure. Reads binary-clean (UTF-8 JSON, no shell
* quoting needed). */
static char *cli_slurp_stream(FILE *f) {
size_t cap = CLI_SLURP_CHUNK;
size_t len = 0;
char *buf = malloc(cap);
if (!buf) {
return NULL;
}
char tmp[CLI_SLURP_CHUNK];
size_t n;
while ((n = fread(tmp, 1, sizeof(tmp), f)) > 0) {
if (len + n + 1 > cap) {
while (len + n + 1 > cap) {
cap *= 2;
}
char *nb = realloc(buf, cap);
if (!nb) {
free(buf);
return NULL;
}
buf = nb;
}
memcpy(buf + len, tmp, n);
len += n;
}
buf[len] = '\0';
return buf;
}
/* Slurp a file path into a heap, NUL-terminated string. Caller frees. */
static char *cli_slurp_file(const char *path) {
FILE *f = fopen(path, "rb");
if (!f) {
return NULL;
}
char *s = cli_slurp_stream(f);
(void)fclose(f);
return s;
}
/* True if the first non-whitespace byte of s is '{' (raw-JSON detection). */
static bool cli_first_nonspace_is_brace(const char *s) {
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') {
s++;
}
return *s == '{';
}
static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json,
bool quiet_requested);
/* A supervised worker runs in the DAEMON's environment, not the requesting
* client's. The daemon admitted this request under the client's session policy
* and re-executed us with the canonical repo_path in the args (both daemon
* spawn paths rewrite it), so the request itself is the worker's workspace
* scope: repository == session root == allowed root. Without this, a fresh
* server fell back to the process-wide CBM_ALLOWED_ROOT inherited from whoever
* started the daemon and refused every admitted session outside it. Returns
* NULL once scoped, otherwise the reason to fail closed: a worker never indexes
* under an ambient policy.
*
* A repository that cannot be canonicalized (it does not exist) can only have
* been admitted by a session with no declared boundary, because containment
* needs a real path. The worker mirrors that with an explicit unrestricted
* policy - still never the environment fallback - so the pipeline reports the
* missing repository as the tool error it always was, instead of the
* supervisor misreading a refused worker as a crash. */
static const char *main_index_worker_scope_request(cbm_mcp_server_t *srv, const char *args_json) {
char *repo_path = cbm_mcp_get_string_arg(args_json, "repo_path");
if (!repo_path || !repo_path[0]) {
free(repo_path);
return "request names no repo_path";
}
char canonical[MAIN_PATH_CAP];
bool exists = cbm_canonical_path(repo_path, canonical, sizeof(canonical)) != 0;
const char *scope = exists ? canonical : repo_path;
bool scoped = cbm_mcp_server_set_session_context(srv, scope, exists ? scope : NULL);
free(repo_path);
return scoped ? NULL : "session context could not be installed";
}
static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_locks,
main_local_maintenance_context_t *maintenance_context) {
cbm_cli_output_flags_t output_flags;
char output_error[CBM_SZ_512] = {0};
if (!cbm_cli_output_flags_parse(&argc, argv, &output_flags, output_error,
sizeof(output_error))) {
(void)fprintf(stderr, "error: %s\n",
output_error[0] ? output_error : "invalid output mode");
(void)fprintf(stderr, CLI_USAGE);
return SKIP_ONE;
}
cbm_cli_diagnostics_configure(output_flags.quiet_requested, output_flags.verbose_requested);
if (argc == 1 && argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) {
(void)fputs(CLI_USAGE, stdout);
return 0;
}
if (argc < MAIN_MIN_ARGC) {
(void)fprintf(stderr, CLI_USAGE);
return SKIP_ONE;
}
bool raw_json = cli_strip_flag(&argc, argv, "--json");
/* Supervisor worker role: when this process was spawned as a supervised index
* worker, run indexing in-process (never re-supervise) and write the result to
* the given file for the parent to read back. Stripped here so the tool
* dispatch below sees only the tool name + its args. */
bool index_worker = cli_strip_flag(&argc, argv, "--index-worker");
(void)cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_BUILD_ARG);
const char *response_out = cli_strip_flag_value(&argc, argv, "--response-out");
(void)cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_MEMORY_BUDGET_ARG);
bool worker_single_thread = cli_strip_flag(&argc, argv, CBM_INDEX_WORKER_SINGLE_THREAD_ARG);
const char *worker_marker = cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_MARKER_ARG);
const char *worker_quarantine =
cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_QUARANTINE_ARG);
if (index_worker) {
/* The graph lives on this process's heaps: SQLite gets heaps of its
* own here, and only here (cbm_sqlite_dedicated_heap). */
cbm_sqlite_dedicated_heap(true);
cbm_memev_process_role("worker");
}
cbm_index_set_worker_role_options(index_worker, response_out, worker_single_thread,
worker_marker, worker_quarantine,
cbm_index_worker_memory_budget_bytes());
if (argc < MAIN_MIN_ARGC) {
(void)fprintf(stderr, CLI_USAGE);
return SKIP_ONE;
}
const char *tool_name = argv[0];
int rem_argc = argc - SKIP_ONE; /* args following the tool name */
char **rem_argv = argv + SKIP_ONE;
/* --help / -h : print per-tool help (from the tool's input_schema) and exit
* before any server work. */
for (int i = 0; i < rem_argc; i++) {
if (strcmp(rem_argv[i], "--help") == 0 || strcmp(rem_argv[i], "-h") == 0) {
if (cbm_cli_print_tool_help(tool_name) != 0) {
(void)fprintf(stderr, "error: unknown tool '%s'\n", tool_name);
return SKIP_ONE;
}
return 0;
}
}
/* Resolve the JSON arguments. Precedence: --args-file, then raw JSON
* (back-compat), then --flags, then piped stdin, then empty {}. */
char *heap_args = NULL; /* freed before return when set */
const char *args_json = "{}";
int args_file_idx = -1;
for (int i = 0; i < rem_argc; i++) {
if (strcmp(rem_argv[i], "--args-file") == 0) {
args_file_idx = i;
break;
}
}
if (args_file_idx >= 0) {
if (args_file_idx + SKIP_ONE >= rem_argc) {
(void)fprintf(stderr, "error: --args-file requires a path argument\n");
return SKIP_ONE;
}
const char *path = rem_argv[args_file_idx + SKIP_ONE];
heap_args = cli_slurp_file(path);
if (!heap_args) {
(void)fprintf(stderr, "error: cannot read args file '%s'\n", path);
return SKIP_ONE;
}
args_json = heap_args;
} else if (rem_argc >= SKIP_ONE && cli_first_nonspace_is_brace(rem_argv[0])) {
/* raw-JSON back-compat: cli <tool> '{"k":"v"}' (deprecated path). Warn on
* STDERR only — stdout must stay clean JSON for piping. */
if (!output_flags.quiet_requested) {
(void)fprintf(stderr,
"warning: passing raw JSON to 'cli %s' is deprecated and "
"will be removed in a future release; use flags (run 'cli "
"%s --help'), --args-file <path>, or piped stdin.\n",
tool_name, tool_name);
}
args_json = rem_argv[0];
} else if (rem_argc >= SKIP_ONE && strncmp(rem_argv[0], "--", 2) == 0) {
/* flag form: cli <tool> --flag value --bare-bool ... */
char *err = NULL;
heap_args = cbm_cli_build_args_json(tool_name, rem_argc, rem_argv, &err);
if (!heap_args) {
(void)fprintf(stderr, "error: %s\n", err ? err : "invalid arguments");
free(err);
return SKIP_ONE;
}
args_json = heap_args;
} else if (cbm_cli_args_from_stdin_allowed(tool_name, cbm_cli_stdin_kind(0))) {
/* piped stdin (UTF-8 clean, no shell quoting): cli <tool> < args.json.
* Gated (#1359): a call complete without arguments must not read a pipe
* nobody is going to write to or close — see the WHY on the predicate. */
heap_args = cli_slurp_stream(stdin);
if (heap_args && heap_args[0]) {
args_json = heap_args;
} else {
free(heap_args);
heap_args = NULL;
args_json = "{}";
}
}
bool progress =
!index_worker && cbm_cli_progress_enabled(output_flags.progress_requested,
output_flags.quiet_requested, cli_isatty(2) != 0);
uint64_t progress_started_ms = cbm_now_ms();
if (progress) {
cbm_progress_sink_init(stderr);
cbm_cli_progress_start(stderr, tool_name);
}
/* Indexing always executes daemon-side now (the daemon's supervisor
* spawns and budgets the worker); no local supervision prep remains for