@tencent-ai/codebuddy-code 2.109.2 → 2.109.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/CHANGELOG.md +16 -0
- package/dist/codebuddy-headless.js +144 -88
- package/dist/codebuddy.js +148 -92
- package/package.json +1 -1
- package/product.cloudhosted.json +2 -2
- package/product.internal.json +2 -2
- package/product.ioa.json +2 -2
- package/product.json +2 -2
- package/product.selfhosted.json +2 -2
- package/vendor/shim/genie-safe-delete.cjs +29 -0
- package/vendor/shim/safe-bin/safe-delete-common.sh +48 -21
- package/vendor/shim/safe-delete-bulk-guard.cjs +447 -0
- package/vendor/shim/sitecustomize.py +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tencent-ai/codebuddy-code",
|
|
3
|
-
"version": "2.109.
|
|
3
|
+
"version": "2.109.3",
|
|
4
4
|
"description": "Use CodeBuddy, Tencent's AI assistant, right from your terminal. CodeBuddy can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
|
|
5
5
|
"main": "lib/node/index.js",
|
|
6
6
|
"typings": "lib/node/index.d.ts",
|
package/product.cloudhosted.json
CHANGED
|
@@ -824,6 +824,6 @@
|
|
|
824
824
|
"SelectImage": true,
|
|
825
825
|
"SkipToolCallSupportCheck": true
|
|
826
826
|
},
|
|
827
|
-
"commit": "
|
|
828
|
-
"date": "2026-06-
|
|
827
|
+
"commit": "68b3db4d874ad5eedb2a970970318018ad0b63af",
|
|
828
|
+
"date": "2026-06-24T02:47:45.617Z"
|
|
829
829
|
}
|
package/product.internal.json
CHANGED
package/product.ioa.json
CHANGED
package/product.json
CHANGED
package/product.selfhosted.json
CHANGED
|
@@ -339,6 +339,6 @@
|
|
|
339
339
|
"ScheduledTasks": true,
|
|
340
340
|
"SkipToolCallSupportCheck": true
|
|
341
341
|
},
|
|
342
|
-
"commit": "
|
|
343
|
-
"date": "2026-06-
|
|
342
|
+
"commit": "68b3db4d874ad5eedb2a970970318018ad0b63af",
|
|
343
|
+
"date": "2026-06-24T02:47:45.607Z"
|
|
344
344
|
}
|
|
@@ -101,6 +101,34 @@ function recordTrash(absPath) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
function checkBulkDeleteGuard(absPath) {
|
|
105
|
+
const guardPath = process.env.CODEBUDDY_SAFE_DELETE_BULK_GUARD;
|
|
106
|
+
const nodeBin = process.env.CODEBUDDY_NODE_BIN;
|
|
107
|
+
if (!process.env.CODEBUDDY_SAFE_DELETE_BULK_STATE_DIR || !process.env.CODEBUDDY_TOOL_CALL_ID) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!guardPath || !nodeBin || !fs.existsSync(guardPath)) {
|
|
111
|
+
throw new Error(`[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] msg=helper-unavailable guard=${guardPath || 'unset'}`);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
execFileSync(nodeBin, [guardPath, 'check', '--target', absPath], {
|
|
115
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
116
|
+
timeout: 10_000,
|
|
117
|
+
windowsHide: true,
|
|
118
|
+
env: {
|
|
119
|
+
...process.env,
|
|
120
|
+
NODE_OPTIONS: '',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
} catch (e) {
|
|
124
|
+
const detail = e && e.stderr ? String(e.stderr).trim() : '';
|
|
125
|
+
if (detail) {
|
|
126
|
+
throw new Error(detail);
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] ${(e && e.message) || String(e)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
// ---------------------------------------------------------------------------
|
|
105
133
|
// 平台分发
|
|
106
134
|
// ---------------------------------------------------------------------------
|
|
@@ -419,6 +447,7 @@ function writeTrashInfo(absPath, baseName) {
|
|
|
419
447
|
* @param {string} absPath 绝对路径
|
|
420
448
|
*/
|
|
421
449
|
function tryTrash(absPath) {
|
|
450
|
+
checkBulkDeleteGuard(absPath);
|
|
422
451
|
trashItem(absPath); // 成功静默;失败抛错,调用方不应继续真删
|
|
423
452
|
recordTrash(absPath);
|
|
424
453
|
return true;
|
|
@@ -45,6 +45,23 @@ safe_delete_record_trash() {
|
|
|
45
45
|
"$path_json" "$timestamp" >> "$CODEBUDDY_SAFE_DELETE_REPORT_PATH" 2>/dev/null || true
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
safe_delete_bulk_guard_check() {
|
|
49
|
+
[ "$#" -gt 0 ] || return 0
|
|
50
|
+
[ -n "${CODEBUDDY_SAFE_DELETE_BULK_STATE_DIR:-}" ] || return 0
|
|
51
|
+
[ -n "${CODEBUDDY_TOOL_CALL_ID:-}" ] || return 0
|
|
52
|
+
if [ -z "${CODEBUDDY_NODE_BIN:-}" ] || [ -z "${CODEBUDDY_SAFE_DELETE_BULK_GUARD:-}" ] || [ ! -f "$CODEBUDDY_SAFE_DELETE_BULK_GUARD" ]; then
|
|
53
|
+
echo "[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] msg=helper-unavailable guard=${CODEBUDDY_SAFE_DELETE_BULK_GUARD:-unset}" >&2
|
|
54
|
+
return 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
local args=()
|
|
58
|
+
local p
|
|
59
|
+
for p in "$@"; do
|
|
60
|
+
args+=(--target "$(safe_delete_abs_path "$p")")
|
|
61
|
+
done
|
|
62
|
+
NODE_OPTIONS= "$CODEBUDDY_NODE_BIN" "$CODEBUDDY_SAFE_DELETE_BULK_GUARD" check "${args[@]}"
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
is_dir_empty() {
|
|
49
66
|
local p="$1"
|
|
50
67
|
[ -z "$(ls -A "$p" 2>/dev/null)" ]
|
|
@@ -347,11 +364,8 @@ trash_one() {
|
|
|
347
364
|
|
|
348
365
|
try_trash() {
|
|
349
366
|
local p="$1"
|
|
350
|
-
local abs
|
|
351
|
-
|
|
352
|
-
/*|[A-Za-z]:/*) ;;
|
|
353
|
-
*) abs="$PWD/$abs" ;;
|
|
354
|
-
esac
|
|
367
|
+
local abs
|
|
368
|
+
abs="$(safe_delete_abs_path "$p")"
|
|
355
369
|
if trash_one "$p"; then
|
|
356
370
|
safe_delete_record_trash "$abs"
|
|
357
371
|
return 0
|
|
@@ -416,6 +430,7 @@ safe_delete_rm() {
|
|
|
416
430
|
fi
|
|
417
431
|
|
|
418
432
|
local _exit=0
|
|
433
|
+
local trash_targets=()
|
|
419
434
|
local p
|
|
420
435
|
for p in "${targets[@]}"; do
|
|
421
436
|
if [ -e "$p" ] || [ -L "$p" ]; then
|
|
@@ -431,19 +446,7 @@ safe_delete_rm() {
|
|
|
431
446
|
continue
|
|
432
447
|
fi
|
|
433
448
|
fi
|
|
434
|
-
|
|
435
|
-
local native_args=()
|
|
436
|
-
$force && native_args+=("-f")
|
|
437
|
-
$recursive && native_args+=("-r")
|
|
438
|
-
$allow_dir && native_args+=("-d")
|
|
439
|
-
if ! "$REAL_RM" "${native_args[@]}" -- "$p"; then
|
|
440
|
-
_exit=1
|
|
441
|
-
fi
|
|
442
|
-
continue
|
|
443
|
-
fi
|
|
444
|
-
if ! try_trash "$p"; then
|
|
445
|
-
_exit=1
|
|
446
|
-
fi
|
|
449
|
+
trash_targets+=("$p")
|
|
447
450
|
elif $force; then
|
|
448
451
|
# -f: silently skip non-existent files (match real rm behavior)
|
|
449
452
|
:
|
|
@@ -453,6 +456,22 @@ safe_delete_rm() {
|
|
|
453
456
|
_exit=1
|
|
454
457
|
fi
|
|
455
458
|
done
|
|
459
|
+
if ! safe_delete_bulk_guard_check "${trash_targets[@]}"; then
|
|
460
|
+
return 1
|
|
461
|
+
fi
|
|
462
|
+
for p in "${trash_targets[@]}"; do
|
|
463
|
+
if safe_delete_is_under_os_tmp_dir "$p"; then
|
|
464
|
+
local native_args=()
|
|
465
|
+
$force && native_args+=("-f")
|
|
466
|
+
$recursive && native_args+=("-r")
|
|
467
|
+
$allow_dir && native_args+=("-d")
|
|
468
|
+
if ! "$REAL_RM" "${native_args[@]}" -- "$p"; then
|
|
469
|
+
_exit=1
|
|
470
|
+
fi
|
|
471
|
+
elif ! try_trash "$p"; then
|
|
472
|
+
_exit=1
|
|
473
|
+
fi
|
|
474
|
+
done
|
|
456
475
|
return $_exit
|
|
457
476
|
}
|
|
458
477
|
|
|
@@ -487,6 +506,9 @@ safe_delete_unlink() {
|
|
|
487
506
|
return 1
|
|
488
507
|
fi
|
|
489
508
|
|
|
509
|
+
if ! safe_delete_bulk_guard_check "$target"; then
|
|
510
|
+
return 1
|
|
511
|
+
fi
|
|
490
512
|
if safe_delete_is_under_os_tmp_dir "$target"; then
|
|
491
513
|
"$REAL_UNLINK" -- "$target"
|
|
492
514
|
return $?
|
|
@@ -529,6 +551,7 @@ safe_delete_rmdir() {
|
|
|
529
551
|
fi
|
|
530
552
|
|
|
531
553
|
local _exit=0
|
|
554
|
+
local trash_targets=()
|
|
532
555
|
local p
|
|
533
556
|
for p in "${targets[@]}"; do
|
|
534
557
|
if [ ! -e "$p" ] && [ ! -L "$p" ]; then
|
|
@@ -546,13 +569,17 @@ safe_delete_rmdir() {
|
|
|
546
569
|
_exit=1
|
|
547
570
|
continue
|
|
548
571
|
fi
|
|
572
|
+
trash_targets+=("$p")
|
|
573
|
+
done
|
|
574
|
+
if ! safe_delete_bulk_guard_check "${trash_targets[@]}"; then
|
|
575
|
+
return 1
|
|
576
|
+
fi
|
|
577
|
+
for p in "${trash_targets[@]}"; do
|
|
549
578
|
if safe_delete_is_under_os_tmp_dir "$p"; then
|
|
550
579
|
if ! "$REAL_RMDIR" -- "$p"; then
|
|
551
580
|
_exit=1
|
|
552
581
|
fi
|
|
553
|
-
|
|
554
|
-
fi
|
|
555
|
-
if ! try_trash "$p"; then
|
|
582
|
+
elif ! try_trash "$p"; then
|
|
556
583
|
_exit=1
|
|
557
584
|
fi
|
|
558
585
|
done
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const CONFIRM_MARKER = '[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED]';
|
|
9
|
+
const REJECTED_MARKER = '[safe-delete][SAFE_DELETE_BULK_REJECTED]';
|
|
10
|
+
const ERROR_MARKER = '[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR]';
|
|
11
|
+
const DEFAULT_THRESHOLD = 20;
|
|
12
|
+
const LOCK_TIMEOUT_MS = 2000;
|
|
13
|
+
const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
14
|
+
const TURN_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
15
|
+
const TARGET_SAMPLE_LIMIT = 5;
|
|
16
|
+
const DISPLAY_COUNT_LIMIT = 9999;
|
|
17
|
+
|
|
18
|
+
class GuardError extends Error {}
|
|
19
|
+
|
|
20
|
+
function fail(message) {
|
|
21
|
+
throw new GuardError(message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const command = argv[2];
|
|
26
|
+
const targets = [];
|
|
27
|
+
let scope;
|
|
28
|
+
for (let i = 3; i < argv.length; i++) {
|
|
29
|
+
const arg = argv[i];
|
|
30
|
+
if (arg === '--target') {
|
|
31
|
+
const target = argv[++i];
|
|
32
|
+
if (!target) fail('missing --target value');
|
|
33
|
+
targets.push(target);
|
|
34
|
+
} else if (arg === '--scope') {
|
|
35
|
+
scope = argv[++i];
|
|
36
|
+
if (!scope) fail('missing --scope value');
|
|
37
|
+
} else {
|
|
38
|
+
fail(`unknown argument: ${arg}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { command, targets, scope };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireEnv(name) {
|
|
45
|
+
const value = process.env[name];
|
|
46
|
+
if (!value) fail(`${name} is not set`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeThreshold(value) {
|
|
51
|
+
const raw = value === undefined || value === null || value === '' ? DEFAULT_THRESHOLD : value;
|
|
52
|
+
const threshold = Number.parseInt(String(raw), 10);
|
|
53
|
+
if (!Number.isFinite(threshold) || threshold <= 0) fail(`invalid threshold: ${value}`);
|
|
54
|
+
return threshold;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createSafeDeleteBulkGuardContext(input) {
|
|
58
|
+
if (!input || !input.stateRoot || !input.sessionId || !input.toolCallId) {
|
|
59
|
+
fail('missing bulk guard context');
|
|
60
|
+
}
|
|
61
|
+
const threshold = normalizeThreshold(input.threshold);
|
|
62
|
+
const sessionId = input.sessionId;
|
|
63
|
+
const conversationRequestId = input.conversationRequestId;
|
|
64
|
+
const sessionHash = crypto.createHash('sha256').update(sessionId).digest('hex');
|
|
65
|
+
const sessionDir = path.join(input.stateRoot, sessionHash);
|
|
66
|
+
return {
|
|
67
|
+
stateRoot: input.stateRoot,
|
|
68
|
+
sessionId,
|
|
69
|
+
toolCallId: input.toolCallId,
|
|
70
|
+
requestId: conversationRequestId || input.toolCallId,
|
|
71
|
+
threshold,
|
|
72
|
+
conversationRequestId,
|
|
73
|
+
sessionHash,
|
|
74
|
+
sessionDir,
|
|
75
|
+
statePath: path.join(sessionDir, 'state.json'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getContext() {
|
|
80
|
+
return createSafeDeleteBulkGuardContext({
|
|
81
|
+
stateRoot: requireEnv('CODEBUDDY_SAFE_DELETE_BULK_STATE_DIR'),
|
|
82
|
+
sessionId: requireEnv('CODEBUDDY_SESSION_ID'),
|
|
83
|
+
toolCallId: requireEnv('CODEBUDDY_TOOL_CALL_ID'),
|
|
84
|
+
conversationRequestId: process.env.CODEBUDDY_CONVERSATION_REQUEST_ID,
|
|
85
|
+
threshold: process.env.CODEBUDDY_SAFE_DELETE_BULK_THRESHOLD || DEFAULT_THRESHOLD,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function defaultState() {
|
|
90
|
+
return { requests: {}, toolApprovals: {}, requestRejections: {} };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeState(parsed) {
|
|
94
|
+
if (!parsed || typeof parsed !== 'object') return defaultState();
|
|
95
|
+
return {
|
|
96
|
+
requests: parsed.requests && typeof parsed.requests === 'object' ? parsed.requests : {},
|
|
97
|
+
toolApprovals: parsed.toolApprovals && typeof parsed.toolApprovals === 'object' ? parsed.toolApprovals : {},
|
|
98
|
+
requestRejections: parsed.requestRejections && typeof parsed.requestRejections === 'object' ? parsed.requestRejections : {},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readState(statePath) {
|
|
103
|
+
try {
|
|
104
|
+
return normalizeState(JSON.parse(fs.readFileSync(statePath, 'utf-8')));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error && error.code === 'ENOENT') return defaultState();
|
|
107
|
+
fail(`failed to read state: ${error && error.message ? error.message : String(error)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writeState(statePath, state) {
|
|
112
|
+
const tmpPath = `${statePath}.${process.pid}.tmp`;
|
|
113
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(state)}\n`, 'utf-8');
|
|
114
|
+
fs.renameSync(tmpPath, statePath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeRequest(request, now) {
|
|
118
|
+
if (!request || typeof request !== 'object') return { count: 0, updatedAt: now };
|
|
119
|
+
return {
|
|
120
|
+
count: Number.isFinite(request.count) ? request.count : 0,
|
|
121
|
+
updatedAt: Number.isFinite(request.updatedAt) ? request.updatedAt : now,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeToolApproval(approval, now) {
|
|
126
|
+
if (!approval || typeof approval !== 'object') return { approved: false, updatedAt: now };
|
|
127
|
+
return {
|
|
128
|
+
approved: approval.approved === true,
|
|
129
|
+
updatedAt: Number.isFinite(approval.updatedAt) ? approval.updatedAt : now,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeRequestRejection(rejection, now) {
|
|
134
|
+
if (!rejection || typeof rejection !== 'object') {
|
|
135
|
+
return { rejected: false, updatedAt: now };
|
|
136
|
+
}
|
|
137
|
+
const targets = Array.isArray(rejection.targets)
|
|
138
|
+
? rejection.targets.filter(target => typeof target === 'string').slice(0, TARGET_SAMPLE_LIMIT)
|
|
139
|
+
: [];
|
|
140
|
+
return {
|
|
141
|
+
rejected: rejection.rejected === true,
|
|
142
|
+
updatedAt: Number.isFinite(rejection.updatedAt) ? rejection.updatedAt : now,
|
|
143
|
+
count: Number.isFinite(rejection.count) ? rejection.count : 0,
|
|
144
|
+
threshold: Number.isFinite(rejection.threshold) ? rejection.threshold : DEFAULT_THRESHOLD,
|
|
145
|
+
targets,
|
|
146
|
+
targetCount: Number.isFinite(rejection.targetCount) ? rejection.targetCount : targets.length,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pruneExpired(state, now) {
|
|
151
|
+
let changed = false;
|
|
152
|
+
const nextRequests = {};
|
|
153
|
+
for (const [requestId, rawRequest] of Object.entries(state.requests)) {
|
|
154
|
+
const request = normalizeRequest(rawRequest, now);
|
|
155
|
+
if (now - request.updatedAt > TURN_STATE_TTL_MS) {
|
|
156
|
+
changed = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
nextRequests[requestId] = request;
|
|
160
|
+
if (
|
|
161
|
+
!rawRequest
|
|
162
|
+
|| typeof rawRequest !== 'object'
|
|
163
|
+
|| rawRequest.count !== request.count
|
|
164
|
+
|| rawRequest.updatedAt !== request.updatedAt
|
|
165
|
+
) {
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (changed) state.requests = nextRequests;
|
|
170
|
+
|
|
171
|
+
const nextToolApprovals = {};
|
|
172
|
+
for (const [toolCallId, rawApproval] of Object.entries(state.toolApprovals)) {
|
|
173
|
+
const approval = normalizeToolApproval(rawApproval, now);
|
|
174
|
+
if (now - approval.updatedAt > TURN_STATE_TTL_MS) {
|
|
175
|
+
changed = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
nextToolApprovals[toolCallId] = approval;
|
|
179
|
+
if (
|
|
180
|
+
!rawApproval
|
|
181
|
+
|| typeof rawApproval !== 'object'
|
|
182
|
+
|| rawApproval.approved !== approval.approved
|
|
183
|
+
|| rawApproval.updatedAt !== approval.updatedAt
|
|
184
|
+
) {
|
|
185
|
+
changed = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (changed) state.toolApprovals = nextToolApprovals;
|
|
189
|
+
|
|
190
|
+
const nextRequestRejections = {};
|
|
191
|
+
for (const [requestId, rawRejection] of Object.entries(state.requestRejections || {})) {
|
|
192
|
+
const rejection = normalizeRequestRejection(rawRejection, now);
|
|
193
|
+
if (now - rejection.updatedAt > TURN_STATE_TTL_MS) {
|
|
194
|
+
changed = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (rejection.rejected) {
|
|
198
|
+
nextRequestRejections[requestId] = rejection;
|
|
199
|
+
}
|
|
200
|
+
if (
|
|
201
|
+
!rawRejection
|
|
202
|
+
|| typeof rawRejection !== 'object'
|
|
203
|
+
|| rawRejection.rejected !== rejection.rejected
|
|
204
|
+
|| rawRejection.updatedAt !== rejection.updatedAt
|
|
205
|
+
|| rawRejection.count !== rejection.count
|
|
206
|
+
|| rawRejection.threshold !== rejection.threshold
|
|
207
|
+
|| rawRejection.targetCount !== rejection.targetCount
|
|
208
|
+
) {
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (changed) state.requestRejections = nextRequestRejections;
|
|
213
|
+
return changed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sleepMs(ms) {
|
|
217
|
+
try {
|
|
218
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
219
|
+
} catch (_) {
|
|
220
|
+
const end = Date.now() + ms;
|
|
221
|
+
while (Date.now() < end) {
|
|
222
|
+
// fallback for constrained Node runtimes
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function removeStaleLock(lockDir) {
|
|
228
|
+
try {
|
|
229
|
+
const stat = fs.statSync(lockDir);
|
|
230
|
+
if (!stat.isDirectory() || Date.now() - stat.mtimeMs <= STALE_LOCK_MS) return false;
|
|
231
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
232
|
+
return true;
|
|
233
|
+
} catch (_) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function withLock(context, fn) {
|
|
239
|
+
fs.mkdirSync(context.sessionDir, { recursive: true });
|
|
240
|
+
const lockDir = path.join(context.sessionDir, '.lock');
|
|
241
|
+
const started = Date.now();
|
|
242
|
+
for (;;) {
|
|
243
|
+
try {
|
|
244
|
+
fs.mkdirSync(lockDir);
|
|
245
|
+
break;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (!error || error.code !== 'EEXIST') fail(`failed to acquire lock: ${error && error.message ? error.message : String(error)}`);
|
|
248
|
+
if (removeStaleLock(lockDir)) continue;
|
|
249
|
+
if (Date.now() - started > LOCK_TIMEOUT_MS) fail('state lock timeout');
|
|
250
|
+
sleepMs(25);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
return fn(context.statePath);
|
|
255
|
+
} finally {
|
|
256
|
+
try { fs.rmdirSync(lockDir); } catch (_) { /* best effort */ }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeTargetPath(target) {
|
|
261
|
+
if (process.platform !== 'win32' || typeof target !== 'string') return target;
|
|
262
|
+
if (/^[A-Za-z]:[\\/]/.test(target)) return target;
|
|
263
|
+
const msysMatch = target.match(/^\/([A-Za-z])(?:\/(.*))?$/);
|
|
264
|
+
if (!msysMatch) return target;
|
|
265
|
+
const drive = msysMatch[1].toUpperCase();
|
|
266
|
+
const rest = (msysMatch[2] || '').replace(/\//g, '\\');
|
|
267
|
+
return rest ? `${drive}:\\${rest}` : `${drive}:\\`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function countTarget(target, remaining) {
|
|
271
|
+
if (remaining <= 0) return 0;
|
|
272
|
+
target = normalizeTargetPath(target);
|
|
273
|
+
let stat;
|
|
274
|
+
try {
|
|
275
|
+
stat = fs.lstatSync(target);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error && error.code === 'ENOENT') return 0;
|
|
278
|
+
fail(`failed to stat target: ${target}`);
|
|
279
|
+
}
|
|
280
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return 1;
|
|
281
|
+
|
|
282
|
+
let dir;
|
|
283
|
+
try {
|
|
284
|
+
dir = fs.opendirSync(target);
|
|
285
|
+
} catch (_) {
|
|
286
|
+
fail(`failed to read directory: ${target}`);
|
|
287
|
+
}
|
|
288
|
+
let count = 0;
|
|
289
|
+
try {
|
|
290
|
+
for (;;) {
|
|
291
|
+
if (count >= remaining) break;
|
|
292
|
+
const entry = dir.readSync();
|
|
293
|
+
if (!entry) break;
|
|
294
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
295
|
+
count += countTarget(path.join(target, entry.name), remaining - count);
|
|
296
|
+
} else {
|
|
297
|
+
count += 1;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
try { dir.closeSync(); } catch (_) { /* best effort */ }
|
|
302
|
+
}
|
|
303
|
+
return count;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function countTargets(targets, limit) {
|
|
307
|
+
let count = 0;
|
|
308
|
+
for (const target of targets) {
|
|
309
|
+
if (count >= limit) break;
|
|
310
|
+
count += countTarget(target, limit - count);
|
|
311
|
+
}
|
|
312
|
+
return count;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function checkSafeDeleteBulkGuard(context, targets) {
|
|
316
|
+
if (!targets || targets.length === 0) fail('check requires at least one target');
|
|
317
|
+
let decision = { kind: 'allow' };
|
|
318
|
+
withLock(context, statePath => {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
const state = readState(statePath);
|
|
321
|
+
const pruned = pruneExpired(state, now);
|
|
322
|
+
const rejection = normalizeRequestRejection(state.requestRejections && state.requestRejections[context.requestId], now);
|
|
323
|
+
if (rejection.rejected) {
|
|
324
|
+
decision = {
|
|
325
|
+
kind: 'rejected',
|
|
326
|
+
payload: {
|
|
327
|
+
count: rejection.count,
|
|
328
|
+
threshold: rejection.threshold,
|
|
329
|
+
scope: 'turn',
|
|
330
|
+
targets: rejection.targets,
|
|
331
|
+
targetCount: rejection.targetCount,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
if (pruned) writeState(statePath, state);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const request = normalizeRequest(state.requests[context.requestId], now);
|
|
338
|
+
const approval = normalizeToolApproval(state.toolApprovals[context.toolCallId], now);
|
|
339
|
+
const deleteCount = countTargets(targets, DISPLAY_COUNT_LIMIT);
|
|
340
|
+
if (deleteCount === 0) {
|
|
341
|
+
if (pruned) writeState(statePath, state);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const totalCount = request.count + deleteCount;
|
|
345
|
+
if (approval.approved) {
|
|
346
|
+
state.requests[context.requestId] = { count: totalCount, updatedAt: now };
|
|
347
|
+
writeState(statePath, state);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (totalCount >= context.threshold) {
|
|
351
|
+
decision = {
|
|
352
|
+
kind: 'confirmRequired',
|
|
353
|
+
payload: {
|
|
354
|
+
count: totalCount,
|
|
355
|
+
threshold: context.threshold,
|
|
356
|
+
scope: 'turn',
|
|
357
|
+
targets: targets.slice(0, TARGET_SAMPLE_LIMIT),
|
|
358
|
+
targetCount: targets.length,
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
if (pruned) writeState(statePath, state);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
state.requests[context.requestId] = { count: totalCount, updatedAt: now };
|
|
365
|
+
writeState(statePath, state);
|
|
366
|
+
});
|
|
367
|
+
return decision;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function handleCheck(args) {
|
|
371
|
+
if (args.targets.length === 0) fail('check requires at least one --target');
|
|
372
|
+
const decision = checkSafeDeleteBulkGuard(getContext(), args.targets);
|
|
373
|
+
if (decision.kind === 'confirmRequired') {
|
|
374
|
+
process.stderr.write(`${CONFIRM_MARKER} ${JSON.stringify(decision.payload)}\n`);
|
|
375
|
+
process.exit(2);
|
|
376
|
+
}
|
|
377
|
+
if (decision.kind === 'rejected') {
|
|
378
|
+
process.stderr.write(`${REJECTED_MARKER} ${JSON.stringify(decision.payload)}\n`);
|
|
379
|
+
process.exit(3);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function approveSafeDeleteBulkGuard(context, scope) {
|
|
384
|
+
if (scope && scope !== 'turn') fail('approve requires scope turn');
|
|
385
|
+
withLock(context, statePath => {
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
const state = readState(statePath);
|
|
388
|
+
pruneExpired(state, now);
|
|
389
|
+
state.toolApprovals[context.toolCallId] = { approved: true, updatedAt: now };
|
|
390
|
+
writeState(statePath, state);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function rejectSafeDeleteBulkGuard(context, payload) {
|
|
395
|
+
const normalized = normalizeRequestRejection({ ...payload, rejected: true }, Date.now());
|
|
396
|
+
withLock(context, statePath => {
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
const state = readState(statePath);
|
|
399
|
+
pruneExpired(state, now);
|
|
400
|
+
state.requests[context.requestId] = {
|
|
401
|
+
count: normalized.count,
|
|
402
|
+
updatedAt: now,
|
|
403
|
+
};
|
|
404
|
+
state.requestRejections[context.requestId] = {
|
|
405
|
+
rejected: true,
|
|
406
|
+
updatedAt: now,
|
|
407
|
+
count: normalized.count,
|
|
408
|
+
threshold: normalized.threshold,
|
|
409
|
+
targets: normalized.targets,
|
|
410
|
+
targetCount: normalized.targetCount,
|
|
411
|
+
};
|
|
412
|
+
delete state.toolApprovals[context.toolCallId];
|
|
413
|
+
writeState(statePath, state);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function handleApprove(args) {
|
|
418
|
+
if (args.scope && args.scope !== 'turn') fail('approve requires scope turn');
|
|
419
|
+
approveSafeDeleteBulkGuard(getContext(), args.scope);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function main() {
|
|
423
|
+
const args = parseArgs(process.argv);
|
|
424
|
+
if (args.command === 'check') return handleCheck(args);
|
|
425
|
+
if (args.command === 'approve') return handleApprove(args);
|
|
426
|
+
fail(`unknown command: ${args.command || '(empty)'}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
module.exports = {
|
|
430
|
+
approveSafeDeleteBulkGuard,
|
|
431
|
+
checkSafeDeleteBulkGuard,
|
|
432
|
+
createSafeDeleteBulkGuardContext,
|
|
433
|
+
rejectSafeDeleteBulkGuard,
|
|
434
|
+
DEFAULT_THRESHOLD,
|
|
435
|
+
CONFIRM_MARKER,
|
|
436
|
+
REJECTED_MARKER,
|
|
437
|
+
ERROR_MARKER,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (require.main === module) {
|
|
441
|
+
try {
|
|
442
|
+
main();
|
|
443
|
+
} catch (error) {
|
|
444
|
+
process.stderr.write(`${ERROR_MARKER} ${error && error.message ? error.message : String(error)}\n`);
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
}
|