@inetafrica/open-claudia 3.0.2 → 3.0.4

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.4 — surviving the second mount, keeping the crons
4
+
5
+ - **A Kubernetes PVC remount no longer crashloops the bot.** The migration snapshot validator demanded exact owner-only modes (`0700` dirs, `0600` files). But kubelet's fsGroup ownership pass ORs group `rwX` + setgid onto every file and directory each time the volume is mounted — so the first v3 boot passed (snapshot created after the mount), and the **second pod recreation** hit `INVALID_MIGRATION_SNAPSHOT: migration snapshot has unsafe permissions` at boot and went CrashLoopBackOff. Every v3 pod on an fsGroup-mounted PVC was one recreation away from this (euvy hit it live on 2026-07-12 and needed a manual chmod rescue). Validation now rejects only world-access bits — group access is the pod's own supplemental group, not an exposure — while snapshots are still *created* `0700`/`0600`.
6
+ - **Legacy crons adopt your saved defaults instead of dying in the archive.** Pre-provider-aware jobs carried no pinned provider or project — they fired with whatever the bot had active — so the v3 migration classified every one of them "no unambiguous project" and archive-disabled it, silently killing real production crons (euvy's 6-hourly Airbyte data-sync among them). The jobs migration now looks up the owning user's saved state (`settings.backend` and `currentSession` name/dir, in both the v3 `users` shape and the pre-v3 global shape), adopts those as the job's provider/project, and marks the record `legacyDefaultsApplied` — preserving the old fire-time behaviour instead of erasing it. Jobs pinned to a removed provider (Cursor) keep their pin, stay archived, and are never reassigned; a job whose owner has no saved defaults still archives safely.
7
+ - **Re-reading the jobs store no longer rewrites stored nulls.** `Number(null)` is `0`, so the re-migration pass that runs on every read flipped `nextAttemptAt`/`lastFireAt` from `null` to epoch-`0` — harmless at runtime but a silent mutation on every load. Migration output is now byte-stable across passes.
8
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. New hermetic probes: fsGroup-mode tolerance (group bits pass, world bits still refused) in `test-provider-migration-backup.js`; legacy-defaults adoption, v2/v3 state shapes, Cursor guard, and migration idempotency in `test-provider-scheduler.js`.
9
+
10
+ ## v3.0.3 — the lock-holder stands its ground
11
+
12
+ - **A persistent 409 no longer restarts the bot.** v3.0.1 taught the poller to ride out short 409 conflicts but still `exit(1)` once a conflict outlived 2 minutes, on the theory that the survivor had to be a real second instance. In practice the rogue poller is lock-blind — a debugger-run dev checkout, a copy on another config dir, or another host on the same token — so the exit killed the *legitimate*, lock-holding service and launchd respawned it straight back into the same fight (five boot→409→exit cycles on 2026-07-12). The poller now never exits over a 409: it pauses 15s per retry, slows to 60s once the streak passes 2 minutes, sends the owner a throttled Telegram alert (max one per hour) naming the likely culprits, and posts an all-clear once the rogue goes away.
13
+ - **The single-instance lock is atomic.** Acquisition was read-check-then-write, so two processes booting together (a launchd respawn racing a manual start) could both judge the lock stale and both claim it — each believing it was the only copy. The lock is now claimed with an exclusive-create (`O_EXCL`) in a bounded retry loop: stale, corrupt, or own-pid locks are unlinked and re-claimed atomically, a live holder still turns the newcomer away, and a process that loses every claim attempt refuses to boot rather than assuming ownership.
14
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. `test-telegram-409-grace.js` now proves the never-exit + owner-alert + all-clear path; `test-single-instance.js` passes unchanged against the atomic rewrite.
15
+
3
16
  ## v3.0.2 — honest context numbers, dream summaries that arrive
4
17
 
5
18
  - **The context warning counts the window again, not the bill.** The v3.0.0 provider rewrite computed the live context number from the provider's *cumulative* turn usage — every tool round-trip re-counted the cached prompt prefix, so a normal turn read as 347k+ "context" (crossing 1M on busy turns), spooked the usage alert, and could prematurely force-compact sessions whose real window was fine. The number now tracks the **peak single API call** in the turn (`peakContextTokens` over per-call usage events) — true window occupancy, the same semantics v2 reported (~160k on a heavy turn). Billing is untouched: ledger records keep the cumulative figure in `billedContextTokens`, and API-reported spend was never affected (v3 bills the same or less than v2 — the display was wrong, not the meter). The usage alert says "Context this turn" whenever the peak is available.
package/bot.js CHANGED
@@ -22,10 +22,10 @@ ensureRuntimeDirectories();
22
22
  const { acquireSingleInstanceLock } = require("./core/single-instance");
23
23
  const instanceLock = acquireSingleInstanceLock({ configDir: CONFIG_DIR });
24
24
  if (!instanceLock.acquired) {
25
- const h = instanceLock.holder;
25
+ const h = instanceLock.holder || {};
26
26
  console.error(
27
27
  `Another Open Claudia instance already owns ${CONFIG_DIR} ` +
28
- `(pid ${h.pid}, started ${h.startedAt || "?"}, ${h.entrypoint || "unknown entrypoint"}). Exiting. ` +
28
+ `(pid ${h.pid || "?"}, started ${h.startedAt || "?"}, ${h.entrypoint || "unknown entrypoint"}). Exiting. ` +
29
29
  "Stop it first (launchctl stop com.claude-telegram-bot), or give a dev run its own OPEN_CLAUDIA_CONFIG_DIR."
30
30
  );
31
31
  process.exit(1);
@@ -41,6 +41,8 @@ class TelegramAdapter {
41
41
  this._conflictSince = 0; // start of the current 409 streak
42
42
  this._conflictTimer = null; // pending paused retry after a 409
43
43
  this._conflictClearTimer = null; // quiet after a retry ⇒ streak over
44
+ this._conflictAlertAt = 0; // last owner alert about a rogue poller (throttle: 1/h)
45
+ this._conflictAlerted = false; // current streak already alerted ⇒ send the all-clear
44
46
  this._wireInbound();
45
47
  }
46
48
 
@@ -142,23 +144,31 @@ class TelegramAdapter {
142
144
  }, 30000);
143
145
  }
144
146
 
145
- // Telegram 409s a getUpdates call while ANOTHER one holds the token. Two
146
- // causes: a ghost long-poll left by an uncleanly-killed predecessor (clears
147
- // within its ≤30s poll timeout), or a genuinely running second instance.
148
- // Exiting on the first 409 turned the ghost case into a KeepAlive respawn
149
- // storm (7 boot→409→die cycles on 2026-07-12). So: pause, retry inside a
150
- // grace window, and exit only if the conflict outlives it that really is
151
- // a second instance, and the supervisor's throttled respawns pace the rest.
147
+ // Telegram 409s a getUpdates call while ANOTHER one holds the token: a
148
+ // ghost long-poll left by an uncleanly-killed predecessor (clears within
149
+ // its ≤30s poll timeout), or a genuinely running second poller. We hold
150
+ // the single-instance lock, so WE are the legitimate copy the rogue is
151
+ // lock-blind (a pre-lock process, a checkout on another CONFIG_DIR, or
152
+ // another host on the same token). Exiting here just handed launchd a
153
+ // respawn into the same fight (the 5-boot storm of 2026-07-12), so we
154
+ // never exit: pause, retry, and once the streak outlives the ghost window
155
+ // slow to 60s retries and alert the owner (max 1/hour) until it clears.
152
156
  _on409Conflict() {
153
157
  const now = Date.now();
154
158
  if (!this._conflictSince) this._conflictSince = now;
155
159
  if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
156
- if (now - this._conflictSince > 120000) {
157
- console.error("Another instance is polling (409 persisted >2min). Exiting.");
158
- process.exit(1);
160
+ const persisted = now - this._conflictSince > 120000;
161
+ if (persisted && this.ownerChatId && now - this._conflictAlertAt > 3600000) {
162
+ this._conflictAlertAt = now;
163
+ this._conflictAlerted = true;
164
+ Promise.resolve(this.bot.sendMessage(
165
+ this.ownerChatId,
166
+ "⚠️ Another poller has held my Telegram token for over 2 minutes — likely a dev checkout, a debugger run, or another host using this token. I hold the instance lock, so I'm staying up and retrying every 60s; messages may lag until it stops.",
167
+ )).catch(() => {});
159
168
  }
160
169
  if (this._conflictTimer) return; // a paused retry is already queued
161
- console.error("409 Conflict: another poller holds this token — pausing 15s (ghost polls clear in <1min; exiting only if this persists >2min).");
170
+ const pauseMs = persisted ? 60000 : 15000;
171
+ console.error(`409 Conflict: another poller holds this token — pausing ${Math.round(pauseMs / 1000)}s, then retrying (we hold the instance lock; never exiting).`);
162
172
  // Stop hammering now; the timer below dials back in.
163
173
  Promise.resolve(this.bot.stopPolling({ cancel: true, reason: "409 conflict backoff" })).catch(() => {});
164
174
  this._conflictTimer = setTimeout(async () => {
@@ -168,8 +178,14 @@ class TelegramAdapter {
168
178
  this._conflictClearTimer = setTimeout(() => {
169
179
  this._conflictClearTimer = null;
170
180
  this._conflictSince = 0;
181
+ if (this._conflictAlerted) {
182
+ this._conflictAlerted = false;
183
+ if (this.ownerChatId) {
184
+ Promise.resolve(this.bot.sendMessage(this.ownerChatId, "✅ The competing Telegram poller is gone — normal polling resumed.")).catch(() => {});
185
+ }
186
+ }
171
187
  }, 45000);
172
- }, 15000);
188
+ }, pauseMs);
173
189
  }
174
190
 
175
191
  _wireInbound() {
package/core/jobs.js CHANGED
@@ -46,6 +46,14 @@ function clone(value) {
46
46
  return value === undefined ? undefined : structuredClone(value);
47
47
  }
48
48
 
49
+ // Number(null) is 0, so a bare Number.isFinite gate rewrites stored nulls to
50
+ // epoch timestamps on every re-migration pass.
51
+ function finiteTimeOrNull(value) {
52
+ if (value === null || value === undefined) return null;
53
+ const number = Number(value);
54
+ return Number.isFinite(number) ? number : null;
55
+ }
56
+
49
57
  function occurrenceId(job, options = {}) {
50
58
  if (nonEmpty(job.occurrenceId)) return nonEmpty(job.occurrenceId);
51
59
  const factory = options.occurrenceIdFactory || (() => (
@@ -155,9 +163,9 @@ function normalizeRunnableJob(record, options = {}) {
155
163
  disabled,
156
164
  attemptCount,
157
165
  maxAttempts,
158
- nextAttemptAt: Number.isFinite(Number(record.nextAttemptAt)) ? Number(record.nextAttemptAt) : null,
166
+ nextAttemptAt: finiteTimeOrNull(record.nextAttemptAt),
159
167
  createdAt,
160
- lastFireAt: Number.isFinite(Number(record.lastFireAt)) ? Number(record.lastFireAt) : null,
168
+ lastFireAt: finiteTimeOrNull(record.lastFireAt),
161
169
  lastFireOk: typeof record.lastFireOk === "boolean" ? record.lastFireOk : null,
162
170
  lastError: isRecord(record.lastError) ? clone(record.lastError) : null,
163
171
  };
@@ -169,6 +177,66 @@ function normalizeRunnableJob(record, options = {}) {
169
177
  return normalized;
170
178
  }
171
179
 
180
+ function readLegacyStateUsers(options = {}) {
181
+ if (options._legacyStateUsers !== undefined) return options._legacyStateUsers;
182
+ let users = null;
183
+ try {
184
+ if (!options.stateFile) throw new Error("no state file wired");
185
+ const raw = JSON.parse(fs.readFileSync(options.stateFile, "utf8"));
186
+ if (isRecord(raw)) {
187
+ // v3 state is { schemaVersion, users: { "<canonicalUserId>": {...} } };
188
+ // the pre-v3 file was one global user state at the top level.
189
+ users = isRecord(raw.users) ? raw.users : { "": raw };
190
+ }
191
+ } catch (_) {}
192
+ options._legacyStateUsers = users;
193
+ return users;
194
+ }
195
+
196
+ function legacyStateDefaultsFor(record, options = {}) {
197
+ const users = readLegacyStateUsers(options);
198
+ if (!users) return null;
199
+ const canonicalUserId = nonEmpty(record.canonicalUserId) || nonEmpty(options.defaultCanonicalUserId);
200
+ const user = (canonicalUserId && isRecord(users[canonicalUserId]) ? users[canonicalUserId] : null)
201
+ || (isRecord(users[""]) ? users[""] : null);
202
+ if (!user) return null;
203
+ const provider = nonEmpty(user.settings?.backend)?.toLowerCase() || null;
204
+ const session = isRecord(user.currentSession) ? user.currentSession : {};
205
+ return {
206
+ provider: provider && RUNNABLE_PROVIDERS.has(provider) ? provider : null,
207
+ project: nonEmpty(session.name),
208
+ projectDir: nonEmpty(session.dir),
209
+ };
210
+ }
211
+
212
+ // Pre-provider-aware jobs fired with whatever provider and project the bot had
213
+ // active at fire time. Adopting the owning user's saved defaults preserves
214
+ // that behaviour instead of archive-disabling every legacy cron. Jobs pinned
215
+ // to a removed provider (Cursor) keep their pin and still archive.
216
+ function applyLegacyDefaults(record, options = {}) {
217
+ if (!isRecord(record)) return record;
218
+ const provider = legacyProvider(record);
219
+ if (provider === "cursor") return record;
220
+ const hasProvider = !!provider;
221
+ const hasProject = !!nonEmpty(record.project);
222
+ if (hasProvider && hasProject) return record;
223
+ const defaults = legacyStateDefaultsFor(record, options);
224
+ if (!defaults) return record;
225
+ const next = clone(record);
226
+ let applied = false;
227
+ if (!hasProvider && defaults.provider) {
228
+ next.provider = defaults.provider;
229
+ applied = true;
230
+ }
231
+ if (!hasProject && defaults.project) {
232
+ next.project = defaults.project;
233
+ if (!nonEmpty(next.projectDir) && defaults.projectDir) next.projectDir = defaults.projectDir;
234
+ applied = true;
235
+ }
236
+ if (applied) next.legacyDefaultsApplied = true;
237
+ return next;
238
+ }
239
+
172
240
  function migrationReason(record, options = {}) {
173
241
  const provider = legacyProvider(record);
174
242
  if (provider === "cursor") return "Pinned to removed provider Cursor Agent";
@@ -194,16 +262,18 @@ function normalizeArchived(record, options = {}) {
194
262
  }
195
263
 
196
264
  function migrateJobsDocument(raw, options = {}) {
197
- const source = isRecord(raw) && raw.schemaVersion === JOBS_SCHEMA_VERSION
198
- ? raw
199
- : { jobs: Array.isArray(raw) ? raw : [], archived: [] };
265
+ const legacySource = !(isRecord(raw) && raw.schemaVersion === JOBS_SCHEMA_VERSION);
266
+ const source = legacySource
267
+ ? { jobs: Array.isArray(raw) ? raw : [], archived: [] }
268
+ : raw;
200
269
  const jobs = [];
201
270
  const archived = [];
202
271
 
203
272
  for (const record of Array.isArray(source.jobs) ? source.jobs : []) {
204
- const normalized = normalizeRunnableJob(record, options);
273
+ const candidate = legacySource ? applyLegacyDefaults(record, options) : record;
274
+ const normalized = normalizeRunnableJob(candidate, options);
205
275
  if (normalized) jobs.push(normalized);
206
- else archived.push(archiveLegacyJob(record, migrationReason(record, options), options));
276
+ else archived.push(archiveLegacyJob(candidate, migrationReason(candidate, options), options));
207
277
  }
208
278
  for (const record of Array.isArray(source.archived) ? source.archived : []) {
209
279
  const normalized = normalizeArchived(record, options);
@@ -281,6 +351,7 @@ function createJobsStore(options = {}) {
281
351
  defaultChannelId: defaults.channelId,
282
352
  defaultAdapter: defaults.adapter,
283
353
  defaultAdapterType: defaults.adapterType,
354
+ stateFile: options.stateFile || path.join(configDir, path.basename(STATE_FILE)),
284
355
  now: options.now,
285
356
  occurrenceIdFactory: options.occurrenceIdFactory,
286
357
  });
@@ -107,8 +107,13 @@ function assertRegularFile(filePath, code, label) {
107
107
  return stat;
108
108
  }
109
109
 
110
- function assertPrivateMode(stat, expectedMask, code, label) {
111
- if (process.platform !== "win32" && (stat.mode & 0o777) !== expectedMask) {
110
+ function assertPrivateMode(stat, code, label) {
111
+ // Kubernetes fsGroup ownership management ORs group rw(x) + setgid onto
112
+ // every file and directory each time the volume is mounted, so an exact
113
+ // owner-only mode check crashloops the second boot on any fsGroup-mounted
114
+ // PVC. Group access is the pod's own supplemental group; only world access
115
+ // is a real exposure.
116
+ if (process.platform !== "win32" && (stat.mode & 0o007) !== 0) {
112
117
  throw migrationError(code, `${label} has unsafe permissions`);
113
118
  }
114
119
  }
@@ -596,18 +601,18 @@ function validateMigrationSnapshot(snapshotDir, options = {}) {
596
601
  if (!snapshotStat.isDirectory() || snapshotStat.isSymbolicLink()) {
597
602
  throw migrationError("INVALID_MIGRATION_SNAPSHOT", "migration snapshot must be a real directory");
598
603
  }
599
- assertPrivateMode(snapshotStat, 0o700, "INVALID_MIGRATION_SNAPSHOT", "migration snapshot");
604
+ assertPrivateMode(snapshotStat, "INVALID_MIGRATION_SNAPSHOT", "migration snapshot");
600
605
  const originalsStat = fs.lstatSync(path.join(resolvedSnapshot, "originals"));
601
606
  if (!originalsStat.isDirectory() || originalsStat.isSymbolicLink()) {
602
607
  throw migrationError("INVALID_MIGRATION_SNAPSHOT", "migration originals directory is invalid");
603
608
  }
604
- assertPrivateMode(originalsStat, 0o700, "INVALID_MIGRATION_SNAPSHOT", "migration originals directory");
609
+ assertPrivateMode(originalsStat, "INVALID_MIGRATION_SNAPSHOT", "migration originals directory");
605
610
  const manifestPath = path.join(resolvedSnapshot, MANIFEST_FILE);
606
611
  const hashPath = path.join(resolvedSnapshot, MANIFEST_HASH_FILE);
607
612
  const manifestStat = assertRegularFile(manifestPath, "INVALID_MIGRATION_MANIFEST", "migration manifest");
608
613
  const manifestHashStat = assertRegularFile(hashPath, "INVALID_MIGRATION_MANIFEST", "migration manifest checksum");
609
- assertPrivateMode(manifestStat, 0o600, "INVALID_MIGRATION_MANIFEST", "migration manifest");
610
- assertPrivateMode(manifestHashStat, 0o600, "INVALID_MIGRATION_MANIFEST", "migration manifest checksum");
614
+ assertPrivateMode(manifestStat, "INVALID_MIGRATION_MANIFEST", "migration manifest");
615
+ assertPrivateMode(manifestHashStat, "INVALID_MIGRATION_MANIFEST", "migration manifest checksum");
611
616
  const manifestBytes = fs.readFileSync(manifestPath);
612
617
  const expectedManifestHash = fs.readFileSync(hashPath, "utf8").trim();
613
618
  if (!/^[a-f0-9]{64}$/.test(expectedManifestHash) || sha256(manifestBytes) !== expectedManifestHash) {
@@ -625,7 +630,7 @@ function validateMigrationSnapshot(snapshotDir, options = {}) {
625
630
  }
626
631
  if (!fs.existsSync(filePath)) throw migrationError("BACKUP_CHECKSUM_MISMATCH", `backup file is missing: ${entry.path}`);
627
632
  const stat = assertRegularFile(filePath, "BACKUP_CHECKSUM_MISMATCH", `backup file ${entry.path}`);
628
- assertPrivateMode(stat, 0o600, "BACKUP_CHECKSUM_MISMATCH", `backup file ${entry.path}`);
633
+ assertPrivateMode(stat, "BACKUP_CHECKSUM_MISMATCH", `backup file ${entry.path}`);
629
634
  const bytes = fs.readFileSync(filePath);
630
635
  if (stat.size !== entry.size || bytes.length !== entry.size || sha256(bytes) !== entry.sha256) {
631
636
  throw migrationError("BACKUP_CHECKSUM_MISMATCH", `backup checksum verification failed: ${entry.path}`);
@@ -17,30 +17,46 @@ function pidAlive(pid) {
17
17
 
18
18
  function acquireSingleInstanceLock({ configDir, name = "bot" }) {
19
19
  const lockPath = path.join(configDir, `${name}.lock`);
20
- try {
21
- const prev = JSON.parse(fs.readFileSync(lockPath, "utf8"));
22
- if (prev && prev.pid && prev.pid !== process.pid && pidAlive(prev.pid)) {
23
- return { acquired: false, holder: prev, lockPath };
24
- }
25
- // Missing, corrupt, own-pid, or dead-pid (stale after SIGKILL): claim it.
26
- } catch (e) {}
27
- fs.writeFileSync(lockPath, JSON.stringify({
20
+ const payload = JSON.stringify({
28
21
  pid: process.pid,
29
22
  startedAt: new Date().toISOString(),
30
23
  entrypoint: process.argv[1] || "",
31
- }));
32
- let released = false;
33
- const release = () => {
34
- if (released) return;
35
- released = true;
24
+ });
25
+ // O_EXCL creation is the only atomic claim the filesystem offers. The old
26
+ // read-check-then-write had a TOCTOU hole: two processes booting together
27
+ // (launchd respawn racing a manual start) could both read "stale" and both
28
+ // write, each believing it held the lock. Now a stale/corrupt lock is
29
+ // unlinked and the claim retried; only a wx-create that succeeds counts.
30
+ for (let attempt = 0; attempt < 5; attempt++) {
36
31
  try {
37
- const cur = JSON.parse(fs.readFileSync(lockPath, "utf8"));
38
- // Never delete a lock a successor already owns.
39
- if (cur && cur.pid === process.pid) fs.unlinkSync(lockPath);
40
- } catch (e) {}
41
- };
42
- process.on("exit", release);
43
- return { acquired: true, lockPath, release };
32
+ fs.writeFileSync(lockPath, payload, { flag: "wx" });
33
+ let released = false;
34
+ const release = () => {
35
+ if (released) return;
36
+ released = true;
37
+ try {
38
+ const cur = JSON.parse(fs.readFileSync(lockPath, "utf8"));
39
+ // Never delete a lock a successor already owns.
40
+ if (cur && cur.pid === process.pid) fs.unlinkSync(lockPath);
41
+ } catch (e) {}
42
+ };
43
+ process.on("exit", release);
44
+ return { acquired: true, lockPath, release };
45
+ } catch (e) {
46
+ if (e.code !== "EEXIST") throw e;
47
+ }
48
+ let prev = null;
49
+ try { prev = JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch (e) {}
50
+ if (prev && prev.pid && prev.pid !== process.pid && pidAlive(prev.pid)) {
51
+ return { acquired: false, holder: prev, lockPath };
52
+ }
53
+ // Corrupt, own-pid, or dead-pid (stale after SIGKILL): clear and retry.
54
+ try { fs.unlinkSync(lockPath); } catch (e) {}
55
+ }
56
+ // Five claim attempts lost to contenders — treat it as someone else's lock.
57
+ let holder = null;
58
+ try { holder = JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch (e) {}
59
+ return { acquired: false, holder, lockPath };
44
60
  }
45
61
 
46
62
  module.exports = { acquireSingleInstanceLock, pidAlive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -124,6 +124,9 @@ async function providerProbe(kind) {
124
124
  const { createUtilityAgentService } = require("./core/utility-agent");
125
125
  const utility = createUtilityAgentService({
126
126
  registry: registryFor(provider),
127
+ // This probe models a global/nightly dream with no active chat. Do not let
128
+ // the parent Open Claudia process's OC_PROVIDER leak into provider choice.
129
+ activeProvider: () => null,
127
130
  env: {
128
131
  UTILITY_PROVIDER: "active",
129
132
  DEFAULT_PROVIDER: kind,
@@ -32,10 +32,11 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.2 approved by Sumeet 2026-07-12 ("Yeh do it and push it out" — peak
36
- // context metric restored, Telegram long-message chunking so dream summaries
37
- // deliver; CHANGELOG documents it as v3.0.2).
38
- assert.strictEqual(pkg.version, "3.0.2", "release version must remain unchanged without explicit approval");
35
+ // 3.0.4 approved by Sumeet 2026-07-12 ("1 yes it's important for that Cron to
36
+ // work 2. Same" + "Ok do it" — fsGroup-tolerant snapshot validation so v3 pods
37
+ // stop crashlooping on PVC remounts, and legacy cron migration adopts the
38
+ // owning user's saved provider/project defaults instead of archive-disabling).
39
+ assert.strictEqual(pkg.version, "3.0.4", "release version must remain unchanged without explicit approval");
39
40
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
40
41
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
41
42
  }
@@ -378,6 +378,52 @@ function sourceChangeProbe() {
378
378
  }
379
379
  }
380
380
 
381
+ function fsGroupToleranceProbe() {
382
+ if (process.platform === "win32") return;
383
+ const root = tempRoot("fsgroup");
384
+ try {
385
+ const configDir = installFixture("claude-only", root);
386
+ const opts = options(configDir);
387
+ const snapshot = ensureMigrationSnapshot(opts);
388
+
389
+ // Simulate the kubelet fsGroup ownership pass: group rwX + setgid ORed
390
+ // onto every directory and group rw onto every file at each pod mount.
391
+ const applyFsGroup = (target) => {
392
+ const stat = fs.lstatSync(target);
393
+ if (stat.isDirectory()) {
394
+ fs.chmodSync(target, (stat.mode & 0o777) | 0o2070);
395
+ for (const entry of fs.readdirSync(target)) applyFsGroup(path.join(target, entry));
396
+ } else {
397
+ fs.chmodSync(target, (stat.mode & 0o777) | 0o060);
398
+ }
399
+ };
400
+ applyFsGroup(snapshot.snapshotDir);
401
+ assert.strictEqual(fs.statSync(snapshot.snapshotDir).mode & 0o7777, 0o2770);
402
+
403
+ const validated = validateMigrationSnapshot(snapshot.snapshotDir, { sources: opts.sources, verifySources: true });
404
+ assert.strictEqual(validated.ok, true, "group bits from a k8s fsGroup mount must not invalidate the snapshot");
405
+ const reused = ensureMigrationSnapshot(opts);
406
+ assert.strictEqual(reused.reused, true, "fsGroup-touched snapshot is reused at boot, not rejected");
407
+
408
+ fs.chmodSync(manifestFile(snapshot.snapshotDir), 0o664);
409
+ assert.throws(
410
+ () => validateMigrationSnapshot(snapshot.snapshotDir),
411
+ (error) => error.code === "INVALID_MIGRATION_MANIFEST" && /unsafe permissions/.test(error.message),
412
+ "world-readable manifest must still be rejected",
413
+ );
414
+ fs.chmodSync(manifestFile(snapshot.snapshotDir), 0o660);
415
+
416
+ fs.chmodSync(snapshot.snapshotDir, 0o2775);
417
+ assert.throws(
418
+ () => validateMigrationSnapshot(snapshot.snapshotDir),
419
+ (error) => error.code === "INVALID_MIGRATION_SNAPSHOT" && /unsafe permissions/.test(error.message),
420
+ "world-accessible snapshot dir must still be rejected",
421
+ );
422
+ } finally {
423
+ fs.rmSync(root, { recursive: true, force: true });
424
+ }
425
+ }
426
+
381
427
  function integrationAndDocsProbe() {
382
428
  const bot = fs.readFileSync(path.join(__dirname, "bot.js"), "utf8");
383
429
  const scheduler = fs.readFileSync(path.join(__dirname, "core", "scheduler.js"), "utf8");
@@ -420,5 +466,6 @@ activationProbe();
420
466
  rollbackProbe();
421
467
  sourceChangeProbe();
422
468
  rawByteProbe();
469
+ fsGroupToleranceProbe();
423
470
  integrationAndDocsProbe();
424
471
  console.log("provider migration backup OK");
@@ -84,6 +84,90 @@ function migrationProbe(jobsApi) {
84
84
  assert.deepStrictEqual(rerun, cursor, "jobs migration must be idempotent");
85
85
  }
86
86
 
87
+ function legacyDefaultsProbe(jobsApi) {
88
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-legacy-defaults-"));
89
+ try {
90
+ const baseDefaults = {
91
+ defaultCanonicalUserId: "telegram:fixture",
92
+ defaultChannelId: "fixture-channel",
93
+ defaultAdapter: "telegram-fixture",
94
+ defaultAdapterType: "telegram",
95
+ now: () => 1_720_000_000_000,
96
+ occurrenceIdFactory: (job) => `occ-${job.id}`,
97
+ };
98
+ const legacyCron = {
99
+ id: "legacy-orphan-cron",
100
+ kind: "cron",
101
+ schedule: "0 */6 * * *",
102
+ prompt: "run the production sync",
103
+ canonicalUserId: "telegram:100",
104
+ channelId: "chan-100",
105
+ adapter: "telegram-fixture",
106
+ adapterType: "telegram",
107
+ };
108
+
109
+ const v3State = path.join(temp, "state-v3.json");
110
+ fs.writeFileSync(v3State, JSON.stringify({
111
+ schemaVersion: 3,
112
+ users: {
113
+ "telegram:100": {
114
+ settings: { backend: "codex" },
115
+ currentSession: { name: "data-sync-runner", dir: "/workspace/data-sync-runner" },
116
+ },
117
+ },
118
+ }));
119
+ const adopted = jobsApi.migrateJobsDocument([{ ...legacyCron }], { ...baseDefaults, stateFile: v3State });
120
+ assert.deepStrictEqual(adopted.archived, [], "legacy cron with saved user defaults must stay runnable");
121
+ assert.strictEqual(adopted.jobs.length, 1);
122
+ assert.strictEqual(adopted.jobs[0].provider, "codex");
123
+ assert.strictEqual(adopted.jobs[0].project, "data-sync-runner");
124
+ assert.strictEqual(adopted.jobs[0].projectDir, "/workspace/data-sync-runner");
125
+ assert.strictEqual(adopted.jobs[0].legacyDefaultsApplied, true);
126
+ assert.strictEqual(adopted.jobs[0].disabled, false);
127
+
128
+ const rerun = jobsApi.migrateJobsDocument(adopted, { ...baseDefaults, stateFile: v3State });
129
+ assert.deepStrictEqual(rerun, adopted, "legacy defaults adoption must be idempotent");
130
+
131
+ const pinned = jobsApi.migrateJobsDocument(
132
+ [{ ...legacyCron, id: "legacy-provider-pinned", provider: "claude" }],
133
+ { ...baseDefaults, stateFile: v3State },
134
+ );
135
+ assert.strictEqual(pinned.jobs[0].provider, "claude", "explicit legacy provider must win over the saved default");
136
+ assert.strictEqual(pinned.jobs[0].project, "data-sync-runner");
137
+ assert.strictEqual(pinned.jobs[0].legacyDefaultsApplied, true);
138
+
139
+ const v2State = path.join(temp, "state-v2.json");
140
+ fs.writeFileSync(v2State, JSON.stringify({
141
+ settings: { backend: "claude" },
142
+ currentSession: { name: "alpha", dir: "/workspace/alpha" },
143
+ }));
144
+ const globalShape = jobsApi.migrateJobsDocument([{ ...legacyCron }], { ...baseDefaults, stateFile: v2State });
145
+ assert.strictEqual(globalShape.jobs.length, 1, "pre-v3 global state shape must supply defaults");
146
+ assert.strictEqual(globalShape.jobs[0].provider, "claude");
147
+ assert.strictEqual(globalShape.jobs[0].project, "alpha");
148
+ assert.strictEqual(globalShape.jobs[0].legacyDefaultsApplied, true);
149
+
150
+ const cursor = jobsApi.migrateJobsDocument(
151
+ [{ ...legacyCron, id: "legacy-cursor-cron", sessionKey: "cursorSessionId" }],
152
+ { ...baseDefaults, stateFile: v3State },
153
+ );
154
+ assert.deepStrictEqual(cursor.jobs, [], "Cursor-pinned jobs must not adopt defaults");
155
+ assert.strictEqual(cursor.archived[0].provider, "cursor");
156
+ assert.strictEqual(cursor.archived[0].project, null, "archived Cursor evidence must stay pristine");
157
+ assert.ok(!cursor.archived[0].legacyDefaultsApplied);
158
+ assert.match(cursor.archived[0].archiveReason, /removed provider/i);
159
+
160
+ const noState = jobsApi.migrateJobsDocument(
161
+ [{ ...legacyCron }],
162
+ { ...baseDefaults, stateFile: path.join(temp, "missing.json") },
163
+ );
164
+ assert.deepStrictEqual(noState.jobs, [], "without saved defaults the legacy cron still archives");
165
+ assert.match(noState.archived[0].archiveReason, /provider/i);
166
+ } finally {
167
+ fs.rmSync(temp, { recursive: true, force: true });
168
+ }
169
+ }
170
+
87
171
  function migrationIntegrationProbe(jobsApi, migrationApi) {
88
172
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-jobs-migration-"));
89
173
  try {
@@ -669,6 +753,7 @@ async function main() {
669
753
  assert.strictEqual(typeof schedulerApi.createSchedulerService, "function");
670
754
 
671
755
  migrationProbe(jobsApi);
756
+ legacyDefaultsProbe(jobsApi);
672
757
  migrationIntegrationProbe(jobsApi, migrationApi);
673
758
  await immutableTerminalProbe(schedulerApi.createSchedulerService);
674
759
  await duplicateFireProbe(schedulerApi.createSchedulerService);