@inetafrica/open-claudia 3.4.24 → 3.5.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/core/scheduler.js CHANGED
@@ -73,6 +73,74 @@ function uniqueId(prefix = "occ") {
73
73
  : `${prefix}-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`;
74
74
  }
75
75
 
76
+ // ── watch-check environment ────────────────────────────────────────────────
77
+ // A watch's `check` runs a shell command in the bot's process tree. Whose
78
+ // environment it gets depends on who set the watch:
79
+ // • a staff member's check gets an ALLOWLIST — the shell essentials and the
80
+ // Open Claudia config dir, nothing else — plus OC_GUARDED_RUN=1 so hooks
81
+ // and CLIs below it know they run for a non-owner. A denylist was not
82
+ // enough: any credential whose name matched no pattern (DATABASE_URL,
83
+ // MONGO_URI, …) reached a staff check (Phase 0 review, MEDIUM);
84
+ // • the owner's check keeps the bot's environment as it always had, so an
85
+ // owner watch that reads a credential from env keeps working.
86
+ // Either way a short-lived, creator-scoped run is stamped on top (below) so
87
+ // the check's reusable tools attribute to whoever created the watch.
88
+ const WATCH_CHECK_ENV_KEEP = new Set([
89
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL",
90
+ "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
91
+ "TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR",
92
+ "TMPDIR", "TMP", "TEMP",
93
+ "OPEN_CLAUDIA_CONFIG_DIR",
94
+ "SystemRoot", "ComSpec", "PATHEXT", "WINDIR",
95
+ ]);
96
+ function buildWatchCheckEnv(source, stamp = {}, { owner = false } = {}) {
97
+ const env = {};
98
+ for (const [key, value] of Object.entries(source || {})) {
99
+ if (value == null) continue;
100
+ if (!owner && !WATCH_CHECK_ENV_KEEP.has(key)) continue;
101
+ env[key] = String(value);
102
+ }
103
+ for (const [key, value] of Object.entries(stamp)) {
104
+ if (value == null || value === "") continue;
105
+ env[key] = String(value);
106
+ }
107
+ if (owner) delete env.OC_GUARDED_RUN;
108
+ else env.OC_GUARDED_RUN = "1";
109
+ return env;
110
+ }
111
+
112
+ // The principal a watch-check's short-lived run acts for: the job's creator,
113
+ // resolved from its stored channel/user the same way runPrincipal resolves a
114
+ // turn's speaker. Owner → the check attributes as the owner (unchanged); a
115
+ // staff creator → the check runs at their level. Fail closed to non-owner.
116
+ function watchCheckPrincipal(job) {
117
+ const adapterType = job && job.adapterType ? String(job.adapterType) : null;
118
+ const channelId = job && job.channelId != null ? String(job.channelId) : null;
119
+ const userId = job && job.userId != null ? String(job.userId) : null;
120
+ let owner = false;
121
+ let name = null;
122
+ let room = null;
123
+ if (adapterType && channelId) {
124
+ try {
125
+ const sp = require("./relationship").speakerFor(adapterType, channelId, userId);
126
+ owner = !!(sp && sp.isOwner === true);
127
+ name = (sp && sp.name) || null;
128
+ room = (sp && sp.room) || null;
129
+ } catch (_) { owner = false; }
130
+ }
131
+ return {
132
+ kind: "watch-check",
133
+ owner,
134
+ canonicalUserId: job && job.canonicalUserId ? String(job.canonicalUserId) : null,
135
+ name,
136
+ adapterId: job && job.adapter ? String(job.adapter) : null,
137
+ adapterType,
138
+ channelId,
139
+ userId,
140
+ room,
141
+ };
142
+ }
143
+
76
144
  function parseFallbacks(value) {
77
145
  if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim().toLowerCase()).filter(Boolean))];
78
146
  return [...new Set(String(value || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean))];
@@ -155,23 +223,50 @@ function createSchedulerService(dependencies = {}) {
155
223
  const timers = new Map();
156
224
  const checksInFlight = new Set();
157
225
  const firesInFlight = new Set();
226
+ // Jobs already on disk before the creator-binding fix carry no `createdBy`.
227
+ // We keep running them AS THEY ARE (their stored channel/user is what the
228
+ // fire path resolves the speaker from — the owner's own watches and wakeups
229
+ // must not silently break), but log the first time each fires so an audit
230
+ // can spot a pre-fix job that outranks a non-owner. Logged once per id/boot.
231
+ const loggedLegacyCreators = new Set();
232
+ function noteLegacyCreatorOnce(job) {
233
+ if (!job || job.createdBy || loggedLegacyCreators.has(job.id)) return;
234
+ loggedLegacyCreators.add(job.id);
235
+ try {
236
+ console.warn(`[scheduler] job ${job.id} (${job.kind}${job.check ? "/watch" : ""}) predates creator binding — running with its stored identity ${job.canonicalUserId || "?"} in ${job.adapterType || "?"}:${job.channelId || "?"}`);
237
+ } catch (_) { /* logging is best-effort */ }
238
+ }
158
239
  // Poll a watch's `check`: exit 0 → the watched condition is met, wake the
159
240
  // agent; non-zero → still idle, skip silently. Runs in the job's project
160
- // dir with the process env, output discarded (exit code is the contract),
241
+ // dir with its creator's env (buildWatchCheckEnv: an allowlist for staff)
242
+ // and a short-lived run is registered for the job's creator so reusable
243
+ // tools attribute correctly — output discarded (exit code is the contract),
161
244
  // hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
162
245
  const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
163
246
  const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
247
+ const attest = require("./speaker-attest");
248
+ const checkRunId = uniqueId("watch-check");
249
+ const principal = watchCheckPrincipal(job);
250
+ let runAttest = "";
251
+ try { runAttest = attest.registerRun(checkRunId, principal); } catch (_) { runAttest = ""; }
252
+ let lb = null;
253
+ try { lb = require("./loopback").info(); } catch (_) { lb = null; }
254
+ const stamp = { OC_RUN_ID: checkRunId, OC_RUN_ATTEST: runAttest };
255
+ if (lb && lb.url) { stamp.OC_SEND_URL = lb.url; stamp.OC_SEND_TOKEN = lb.token; }
256
+ const env = buildWatchCheckEnv(process.env, stamp, { owner: principal.owner === true });
164
257
  let settled = false;
165
258
  const finish = (code, timedOut) => {
166
259
  if (settled) return;
167
260
  settled = true;
168
261
  try { clearTimer(timer); } catch (_) {}
262
+ try { attest.endRun(checkRunId); } catch (_) {}
169
263
  resolve({ code, timedOut: !!timedOut });
170
264
  };
171
265
  let child;
172
266
  try {
173
- child = spawn("sh", ["-c", String(command)], { cwd, env: process.env, stdio: "ignore" });
267
+ child = spawn("sh", ["-c", String(command)], { cwd, env, stdio: "ignore" });
174
268
  } catch (error) {
269
+ try { attest.endRun(checkRunId); } catch (_) {}
175
270
  return resolve({ code: 127, timedOut: false });
176
271
  }
177
272
  const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
@@ -624,6 +719,7 @@ function createSchedulerService(dependencies = {}) {
624
719
  };
625
720
 
626
721
  return runInChat(chat, async () => {
722
+ noteLegacyCreatorOnce(job);
627
723
  const state = stateForUser(canonicalUserId);
628
724
  if (state.runningProcess || state.preparingRun || state.isCompacting) {
629
725
  try { return markBusy(job); }
@@ -974,6 +1070,8 @@ module.exports = {
974
1070
  createSchedulerService,
975
1071
  parseFallbacks,
976
1072
  terminalRunSucceeded,
1073
+ buildWatchCheckEnv,
1074
+ watchCheckPrincipal,
977
1075
  setAdapters,
978
1076
  initScheduler,
979
1077
  scheduleJob: service.scheduleJob,
@@ -53,4 +53,85 @@ function verify(canonicalUserId, attest) {
53
53
  try { return crypto.timingSafeEqual(a, b); } catch (e) { return false; }
54
54
  }
55
55
 
56
- module.exports = { mint, verify };
56
+ // ── Run attestation ─────────────────────────────────────────────────────────
57
+ // The id attest above says "the runner stamped this canonical id". It is not
58
+ // bound to a run: a copy lifted from another process's environment stays valid
59
+ // for the whole boot. And the CLIs that decided the speaker locally (tool,
60
+ // keyring, entity) read OC_CHANNEL_* from their own env, so a shell that
61
+ // simply unset or rewrote those variables was treated as the owner.
62
+ //
63
+ // A run attestation binds a LIVE run to the principal it acts for, decided in
64
+ // the bot process: the runner registers every agent run it spawns
65
+ // (runId -> principal) and stamps OC_RUN_ID + OC_RUN_ATTEST into the run's
66
+ // env; the loopback answers "who is this?" only for a registered, still-live
67
+ // run whose attest matches. Anything else (no attest, a wrong one, a run that
68
+ // has ended, a detached process that outlived its turn) is UNATTRIBUTED, and
69
+ // unattributed never carries owner powers.
70
+ const RUN_TTL_MS = 12 * 60 * 60 * 1000;
71
+ const runs = new Map();
72
+
73
+ function runSignature(runId) {
74
+ return crypto.createHmac("sha256", secret()).update(`run\0${runId}`).digest("hex");
75
+ }
76
+
77
+ function principalOf(principal = {}) {
78
+ // Bot-internal work with no speaker (compaction, owner jobs) acts for the
79
+ // owner; a run is guarded exactly when it acts for someone who is not.
80
+ const owner = principal.owner === true;
81
+ const text = (value) => (value == null || value === "" ? null : String(value));
82
+ return {
83
+ kind: text(principal.kind) || "turn",
84
+ owner,
85
+ guarded: !owner,
86
+ canonicalUserId: text(principal.canonicalUserId),
87
+ name: text(principal.name),
88
+ adapterId: text(principal.adapterId),
89
+ adapterType: text(principal.adapterType),
90
+ channelId: text(principal.channelId),
91
+ userId: text(principal.userId),
92
+ room: text(principal.room),
93
+ purpose: text(principal.purpose),
94
+ };
95
+ }
96
+
97
+ function pruneRuns(now = Date.now()) {
98
+ for (const [id, record] of runs) {
99
+ if (now - record.registeredAt > RUN_TTL_MS) runs.delete(id);
100
+ }
101
+ }
102
+
103
+ // Returns the attest to stamp as OC_RUN_ATTEST. Re-registering a run (a retry
104
+ // of the same runId) replaces its principal.
105
+ function registerRun(runId, principal = {}) {
106
+ const id = String(runId || "");
107
+ if (!id) return "";
108
+ pruneRuns();
109
+ runs.set(id, { ...principalOf(principal), runId: id, registeredAt: Date.now() });
110
+ return runSignature(id);
111
+ }
112
+
113
+ function endRun(runId) {
114
+ runs.delete(String(runId || ""));
115
+ }
116
+
117
+ // The principal of a live run whose attest matches, or null (fail closed).
118
+ function verifyRun(runId, attest) {
119
+ const id = String(runId || "");
120
+ const sig = String(attest || "");
121
+ if (!id || !sig) return null;
122
+ const record = runs.get(id);
123
+ if (!record) return null;
124
+ if (Date.now() - record.registeredAt > RUN_TTL_MS) { runs.delete(id); return null; }
125
+ const a = Buffer.from(sig, "utf-8");
126
+ const b = Buffer.from(runSignature(id), "utf-8");
127
+ if (a.length !== b.length) return null;
128
+ try { if (!crypto.timingSafeEqual(a, b)) return null; } catch (e) { return null; }
129
+ return { ...record };
130
+ }
131
+
132
+ function liveRuns() {
133
+ pruneRuns();
134
+ return Array.from(runs.values(), (record) => ({ ...record }));
135
+ }
136
+
137
+ module.exports = { mint, verify, registerRun, endRun, verifyRun, liveRuns, principalOf };