@oyasmi/pipiclaw 0.5.9 → 0.6.1
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/agent/channel-runner.d.ts +8 -0
- package/dist/agent/channel-runner.js +132 -24
- package/dist/agent/context-budget.d.ts +9 -0
- package/dist/agent/context-budget.js +31 -0
- package/dist/agent/session-events.js +11 -4
- package/dist/agent/type-guards.js +4 -2
- package/dist/agent/types.d.ts +10 -3
- package/dist/agent/types.js +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/memory/candidates.d.ts +8 -5
- package/dist/memory/candidates.js +92 -42
- package/dist/memory/consolidation.js +13 -4
- package/dist/memory/recall.d.ts +2 -2
- package/dist/memory/recall.js +2 -3
- package/dist/memory/session.js +2 -2
- package/dist/memory/sidecar-worker.d.ts +1 -0
- package/dist/memory/sidecar-worker.js +56 -1
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/runtime/bootstrap.d.ts +1 -0
- package/dist/runtime/bootstrap.js +52 -13
- package/dist/runtime/delivery.js +101 -12
- package/dist/runtime/dingtalk.d.ts +11 -1
- package/dist/runtime/dingtalk.js +69 -24
- package/dist/runtime/events.d.ts +17 -2
- package/dist/runtime/events.js +107 -19
- package/dist/security/command-guard.js +4 -0
- package/dist/security/config.d.ts +6 -0
- package/dist/security/config.js +38 -6
- package/dist/security/path-guard.js +4 -0
- package/dist/security/platform.d.ts +1 -0
- package/dist/security/platform.js +3 -0
- package/dist/settings.d.ts +4 -1
- package/dist/settings.js +31 -6
- package/dist/shared/config-diagnostics.d.ts +7 -0
- package/dist/shared/config-diagnostics.js +3 -0
- package/dist/subagents/tool.d.ts +2 -0
- package/dist/subagents/tool.js +2 -3
- package/dist/tools/config.d.ts +7 -0
- package/dist/tools/config.js +63 -7
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +3 -2
- package/dist/web/client.d.ts +1 -0
- package/dist/web/client.js +30 -18
- package/dist/web/config.d.ts +1 -0
- package/dist/web/config.js +1 -0
- package/dist/web/fetch.d.ts +1 -0
- package/dist/web/fetch.js +7 -5
- package/dist/web/search-providers.js +6 -3
- package/package.json +1 -1
package/dist/runtime/dingtalk.js
CHANGED
|
@@ -78,6 +78,7 @@ export class DingTalkBot {
|
|
|
78
78
|
this.isReconnecting = false;
|
|
79
79
|
this.isStopped = false;
|
|
80
80
|
this.reconnectAttempts = 0;
|
|
81
|
+
this.hasReportedReady = false;
|
|
81
82
|
// Deduplication cache (Set for O(1) lookup, order array for FIFO eviction)
|
|
82
83
|
this.processedIds = new Set();
|
|
83
84
|
this.processedIdsOrder = [];
|
|
@@ -180,8 +181,10 @@ export class DingTalkBot {
|
|
|
180
181
|
this.client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
|
|
181
182
|
return this.handleRawMessage(msg);
|
|
182
183
|
});
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
const connected = await this.doReconnect(true); // Initial connection
|
|
185
|
+
if (!connected) {
|
|
186
|
+
log.logWarning("DingTalk: initial stream connection not ready yet; retrying in background");
|
|
187
|
+
}
|
|
185
188
|
}
|
|
186
189
|
handleRawMessage(msg) {
|
|
187
190
|
// 1. Immediate ACK
|
|
@@ -213,16 +216,17 @@ export class DingTalkBot {
|
|
|
213
216
|
}
|
|
214
217
|
async doReconnect(immediate = false) {
|
|
215
218
|
if (this.isReconnecting || this.isStopped || !this.client)
|
|
216
|
-
return;
|
|
219
|
+
return false;
|
|
217
220
|
this.isReconnecting = true;
|
|
218
221
|
let connectionFailed = false;
|
|
222
|
+
let connected = false;
|
|
219
223
|
if (!immediate && this.reconnectAttempts > 0) {
|
|
220
224
|
const delay = Math.min(1000 * 2 ** this.reconnectAttempts + Math.random() * 1000, 30000);
|
|
221
225
|
log.logInfo(`DingTalk: waiting ${Math.round(delay / 1000)}s before reconnecting...`);
|
|
222
226
|
await this.waitForDelay(delay);
|
|
223
227
|
if (this.isStopped || !this.client) {
|
|
224
228
|
this.isReconnecting = false;
|
|
225
|
-
return;
|
|
229
|
+
return false;
|
|
226
230
|
}
|
|
227
231
|
}
|
|
228
232
|
try {
|
|
@@ -234,6 +238,11 @@ export class DingTalkBot {
|
|
|
234
238
|
this.lastSocketAvailableTime = Date.now();
|
|
235
239
|
this.reconnectAttempts = 0; // Success, reset backoff
|
|
236
240
|
log.logInfo("DingTalk: connected to stream.");
|
|
241
|
+
if (!this.hasReportedReady) {
|
|
242
|
+
log.logConnected();
|
|
243
|
+
this.hasReportedReady = true;
|
|
244
|
+
}
|
|
245
|
+
connected = true;
|
|
237
246
|
// Setup keep alive
|
|
238
247
|
this.clearKeepAliveTimer();
|
|
239
248
|
this.keepAliveTimer = this.setTrackedInterval(() => {
|
|
@@ -291,6 +300,7 @@ export class DingTalkBot {
|
|
|
291
300
|
if (connectionFailed && !this.isStopped) {
|
|
292
301
|
this.scheduleReconnect(0, false);
|
|
293
302
|
}
|
|
303
|
+
return connected;
|
|
294
304
|
}
|
|
295
305
|
async stop() {
|
|
296
306
|
log.logInfo("DingTalk: stopping bot");
|
|
@@ -352,48 +362,80 @@ export class DingTalkBot {
|
|
|
352
362
|
await this.createCard(channelId);
|
|
353
363
|
}
|
|
354
364
|
/**
|
|
355
|
-
*
|
|
365
|
+
* Replace the active card content with a full snapshot.
|
|
356
366
|
*/
|
|
357
|
-
async
|
|
367
|
+
async replaceCard(channelId, content, finalize = false, failed = false) {
|
|
358
368
|
let card = this.activeCards.get(channelId);
|
|
359
|
-
if ((!card || card.finished) &&
|
|
369
|
+
if ((!card || card.finished) && this.config.cardTemplateId && (content.trim() || !finalize || failed)) {
|
|
360
370
|
await this.ensureCard(channelId);
|
|
361
371
|
card = this.activeCards.get(channelId);
|
|
362
372
|
}
|
|
363
373
|
if (!card || card.finished) {
|
|
364
|
-
if (finalize) {
|
|
374
|
+
if (finalize && content.trim()) {
|
|
365
375
|
return this.sendPlain(channelId, content);
|
|
366
376
|
}
|
|
367
377
|
return false;
|
|
368
378
|
}
|
|
369
|
-
const streamed = await this.streamCard(card, content,
|
|
370
|
-
|
|
379
|
+
const streamed = await this.streamCard(card, content, {
|
|
380
|
+
append: false,
|
|
381
|
+
finalize,
|
|
382
|
+
failed,
|
|
383
|
+
});
|
|
384
|
+
if (!streamed || finalize || failed) {
|
|
371
385
|
this.activeCards.delete(channelId);
|
|
372
386
|
}
|
|
373
387
|
return streamed;
|
|
374
388
|
}
|
|
375
389
|
/**
|
|
376
|
-
*
|
|
377
|
-
* Returns true if a card was finalized, false if no active card existed.
|
|
390
|
+
* Append a delta to the active card transcript.
|
|
378
391
|
*/
|
|
379
|
-
async
|
|
392
|
+
async appendToCard(channelId, content, finalize = false, failed = false) {
|
|
393
|
+
if (!content && !finalize && !failed) {
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
380
396
|
let card = this.activeCards.get(channelId);
|
|
381
|
-
if ((!card || card.finished) && this.config.cardTemplateId && content.trim()) {
|
|
397
|
+
if ((!card || card.finished) && !finalize && !failed && this.config.cardTemplateId && content.trim()) {
|
|
382
398
|
await this.ensureCard(channelId);
|
|
383
399
|
card = this.activeCards.get(channelId);
|
|
384
400
|
}
|
|
385
401
|
if (!card || card.finished) {
|
|
402
|
+
if (finalize && content.trim()) {
|
|
403
|
+
return this.sendPlain(channelId, content);
|
|
404
|
+
}
|
|
386
405
|
return false;
|
|
387
406
|
}
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
407
|
+
const streamed = await this.streamCard(card, content, {
|
|
408
|
+
append: true,
|
|
409
|
+
finalize,
|
|
410
|
+
failed,
|
|
411
|
+
});
|
|
412
|
+
if (!streamed || finalize || failed) {
|
|
413
|
+
this.activeCards.delete(channelId);
|
|
414
|
+
}
|
|
415
|
+
return streamed;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Stream content to the active AI Card for a channel using full replacement semantics.
|
|
419
|
+
*/
|
|
420
|
+
async streamToCard(channelId, content, finalize = false) {
|
|
421
|
+
return this.replaceCard(channelId, content, finalize, false);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Finalize the active card for a channel without falling back to a plain message.
|
|
425
|
+
* Returns true if a card was finalized, false if no active card existed.
|
|
426
|
+
*/
|
|
427
|
+
async finalizeExistingCard(channelId, content) {
|
|
428
|
+
const finalized = await this.replaceCard(channelId, content, true, false);
|
|
429
|
+
if (!finalized) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
return true;
|
|
391
433
|
}
|
|
392
434
|
/**
|
|
393
435
|
* Finalize and remove the active card for a channel.
|
|
394
436
|
*/
|
|
395
437
|
async finalizeCard(channelId, content) {
|
|
396
|
-
const finalized = await this.
|
|
438
|
+
const finalized = await this.replaceCard(channelId, content, true, false);
|
|
397
439
|
if (!finalized) {
|
|
398
440
|
return this.sendPlain(channelId, content);
|
|
399
441
|
}
|
|
@@ -519,7 +561,7 @@ export class DingTalkBot {
|
|
|
519
561
|
this.activeCards.set(channelId, card);
|
|
520
562
|
return card;
|
|
521
563
|
}
|
|
522
|
-
async streamCard(card, content,
|
|
564
|
+
async streamCard(card, content, options) {
|
|
523
565
|
// Refresh token if needed
|
|
524
566
|
const ageSecs = Date.now() / 1000 - card.createdAt;
|
|
525
567
|
if (ageSecs > TOKEN_REFRESH_SECS) {
|
|
@@ -533,9 +575,12 @@ export class DingTalkBot {
|
|
|
533
575
|
guid: `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
|
|
534
576
|
key: card.templateKey,
|
|
535
577
|
content,
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
578
|
+
append: options.append,
|
|
579
|
+
finished: options.finalize,
|
|
580
|
+
failed: options.failed,
|
|
581
|
+
isFull: !options.append,
|
|
582
|
+
isFinalize: options.finalize,
|
|
583
|
+
isError: options.failed,
|
|
539
584
|
};
|
|
540
585
|
const start = Date.now();
|
|
541
586
|
try {
|
|
@@ -550,8 +595,8 @@ export class DingTalkBot {
|
|
|
550
595
|
log.logWarning(`DingTalk Card: streaming request took ${duration}ms (slow)`);
|
|
551
596
|
}
|
|
552
597
|
card.lastUpdated = Date.now() / 1000;
|
|
553
|
-
card.content = content;
|
|
554
|
-
if (finalize) {
|
|
598
|
+
card.content = options.append ? `${card.content}${content}` : content;
|
|
599
|
+
if (options.finalize || options.failed) {
|
|
555
600
|
card.finished = true;
|
|
556
601
|
}
|
|
557
602
|
return true;
|
package/dist/runtime/events.d.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
import type { SecurityConfig } from "../security/types.js";
|
|
1
2
|
import type { DingTalkBot } from "./dingtalk.js";
|
|
3
|
+
export interface EventAction {
|
|
4
|
+
type: "bash";
|
|
5
|
+
command: string;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
}
|
|
2
8
|
export interface ImmediateEvent {
|
|
3
9
|
type: "immediate";
|
|
4
10
|
channelId: string;
|
|
5
11
|
text: string;
|
|
12
|
+
preAction?: EventAction;
|
|
6
13
|
}
|
|
7
14
|
export interface OneShotEvent {
|
|
8
15
|
type: "one-shot";
|
|
9
16
|
channelId: string;
|
|
10
17
|
text: string;
|
|
11
18
|
at: string;
|
|
19
|
+
preAction?: EventAction;
|
|
12
20
|
}
|
|
13
21
|
export interface PeriodicEvent {
|
|
14
22
|
type: "periodic";
|
|
@@ -16,18 +24,20 @@ export interface PeriodicEvent {
|
|
|
16
24
|
text: string;
|
|
17
25
|
schedule: string;
|
|
18
26
|
timezone: string;
|
|
27
|
+
preAction?: EventAction;
|
|
19
28
|
}
|
|
20
29
|
export type ScheduledEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
|
|
21
30
|
export declare class EventsWatcher {
|
|
22
31
|
private eventsDir;
|
|
23
32
|
private bot;
|
|
33
|
+
private commandGuardConfig?;
|
|
24
34
|
private timers;
|
|
25
35
|
private crons;
|
|
26
36
|
private debounceTimers;
|
|
27
37
|
private startTime;
|
|
28
38
|
private watcher;
|
|
29
39
|
private knownFiles;
|
|
30
|
-
constructor(eventsDir: string, bot: DingTalkBot);
|
|
40
|
+
constructor(eventsDir: string, bot: DingTalkBot, commandGuardConfig?: SecurityConfig["commandGuard"] | undefined);
|
|
31
41
|
start(): void;
|
|
32
42
|
stop(): void;
|
|
33
43
|
private debounce;
|
|
@@ -36,15 +46,20 @@ export declare class EventsWatcher {
|
|
|
36
46
|
private handleDelete;
|
|
37
47
|
private cancelScheduled;
|
|
38
48
|
private handleFile;
|
|
49
|
+
private parsePreAction;
|
|
39
50
|
private parseEvent;
|
|
40
51
|
private handleImmediate;
|
|
41
52
|
private handleOneShot;
|
|
42
53
|
private handlePeriodic;
|
|
43
54
|
private execute;
|
|
55
|
+
private runPreAction;
|
|
44
56
|
private deleteFile;
|
|
57
|
+
private getInvalidMarkerPath;
|
|
58
|
+
private markInvalid;
|
|
59
|
+
private clearInvalidMarker;
|
|
45
60
|
private sleep;
|
|
46
61
|
}
|
|
47
62
|
/**
|
|
48
63
|
* Create and start an events watcher.
|
|
49
64
|
*/
|
|
50
|
-
export declare function createEventsWatcher(workspaceDir: string, bot: DingTalkBot): EventsWatcher;
|
|
65
|
+
export declare function createEventsWatcher(workspaceDir: string, bot: DingTalkBot, commandGuardConfig?: SecurityConfig["commandGuard"]): EventsWatcher;
|
package/dist/runtime/events.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
1
2
|
import { Cron } from "croner";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "fs";
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch, writeFileSync } from "fs";
|
|
3
4
|
import { readFile } from "fs/promises";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import * as log from "../log.js";
|
|
7
|
+
import { guardCommand } from "../security/command-guard.js";
|
|
6
8
|
// ============================================================================
|
|
7
9
|
// EventsWatcher
|
|
8
10
|
// ============================================================================
|
|
@@ -11,9 +13,10 @@ const MAX_RETRIES = 3;
|
|
|
11
13
|
const RETRY_BASE_MS = 100;
|
|
12
14
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
13
15
|
export class EventsWatcher {
|
|
14
|
-
constructor(eventsDir, bot) {
|
|
16
|
+
constructor(eventsDir, bot, commandGuardConfig) {
|
|
15
17
|
this.eventsDir = eventsDir;
|
|
16
18
|
this.bot = bot;
|
|
19
|
+
this.commandGuardConfig = commandGuardConfig;
|
|
17
20
|
this.timers = new Map();
|
|
18
21
|
this.crons = new Map();
|
|
19
22
|
this.debounceTimers = new Map();
|
|
@@ -128,10 +131,11 @@ export class EventsWatcher {
|
|
|
128
131
|
}
|
|
129
132
|
if (!event) {
|
|
130
133
|
log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
|
|
131
|
-
this.
|
|
134
|
+
this.markInvalid(filename, lastError?.message ?? "Unknown event parse error");
|
|
132
135
|
return;
|
|
133
136
|
}
|
|
134
137
|
this.knownFiles.add(filename);
|
|
138
|
+
this.clearInvalidMarker(filename);
|
|
135
139
|
switch (event.type) {
|
|
136
140
|
case "immediate":
|
|
137
141
|
this.handleImmediate(filename, event);
|
|
@@ -144,19 +148,39 @@ export class EventsWatcher {
|
|
|
144
148
|
break;
|
|
145
149
|
}
|
|
146
150
|
}
|
|
151
|
+
parsePreAction(data, filename) {
|
|
152
|
+
if (!data.preAction)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (typeof data.preAction !== "object" || data.preAction === null) {
|
|
155
|
+
throw new Error(`Invalid 'preAction' field in ${filename}, expected an object`);
|
|
156
|
+
}
|
|
157
|
+
const action = data.preAction;
|
|
158
|
+
if (action.type !== "bash") {
|
|
159
|
+
throw new Error(`Unsupported preAction type '${String(action.type)}' in ${filename}, only 'bash' is supported`);
|
|
160
|
+
}
|
|
161
|
+
if (typeof action.command !== "string" || action.command.trim().length === 0) {
|
|
162
|
+
throw new Error(`Missing or empty 'preAction.command' in ${filename}`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
type: "bash",
|
|
166
|
+
command: action.command,
|
|
167
|
+
...(typeof action.timeout === "number" ? { timeout: action.timeout } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
147
170
|
parseEvent(content, filename) {
|
|
148
171
|
const data = JSON.parse(content);
|
|
149
172
|
if (!data.type || !data.channelId || !data.text) {
|
|
150
173
|
throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
|
|
151
174
|
}
|
|
175
|
+
const preAction = this.parsePreAction(data, filename);
|
|
152
176
|
switch (data.type) {
|
|
153
177
|
case "immediate":
|
|
154
|
-
return { type: "immediate", channelId: data.channelId, text: data.text };
|
|
178
|
+
return { type: "immediate", channelId: data.channelId, text: data.text, preAction };
|
|
155
179
|
case "one-shot":
|
|
156
180
|
if (!data.at) {
|
|
157
181
|
throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
|
|
158
182
|
}
|
|
159
|
-
return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at };
|
|
183
|
+
return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at, preAction };
|
|
160
184
|
case "periodic":
|
|
161
185
|
if (!data.schedule) {
|
|
162
186
|
throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
|
|
@@ -170,12 +194,13 @@ export class EventsWatcher {
|
|
|
170
194
|
text: data.text,
|
|
171
195
|
schedule: data.schedule,
|
|
172
196
|
timezone: data.timezone,
|
|
197
|
+
preAction,
|
|
173
198
|
};
|
|
174
199
|
default:
|
|
175
200
|
throw new Error(`Unknown event type '${data.type}' in ${filename}`);
|
|
176
201
|
}
|
|
177
202
|
}
|
|
178
|
-
handleImmediate(filename, event) {
|
|
203
|
+
async handleImmediate(filename, event) {
|
|
179
204
|
const filePath = join(this.eventsDir, filename);
|
|
180
205
|
try {
|
|
181
206
|
const stat = statSync(filePath);
|
|
@@ -189,14 +214,14 @@ export class EventsWatcher {
|
|
|
189
214
|
return;
|
|
190
215
|
}
|
|
191
216
|
log.logInfo(`Executing immediate event: ${filename}`);
|
|
192
|
-
this.execute(filename, event);
|
|
217
|
+
await this.execute(filename, event);
|
|
193
218
|
}
|
|
194
219
|
handleOneShot(filename, event) {
|
|
195
220
|
const atTime = new Date(event.at).getTime();
|
|
196
221
|
const now = Date.now();
|
|
197
222
|
if (!Number.isFinite(atTime)) {
|
|
198
223
|
log.logWarning(`Invalid one-shot time for ${filename}: ${event.at}`);
|
|
199
|
-
this.
|
|
224
|
+
this.markInvalid(filename, `Invalid one-shot time: ${event.at}`);
|
|
200
225
|
return;
|
|
201
226
|
}
|
|
202
227
|
if (atTime <= now) {
|
|
@@ -207,22 +232,32 @@ export class EventsWatcher {
|
|
|
207
232
|
const delay = atTime - now;
|
|
208
233
|
if (delay > MAX_TIMEOUT_MS) {
|
|
209
234
|
log.logWarning(`One-shot event exceeds maximum supported delay for ${filename}: ${event.at}. Use a periodic cron event instead.`);
|
|
210
|
-
this.
|
|
235
|
+
this.markInvalid(filename, `One-shot event exceeds maximum supported delay: ${event.at}`);
|
|
211
236
|
return;
|
|
212
237
|
}
|
|
213
238
|
log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
|
|
214
|
-
const timer = setTimeout(() => {
|
|
239
|
+
const timer = setTimeout(async () => {
|
|
215
240
|
this.timers.delete(filename);
|
|
216
|
-
|
|
217
|
-
|
|
241
|
+
try {
|
|
242
|
+
log.logInfo(`Executing one-shot event: ${filename}`);
|
|
243
|
+
await this.execute(filename, event);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
log.logWarning(`One-shot event execution failed: ${filename}`, String(err));
|
|
247
|
+
}
|
|
218
248
|
}, delay);
|
|
219
249
|
this.timers.set(filename, timer);
|
|
220
250
|
}
|
|
221
251
|
handlePeriodic(filename, event) {
|
|
222
252
|
try {
|
|
223
|
-
const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {
|
|
224
|
-
|
|
225
|
-
|
|
253
|
+
const cron = new Cron(event.schedule, { timezone: event.timezone }, async () => {
|
|
254
|
+
try {
|
|
255
|
+
log.logInfo(`Executing periodic event: ${filename}`);
|
|
256
|
+
await this.execute(filename, event, false);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
log.logWarning(`Periodic event execution failed: ${filename}`, String(err));
|
|
260
|
+
}
|
|
226
261
|
});
|
|
227
262
|
this.crons.set(filename, cron);
|
|
228
263
|
const next = cron.nextRun();
|
|
@@ -230,10 +265,20 @@ export class EventsWatcher {
|
|
|
230
265
|
}
|
|
231
266
|
catch (err) {
|
|
232
267
|
log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
|
|
233
|
-
this.
|
|
268
|
+
this.markInvalid(filename, `Invalid cron schedule: ${event.schedule}\n${String(err)}`);
|
|
234
269
|
}
|
|
235
270
|
}
|
|
236
|
-
execute(filename, event, deleteAfter = true) {
|
|
271
|
+
async execute(filename, event, deleteAfter = true) {
|
|
272
|
+
if (event.preAction) {
|
|
273
|
+
try {
|
|
274
|
+
await this.runPreAction(event.preAction, filename);
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
278
|
+
log.logInfo(`Pre-action gate blocked event: ${filename} (${reason})`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
237
282
|
let scheduleInfo;
|
|
238
283
|
switch (event.type) {
|
|
239
284
|
case "immediate":
|
|
@@ -269,6 +314,25 @@ export class EventsWatcher {
|
|
|
269
314
|
}
|
|
270
315
|
}
|
|
271
316
|
}
|
|
317
|
+
runPreAction(action, filename) {
|
|
318
|
+
if (this.commandGuardConfig?.enabled) {
|
|
319
|
+
const guardResult = guardCommand(action.command, this.commandGuardConfig);
|
|
320
|
+
if (!guardResult.allowed) {
|
|
321
|
+
log.logWarning(`Pre-action command blocked by guard for ${filename}: ${guardResult.reason}`);
|
|
322
|
+
return Promise.reject(new Error(`guard: ${guardResult.reason}`));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const child = exec(action.command, { timeout: action.timeout ?? 10_000 });
|
|
327
|
+
child.on("close", (code) => {
|
|
328
|
+
if (code === 0)
|
|
329
|
+
resolve();
|
|
330
|
+
else
|
|
331
|
+
reject(new Error(`exit ${code}`));
|
|
332
|
+
});
|
|
333
|
+
child.on("error", reject);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
272
336
|
deleteFile(filename) {
|
|
273
337
|
const filePath = join(this.eventsDir, filename);
|
|
274
338
|
try {
|
|
@@ -279,8 +343,32 @@ export class EventsWatcher {
|
|
|
279
343
|
log.logWarning(`Failed to delete event file: ${filename}`, String(err));
|
|
280
344
|
}
|
|
281
345
|
}
|
|
346
|
+
this.clearInvalidMarker(filename);
|
|
282
347
|
this.knownFiles.delete(filename);
|
|
283
348
|
}
|
|
349
|
+
getInvalidMarkerPath(filename) {
|
|
350
|
+
return join(this.eventsDir, `${filename}.error.txt`);
|
|
351
|
+
}
|
|
352
|
+
markInvalid(filename, message) {
|
|
353
|
+
try {
|
|
354
|
+
writeFileSync(this.getInvalidMarkerPath(filename), [`timestamp: ${new Date().toISOString()}`, `file: ${filename}`, "", message.trim()].join("\n"), "utf-8");
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
log.logWarning(`Failed to write event error marker: ${filename}`, String(err));
|
|
358
|
+
}
|
|
359
|
+
this.knownFiles.add(filename);
|
|
360
|
+
}
|
|
361
|
+
clearInvalidMarker(filename) {
|
|
362
|
+
const markerPath = this.getInvalidMarkerPath(filename);
|
|
363
|
+
try {
|
|
364
|
+
unlinkSync(markerPath);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
|
|
368
|
+
log.logWarning(`Failed to delete event error marker: ${filename}`, String(err));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
284
372
|
sleep(ms) {
|
|
285
373
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
286
374
|
}
|
|
@@ -288,7 +376,7 @@ export class EventsWatcher {
|
|
|
288
376
|
/**
|
|
289
377
|
* Create and start an events watcher.
|
|
290
378
|
*/
|
|
291
|
-
export function createEventsWatcher(workspaceDir, bot) {
|
|
379
|
+
export function createEventsWatcher(workspaceDir, bot, commandGuardConfig) {
|
|
292
380
|
const eventsDir = join(workspaceDir, "events");
|
|
293
|
-
return new EventsWatcher(eventsDir, bot);
|
|
381
|
+
return new EventsWatcher(eventsDir, bot, commandGuardConfig);
|
|
294
382
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
+
import { isWindowsPlatform } from "./platform.js";
|
|
2
3
|
const WHITESPACE = /\s+/;
|
|
3
4
|
function stripNullAndNormalize(text) {
|
|
4
5
|
return text.replace(/\0/g, "").normalize("NFKC");
|
|
@@ -402,6 +403,9 @@ export function guardCommand(command, config) {
|
|
|
402
403
|
if (!config.enabled) {
|
|
403
404
|
return { allowed: true };
|
|
404
405
|
}
|
|
406
|
+
if (isWindowsPlatform()) {
|
|
407
|
+
return { allowed: true };
|
|
408
|
+
}
|
|
405
409
|
const atoms = splitCommandChain(command);
|
|
406
410
|
const normalizedWhole = stripNullAndNormalize(command);
|
|
407
411
|
for (const allowPattern of config.allowPatterns) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import type { ConfigDiagnostic } from "../shared/config-diagnostics.js";
|
|
1
2
|
import type { SecurityConfig } from "./types.js";
|
|
3
|
+
export interface LoadedSecurityConfig {
|
|
4
|
+
config: SecurityConfig;
|
|
5
|
+
diagnostics: ConfigDiagnostic[];
|
|
6
|
+
}
|
|
2
7
|
export declare const DEFAULT_SECURITY_CONFIG: SecurityConfig;
|
|
3
8
|
export declare function getSecurityConfigPath(appHomeDir?: string): string;
|
|
9
|
+
export declare function loadSecurityConfigWithDiagnostics(appHomeDir?: string): LoadedSecurityConfig;
|
|
4
10
|
export declare function loadSecurityConfig(appHomeDir?: string): SecurityConfig;
|
package/dist/security/config.js
CHANGED
|
@@ -34,14 +34,30 @@ function asStringArray(value) {
|
|
|
34
34
|
function asOptionalString(value) {
|
|
35
35
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
36
36
|
}
|
|
37
|
-
function
|
|
37
|
+
function pushInvalidSecurityDiagnostic(diagnostics, configPath, field, message) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
source: "security",
|
|
40
|
+
path: configPath,
|
|
41
|
+
severity: "warning",
|
|
42
|
+
message: `${field}: ${message}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function mergeSecurityConfig(source, configPath, diagnostics) {
|
|
38
46
|
if (!isRecord(source)) {
|
|
47
|
+
pushInvalidSecurityDiagnostic(diagnostics, configPath, "root", "expected a JSON object; using defaults");
|
|
39
48
|
return DEFAULT_SECURITY_CONFIG;
|
|
40
49
|
}
|
|
41
50
|
const commandGuard = isRecord(source.commandGuard) ? source.commandGuard : {};
|
|
42
51
|
const pathGuard = isRecord(source.pathGuard) ? source.pathGuard : {};
|
|
43
52
|
const networkGuard = isRecord(source.networkGuard) ? source.networkGuard : {};
|
|
44
53
|
const audit = isRecord(source.audit) ? source.audit : {};
|
|
54
|
+
if (networkGuard.maxRedirects !== undefined) {
|
|
55
|
+
const maxRedirects = networkGuard.maxRedirects;
|
|
56
|
+
const isValidMaxRedirects = typeof maxRedirects === "number" && Number.isFinite(maxRedirects) && maxRedirects > 0;
|
|
57
|
+
if (!isValidMaxRedirects) {
|
|
58
|
+
pushInvalidSecurityDiagnostic(diagnostics, configPath, "networkGuard.maxRedirects", "expected a positive integer; using default");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
45
61
|
return {
|
|
46
62
|
enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_SECURITY_CONFIG.enabled,
|
|
47
63
|
commandGuard: {
|
|
@@ -85,17 +101,33 @@ function mergeSecurityConfig(source) {
|
|
|
85
101
|
export function getSecurityConfigPath(appHomeDir = APP_HOME_DIR) {
|
|
86
102
|
return join(appHomeDir, "security.json");
|
|
87
103
|
}
|
|
88
|
-
export function
|
|
104
|
+
export function loadSecurityConfigWithDiagnostics(appHomeDir = APP_HOME_DIR) {
|
|
89
105
|
const configPath = getSecurityConfigPath(appHomeDir);
|
|
90
106
|
if (!existsSync(configPath)) {
|
|
91
|
-
return DEFAULT_SECURITY_CONFIG;
|
|
107
|
+
return { config: DEFAULT_SECURITY_CONFIG, diagnostics: [] };
|
|
92
108
|
}
|
|
93
109
|
try {
|
|
94
110
|
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
95
|
-
|
|
111
|
+
const diagnostics = [];
|
|
112
|
+
return {
|
|
113
|
+
config: mergeSecurityConfig(raw, configPath, diagnostics),
|
|
114
|
+
diagnostics,
|
|
115
|
+
};
|
|
96
116
|
}
|
|
97
117
|
catch (error) {
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
return {
|
|
119
|
+
config: DEFAULT_SECURITY_CONFIG,
|
|
120
|
+
diagnostics: [
|
|
121
|
+
{
|
|
122
|
+
source: "security",
|
|
123
|
+
path: configPath,
|
|
124
|
+
severity: "error",
|
|
125
|
+
message: error instanceof Error ? error.message : String(error),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
100
129
|
}
|
|
101
130
|
}
|
|
131
|
+
export function loadSecurityConfig(appHomeDir = APP_HOME_DIR) {
|
|
132
|
+
return loadSecurityConfigWithDiagnostics(appHomeDir).config;
|
|
133
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
|
|
4
|
+
import { isWindowsPlatform } from "./platform.js";
|
|
4
5
|
const PRIVATE_KEY_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx"]);
|
|
5
6
|
const PRIVATE_KEY_NAME_HINTS = /(id_rsa|id_ed25519|private|secret|credentials)/i;
|
|
6
7
|
const PROC_MEM_PATH = /^\/proc\/\d+\/mem(?:\/|$)/;
|
|
@@ -197,6 +198,9 @@ export function guardPath(rawPath, operation, ctx) {
|
|
|
197
198
|
if (!ctx.config.enabled) {
|
|
198
199
|
return { allowed: true, operation, rawPath };
|
|
199
200
|
}
|
|
201
|
+
if (isWindowsPlatform()) {
|
|
202
|
+
return { allowed: true, operation, rawPath };
|
|
203
|
+
}
|
|
200
204
|
const homeDir = ctx.homeDir ?? homedir();
|
|
201
205
|
const effectiveCtx = {
|
|
202
206
|
...ctx,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isWindowsPlatform(): boolean;
|
package/dist/settings.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* This module currently provides only PipiclawSettingsManager.
|
|
8
8
|
*/
|
|
9
9
|
import type { Transport } from "@mariozechner/pi-ai";
|
|
10
|
+
import type { ConfigDiagnostic } from "./shared/config-diagnostics.js";
|
|
10
11
|
type PackageSource = string | {
|
|
11
12
|
source: string;
|
|
12
13
|
extensions?: string[];
|
|
@@ -83,10 +84,13 @@ export interface PipiclawSettings {
|
|
|
83
84
|
export declare class PipiclawSettingsManager {
|
|
84
85
|
private settingsPath;
|
|
85
86
|
private settings;
|
|
87
|
+
private loadErrors;
|
|
86
88
|
constructor(baseDir: string);
|
|
87
89
|
private load;
|
|
88
90
|
private save;
|
|
89
91
|
reload(): void;
|
|
92
|
+
drainErrors(): SettingsError[];
|
|
93
|
+
getDiagnostics(): ConfigDiagnostic[];
|
|
90
94
|
getCompactionSettings(): PipiclawCompactionSettings;
|
|
91
95
|
getCompactionEnabled(): boolean;
|
|
92
96
|
setCompactionEnabled(enabled: boolean): void;
|
|
@@ -176,6 +180,5 @@ export declare class PipiclawSettingsManager {
|
|
|
176
180
|
getProjectSettings(): Settings;
|
|
177
181
|
applyOverrides(overrides: Partial<Settings>): void;
|
|
178
182
|
flush(): Promise<void>;
|
|
179
|
-
drainErrors(): SettingsError[];
|
|
180
183
|
}
|
|
181
184
|
export {};
|