@cruxy/cli 0.29.2 → 0.29.3
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.
- package/dist/cli/commands/mcp.js +1 -1
- package/dist/config/credentials.d.ts +3 -4
- package/dist/config/credentials.js +74 -21
- package/dist/config/owner-only.d.ts +19 -0
- package/dist/config/owner-only.js +114 -0
- package/dist/config/schema.d.ts +32 -32
- package/dist/errors/constructors.d.ts +9 -0
- package/dist/errors/constructors.js +27 -0
- package/dist/errors/types.d.ts +6 -0
- package/dist/errors/types.js +7 -0
- package/dist/indexing/service.js +4 -1
- package/dist/memory/types.d.ts +2 -2
- package/dist/testing/runner.js +5 -17
- package/dist/tools/shell/exec.js +5 -19
- package/dist/utils/child-tree.d.ts +9 -11
- package/dist/utils/child-tree.js +11 -25
- package/dist/utils/process-tree.d.ts +16 -0
- package/dist/utils/process-tree.js +81 -0
- package/package.json +4 -2
package/dist/cli/commands/mcp.js
CHANGED
|
@@ -131,7 +131,7 @@ export function mcpCommand() {
|
|
|
131
131
|
]);
|
|
132
132
|
}
|
|
133
133
|
const io = defaultOnboardingIO(shouldUseColor(process.stderr));
|
|
134
|
-
io.write(`${t.strong(`credential for "${server}"`)} ${t.muted(`(stored as "${ref}" in ~/.cruxy/credentials.json,
|
|
134
|
+
io.write(`${t.strong(`credential for "${server}"`)} ${t.muted(`(stored as "${ref}" in ~/.cruxy/credentials.json, owner-only)`)}\n`);
|
|
135
135
|
io.write(`${t.muted("paste the bearer token (input hidden): ")}`);
|
|
136
136
|
const token = (await io.readSecret()).trim();
|
|
137
137
|
if (!token) {
|
|
@@ -9,11 +9,10 @@ export declare function readCredential(provider: string, file?: string): string
|
|
|
9
9
|
* secret is sourced from. Never throws.
|
|
10
10
|
*/
|
|
11
11
|
export declare function readMcpCredential(ref: string, file?: string): string | undefined;
|
|
12
|
-
/** Persist MCP bearer token for credential name `ref`. Same
|
|
12
|
+
/** Persist MCP bearer token for credential name `ref`. Same owner-only guarantee. */
|
|
13
13
|
export declare function writeMcpCredential(ref: string, token: string, file?: string): void;
|
|
14
14
|
/**
|
|
15
|
-
* Persist `key` for `provider`, merging into any existing store.
|
|
16
|
-
*
|
|
17
|
-
* with an explicit `chmod` after write (mkdir/write modes are umask-masked).
|
|
15
|
+
* Persist `key` for `provider`, merging into any existing store. Written
|
|
16
|
+
* owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
|
|
18
17
|
*/
|
|
19
18
|
export declare function writeCredential(provider: string, key: string, file?: string): void;
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CREDENTIALS_FILE_NAME } from "../constants.js";
|
|
4
|
+
import { credentialsUnprotected } from "../errors/index.js";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { enforceOwnerOnly, isOwnerOnly } from "./owner-only.js";
|
|
4
7
|
import { globalDir } from "./paths.js";
|
|
5
8
|
/**
|
|
6
9
|
* The credentials store (U.6) — the one place a provider API key is persisted.
|
|
7
10
|
* It lives **outside** `config.json` on purpose: config is secret-free by design
|
|
8
11
|
* ("the API key comes from env"), so secrets get their own file with restrictive
|
|
9
|
-
* permissions
|
|
10
|
-
*
|
|
12
|
+
* permissions, the same shape as gh/aws/npm. `resolveApiKey` reads it as a
|
|
13
|
+
* fallback after the environment.
|
|
14
|
+
*
|
|
15
|
+
* "Restrictive" means **owner-only**: POSIX `0600` (dir `0700`), and on Windows
|
|
16
|
+
* an NTFS ACL granting only the current user (POSIX modes are meaningless there —
|
|
17
|
+
* see {@link enforceOwnerOnly}). The guarantee is enforced AND verified on every
|
|
18
|
+
* write; if it cannot be established the write is refused loudly
|
|
19
|
+
* (`CRUXY_E_CREDENTIALS_UNPROTECTED`) rather than persisting a secret at
|
|
20
|
+
* permissions we could not secure.
|
|
11
21
|
*/
|
|
12
22
|
/** Bumped if the on-disk shape ever changes. */
|
|
13
23
|
const CREDENTIALS_VERSION = 1;
|
|
@@ -15,6 +25,26 @@ const CREDENTIALS_VERSION = 1;
|
|
|
15
25
|
export function credentialsPath() {
|
|
16
26
|
return join(globalDir(), CREDENTIALS_FILE_NAME);
|
|
17
27
|
}
|
|
28
|
+
/** Paths already warned about this process, so a loose-perms warning fires once. */
|
|
29
|
+
const warnedLoosePerms = new Set();
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort: warn (never refuse) if an existing store is readable beyond its
|
|
32
|
+
* owner, so an upgrade from a build that couldn't secure it isn't silently
|
|
33
|
+
* insecure — and isn't bricked either. Fires at most once per path per process.
|
|
34
|
+
*/
|
|
35
|
+
function warnIfLoosePerms(file) {
|
|
36
|
+
if (warnedLoosePerms.has(file))
|
|
37
|
+
return;
|
|
38
|
+
warnedLoosePerms.add(file);
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(file) && !isOwnerOnly(file)) {
|
|
41
|
+
logger.warn(`credentials store ${file} is not owner-only — other users on this machine may be able to read it. Re-run \`cruxy login\` (or re-store the MCP credential) to repair its permissions.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Perms could not be inspected — stay silent rather than cry wolf.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
18
48
|
/** Parse the store at `file`, or `null` if absent/unreadable/malformed. */
|
|
19
49
|
function readStore(file) {
|
|
20
50
|
if (!existsSync(file))
|
|
@@ -35,6 +65,7 @@ function readStore(file) {
|
|
|
35
65
|
}
|
|
36
66
|
/** The stored key for `provider`, or `undefined`. Never throws. */
|
|
37
67
|
export function readCredential(provider, file = credentialsPath()) {
|
|
68
|
+
warnIfLoosePerms(file);
|
|
38
69
|
const store = readStore(file);
|
|
39
70
|
const key = store?.keys[provider];
|
|
40
71
|
return typeof key === "string" && key !== "" ? key : undefined;
|
|
@@ -46,52 +77,74 @@ export function readCredential(provider, file = credentialsPath()) {
|
|
|
46
77
|
* secret is sourced from. Never throws.
|
|
47
78
|
*/
|
|
48
79
|
export function readMcpCredential(ref, file = credentialsPath()) {
|
|
80
|
+
warnIfLoosePerms(file);
|
|
49
81
|
const store = readStore(file);
|
|
50
82
|
const token = store?.mcp?.[ref];
|
|
51
83
|
return typeof token === "string" && token !== "" ? token : undefined;
|
|
52
84
|
}
|
|
53
|
-
/** Persist MCP bearer token for credential name `ref`. Same
|
|
85
|
+
/** Persist MCP bearer token for credential name `ref`. Same owner-only guarantee. */
|
|
54
86
|
export function writeMcpCredential(ref, token, file = credentialsPath()) {
|
|
55
87
|
writeInto(file, (store) => {
|
|
56
88
|
store.mcp ??= {};
|
|
57
89
|
store.mcp[ref] = token;
|
|
58
|
-
});
|
|
90
|
+
}, "mcp");
|
|
59
91
|
}
|
|
60
92
|
/**
|
|
61
|
-
* Persist `key` for `provider`, merging into any existing store.
|
|
62
|
-
*
|
|
63
|
-
* with an explicit `chmod` after write (mkdir/write modes are umask-masked).
|
|
93
|
+
* Persist `key` for `provider`, merging into any existing store. Written
|
|
94
|
+
* owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
|
|
64
95
|
*/
|
|
65
96
|
export function writeCredential(provider, key, file = credentialsPath()) {
|
|
66
97
|
writeInto(file, (store) => {
|
|
67
98
|
store.keys[provider] = key;
|
|
68
|
-
});
|
|
99
|
+
}, "provider");
|
|
69
100
|
}
|
|
70
101
|
/**
|
|
71
|
-
* Merge `mutate` into the store and persist it owner-only
|
|
72
|
-
*
|
|
73
|
-
*
|
|
102
|
+
* Merge `mutate` into the store and persist it owner-only. The one write path
|
|
103
|
+
* shared by every credential namespace, and the single place the owner-only
|
|
104
|
+
* guarantee is enforced:
|
|
105
|
+
*
|
|
106
|
+
* 1. The DIRECTORY is made owner-only FIRST, before any secret is written — so
|
|
107
|
+
* the file inherits owner-only at creation (no window where it exists under
|
|
108
|
+
* inherited permissions), and if the directory can't be secured we refuse
|
|
109
|
+
* before a secret ever touches disk.
|
|
110
|
+
* 2. The new content is written to a temp file, made owner-only + VERIFIED,
|
|
111
|
+
* then atomically renamed over the target. A failure removes the temp and
|
|
112
|
+
* leaves any existing store untouched — never a torn or clobbered write.
|
|
113
|
+
*
|
|
114
|
+
* Enforcement runs on every write, so a pre-existing store with loose
|
|
115
|
+
* permissions is repaired in place. If owner-only cannot be established the write
|
|
116
|
+
* is refused with `CRUXY_E_CREDENTIALS_UNPROTECTED` (`kind` tailors the
|
|
117
|
+
* remediation: provider keys → env var; MCP tokens → no env fallback).
|
|
74
118
|
*/
|
|
75
|
-
function writeInto(file, mutate) {
|
|
119
|
+
function writeInto(file, mutate, kind) {
|
|
76
120
|
const dir = dirname(file);
|
|
77
|
-
mkdirSync(dir, { recursive: true
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
78
122
|
try {
|
|
79
|
-
|
|
123
|
+
enforceOwnerOnly(dir, { directory: true });
|
|
80
124
|
}
|
|
81
|
-
catch {
|
|
82
|
-
//
|
|
125
|
+
catch (err) {
|
|
126
|
+
// Refuse BEFORE writing any secret — nothing sensitive has hit disk yet.
|
|
127
|
+
throw credentialsUnprotected(kind, dir, err);
|
|
83
128
|
}
|
|
84
129
|
const store = readStore(file) ?? { version: CREDENTIALS_VERSION, keys: {} };
|
|
85
130
|
store.version = CREDENTIALS_VERSION;
|
|
86
131
|
mutate(store);
|
|
87
|
-
|
|
132
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
133
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", {
|
|
88
134
|
encoding: "utf8",
|
|
89
135
|
mode: 0o600,
|
|
90
136
|
});
|
|
91
137
|
try {
|
|
92
|
-
|
|
138
|
+
enforceOwnerOnly(tmp, { directory: false }); // sets + verifies, or throws
|
|
139
|
+
renameSync(tmp, file); // atomic replace; the owner-only ACL moves with it
|
|
93
140
|
}
|
|
94
|
-
catch {
|
|
95
|
-
|
|
141
|
+
catch (err) {
|
|
142
|
+
try {
|
|
143
|
+
rmSync(tmp, { force: true });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// The temp may already be gone; the target store is untouched regardless.
|
|
147
|
+
}
|
|
148
|
+
throw credentialsUnprotected(kind, file, err);
|
|
96
149
|
}
|
|
97
150
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make `path` owner-only, or throw. On Windows the ACL is verified after the
|
|
3
|
+
* edit (both the `icacls` exit code AND a read-back), so a returned call is a
|
|
4
|
+
* genuine guarantee, never a best-effort attempt.
|
|
5
|
+
*
|
|
6
|
+
* @throws if owner-only permissions cannot be established (non-zero `icacls`,
|
|
7
|
+
* an unresolvable SID, a filesystem without ACLs/modes, …).
|
|
8
|
+
*/
|
|
9
|
+
export declare function enforceOwnerOnly(path: string, opts?: {
|
|
10
|
+
directory?: boolean;
|
|
11
|
+
}): void;
|
|
12
|
+
/**
|
|
13
|
+
* Whether `path` is currently owner-only. POSIX: no group/other bits. Windows:
|
|
14
|
+
* best-effort — the ACL names no broad principal (Everyone / Authenticated
|
|
15
|
+
* Users / Users). Used both to verify {@link enforceOwnerOnly} and to warn on a
|
|
16
|
+
* pre-existing store with loose permissions. Returns `true` when it genuinely
|
|
17
|
+
* cannot tell (a missing tool), so a warning path never cries wolf.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isOwnerOnly(path: string): boolean;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, statSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform "owner-only" file/dir permissions — the one place that knows
|
|
5
|
+
* how to make a path readable by nobody but its owner, and how to check whether
|
|
6
|
+
* it already is.
|
|
7
|
+
*
|
|
8
|
+
* POSIX is `chmod` (`0600` file / `0700` dir). Windows has no POSIX modes —
|
|
9
|
+
* `fs.chmod` there only toggles the read-only attribute and never touches the
|
|
10
|
+
* NTFS ACL (a libuv limitation), so it is worthless for confidentiality. The
|
|
11
|
+
* Windows equivalent is an ACL edit via `icacls`: strip the inherited ACEs (the
|
|
12
|
+
* only source of "others" access on a fresh file) and grant Full to exactly the
|
|
13
|
+
* current user's SID.
|
|
14
|
+
*
|
|
15
|
+
* Every function here THROWS rather than swallowing a failure — the caller (the
|
|
16
|
+
* credential store) turns that into a loud refusal to persist a secret it can't
|
|
17
|
+
* secure. Nothing here silently claims success.
|
|
18
|
+
*/
|
|
19
|
+
const isWindows = process.platform === "win32";
|
|
20
|
+
/** `undefined` = not yet resolved, `null` = resolution failed this process. */
|
|
21
|
+
let cachedSid;
|
|
22
|
+
/**
|
|
23
|
+
* The current user's SID (e.g. `S-1-5-21-…`) via `whoami /user`, resolved once
|
|
24
|
+
* and cached. We grant to the SID — never a name — so the ACL is correct
|
|
25
|
+
* regardless of the machine's display language (`BUILTIN\Users` etc. localise).
|
|
26
|
+
* Returns `null` if it cannot be determined.
|
|
27
|
+
*/
|
|
28
|
+
function currentUserSid() {
|
|
29
|
+
if (cachedSid !== undefined)
|
|
30
|
+
return cachedSid;
|
|
31
|
+
try {
|
|
32
|
+
const out = execFileSync("whoami", ["/user", "/fo", "csv", "/nh"], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
});
|
|
35
|
+
const m = out.match(/S-1-[0-9-]+/);
|
|
36
|
+
cachedSid = m ? m[0] : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
cachedSid = null;
|
|
40
|
+
}
|
|
41
|
+
return cachedSid;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Make `path` owner-only, or throw. On Windows the ACL is verified after the
|
|
45
|
+
* edit (both the `icacls` exit code AND a read-back), so a returned call is a
|
|
46
|
+
* genuine guarantee, never a best-effort attempt.
|
|
47
|
+
*
|
|
48
|
+
* @throws if owner-only permissions cannot be established (non-zero `icacls`,
|
|
49
|
+
* an unresolvable SID, a filesystem without ACLs/modes, …).
|
|
50
|
+
*/
|
|
51
|
+
export function enforceOwnerOnly(path, opts = {}) {
|
|
52
|
+
const directory = opts.directory ?? false;
|
|
53
|
+
if (isWindows) {
|
|
54
|
+
const sid = currentUserSid();
|
|
55
|
+
if (!sid) {
|
|
56
|
+
throw new Error("could not resolve the current user's SID (whoami failed) — cannot set an owner-only ACL");
|
|
57
|
+
}
|
|
58
|
+
// /inheritance:r removes inherited ACEs (where any "others" access comes
|
|
59
|
+
// from); /grant:r replaces the DACL with exactly this one grant. A directory
|
|
60
|
+
// grant carries (OI)(CI) so files created inside inherit owner-only at birth.
|
|
61
|
+
// The SID must be written `*S-1-…` — a bare token is read as an account NAME
|
|
62
|
+
// ("No mapping between account names and security IDs" otherwise).
|
|
63
|
+
const principal = `*${sid}`;
|
|
64
|
+
const grant = directory ? `${principal}:(OI)(CI)F` : `${principal}:F`;
|
|
65
|
+
try {
|
|
66
|
+
execFileSync("icacls", [path, "/inheritance:r", "/grant:r", grant], {
|
|
67
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const stderr = err.stderr?.toString().trim();
|
|
72
|
+
throw new Error(`icacls could not restrict "${path}"${stderr ? `: ${stderr}` : ""}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
chmodSync(path, directory ? 0o700 : 0o600);
|
|
77
|
+
}
|
|
78
|
+
// Verify the end state rather than trust the set — the guarantee is the point.
|
|
79
|
+
if (!isOwnerOnly(path)) {
|
|
80
|
+
throw new Error(`"${path}" is not owner-only after attempting to restrict it`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether `path` is currently owner-only. POSIX: no group/other bits. Windows:
|
|
85
|
+
* best-effort — the ACL names no broad principal (Everyone / Authenticated
|
|
86
|
+
* Users / Users). Used both to verify {@link enforceOwnerOnly} and to warn on a
|
|
87
|
+
* pre-existing store with loose permissions. Returns `true` when it genuinely
|
|
88
|
+
* cannot tell (a missing tool), so a warning path never cries wolf.
|
|
89
|
+
*/
|
|
90
|
+
export function isOwnerOnly(path) {
|
|
91
|
+
if (!isWindows) {
|
|
92
|
+
return (statSync(path).mode & 0o077) === 0;
|
|
93
|
+
}
|
|
94
|
+
let out;
|
|
95
|
+
try {
|
|
96
|
+
out = execFileSync("icacls", [path], { encoding: "utf8" });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return true; // can't inspect → don't raise a false alarm
|
|
100
|
+
}
|
|
101
|
+
// `icacls <path>` resolves SIDs to names (locale-dependent on non-English
|
|
102
|
+
// Windows, hence best-effort): flag the well-known broad principals by name
|
|
103
|
+
// and by SID for the cases icacls leaves a SID unresolved.
|
|
104
|
+
const broad = [
|
|
105
|
+
/\bEveryone\b/i,
|
|
106
|
+
/\bAuthenticated Users\b/i,
|
|
107
|
+
/\bBUILTIN\\Users\b/i,
|
|
108
|
+
/\\Users:/i,
|
|
109
|
+
/S-1-1-0/, // Everyone
|
|
110
|
+
/S-1-5-11/, // Authenticated Users
|
|
111
|
+
/S-1-5-32-545/, // BUILTIN\Users
|
|
112
|
+
];
|
|
113
|
+
return !broad.some((re) => re.test(out));
|
|
114
|
+
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -1796,6 +1796,22 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1796
1796
|
approval: {
|
|
1797
1797
|
mode: "prompt";
|
|
1798
1798
|
};
|
|
1799
|
+
mcp: {
|
|
1800
|
+
startupTimeout: number;
|
|
1801
|
+
requestTimeout: number;
|
|
1802
|
+
servers: Record<string, {
|
|
1803
|
+
args: string[];
|
|
1804
|
+
env: Record<string, string>;
|
|
1805
|
+
command?: string | undefined;
|
|
1806
|
+
credentialRef?: string | undefined;
|
|
1807
|
+
url?: string | undefined;
|
|
1808
|
+
headers?: Record<string, string> | undefined;
|
|
1809
|
+
}>;
|
|
1810
|
+
enabled: boolean;
|
|
1811
|
+
maxToolsPerServer: number;
|
|
1812
|
+
maxDescriptionChars: number;
|
|
1813
|
+
maxSchemaBytes: number;
|
|
1814
|
+
};
|
|
1799
1815
|
subagent: {
|
|
1800
1816
|
maxDepth: number;
|
|
1801
1817
|
maxConcurrency: number;
|
|
@@ -1879,22 +1895,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1879
1895
|
map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
|
|
1880
1896
|
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
1881
1897
|
};
|
|
1882
|
-
mcp: {
|
|
1883
|
-
startupTimeout: number;
|
|
1884
|
-
requestTimeout: number;
|
|
1885
|
-
servers: Record<string, {
|
|
1886
|
-
args: string[];
|
|
1887
|
-
env: Record<string, string>;
|
|
1888
|
-
command?: string | undefined;
|
|
1889
|
-
credentialRef?: string | undefined;
|
|
1890
|
-
url?: string | undefined;
|
|
1891
|
-
headers?: Record<string, string> | undefined;
|
|
1892
|
-
}>;
|
|
1893
|
-
enabled: boolean;
|
|
1894
|
-
maxToolsPerServer: number;
|
|
1895
|
-
maxDescriptionChars: number;
|
|
1896
|
-
maxSchemaBytes: number;
|
|
1897
|
-
};
|
|
1898
1898
|
web: {
|
|
1899
1899
|
provider: "tavily";
|
|
1900
1900
|
timeoutMs: number;
|
|
@@ -1951,6 +1951,22 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1951
1951
|
approval?: {
|
|
1952
1952
|
mode?: "prompt" | undefined;
|
|
1953
1953
|
} | undefined;
|
|
1954
|
+
mcp?: {
|
|
1955
|
+
startupTimeout?: number | undefined;
|
|
1956
|
+
requestTimeout?: number | undefined;
|
|
1957
|
+
servers?: Record<string, {
|
|
1958
|
+
command?: string | undefined;
|
|
1959
|
+
credentialRef?: string | undefined;
|
|
1960
|
+
url?: string | undefined;
|
|
1961
|
+
args?: string[] | undefined;
|
|
1962
|
+
env?: Record<string, string> | undefined;
|
|
1963
|
+
headers?: Record<string, string> | undefined;
|
|
1964
|
+
}> | undefined;
|
|
1965
|
+
enabled?: boolean | undefined;
|
|
1966
|
+
maxToolsPerServer?: number | undefined;
|
|
1967
|
+
maxDescriptionChars?: number | undefined;
|
|
1968
|
+
maxSchemaBytes?: number | undefined;
|
|
1969
|
+
} | undefined;
|
|
1954
1970
|
subagent?: {
|
|
1955
1971
|
maxDepth?: number | undefined;
|
|
1956
1972
|
maxConcurrency?: number | undefined;
|
|
@@ -2034,22 +2050,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
2034
2050
|
map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
|
|
2035
2051
|
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
2036
2052
|
} | undefined;
|
|
2037
|
-
mcp?: {
|
|
2038
|
-
startupTimeout?: number | undefined;
|
|
2039
|
-
requestTimeout?: number | undefined;
|
|
2040
|
-
servers?: Record<string, {
|
|
2041
|
-
command?: string | undefined;
|
|
2042
|
-
credentialRef?: string | undefined;
|
|
2043
|
-
url?: string | undefined;
|
|
2044
|
-
args?: string[] | undefined;
|
|
2045
|
-
env?: Record<string, string> | undefined;
|
|
2046
|
-
headers?: Record<string, string> | undefined;
|
|
2047
|
-
}> | undefined;
|
|
2048
|
-
enabled?: boolean | undefined;
|
|
2049
|
-
maxToolsPerServer?: number | undefined;
|
|
2050
|
-
maxDescriptionChars?: number | undefined;
|
|
2051
|
-
maxSchemaBytes?: number | undefined;
|
|
2052
|
-
} | undefined;
|
|
2053
2053
|
web?: {
|
|
2054
2054
|
provider?: "tavily" | undefined;
|
|
2055
2055
|
timeoutMs?: number | undefined;
|
|
@@ -24,6 +24,15 @@ export declare function configParse(path: string, underlying?: unknown): CruxyEr
|
|
|
24
24
|
export declare function configInvalid(issues: string, path?: string): CruxyError;
|
|
25
25
|
export declare function authMissingKey(provider: string, envVar: string): CruxyError;
|
|
26
26
|
export declare function authInvalid(underlying?: unknown): CruxyError;
|
|
27
|
+
/**
|
|
28
|
+
* A credential could not be persisted with owner-only permissions, so it was
|
|
29
|
+
* NOT written (C.27c). Chiefly a Windows case: the store's ACL could not be
|
|
30
|
+
* restricted to the current user (non-NTFS filesystem, `icacls`/SID
|
|
31
|
+
* unavailable), and we refuse to leave a secret at inheritable permissions while
|
|
32
|
+
* claiming otherwise. Provider keys point at the env-var fallback; MCP tokens
|
|
33
|
+
* have no such fallback, so the message says so plainly.
|
|
34
|
+
*/
|
|
35
|
+
export declare function credentialsUnprotected(kind: "provider" | "mcp", path: string, underlying?: unknown): CruxyError;
|
|
27
36
|
export declare function gatewayUnreachable(underlying?: unknown): CruxyError;
|
|
28
37
|
export declare function apiError(underlying?: unknown): CruxyError;
|
|
29
38
|
export declare function apiRateLimit(underlying?: unknown): CruxyError;
|
|
@@ -142,6 +142,33 @@ export function authInvalid(underlying) {
|
|
|
142
142
|
underlying,
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* A credential could not be persisted with owner-only permissions, so it was
|
|
147
|
+
* NOT written (C.27c). Chiefly a Windows case: the store's ACL could not be
|
|
148
|
+
* restricted to the current user (non-NTFS filesystem, `icacls`/SID
|
|
149
|
+
* unavailable), and we refuse to leave a secret at inheritable permissions while
|
|
150
|
+
* claiming otherwise. Provider keys point at the env-var fallback; MCP tokens
|
|
151
|
+
* have no such fallback, so the message says so plainly.
|
|
152
|
+
*/
|
|
153
|
+
export function credentialsUnprotected(kind, path, underlying) {
|
|
154
|
+
const nextSteps = kind === "provider"
|
|
155
|
+
? [
|
|
156
|
+
"set the key via the CRUXY_API_KEY environment variable instead — env is never written to disk and always wins",
|
|
157
|
+
"or store it on a filesystem that supports owner-only permissions (NTFS, not FAT/exFAT)",
|
|
158
|
+
]
|
|
159
|
+
: [
|
|
160
|
+
"an MCP bearer token has no environment fallback — it can only live in the owner-only store",
|
|
161
|
+
"store it on a filesystem that supports owner-only permissions (NTFS, not FAT/exFAT)",
|
|
162
|
+
];
|
|
163
|
+
return new CruxyError({
|
|
164
|
+
code: ErrorCode.CredentialsUnprotected,
|
|
165
|
+
title: `refusing to write a credential that cannot be made owner-only: ${path}`,
|
|
166
|
+
cause: scrubbedMessageOf(underlying),
|
|
167
|
+
nextSteps,
|
|
168
|
+
underlying,
|
|
169
|
+
meta: { path, kind },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
145
172
|
// ── network (exit 5) ──────────────────────────────────────────────────────────
|
|
146
173
|
export function gatewayUnreachable(underlying) {
|
|
147
174
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export declare const ErrorCode: {
|
|
|
29
29
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
30
30
|
readonly AuthInvalid: "CRUXY_E_AUTH_INVALID";
|
|
31
31
|
readonly ForgeAuth: "CRUXY_E_FORGE_AUTH";
|
|
32
|
+
/** A credential could not be persisted with owner-only permissions (C.27c) —
|
|
33
|
+
* e.g. on Windows the store's ACL could not be restricted to the current user
|
|
34
|
+
* (non-NTFS filesystem, `icacls`/SID unavailable). Refused loudly rather than
|
|
35
|
+
* written world-inheritable: a secret is never persisted at permissions we
|
|
36
|
+
* could not verify as owner-only. */
|
|
37
|
+
readonly CredentialsUnprotected: "CRUXY_E_CREDENTIALS_UNPROTECTED";
|
|
32
38
|
readonly GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE";
|
|
33
39
|
readonly GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED";
|
|
34
40
|
readonly Api: "CRUXY_E_API";
|
package/dist/errors/types.js
CHANGED
|
@@ -33,6 +33,12 @@ export const ErrorCode = {
|
|
|
33
33
|
AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
|
|
34
34
|
AuthInvalid: "CRUXY_E_AUTH_INVALID",
|
|
35
35
|
ForgeAuth: "CRUXY_E_FORGE_AUTH",
|
|
36
|
+
/** A credential could not be persisted with owner-only permissions (C.27c) —
|
|
37
|
+
* e.g. on Windows the store's ACL could not be restricted to the current user
|
|
38
|
+
* (non-NTFS filesystem, `icacls`/SID unavailable). Refused loudly rather than
|
|
39
|
+
* written world-inheritable: a secret is never persisted at permissions we
|
|
40
|
+
* could not verify as owner-only. */
|
|
41
|
+
CredentialsUnprotected: "CRUXY_E_CREDENTIALS_UNPROTECTED",
|
|
36
42
|
// network (exit 5)
|
|
37
43
|
GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE",
|
|
38
44
|
GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
|
|
@@ -249,6 +255,7 @@ const EXIT_CODES = {
|
|
|
249
255
|
[ErrorCode.AuthMissingKey]: 4,
|
|
250
256
|
[ErrorCode.AuthInvalid]: 4,
|
|
251
257
|
[ErrorCode.ForgeAuth]: 4,
|
|
258
|
+
[ErrorCode.CredentialsUnprotected]: 4,
|
|
252
259
|
[ErrorCode.GatewayUnreachable]: 5,
|
|
253
260
|
[ErrorCode.GitPushFailed]: 5,
|
|
254
261
|
[ErrorCode.Api]: 6,
|
package/dist/indexing/service.js
CHANGED
|
@@ -131,7 +131,10 @@ async function openStore(root, kind, logger) {
|
|
|
131
131
|
// an in-memory index with a warning (no quality loss, just no persistence).
|
|
132
132
|
if (kind === "sqlite")
|
|
133
133
|
throw indexStoreUnavailable(err);
|
|
134
|
-
logger.warn(`
|
|
134
|
+
logger.warn(`persistent index unavailable (${err.message}); using an ephemeral ` +
|
|
135
|
+
`in-memory index — it reindexes from scratch each session. To enable persistence, ` +
|
|
136
|
+
`let better-sqlite3 build (install your platform's C/C++ build tools) or run on Node ≥22 ` +
|
|
137
|
+
`(which ships a prebuilt binary).`);
|
|
135
138
|
return { store: new InMemoryVectorStore(), storePath: null };
|
|
136
139
|
}
|
|
137
140
|
}
|
package/dist/memory/types.d.ts
CHANGED
|
@@ -45,14 +45,14 @@ export declare const MemoryEntrySchema: z.ZodObject<{
|
|
|
45
45
|
/** ISO 8601 timestamp the entry was recorded. */
|
|
46
46
|
createdAt: z.ZodString;
|
|
47
47
|
}, "strict", z.ZodTypeAny, {
|
|
48
|
-
id: string;
|
|
49
48
|
kind: "fact" | "decision" | "preference";
|
|
49
|
+
id: string;
|
|
50
50
|
createdAt: string;
|
|
51
51
|
content: string;
|
|
52
52
|
scope: "project" | "user";
|
|
53
53
|
}, {
|
|
54
|
-
id: string;
|
|
55
54
|
kind: "fact" | "decision" | "preference";
|
|
55
|
+
id: string;
|
|
56
56
|
createdAt: string;
|
|
57
57
|
content: string;
|
|
58
58
|
scope: "project" | "user";
|
package/dist/testing/runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { killTree, spawnTree } from "../utils/process-tree.js";
|
|
2
2
|
import { parseFailures } from "./parse.js";
|
|
3
3
|
/**
|
|
4
4
|
* The shipped {@link TestRunner}: spawn the command via the system shell (the
|
|
@@ -15,11 +15,10 @@ export class CommandTestRunner {
|
|
|
15
15
|
const capture = new TailCapture(opts.captureBytes);
|
|
16
16
|
let child;
|
|
17
17
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
18
|
+
// Same killable-tree discipline as run_command: `spawnTree` groups the
|
|
19
|
+
// shell (POSIX process group / win32 OS tree) so a timeout can reap the
|
|
20
|
+
// whole tree via `killTree`.
|
|
21
|
+
child = spawnTree(command, [], { shell: true, cwd: opts.cwd });
|
|
23
22
|
}
|
|
24
23
|
catch (err) {
|
|
25
24
|
resolve(failed(null, err.message, startedAt));
|
|
@@ -111,14 +110,3 @@ export class TailCapture {
|
|
|
111
110
|
return this.truncated ? `… [earlier output truncated]\n${body}` : body;
|
|
112
111
|
}
|
|
113
112
|
}
|
|
114
|
-
/** Kill the whole process group (POSIX; matches run_command's behavior). */
|
|
115
|
-
function killTree(pid) {
|
|
116
|
-
if (pid === undefined)
|
|
117
|
-
return;
|
|
118
|
-
try {
|
|
119
|
-
process.kill(-pid, "SIGKILL");
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
// Already exited, or no group — nothing to kill.
|
|
123
|
-
}
|
|
124
|
-
}
|
package/dist/tools/shell/exec.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { killTree, spawnTree } from "../../utils/process-tree.js";
|
|
2
2
|
/**
|
|
3
3
|
* Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
|
|
4
4
|
* it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
|
|
@@ -50,9 +50,10 @@ async function runSandboxed(command, ctx) {
|
|
|
50
50
|
function runBounded(command, ctx) {
|
|
51
51
|
const { timeoutMs, maxOutputBytes } = ctx.config.shell;
|
|
52
52
|
return new Promise((resolve) => {
|
|
53
|
-
// `
|
|
54
|
-
//
|
|
55
|
-
|
|
53
|
+
// `spawnTree` makes the child the head of a killable tree (its own process
|
|
54
|
+
// group on POSIX; the OS parent-PID tree on win32) so the whole tree — the
|
|
55
|
+
// shell plus anything it spawns — can be killed on timeout via `killTree`.
|
|
56
|
+
const child = spawnTree(command, [], { shell: true, cwd: ctx.cwd });
|
|
56
57
|
const chunks = [];
|
|
57
58
|
let captured = 0;
|
|
58
59
|
let truncated = false;
|
|
@@ -150,18 +151,3 @@ function runBounded(command, ctx) {
|
|
|
150
151
|
});
|
|
151
152
|
});
|
|
152
153
|
}
|
|
153
|
-
/**
|
|
154
|
-
* Kill the command's entire process group. POSIX-specific (negative pid targets
|
|
155
|
-
* the group); fine on our darwin/linux targets. Swallows errors — the process
|
|
156
|
-
* may already be gone.
|
|
157
|
-
*/
|
|
158
|
-
function killTree(pid) {
|
|
159
|
-
if (pid === undefined)
|
|
160
|
-
return;
|
|
161
|
-
try {
|
|
162
|
-
process.kill(-pid, "SIGKILL");
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
// Already exited, or no group — nothing to kill.
|
|
166
|
-
}
|
|
167
|
-
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The process-EXIT backstop for long-lived child trees (C.12 LSP servers, C.27
|
|
3
|
+
* MCP servers). The platform-aware spawn/kill primitives themselves now live in
|
|
4
|
+
* {@link ./process-tree.js} — {@link killTree} is re-exported here unchanged so
|
|
5
|
+
* existing LSP/MCP importers keep their import path, and so this backstop and
|
|
6
|
+
* those transports reap trees the SAME way on every platform (negative-PID
|
|
7
|
+
* `SIGKILL` on POSIX, `taskkill /T /F` on win32 — no more orphaned grandchildren
|
|
8
|
+
* on Windows).
|
|
7
9
|
*
|
|
8
10
|
* A per-session graceful shutdown covers the normal path, but a hard exit
|
|
9
11
|
* (Ctrl-C, an uncaught throw) would otherwise orphan these trees. So every live
|
|
@@ -15,12 +17,8 @@
|
|
|
15
17
|
* and MCP (newline-delimited JSON) share the SAME backstop, so `killTrackedTrees`
|
|
16
18
|
* on exit reaps both and there is a single source of truth for "no orphans".
|
|
17
19
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
* a direct kill when there is no group (or on win32). Swallows errors — the
|
|
21
|
-
* process may already be gone.
|
|
22
|
-
*/
|
|
23
|
-
export declare function killTree(pid: number | undefined): void;
|
|
20
|
+
import { killTree } from "./process-tree.js";
|
|
21
|
+
export { killTree };
|
|
24
22
|
/**
|
|
25
23
|
* Force-kill the process group of every tracked-but-not-yet-shut-down child,
|
|
26
24
|
* then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
|
package/dist/utils/child-tree.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The process-EXIT backstop for long-lived child trees (C.12 LSP servers, C.27
|
|
3
|
+
* MCP servers). The platform-aware spawn/kill primitives themselves now live in
|
|
4
|
+
* {@link ./process-tree.js} — {@link killTree} is re-exported here unchanged so
|
|
5
|
+
* existing LSP/MCP importers keep their import path, and so this backstop and
|
|
6
|
+
* those transports reap trees the SAME way on every platform (negative-PID
|
|
7
|
+
* `SIGKILL` on POSIX, `taskkill /T /F` on win32 — no more orphaned grandchildren
|
|
8
|
+
* on Windows).
|
|
7
9
|
*
|
|
8
10
|
* A per-session graceful shutdown covers the normal path, but a hard exit
|
|
9
11
|
* (Ctrl-C, an uncaught throw) would otherwise orphan these trees. So every live
|
|
@@ -15,26 +17,10 @@
|
|
|
15
17
|
* and MCP (newline-delimited JSON) share the SAME backstop, so `killTrackedTrees`
|
|
16
18
|
* on exit reaps both and there is a single source of truth for "no orphans".
|
|
17
19
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*/
|
|
23
|
-
export function killTree(pid) {
|
|
24
|
-
if (pid === undefined)
|
|
25
|
-
return;
|
|
26
|
-
try {
|
|
27
|
-
process.kill(-pid, "SIGKILL");
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
try {
|
|
31
|
-
process.kill(pid, "SIGKILL");
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
/* already exited */
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
20
|
+
import { killTree } from "./process-tree.js";
|
|
21
|
+
// Re-exported so LSP/MCP transports (and their tests) keep importing `killTree`
|
|
22
|
+
// from here; the implementation is the shared, platform-aware one.
|
|
23
|
+
export { killTree };
|
|
38
24
|
const livePids = new Set();
|
|
39
25
|
let handlersInstalled = false;
|
|
40
26
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Spawn a child as the head of a killable process tree, applying the
|
|
4
|
+
* platform-correct grouping options on top of the caller's own (`shell`, `cwd`,
|
|
5
|
+
* `stdio`, `env`, …). Any `detached`/`windowsHide` the caller passes is
|
|
6
|
+
* overridden — grouping is this module's responsibility, not the call site's.
|
|
7
|
+
*/
|
|
8
|
+
export declare function spawnTree(command: string, args?: readonly string[], options?: SpawnOptions): ChildProcess;
|
|
9
|
+
/**
|
|
10
|
+
* Kill a child's ENTIRE process tree — the child and every descendant it
|
|
11
|
+
* spawned. POSIX: negative-PID `SIGKILL` targets the process group created by
|
|
12
|
+
* {@link spawnTree}'s `detached`. win32: `taskkill /PID <pid> /T /F` walks the
|
|
13
|
+
* OS tree (`/T`) and force-terminates it (`/F`). Fire-and-forget and
|
|
14
|
+
* error-swallowing on both paths — the tree may already be gone.
|
|
15
|
+
*/
|
|
16
|
+
export declare function killTree(pid: number | undefined): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn, } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* The single platform-aware primitive for spawning a killable process tree and
|
|
4
|
+
* reaping it whole. Both halves — {@link spawnTree} and {@link killTree} — MUST
|
|
5
|
+
* come from here as a pair, because how a child is spawned decides how its tree
|
|
6
|
+
* can be killed, and the two differ by platform:
|
|
7
|
+
*
|
|
8
|
+
* - POSIX: spawn `detached` so the child leads its own process group, then kill
|
|
9
|
+
* the whole group with a negative-PID `SIGKILL`. One signal reaps the shell
|
|
10
|
+
* AND everything it forked (a `shell:true` grandchild, gopls's `go`, …).
|
|
11
|
+
* - win32: there is no process-group signalling to lean on. `detached` there
|
|
12
|
+
* means "new console / new group" — the wrong semantics, and a flashing
|
|
13
|
+
* window. So we DON'T detach (just `windowsHide`), and kill by walking the
|
|
14
|
+
* real OS parent-PID tree with `taskkill /T /F`, which a negative-PID signal
|
|
15
|
+
* could never do on Windows (it would kill only the direct child and orphan
|
|
16
|
+
* the grandchildren).
|
|
17
|
+
*
|
|
18
|
+
* This module exists because that pairing used to be copy-pasted — three
|
|
19
|
+
* POSIX-only `killTree` variants (run_command, the test runner, the LSP/MCP
|
|
20
|
+
* backstop), each of which silently orphaned grandchildren on Windows. There is
|
|
21
|
+
* now one implementation; a change to the kill discipline changes every path.
|
|
22
|
+
*/
|
|
23
|
+
const isWindows = process.platform === "win32";
|
|
24
|
+
/**
|
|
25
|
+
* Spawn a child as the head of a killable process tree, applying the
|
|
26
|
+
* platform-correct grouping options on top of the caller's own (`shell`, `cwd`,
|
|
27
|
+
* `stdio`, `env`, …). Any `detached`/`windowsHide` the caller passes is
|
|
28
|
+
* overridden — grouping is this module's responsibility, not the call site's.
|
|
29
|
+
*/
|
|
30
|
+
export function spawnTree(command, args = [], options = {}) {
|
|
31
|
+
const grouped = isWindows
|
|
32
|
+
? { ...options, detached: false, windowsHide: true }
|
|
33
|
+
: { ...options, detached: true };
|
|
34
|
+
return spawn(command, args, grouped);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Kill a child's ENTIRE process tree — the child and every descendant it
|
|
38
|
+
* spawned. POSIX: negative-PID `SIGKILL` targets the process group created by
|
|
39
|
+
* {@link spawnTree}'s `detached`. win32: `taskkill /PID <pid> /T /F` walks the
|
|
40
|
+
* OS tree (`/T`) and force-terminates it (`/F`). Fire-and-forget and
|
|
41
|
+
* error-swallowing on both paths — the tree may already be gone.
|
|
42
|
+
*/
|
|
43
|
+
export function killTree(pid) {
|
|
44
|
+
if (pid === undefined)
|
|
45
|
+
return;
|
|
46
|
+
if (isWindows) {
|
|
47
|
+
killTreeWindows(pid);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
// Negative PID = "the whole group led by `pid`", not just `pid`.
|
|
52
|
+
process.kill(-pid, "SIGKILL");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Already exited, or no group — nothing to kill.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reap `pid`'s tree via `taskkill`. The killer is itself a child process, so we
|
|
60
|
+
* reap IT too: `stdio: "ignore"` gives it nothing to block on, the `error`
|
|
61
|
+
* handler swallows a missing-`taskkill`/EPIPE rejection (an unhandled `error`
|
|
62
|
+
* event would otherwise crash the process), and `unref` keeps this short-lived
|
|
63
|
+
* helper from holding the event loop open. taskkill exits on its own; we neither
|
|
64
|
+
* wait for it nor let it leak.
|
|
65
|
+
*/
|
|
66
|
+
function killTreeWindows(pid) {
|
|
67
|
+
try {
|
|
68
|
+
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
69
|
+
stdio: "ignore",
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
});
|
|
72
|
+
killer.on("error", () => {
|
|
73
|
+
// taskkill unavailable (should not happen on win32) — nothing more to do.
|
|
74
|
+
});
|
|
75
|
+
killer.unref();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Even the spawn attempt failed — the tree, if any, outlives us. Nothing
|
|
79
|
+
// more we can do from here.
|
|
80
|
+
}
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.3",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
"directory": "packages/cli"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"better-sqlite3": "^12.11.1",
|
|
33
32
|
"commander": "^12.1.0",
|
|
34
33
|
"fastembed": "^2.1.0",
|
|
35
34
|
"picocolors": "^1.1.1",
|
|
@@ -39,6 +38,9 @@
|
|
|
39
38
|
"zod-to-json-schema": "^3.23.5",
|
|
40
39
|
"@cruxy/sdk": "0.2.1"
|
|
41
40
|
},
|
|
41
|
+
"optionalDependencies": {
|
|
42
|
+
"better-sqlite3": "^12.11.1"
|
|
43
|
+
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@types/better-sqlite3": "^7.6.13",
|
|
44
46
|
"@types/node": "^22.10.0",
|