@mininglamp-oss/cc-channel-octo 1.0.1-dev.0ac574a

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/LICENSE +191 -0
  3. package/README.md +577 -0
  4. package/config.bot.example.json +15 -0
  5. package/config.example.json +33 -0
  6. package/dist/agent-bridge.d.ts +91 -0
  7. package/dist/agent-bridge.js +397 -0
  8. package/dist/agent-bridge.js.map +1 -0
  9. package/dist/cli.d.ts +109 -0
  10. package/dist/cli.js +467 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/commands.d.ts +57 -0
  13. package/dist/commands.js +121 -0
  14. package/dist/commands.js.map +1 -0
  15. package/dist/config.d.ts +294 -0
  16. package/dist/config.js +344 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/configure.d.ts +11 -0
  19. package/dist/configure.js +106 -0
  20. package/dist/configure.js.map +1 -0
  21. package/dist/cron-evaluator.d.ts +53 -0
  22. package/dist/cron-evaluator.js +191 -0
  23. package/dist/cron-evaluator.js.map +1 -0
  24. package/dist/cron-fire-marker.d.ts +24 -0
  25. package/dist/cron-fire-marker.js +25 -0
  26. package/dist/cron-fire-marker.js.map +1 -0
  27. package/dist/cron-scheduler.d.ts +46 -0
  28. package/dist/cron-scheduler.js +114 -0
  29. package/dist/cron-scheduler.js.map +1 -0
  30. package/dist/cron-store.d.ts +62 -0
  31. package/dist/cron-store.js +63 -0
  32. package/dist/cron-store.js.map +1 -0
  33. package/dist/cron-tool.d.ts +44 -0
  34. package/dist/cron-tool.js +151 -0
  35. package/dist/cron-tool.js.map +1 -0
  36. package/dist/cwd-resolver.d.ts +72 -0
  37. package/dist/cwd-resolver.js +166 -0
  38. package/dist/cwd-resolver.js.map +1 -0
  39. package/dist/db-adapter.d.ts +21 -0
  40. package/dist/db-adapter.js +64 -0
  41. package/dist/db-adapter.js.map +1 -0
  42. package/dist/file-inline-wrap.d.ts +94 -0
  43. package/dist/file-inline-wrap.js +243 -0
  44. package/dist/file-inline-wrap.js.map +1 -0
  45. package/dist/gateway.d.ts +105 -0
  46. package/dist/gateway.js +425 -0
  47. package/dist/gateway.js.map +1 -0
  48. package/dist/group-config.d.ts +41 -0
  49. package/dist/group-config.js +104 -0
  50. package/dist/group-config.js.map +1 -0
  51. package/dist/group-context.d.ts +81 -0
  52. package/dist/group-context.js +466 -0
  53. package/dist/group-context.js.map +1 -0
  54. package/dist/inbound.d.ts +136 -0
  55. package/dist/inbound.js +667 -0
  56. package/dist/inbound.js.map +1 -0
  57. package/dist/index.d.ts +65 -0
  58. package/dist/index.js +1026 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/media-inbound.d.ts +38 -0
  61. package/dist/media-inbound.js +131 -0
  62. package/dist/media-inbound.js.map +1 -0
  63. package/dist/mention-utils.d.ts +108 -0
  64. package/dist/mention-utils.js +199 -0
  65. package/dist/mention-utils.js.map +1 -0
  66. package/dist/octo/api.d.ts +148 -0
  67. package/dist/octo/api.js +320 -0
  68. package/dist/octo/api.js.map +1 -0
  69. package/dist/octo/socket.d.ts +102 -0
  70. package/dist/octo/socket.js +793 -0
  71. package/dist/octo/socket.js.map +1 -0
  72. package/dist/octo/types.d.ts +126 -0
  73. package/dist/octo/types.js +35 -0
  74. package/dist/octo/types.js.map +1 -0
  75. package/dist/prompt-safety.d.ts +78 -0
  76. package/dist/prompt-safety.js +148 -0
  77. package/dist/prompt-safety.js.map +1 -0
  78. package/dist/session-router.d.ts +144 -0
  79. package/dist/session-router.js +490 -0
  80. package/dist/session-router.js.map +1 -0
  81. package/dist/session-store.d.ts +89 -0
  82. package/dist/session-store.js +297 -0
  83. package/dist/session-store.js.map +1 -0
  84. package/dist/skill-linker.d.ts +31 -0
  85. package/dist/skill-linker.js +160 -0
  86. package/dist/skill-linker.js.map +1 -0
  87. package/dist/stream-relay.d.ts +42 -0
  88. package/dist/stream-relay.js +243 -0
  89. package/dist/stream-relay.js.map +1 -0
  90. package/dist/url-policy.d.ts +103 -0
  91. package/dist/url-policy.js +290 -0
  92. package/dist/url-policy.js.map +1 -0
  93. package/package.json +79 -0
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The Anthropic SDK appends `/v1/messages` to ANTHROPIC_BASE_URL. A gateway
3
+ * pasted with a trailing `/v1` would otherwise yield `/v1/v1/messages` (404,
4
+ * misreported as a model error). Strip a trailing `/v1` (optionally with a
5
+ * slash) so the stored base is the host root. Pure for unit testing.
6
+ */
7
+ export declare function normalizeGatewayUrl(raw: string): string;
8
+ export declare function configure(gatewayUrl: string, apiKey: string, configPath?: string, opts?: {
9
+ model?: string;
10
+ apiUrl?: string;
11
+ }): void;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `configure` subcommand backend: write the LLM gateway URL + API key into the
3
+ * global config's `sdk` block. Daemon-driven one-click install calls
4
+ * `cc-channel-octo configure --gateway-url <url> --api-key <key>`.
5
+ *
6
+ * Independent of loadConfig(): loadConfig requires apiUrl (bot binding comes
7
+ * later via the provision flow), but install must be able to write gateway+key
8
+ * before any bot exists. So this does a raw read-merge-write of the JSON file,
9
+ * touching only sdk.anthropicBaseUrl + sdk.apiKey.
10
+ */
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, renameSync, unlinkSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ import { DEFAULT_CONFIG_PATH } from './config.js';
14
+ import { isAllowedApiUrl } from './url-policy.js';
15
+ /**
16
+ * The Anthropic SDK appends `/v1/messages` to ANTHROPIC_BASE_URL. A gateway
17
+ * pasted with a trailing `/v1` would otherwise yield `/v1/v1/messages` (404,
18
+ * misreported as a model error). Strip a trailing `/v1` (optionally with a
19
+ * slash) so the stored base is the host root. Pure for unit testing.
20
+ */
21
+ export function normalizeGatewayUrl(raw) {
22
+ return raw.trim().replace(/\/v1\/?$/i, '');
23
+ }
24
+ export function configure(gatewayUrl, apiKey, configPath, opts) {
25
+ if (!gatewayUrl)
26
+ throw new Error('configure: --gateway-url is required');
27
+ if (!apiKey)
28
+ throw new Error('configure: --api-key is required');
29
+ // The gateway receives the API key + all prompt/response content, so it gets
30
+ // the same SSRF policy as apiUrl (mirrors loadConfig's anthropicBaseUrl check).
31
+ if (!isAllowedApiUrl(gatewayUrl)) {
32
+ throw new Error(`configure: unsafe --gateway-url ${gatewayUrl} (must be https:// or http://localhost)`);
33
+ }
34
+ // apiUrl is the Octo IM server (cc's top-level config.apiUrl). The daemon
35
+ // passes its server url at install time so the zero-bot idle gateway can boot
36
+ // (loadConfig requires apiUrl). Same SSRF policy as the gateway url.
37
+ if (opts?.apiUrl && !isAllowedApiUrl(opts.apiUrl)) {
38
+ throw new Error(`configure: unsafe --api-url ${opts.apiUrl} (must be https:// or http://localhost)`);
39
+ }
40
+ const normalizedUrl = normalizeGatewayUrl(gatewayUrl);
41
+ const path = configPath ?? DEFAULT_CONFIG_PATH;
42
+ let existing = {};
43
+ if (existsSync(path)) {
44
+ try {
45
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
46
+ // Validate that the root is a plain object before treating it as one.
47
+ if (!(parsed && typeof parsed === 'object' && !Array.isArray(parsed))) {
48
+ throw new Error(`configure: existing config at ${path} is not a JSON object`);
49
+ }
50
+ existing = parsed;
51
+ }
52
+ catch (err) {
53
+ // Re-throw the clear "not a JSON object" error as-is; wrap parse errors.
54
+ if (err instanceof Error && err.message.includes('is not a JSON object')) {
55
+ throw err;
56
+ }
57
+ throw new Error(`configure: failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ }
60
+ // Narrow the existing sdk block to a plain object before merging (the file is
61
+ // untyped JSON; repo lint forbids `any`, so read it as unknown + narrow).
62
+ const existingSdk = existing.sdk && typeof existing.sdk === 'object' && !Array.isArray(existing.sdk)
63
+ ? existing.sdk
64
+ : {};
65
+ const merged = {
66
+ ...existing,
67
+ sdk: { ...existingSdk, anthropicBaseUrl: normalizedUrl, apiKey },
68
+ };
69
+ // Write model only when provided; omitting it PRESERVES any existing sdk.model
70
+ // (the existingSdk spread above) so a re-configure that just rotates the key
71
+ // never wipes the model. Resetting model→default is intentionally not a
72
+ // configure feature (add an explicit --clear-model later if ever needed).
73
+ if (opts?.model) {
74
+ merged.sdk.model = opts.model;
75
+ }
76
+ // The Octo IM server url lives at the top level (not under sdk).
77
+ if (opts?.apiUrl) {
78
+ merged.apiUrl = opts.apiUrl;
79
+ }
80
+ mkdirSync(dirname(path), { recursive: true });
81
+ // Atomic write: temp file in same directory with 0600 mode, then rename.
82
+ // `wx` (exclusive create) refuses to write through a pre-existing file or a
83
+ // symlink prepositioned at the temp path — important for a secret-bearing
84
+ // writer. The pid+timestamp name makes a real collision practically impossible.
85
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
86
+ try {
87
+ writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
88
+ renameSync(tmpPath, path);
89
+ // Belt-and-suspenders: force 0600 on the final file too.
90
+ chmodSync(path, 0o600);
91
+ }
92
+ catch (err) {
93
+ // Best-effort cleanup of OUR temp file — but if the failure was EEXIST, the
94
+ // path already existed and is not ours to delete.
95
+ if (err.code !== 'EEXIST') {
96
+ try {
97
+ unlinkSync(tmpPath);
98
+ }
99
+ catch {
100
+ /* already gone or never created — fine */
101
+ }
102
+ }
103
+ throw new Error(`configure: failed to write ${path}: ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ }
106
+ //# sourceMappingURL=configure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configure.js","sourceRoot":"","sources":["../src/configure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAC/G,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAEjD;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC7C,OAAO,GAAG,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;AAC5C,CAAC;AAED,MAAM,UAAU,SAAS,CACvB,UAAkB,EAClB,MAAc,EACd,UAAmB,EACnB,IAA0C;IAE1C,IAAI,CAAC,UAAU;QAAE,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAA;IACxE,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAA;IAChE,6EAA6E;IAC7E,gFAAgF;IAChF,IAAI,CAAC,eAAe,CAAC,UAAU,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,mCAAmC,UAAU,yCAAyC,CAAC,CAAA;IACzG,CAAC;IACD,0EAA0E;IAC1E,8EAA8E;IAC9E,qEAAqE;IACrE,IAAI,IAAI,EAAE,MAAM,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CAAC,+BAA+B,IAAI,CAAC,MAAM,yCAAyC,CAAC,CAAA;IACtG,CAAC;IACD,MAAM,aAAa,GAAG,mBAAmB,CAAC,UAAU,CAAC,CAAA;IACrD,MAAM,IAAI,GAAG,UAAU,IAAI,mBAAmB,CAAA;IAC9C,IAAI,QAAQ,GAA4B,EAAE,CAAA;IAC1C,IAAI,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACrB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAY,CAAA;YACjE,sEAAsE;YACtE,IAAI,CAAC,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;gBACtE,MAAM,IAAI,KAAK,CAAC,iCAAiC,IAAI,uBAAuB,CAAC,CAAA;YAC/E,CAAC;YACD,QAAQ,GAAG,MAAiC,CAAA;QAC9C,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,yEAAyE;YACzE,IAAI,GAAG,YAAY,KAAK,IAAI,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC,EAAE,CAAC;gBACzE,MAAM,GAAG,CAAA;YACX,CAAC;YACD,MAAM,IAAI,KAAK,CAAC,8BAA8B,IAAI,KAAK,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAC5G,CAAC;IACH,CAAC;IACD,8EAA8E;IAC9E,0EAA0E;IAC1E,MAAM,WAAW,GACf,QAAQ,CAAC,GAAG,IAAI,OAAO,QAAQ,CAAC,GAAG,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC;QAC9E,CAAC,CAAE,QAAQ,CAAC,GAA+B;QAC3C,CAAC,CAAC,EAAE,CAAA;IACR,MAAM,MAAM,GAA4B;QACtC,GAAG,QAAQ;QACX,GAAG,EAAE,EAAE,GAAG,WAAW,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,EAAE;KACjE,CAAA;IACD,+EAA+E;IAC/E,6EAA6E;IAC7E,wEAAwE;IACxE,0EAA0E;IAC1E,IAAI,IAAI,EAAE,KAAK,EAAE,CAAC;QACf,MAAM,CAAC,GAA+B,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAA;IAC5D,CAAC;IACD,iEAAiE;IACjE,IAAI,IAAI,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAA;IAC7B,CAAC;IACD,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAE7C,yEAAyE;IACzE,4EAA4E;IAC5E,0EAA0E;IAC1E,gFAAgF;IAChF,MAAM,OAAO,GAAG,GAAG,IAAI,QAAQ,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,EAAE,CAAA;IAC1D,IAAI,CAAC;QACH,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;QAC3F,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;QACzB,yDAAyD;QACzD,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;IACxB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,4EAA4E;QAC5E,kDAAkD;QAClD,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACrD,IAAI,CAAC;gBACH,UAAU,CAAC,OAAO,CAAC,CAAA;YACrB,CAAC;YAAC,MAAM,CAAC;gBACP,0CAA0C;YAC5C,CAAC;QACH,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,8BAA8B,IAAI,KAAK,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAC5G,CAAC;AACH,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * #115: Cron schedule evaluation — pure functions, no I/O.
3
+ *
4
+ * Supports two schedule forms:
5
+ * - **5-field cron** `"minute hour dom month dow"` — each field is `*`, an
6
+ * integer, `*` + `/step`, an `a-b` range, or a comma list of those. Standard
7
+ * Unix semantics: dom/dow are OR'd when both are restricted (a task fires when
8
+ * either matches), matching cron's historical behavior.
9
+ * - **one-shot ISO datetime** `"2026-06-09T09:00:00Z"` — fires once at that
10
+ * instant, then never again.
11
+ *
12
+ * Kept dependency-free (a tiny evaluator beats pulling a cron library for this).
13
+ *
14
+ * TIMEZONE: cron fields are matched against the gateway process's LOCAL time
15
+ * (`Date#getHours()` etc.), so `"0 9 * * *"` means 9am in the server's timezone.
16
+ * One-shot ISO datetimes are absolute instants (honor any offset/`Z` in the
17
+ * string). Set `TZ=...` on the process to control cron-field interpretation.
18
+ */
19
+ /** A parsed 5-field cron expression: each field is the set of allowed values. */
20
+ export interface ParsedCron {
21
+ minute: Set<number>;
22
+ hour: Set<number>;
23
+ dom: Set<number>;
24
+ month: Set<number>;
25
+ dow: Set<number>;
26
+ /** True when dom/dow were both restricted (affects OR semantics). */
27
+ domRestricted: boolean;
28
+ dowRestricted: boolean;
29
+ }
30
+ /** Parse a 5-field cron expression. Returns null when invalid. */
31
+ export declare function parseCronExpression(expr: string): ParsedCron | null;
32
+ /** True when `date` (local time) matches the parsed cron. */
33
+ export declare function matchesCron(p: ParsedCron, date: Date): boolean;
34
+ /** Heuristic: is this schedule a one-shot ISO datetime (vs a cron expr)? */
35
+ export declare function isOneShotSchedule(schedule: string): boolean;
36
+ /**
37
+ * Strictly parse a one-shot ISO-8601 datetime → Unix ms, or null if invalid.
38
+ *
39
+ * `new Date()` alone is too lenient (e.g. it rolls "2026-13-13T…" into a real
40
+ * but wrong instant instead of rejecting it). We additionally require the
41
+ * canonical shape via regex AND verify the authored wall-clock fields name a
42
+ * real calendar instant, so an out-of-range month/day/hour can't sneak through
43
+ * as a silently shifted time — for ALL zone forms (Z, ±hh:mm, or none).
44
+ */
45
+ export declare function parseOneShot(schedule: string): number | null;
46
+ /**
47
+ * Compute the next fire time (Unix ms) strictly after `fromMs`, or null when
48
+ * there is none (a past/invalid one-shot, or an impossible cron).
49
+ *
50
+ * - one-shot ISO: its instant if still in the future, else null.
51
+ * - cron: scan minute-by-minute from the next whole minute, up to ~366 days.
52
+ */
53
+ export declare function computeNextRun(schedule: string, recurring: boolean, fromMs: number): number | null;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * #115: Cron schedule evaluation — pure functions, no I/O.
3
+ *
4
+ * Supports two schedule forms:
5
+ * - **5-field cron** `"minute hour dom month dow"` — each field is `*`, an
6
+ * integer, `*` + `/step`, an `a-b` range, or a comma list of those. Standard
7
+ * Unix semantics: dom/dow are OR'd when both are restricted (a task fires when
8
+ * either matches), matching cron's historical behavior.
9
+ * - **one-shot ISO datetime** `"2026-06-09T09:00:00Z"` — fires once at that
10
+ * instant, then never again.
11
+ *
12
+ * Kept dependency-free (a tiny evaluator beats pulling a cron library for this).
13
+ *
14
+ * TIMEZONE: cron fields are matched against the gateway process's LOCAL time
15
+ * (`Date#getHours()` etc.), so `"0 9 * * *"` means 9am in the server's timezone.
16
+ * One-shot ISO datetimes are absolute instants (honor any offset/`Z` in the
17
+ * string). Set `TZ=...` on the process to control cron-field interpretation.
18
+ */
19
+ const FIELD_RANGES = [
20
+ ['minute', 0, 59],
21
+ ['hour', 0, 23],
22
+ ['dom', 1, 31],
23
+ ['month', 1, 12],
24
+ ['dow', 0, 6], // 0 = Sunday
25
+ ];
26
+ /** Expand one cron field into a set of allowed integers, or null if invalid. */
27
+ function parseField(raw, min, max) {
28
+ const out = new Set();
29
+ for (const part of raw.split(',')) {
30
+ const seg = part.trim();
31
+ if (seg === '')
32
+ return null;
33
+ // step: "*/n" or "a-b/n" or "a/n"
34
+ let stepStr;
35
+ let rangeStr = seg;
36
+ const slash = seg.indexOf('/');
37
+ if (slash !== -1) {
38
+ rangeStr = seg.slice(0, slash);
39
+ stepStr = seg.slice(slash + 1);
40
+ }
41
+ let step = 1;
42
+ if (stepStr !== undefined) {
43
+ if (!/^\d+$/.test(stepStr))
44
+ return null;
45
+ step = Number(stepStr);
46
+ if (step < 1)
47
+ return null;
48
+ }
49
+ let lo;
50
+ let hi;
51
+ if (rangeStr === '*') {
52
+ lo = min;
53
+ hi = max;
54
+ }
55
+ else if (/^\d+$/.test(rangeStr)) {
56
+ lo = hi = Number(rangeStr);
57
+ // A bare number with a step (e.g. "5/10") means "from 5 to max, step".
58
+ if (stepStr !== undefined)
59
+ hi = max;
60
+ }
61
+ else {
62
+ const m = /^(\d+)-(\d+)$/.exec(rangeStr);
63
+ if (!m)
64
+ return null;
65
+ lo = Number(m[1]);
66
+ hi = Number(m[2]);
67
+ }
68
+ if (lo < min || hi > max || lo > hi)
69
+ return null;
70
+ for (let v = lo; v <= hi; v += step)
71
+ out.add(v);
72
+ }
73
+ return out.size > 0 ? out : null;
74
+ }
75
+ /** Parse a 5-field cron expression. Returns null when invalid. */
76
+ export function parseCronExpression(expr) {
77
+ const fields = expr.trim().split(/\s+/);
78
+ if (fields.length !== 5)
79
+ return null;
80
+ const sets = [];
81
+ for (let i = 0; i < 5; i++) {
82
+ const [, min, max] = FIELD_RANGES[i];
83
+ const set = parseField(fields[i], min, max);
84
+ if (!set)
85
+ return null;
86
+ sets.push(set);
87
+ }
88
+ return {
89
+ minute: sets[0],
90
+ hour: sets[1],
91
+ dom: sets[2],
92
+ month: sets[3],
93
+ dow: sets[4],
94
+ domRestricted: fields[2] !== '*',
95
+ dowRestricted: fields[4] !== '*',
96
+ };
97
+ }
98
+ /** True when `date` (local time) matches the parsed cron. */
99
+ export function matchesCron(p, date) {
100
+ if (!p.minute.has(date.getMinutes()))
101
+ return false;
102
+ if (!p.hour.has(date.getHours()))
103
+ return false;
104
+ if (!p.month.has(date.getMonth() + 1))
105
+ return false;
106
+ const domOk = p.dom.has(date.getDate());
107
+ const dowOk = p.dow.has(date.getDay());
108
+ // Standard cron OR semantics: if BOTH dom and dow are restricted, match when
109
+ // EITHER matches; otherwise both (the unrestricted one is always true) must.
110
+ if (p.domRestricted && p.dowRestricted)
111
+ return domOk || dowOk;
112
+ return domOk && dowOk;
113
+ }
114
+ /** Heuristic: is this schedule a one-shot ISO datetime (vs a cron expr)? */
115
+ export function isOneShotSchedule(schedule) {
116
+ // Cron exprs are space-separated fields; ISO datetimes contain 'T' and no spaces.
117
+ return schedule.includes('T') && !/\s/.test(schedule.trim());
118
+ }
119
+ /**
120
+ * Strictly parse a one-shot ISO-8601 datetime → Unix ms, or null if invalid.
121
+ *
122
+ * `new Date()` alone is too lenient (e.g. it rolls "2026-13-13T…" into a real
123
+ * but wrong instant instead of rejecting it). We additionally require the
124
+ * canonical shape via regex AND verify the authored wall-clock fields name a
125
+ * real calendar instant, so an out-of-range month/day/hour can't sneak through
126
+ * as a silently shifted time — for ALL zone forms (Z, ±hh:mm, or none).
127
+ */
128
+ export function parseOneShot(schedule) {
129
+ const s = schedule.trim();
130
+ // YYYY-MM-DDThh:mm(:ss(.fff)?)? with optional Z or ±hh:mm offset.
131
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?$/.exec(s);
132
+ if (!m)
133
+ return null;
134
+ const t = new Date(s).getTime();
135
+ if (Number.isNaN(t))
136
+ return null;
137
+ // Reject lenient rollover. Calendar validity (is "Feb 31" a real date? is hour
138
+ // 25 valid?) is INDEPENDENT of the timezone, so validate the authored
139
+ // wall-clock fields with a zone-free UTC round-trip probe: if Date.UTC had to
140
+ // roll any field over, the rendered field won't match what the user wrote.
141
+ // This catches offset rollover (e.g. 2026-02-31T00:00:00+08:00) too — the old
142
+ // code skipped the check for numeric offsets and let it roll into March.
143
+ const yr = Number(m[1]);
144
+ const mo = Number(m[2]);
145
+ const day = Number(m[3]);
146
+ const hh = Number(m[4]);
147
+ const mm = Number(m[5]);
148
+ const ss = m[6] !== undefined ? Number(m[6]) : 0;
149
+ const probe = new Date(Date.UTC(yr, mo - 1, day, hh, mm, ss));
150
+ if (probe.getUTCFullYear() !== yr ||
151
+ probe.getUTCMonth() !== mo - 1 ||
152
+ probe.getUTCDate() !== day ||
153
+ probe.getUTCHours() !== hh ||
154
+ probe.getUTCMinutes() !== mm ||
155
+ probe.getUTCSeconds() !== ss) {
156
+ return null;
157
+ }
158
+ return t;
159
+ }
160
+ /**
161
+ * Compute the next fire time (Unix ms) strictly after `fromMs`, or null when
162
+ * there is none (a past/invalid one-shot, or an impossible cron).
163
+ *
164
+ * - one-shot ISO: its instant if still in the future, else null.
165
+ * - cron: scan minute-by-minute from the next whole minute, up to ~366 days.
166
+ */
167
+ export function computeNextRun(schedule, recurring, fromMs) {
168
+ if (isOneShotSchedule(schedule)) {
169
+ const t = parseOneShot(schedule);
170
+ if (t === null)
171
+ return null;
172
+ return t > fromMs ? t : null;
173
+ }
174
+ const parsed = parseCronExpression(schedule);
175
+ if (!parsed)
176
+ return null;
177
+ void recurring; // cron exprs are inherently recurring; flag kept for symmetry
178
+ // Start at the next whole minute boundary after fromMs.
179
+ const start = new Date(fromMs);
180
+ start.setSeconds(0, 0);
181
+ start.setMinutes(start.getMinutes() + 1);
182
+ const MAX_MINUTES = 366 * 24 * 60;
183
+ const cursor = new Date(start);
184
+ for (let i = 0; i < MAX_MINUTES; i++) {
185
+ if (matchesCron(parsed, cursor))
186
+ return cursor.getTime();
187
+ cursor.setMinutes(cursor.getMinutes() + 1);
188
+ }
189
+ return null; // impossible schedule (e.g. Feb 31)
190
+ }
191
+ //# sourceMappingURL=cron-evaluator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron-evaluator.js","sourceRoot":"","sources":["../src/cron-evaluator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * #115: Cron-fire authenticity marker.
3
+ *
4
+ * Synthetic cron messages bypass the group @mention gate. To stop a malicious
5
+ * group member from forging that bypass by putting `_cronFire: true` in a real
6
+ * message payload, the scheduler stamps each synthetic message with a secret
7
+ * nonce generated ONCE at process start, and the router accepts the bypass only
8
+ * when the nonce matches. The nonce never leaves the process (it is not derived
9
+ * from anything an attacker can observe), so an inbound WS message cannot carry
10
+ * the right value.
11
+ *
12
+ * Shared in its own tiny module so both `cron-scheduler` (stamp) and
13
+ * `session-router` (verify) depend on it without a circular import.
14
+ */
15
+ /** Per-process secret. Regenerated each start — synthetic messages are
16
+ * in-memory and short-lived, so a fresh nonce per process is fine. */
17
+ export declare const CRON_FIRE_NONCE: string;
18
+ /** Payload key carrying the nonce on a synthetic cron message. */
19
+ export declare const CRON_FIRE_NONCE_KEY = "_cronFireNonce";
20
+ /** True only for a genuine in-process cron fire (marker + matching nonce). */
21
+ export declare function isAuthenticCronFire(payload: {
22
+ _cronFire?: unknown;
23
+ [k: string]: unknown;
24
+ }): boolean;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * #115: Cron-fire authenticity marker.
3
+ *
4
+ * Synthetic cron messages bypass the group @mention gate. To stop a malicious
5
+ * group member from forging that bypass by putting `_cronFire: true` in a real
6
+ * message payload, the scheduler stamps each synthetic message with a secret
7
+ * nonce generated ONCE at process start, and the router accepts the bypass only
8
+ * when the nonce matches. The nonce never leaves the process (it is not derived
9
+ * from anything an attacker can observe), so an inbound WS message cannot carry
10
+ * the right value.
11
+ *
12
+ * Shared in its own tiny module so both `cron-scheduler` (stamp) and
13
+ * `session-router` (verify) depend on it without a circular import.
14
+ */
15
+ import { randomBytes } from 'node:crypto';
16
+ /** Per-process secret. Regenerated each start — synthetic messages are
17
+ * in-memory and short-lived, so a fresh nonce per process is fine. */
18
+ export const CRON_FIRE_NONCE = randomBytes(16).toString('hex');
19
+ /** Payload key carrying the nonce on a synthetic cron message. */
20
+ export const CRON_FIRE_NONCE_KEY = '_cronFireNonce';
21
+ /** True only for a genuine in-process cron fire (marker + matching nonce). */
22
+ export function isAuthenticCronFire(payload) {
23
+ return payload._cronFire === true && payload[CRON_FIRE_NONCE_KEY] === CRON_FIRE_NONCE;
24
+ }
25
+ //# sourceMappingURL=cron-fire-marker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron-fire-marker.js","sourceRoot":"","sources":["../src/cron-fire-marker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C;uEACuE;AACvE,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAE/D,kEAAkE;AAClE,MAAM,CAAC,MAAM,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD,8EAA8E;AAC9E,MAAM,UAAU,mBAAmB,CAAC,OAAsD;IACxF,OAAO,OAAO,CAAC,SAAS,KAAK,IAAI,IAAI,OAAO,CAAC,mBAAmB,CAAC,KAAK,eAAe,CAAC;AACxF,CAAC"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * #115: Cron scheduler — a resident per-bot loop that fires due tasks.
3
+ *
4
+ * Every tick it loads the bot's cron.json, and for each enabled task whose
5
+ * `nextRun` is past, synthesizes a BotMessage (the task's prompt as a Text
6
+ * message, bound to the task's session coords, marked `_cronFire`) and hands it
7
+ * to `onFire` — which the gateway wires to the same `onInbound` real messages
8
+ * use, so a fired task runs through the entire normal pipeline.
9
+ *
10
+ * Best-effort throughout: a failing task is logged and skipped, never crashing
11
+ * the loop. Missed tasks (process was down across their window) fire ONCE on
12
+ * catch-up, then advance to the next future occurrence — no thundering herd.
13
+ */
14
+ import type { BotMessage } from './octo/types.js';
15
+ import { CronStore, type CronTask } from './cron-store.js';
16
+ /** How often the scheduler scans cron.json (ms). 30s → ≤30s firing latency. */
17
+ export declare const CRON_TICK_MS = 30000;
18
+ export interface CronSchedulerOptions {
19
+ cronStore: CronStore;
20
+ /**
21
+ * Invoked with a synthetic BotMessage when a task is due (= onInbound). Fire
22
+ * is non-blocking; the scheduler does not await it and nextRun advances
23
+ * regardless (a failed fire is logged at the handler's own catch site —
24
+ * attributed to the task via the `cron:<id>:<ts>` message_id — not retried, to
25
+ * avoid an error loop hammering the channel).
26
+ */
27
+ onFire: (msg: BotMessage) => void;
28
+ /** Log prefix, e.g. "[bot-id] " in multi-bot mode. */
29
+ label?: string;
30
+ }
31
+ /** Build the synthetic inbound message for a fired task. */
32
+ export declare function synthesizeCronMessage(task: CronTask): BotMessage;
33
+ export declare class CronScheduler {
34
+ private readonly opts;
35
+ private timer;
36
+ constructor(opts: CronSchedulerOptions);
37
+ /** Arm the periodic scan. Idempotent. */
38
+ start(): void;
39
+ /** Stop scanning. */
40
+ stop(): void;
41
+ /**
42
+ * One scan: fire due tasks, advance/drop them, persist. Exposed for tests.
43
+ * Never throws.
44
+ */
45
+ tick(): void;
46
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * #115: Cron scheduler — a resident per-bot loop that fires due tasks.
3
+ *
4
+ * Every tick it loads the bot's cron.json, and for each enabled task whose
5
+ * `nextRun` is past, synthesizes a BotMessage (the task's prompt as a Text
6
+ * message, bound to the task's session coords, marked `_cronFire`) and hands it
7
+ * to `onFire` — which the gateway wires to the same `onInbound` real messages
8
+ * use, so a fired task runs through the entire normal pipeline.
9
+ *
10
+ * Best-effort throughout: a failing task is logged and skipped, never crashing
11
+ * the loop. Missed tasks (process was down across their window) fire ONCE on
12
+ * catch-up, then advance to the next future occurrence — no thundering herd.
13
+ */
14
+ import { MessageType } from './octo/types.js';
15
+ import { computeNextRun } from './cron-evaluator.js';
16
+ import { CRON_FIRE_NONCE, CRON_FIRE_NONCE_KEY } from './cron-fire-marker.js';
17
+ /** How often the scheduler scans cron.json (ms). 30s → ≤30s firing latency. */
18
+ export const CRON_TICK_MS = 30_000;
19
+ /** Build the synthetic inbound message for a fired task. */
20
+ export function synthesizeCronMessage(task) {
21
+ return {
22
+ message_id: `cron:${task.id}:${Date.now()}`,
23
+ message_seq: 0,
24
+ from_uid: task.fromUid,
25
+ from_name: task.fromName,
26
+ channel_id: task.channelId,
27
+ channel_type: task.channelType,
28
+ timestamp: Math.floor(Date.now() / 1000),
29
+ payload: {
30
+ type: MessageType.Text,
31
+ content: task.prompt,
32
+ // Synthetic marker + per-process nonce: lets the router bypass the group
33
+ // @mention gate for genuine in-process cron fires only (see
34
+ // session-router isCronFire / cron-fire-marker). A forged inbound payload
35
+ // can set `_cronFire` but cannot know the secret nonce. Allowed by the
36
+ // MessagePayload index signature; never set on real inbound messages.
37
+ _cronFire: true,
38
+ [CRON_FIRE_NONCE_KEY]: CRON_FIRE_NONCE,
39
+ },
40
+ };
41
+ }
42
+ export class CronScheduler {
43
+ opts;
44
+ timer = null;
45
+ constructor(opts) {
46
+ this.opts = opts;
47
+ }
48
+ /** Arm the periodic scan. Idempotent. */
49
+ start() {
50
+ if (this.timer)
51
+ return;
52
+ this.timer = setInterval(() => this.tick(), CRON_TICK_MS);
53
+ this.timer.unref(); // never keep the process alive on the cron loop alone
54
+ }
55
+ /** Stop scanning. */
56
+ stop() {
57
+ if (this.timer) {
58
+ clearInterval(this.timer);
59
+ this.timer = null;
60
+ }
61
+ }
62
+ /**
63
+ * One scan: fire due tasks, advance/drop them, persist. Exposed for tests.
64
+ * Never throws.
65
+ */
66
+ tick() {
67
+ const now = Date.now();
68
+ // Single atomic read-modify-write: fire due tasks and persist the survivor
69
+ // set in one synchronous pass, so a concurrent tool create/delete (which
70
+ // also goes through cronStore.update) can't lose updates against us.
71
+ try {
72
+ this.opts.cronStore.update((tasks) => {
73
+ const survivors = [];
74
+ let changed = false;
75
+ for (const task of tasks) {
76
+ if (!task.enabled || task.nextRun === null || task.nextRun > now) {
77
+ survivors.push(task);
78
+ continue;
79
+ }
80
+ changed = true;
81
+ // Due. Fire (best-effort; onFire is fire-and-forget).
82
+ const lateMin = Math.round((now - task.nextRun) / 60_000);
83
+ if (lateMin >= 1) {
84
+ console.warn(`[cc-channel-octo] ${this.opts.label ?? ''}cron: task ${task.id} (${task.schedule}) ` +
85
+ `fired ${lateMin} min late (catch-up)`);
86
+ }
87
+ try {
88
+ // Fire-and-forget: onFire (= onInbound) drives the full pipeline,
89
+ // which swallows its own errors and posts a user-facing reply. A
90
+ // FAILED fire is attributed to this task at handleMessage's catch
91
+ // site (via the `cron:<id>:<ts>` message_id) — not here, because the
92
+ // returned value is void and never rejects.
93
+ this.opts.onFire(synthesizeCronMessage(task));
94
+ }
95
+ catch (err) {
96
+ console.error(`[cc-channel-octo] ${this.opts.label ?? ''}cron: onFire threw for ${task.id}: ${String(err)}`);
97
+ }
98
+ task.lastRun = now;
99
+ if (task.recurring) {
100
+ task.nextRun = computeNextRun(task.schedule, true, now);
101
+ survivors.push(task); // keep; next future occurrence (or null → inert)
102
+ }
103
+ // one-shot: drop (not pushed to survivors)
104
+ }
105
+ // Return the SAME reference when nothing fired so update() skips the write.
106
+ return changed ? survivors : tasks;
107
+ });
108
+ }
109
+ catch (err) {
110
+ console.error(`[cc-channel-octo] ${this.opts.label ?? ''}cron: tick failed: ${String(err)}`);
111
+ }
112
+ }
113
+ }
114
+ //# sourceMappingURL=cron-scheduler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron-scheduler.js","sourceRoot":"","sources":["../src/cron-scheduler.ts"],"names":[],"mappings":"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"}