@coclaw/openclaw-coclaw 0.18.0 → 0.19.2
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/index.js +62 -17
- package/openclaw.plugin.json +3 -0
- package/package.json +1 -1
- package/src/agent-cancel-heuristic.js +42 -0
- package/src/auto-upgrade/state.js +4 -3
- package/src/auto-upgrade/updater.js +2 -3
- package/src/common/claw-binding.js +53 -14
- package/src/device-identity.js +3 -7
- package/src/file-manager/handler.js +1 -1
- package/src/realtime-bridge.js +158 -105
- package/src/utils/atomic-write.js +37 -1
- package/src/utils/memory-queue.js +310 -0
- package/src/webrtc/agent-run-response.js +20 -0
- package/src/webrtc/dc-chunking.js +20 -1
- package/src/webrtc/rpc-dc-sender.js +178 -0
- package/src/webrtc/webrtc-peer.js +225 -65
- package/src/webrtc/rpc-send-queue.js +0 -271
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
|
14
14
|
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
15
15
|
import { createFileHandler } from './src/file-manager/handler.js';
|
|
16
16
|
import { abortAgentRun } from './src/agent-abort.js';
|
|
17
|
+
import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
|
|
17
18
|
import { remoteLog } from './src/remote-log.js';
|
|
18
19
|
|
|
19
20
|
import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
|
|
@@ -200,9 +201,27 @@ const plugin = {
|
|
|
200
201
|
},
|
|
201
202
|
});
|
|
202
203
|
|
|
204
|
+
// enroll 并发控制:同一时刻只允许一个活跃 enroll。
|
|
205
|
+
// 声明前置以便 doBind/doUnbind 入口可调用 cancelActiveEnroll;
|
|
206
|
+
// .finally 仍在 enroll 自身的回调里把 activeEnrollAbort 置 null。
|
|
207
|
+
let activeEnrollAbort = null;
|
|
208
|
+
|
|
209
|
+
function cancelActiveEnroll() {
|
|
210
|
+
if (activeEnrollAbort) {
|
|
211
|
+
logger.info?.('[coclaw] cancelling active enroll');
|
|
212
|
+
activeEnrollAbort.abort();
|
|
213
|
+
// 立即清 ref:避免后续 cancelActiveEnroll 对同一已 abort 的 controller 重复 log;
|
|
214
|
+
// 原 enroll 自己的 .finally 仍负责本身分支的兜底清理(按 ref 相等判断)
|
|
215
|
+
activeEnrollAbort = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
203
219
|
// --- bind/unbind 共享逻辑(RPC handler + 斜杠命令共用) ---
|
|
204
220
|
|
|
205
221
|
async function doBind({ code, serverUrl }) {
|
|
222
|
+
// 显式 bind 必须取消进行中的 enroll:否则 enroll 后到的 token 走 partial-failure
|
|
223
|
+
// rollback 路径前可能仍写入旧 config,污染刚 bind 完的本地状态
|
|
224
|
+
cancelActiveEnroll();
|
|
206
225
|
await stopRealtimeBridge();
|
|
207
226
|
let result;
|
|
208
227
|
try {
|
|
@@ -223,6 +242,8 @@ const plugin = {
|
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
async function doUnbind({ serverUrl }) {
|
|
245
|
+
// unbind 同样取消进行中的 enroll,避免 server 解绑后 enroll token 又写回本地
|
|
246
|
+
cancelActiveEnroll();
|
|
226
247
|
const result = await unbindClaw({
|
|
227
248
|
serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
|
|
228
249
|
});
|
|
@@ -233,8 +254,13 @@ const plugin = {
|
|
|
233
254
|
api.registerGatewayMethod('coclaw.bind', async ({ params, respond }) => {
|
|
234
255
|
try {
|
|
235
256
|
const code = params?.code;
|
|
236
|
-
if (
|
|
237
|
-
respondInvalid(respond, 'code
|
|
257
|
+
if (typeof code !== 'string' || code.length === 0) {
|
|
258
|
+
respondInvalid(respond, 'code must be a non-empty string');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (params?.serverUrl !== undefined
|
|
262
|
+
&& (typeof params.serverUrl !== 'string' || params.serverUrl.trim().length === 0)) {
|
|
263
|
+
respondInvalid(respond, 'serverUrl must be a non-empty string');
|
|
238
264
|
return;
|
|
239
265
|
}
|
|
240
266
|
const result = await doBind({
|
|
@@ -256,6 +282,11 @@ const plugin = {
|
|
|
256
282
|
|
|
257
283
|
api.registerGatewayMethod('coclaw.unbind', async ({ params, respond }) => {
|
|
258
284
|
try {
|
|
285
|
+
if (params?.serverUrl !== undefined
|
|
286
|
+
&& (typeof params.serverUrl !== 'string' || params.serverUrl.trim().length === 0)) {
|
|
287
|
+
respondInvalid(respond, 'serverUrl must be a non-empty string');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
259
290
|
const result = await doUnbind({ serverUrl: params?.serverUrl });
|
|
260
291
|
respond(true, { status: { clawId: result.clawId } });
|
|
261
292
|
}
|
|
@@ -264,15 +295,15 @@ const plugin = {
|
|
|
264
295
|
}
|
|
265
296
|
});
|
|
266
297
|
|
|
267
|
-
// enroll 并发控制:同一时刻只允许一个活跃 enroll
|
|
268
|
-
let activeEnrollAbort = null;
|
|
269
|
-
|
|
270
298
|
api.registerGatewayMethod('coclaw.enroll', async ({ params, respond }) => {
|
|
271
299
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
300
|
+
if (params?.serverUrl !== undefined
|
|
301
|
+
&& (typeof params.serverUrl !== 'string' || params.serverUrl.trim().length === 0)) {
|
|
302
|
+
respondInvalid(respond, 'serverUrl must be a non-empty string');
|
|
303
|
+
return;
|
|
275
304
|
}
|
|
305
|
+
// 取消前一个 enroll(与 doBind/doUnbind 共享 helper)
|
|
306
|
+
cancelActiveEnroll();
|
|
276
307
|
const abortController = new AbortController();
|
|
277
308
|
activeEnrollAbort = abortController;
|
|
278
309
|
|
|
@@ -333,6 +364,11 @@ const plugin = {
|
|
|
333
364
|
|
|
334
365
|
api.registerGatewayMethod('nativeui.sessions.get', ({ params, respond }) => {
|
|
335
366
|
try {
|
|
367
|
+
const sessionId = params?.sessionId;
|
|
368
|
+
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
369
|
+
respondInvalid(respond, 'sessionId required');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
336
372
|
respond(true, manager.get(params ?? {}));
|
|
337
373
|
}
|
|
338
374
|
catch (err) {
|
|
@@ -463,12 +499,14 @@ const plugin = {
|
|
|
463
499
|
respondInvalid(respond, 'No valid change field provided (supported: title)');
|
|
464
500
|
return;
|
|
465
501
|
}
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
if (!
|
|
469
|
-
|
|
502
|
+
// 先检查 topic 是否存在:避免 updateTitle 内部 throw 后被 respondError 错报为 INTERNAL_ERROR
|
|
503
|
+
const existing = topicManager.get({ topicId })?.topic;
|
|
504
|
+
if (!existing) {
|
|
505
|
+
respond(false, undefined, { code: 'NOT_FOUND', message: `Topic not found: ${topicId}` });
|
|
470
506
|
return;
|
|
471
507
|
}
|
|
508
|
+
await topicManager.updateTitle({ topicId, title: changes.title });
|
|
509
|
+
const { topic } = topicManager.get({ topicId });
|
|
472
510
|
respond(true, { topic });
|
|
473
511
|
}
|
|
474
512
|
catch (err) {
|
|
@@ -550,6 +588,8 @@ const plugin = {
|
|
|
550
588
|
// 取消正在执行的 embedded agent run(通过 OpenClaw 全局 symbol 侧门)
|
|
551
589
|
// 侧门不存在 / sessionId 未注册 / handle.abort 抛异常时返回 { ok:false, reason } —— UI 静默降级
|
|
552
590
|
// UI 可能在 OpenClaw 注册 sessionId 前点 STOP(注册空窗期),此时返回 not-found;UI 会按 500ms 间隔重试。
|
|
591
|
+
// UI 自 v0.20 起额外透传 runDuration / abortDuration(墙钟差,毫秒)供启发判定:
|
|
592
|
+
// 侧门返 not-found 但双闸都达阈值时升格为 gone,告知 UI 主动收尾。旧 UI 不传时退化为透传 not-found。
|
|
553
593
|
api.registerGatewayMethod('coclaw.agent.abort', ({ params, respond }) => {
|
|
554
594
|
try {
|
|
555
595
|
const sessionId = params?.sessionId;
|
|
@@ -558,7 +598,10 @@ const plugin = {
|
|
|
558
598
|
respondInvalid(respond, 'sessionId is required');
|
|
559
599
|
return;
|
|
560
600
|
}
|
|
561
|
-
const
|
|
601
|
+
const abortResult = abortAgentRun(sessionId);
|
|
602
|
+
const runDuration = typeof params?.runDuration === 'number' ? params.runDuration : undefined;
|
|
603
|
+
const abortDuration = typeof params?.abortDuration === 'number' ? params.abortDuration : undefined;
|
|
604
|
+
const result = decideCancelResponse(abortResult, { runDuration, abortDuration });
|
|
562
605
|
// not-found 是 UI 重试期常态(注册空窗),不打日志避免噪音;其余分支保留 info
|
|
563
606
|
if (result.reason !== 'not-found') {
|
|
564
607
|
logger.info?.(`[coclaw.agent.abort] result sessionId=${sessionId} ok=${result.ok}${result.reason ? ` reason=${result.reason}` : ''}${result.error ? ` error=${result.error}` : ''}`);
|
|
@@ -570,6 +613,10 @@ const plugin = {
|
|
|
570
613
|
// 侧门缺失或 handle shape 变化:OpenClaw 升级契约变更的早期信号
|
|
571
614
|
remoteLog(`abort.not-supported sid=${sessionId}`);
|
|
572
615
|
}
|
|
616
|
+
else if (result.reason === 'gone') {
|
|
617
|
+
// 启发升格:双闸均达阈值,把 not-found 升格为 gone,让 UI 主动 settleByCancel
|
|
618
|
+
remoteLog(`abort.gone sid=${sessionId} runDur=${runDuration} abortDur=${abortDuration}`);
|
|
619
|
+
}
|
|
573
620
|
respond(true, result);
|
|
574
621
|
}
|
|
575
622
|
catch (err) {
|
|
@@ -663,10 +710,8 @@ const plugin = {
|
|
|
663
710
|
}
|
|
664
711
|
|
|
665
712
|
if (action === 'enroll') {
|
|
666
|
-
// 并发控制:取消前一个 enroll(与 RPC
|
|
667
|
-
|
|
668
|
-
activeEnrollAbort.abort();
|
|
669
|
-
}
|
|
713
|
+
// 并发控制:取消前一个 enroll(与 RPC 路径共享 helper)
|
|
714
|
+
cancelActiveEnroll();
|
|
670
715
|
const abortController = new AbortController();
|
|
671
716
|
activeEnrollAbort = abortController;
|
|
672
717
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-cancel-heuristic:取消请求的启发式判定
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw 侧门 abort 返回 not-found 时,单凭这个信号无法区分两种状态:
|
|
5
|
+
* 1. 注册空窗:UI 已发 send,但 OpenClaw 还没把 run 注册到 activeRuns
|
|
6
|
+
* → UI 应继续 tick 重试,等注册完成
|
|
7
|
+
* 2. run 已实际结束/丢失但终态信号未送达 UI(compaction-retry 边界、
|
|
8
|
+
* 上游 lifecycle:end 漏发、网络丢包等)
|
|
9
|
+
* → UI 应主动收尾,避免无限 tick
|
|
10
|
+
*
|
|
11
|
+
* 用 UI 透传的两个墙钟时长做"双闸"启发:
|
|
12
|
+
* - runDuration ≥ 3min:从 onAccepted 到现在;正常 run 不会这么久仍在跑
|
|
13
|
+
* - abortDuration ≥ 1min:从首次 STOP 到现在;正常 run 在 1min 内能响应取消
|
|
14
|
+
* 双闸都满足才升格为 'gone',告知 UI 主动 settleByCancel + 提示用户。
|
|
15
|
+
*
|
|
16
|
+
* 阈值偏保守,宁可让 UI 多 tick 几次也不误升格。
|
|
17
|
+
*
|
|
18
|
+
* 兼容旧 UI:旧 UI 不传 runDuration/abortDuration,ctx 字段为 undefined,
|
|
19
|
+
* 双闸永远不命中,行为退化为原样透传 not-found(与无启发时一致)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const RUN_DURATION_GONE_THRESHOLD_MS = 3 * 60 * 1000;
|
|
23
|
+
export const ABORT_DURATION_GONE_THRESHOLD_MS = 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 根据侧门 abort 结果 + UI 上下文决定最终响应
|
|
27
|
+
* @param {object} abortResult - abortAgentRun 的返回值
|
|
28
|
+
* @param {object} [ctx] - { runDuration, abortDuration }(毫秒),旧 UI 不传时为 undefined
|
|
29
|
+
* @returns {object} 透传或升格后的响应(保持 abortResult 同形 shape)
|
|
30
|
+
*/
|
|
31
|
+
export function decideCancelResponse(abortResult, ctx) {
|
|
32
|
+
// ok 与非 not-found 原因(not-supported / abort-threw 等)原样透传
|
|
33
|
+
if (abortResult.ok) return abortResult;
|
|
34
|
+
if (abortResult.reason !== 'not-found') return abortResult;
|
|
35
|
+
|
|
36
|
+
const runDur = ctx?.runDuration;
|
|
37
|
+
const abortDur = ctx?.abortDuration;
|
|
38
|
+
const runHit = typeof runDur === 'number' && Number.isFinite(runDur) && runDur >= RUN_DURATION_GONE_THRESHOLD_MS;
|
|
39
|
+
const abortHit = typeof abortDur === 'number' && Number.isFinite(abortDur) && abortDur >= ABORT_DURATION_GONE_THRESHOLD_MS;
|
|
40
|
+
if (runHit && abortHit) return { ok: false, reason: 'gone' };
|
|
41
|
+
return abortResult;
|
|
42
|
+
}
|
|
@@ -12,6 +12,7 @@ import nodePath from 'node:path';
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
|
|
14
14
|
import { getRuntime } from '../runtime.js';
|
|
15
|
+
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
15
16
|
|
|
16
17
|
const CHANNEL_ID = 'coclaw';
|
|
17
18
|
const STATE_FILENAME = 'upgrade-state.json';
|
|
@@ -61,8 +62,7 @@ export async function readState() {
|
|
|
61
62
|
*/
|
|
62
63
|
export async function writeState(state) {
|
|
63
64
|
const filePath = getStatePath();
|
|
64
|
-
await
|
|
65
|
-
await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
65
|
+
await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
@@ -119,7 +119,8 @@ async function trimLog(filePath) {
|
|
|
119
119
|
const lines = content.split('\n').filter(Boolean);
|
|
120
120
|
if (lines.length <= LOG_MAX_LINES) return;
|
|
121
121
|
const kept = lines.slice(-LOG_KEEP_LINES);
|
|
122
|
-
|
|
122
|
+
// 整文件覆写走 atomic:truncate-then-write 中途崩溃会清空整个 log
|
|
123
|
+
await atomicWriteFile(filePath, `${kept.join('\n')}\n`);
|
|
123
124
|
}
|
|
124
125
|
catch {
|
|
125
126
|
// 截断失败不影响主流程
|
|
@@ -6,6 +6,7 @@ import { spawnUpgradeWorker } from './updater-spawn.js';
|
|
|
6
6
|
import { readState, resolveStateDir, writeState } from './state.js';
|
|
7
7
|
import { getRuntime } from '../runtime.js';
|
|
8
8
|
import { remoteLog } from '../remote-log.js';
|
|
9
|
+
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
9
10
|
|
|
10
11
|
// 首次检查延迟较长:失败时由 worker 触发 gateway restart,scheduler 重启后会重新计时;
|
|
11
12
|
// 60 分钟基线(实际随机 60-120 分钟)能把"失败→重启→再次检查"的循环周期拉长,
|
|
@@ -102,11 +103,9 @@ export async function isUpgradeLocked(opts) {
|
|
|
102
103
|
*/
|
|
103
104
|
export async function writeUpgradeLock(pid) {
|
|
104
105
|
const lockPath = getLockPath();
|
|
105
|
-
await
|
|
106
|
-
await fs.writeFile(
|
|
106
|
+
await atomicWriteFile(
|
|
107
107
|
lockPath,
|
|
108
108
|
`${JSON.stringify({ pid, ts: new Date().toISOString() })}\n`,
|
|
109
|
-
'utf8',
|
|
110
109
|
);
|
|
111
110
|
}
|
|
112
111
|
|
|
@@ -63,12 +63,25 @@ export async function bindClaw({ code, serverUrl }, deps = {}) {
|
|
|
63
63
|
throw new Error('invalid bind response');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
try {
|
|
67
|
+
await writeCfg({
|
|
68
|
+
serverUrl: baseUrl,
|
|
69
|
+
clawId: data.clawId,
|
|
70
|
+
token: data.token,
|
|
71
|
+
boundAt: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (writeErr) {
|
|
75
|
+
// 本地 writeCfg 失败 → 回滚 server 端,避免产生孤儿 claw(与 unbind 强制不容错的红线对称)
|
|
76
|
+
await unbindServer({ baseUrl, token: data.token }).catch(() => {
|
|
77
|
+
// 回滚失败不掩盖原因;用户根据原始 writeErr.message 排查,
|
|
78
|
+
// server 端孤儿可通过下次 enroll/bind 时 401/404/410 再清理
|
|
79
|
+
});
|
|
80
|
+
const wrapped = new Error(`bind succeeded on server but local write failed: ${writeErr.message}`);
|
|
81
|
+
wrapped.code = 'BIND_LOCAL_WRITE_FAILED';
|
|
82
|
+
wrapped.cause = writeErr;
|
|
83
|
+
throw wrapped;
|
|
84
|
+
}
|
|
72
85
|
|
|
73
86
|
return {
|
|
74
87
|
clawId: data.clawId,
|
|
@@ -105,7 +118,12 @@ export async function enrollClaw({ serverUrl }, deps = {}) {
|
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }, deps = {}) {
|
|
108
|
-
const {
|
|
121
|
+
const {
|
|
122
|
+
waitClaimCode = waitClaimCodeOnServer,
|
|
123
|
+
writeCfg = writeConfig,
|
|
124
|
+
unbindServer = unbindWithServer,
|
|
125
|
+
retryDelayMs = 2000,
|
|
126
|
+
} = deps;
|
|
109
127
|
const baseUrl = resolveServerUrl(serverUrl);
|
|
110
128
|
|
|
111
129
|
// 循环长轮询,直到成功或超时
|
|
@@ -122,7 +140,12 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
|
|
|
122
140
|
if (err?.response?.status === 404) {
|
|
123
141
|
throw new Error('claim code not found or expired');
|
|
124
142
|
}
|
|
125
|
-
//
|
|
143
|
+
// server 408 + CLAIM_TIMEOUT body 表示 claim 在 server 端已过期,与 404 同列终态;
|
|
144
|
+
// 没有 CLAIM_TIMEOUT body 的 408(极少见)保持瞬态错误重试
|
|
145
|
+
if (err?.response?.status === 408 && err.response?.data?.code === 'CLAIM_TIMEOUT') {
|
|
146
|
+
throw new Error('claim code expired');
|
|
147
|
+
}
|
|
148
|
+
// 其他所有错误(HTTP 500、网络超时、TimeoutError 等)延迟后重试,
|
|
126
149
|
// 确保后台等待不会因瞬时故障而终止
|
|
127
150
|
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
128
151
|
continue;
|
|
@@ -130,12 +153,28 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
|
|
|
130
153
|
|
|
131
154
|
// 已认领
|
|
132
155
|
if (data?.clawId && data?.token) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
token: data.token
|
|
137
|
-
|
|
138
|
-
}
|
|
156
|
+
// long-poll 期间 abort 到达 → 此时 server 已发 token,与 D bug D-3 #4 同模式:
|
|
157
|
+
// 不能落盘旧 token(会污染新 enroll/bind 的本地状态),同时回滚 server 端避免孤儿
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
await unbindServer({ baseUrl, token: data.token }).catch(() => {});
|
|
160
|
+
throw new Error('enroll cancelled');
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await writeCfg({
|
|
164
|
+
serverUrl: baseUrl,
|
|
165
|
+
clawId: data.clawId,
|
|
166
|
+
token: data.token,
|
|
167
|
+
boundAt: new Date().toISOString(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (writeErr) {
|
|
171
|
+
// 与 bindClaw 对称:本地写失败 → 回滚 server 端,避免孤儿 claw
|
|
172
|
+
await unbindServer({ baseUrl, token: data.token }).catch(() => {});
|
|
173
|
+
const wrapped = new Error(`enroll succeeded on server but local write failed: ${writeErr.message}`);
|
|
174
|
+
wrapped.code = 'BIND_LOCAL_WRITE_FAILED';
|
|
175
|
+
wrapped.cause = writeErr;
|
|
176
|
+
throw wrapped;
|
|
177
|
+
}
|
|
139
178
|
return { clawId: data.clawId };
|
|
140
179
|
}
|
|
141
180
|
|
package/src/device-identity.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import nodePath from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { getRuntime } from './runtime.js';
|
|
7
|
+
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
7
8
|
|
|
8
9
|
const CHANNEL_ID = 'coclaw';
|
|
9
10
|
const IDENTITY_FILENAME = 'device-identity.json';
|
|
@@ -107,9 +108,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
107
108
|
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
|
108
109
|
if (derivedId && derivedId !== parsed.deviceId) {
|
|
109
110
|
const updated = { ...parsed, deviceId: derivedId };
|
|
110
|
-
|
|
111
|
-
/* c8 ignore next -- best-effort chmod */
|
|
112
|
-
try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
|
|
111
|
+
atomicWriteFileSync(fp, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
113
112
|
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
|
114
113
|
}
|
|
115
114
|
return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
|
@@ -123,7 +122,6 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
123
122
|
}
|
|
124
123
|
|
|
125
124
|
const identity = generateIdentity();
|
|
126
|
-
fs.mkdirSync(nodePath.dirname(fp), { recursive: true });
|
|
127
125
|
const stored = {
|
|
128
126
|
version: 1,
|
|
129
127
|
deviceId: identity.deviceId,
|
|
@@ -131,9 +129,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
131
129
|
privateKeyPem: identity.privateKeyPem,
|
|
132
130
|
createdAtMs: Date.now(),
|
|
133
131
|
};
|
|
134
|
-
|
|
135
|
-
/* c8 ignore next -- best-effort chmod */
|
|
136
|
-
try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
|
|
132
|
+
atomicWriteFileSync(fp, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
137
133
|
return identity;
|
|
138
134
|
}
|
|
139
135
|
|
|
@@ -216,7 +216,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
216
216
|
throw e;
|
|
217
217
|
}
|
|
218
218
|
if (stat.isDirectory()) {
|
|
219
|
-
if (params?.force) {
|
|
219
|
+
if (params?.force === true) {
|
|
220
220
|
await _rm(resolved, { recursive: true, force: true });
|
|
221
221
|
} else {
|
|
222
222
|
try {
|