@inetafrica/open-claudia 3.0.35 → 3.0.37
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 +12 -0
- package/bin/cli.js +24 -0
- package/bin/schedule.js +71 -2
- package/core/actions.js +2 -2
- package/core/auth-flow.js +5 -3
- package/core/credential-detect.js +55 -0
- package/core/handlers.js +73 -0
- package/core/loopback.js +52 -0
- package/core/router.js +53 -9
- package/core/scheduler.js +105 -5
- package/core/system-prompt.js +6 -0
- package/package.json +3 -2
- package/test-credential-capture.js +109 -0
- package/test-provider-language.js +15 -9
- package/test-provider-scheduler.js +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.37 — Silent background watches, and pasted secrets are accepted on any provider
|
|
4
|
+
|
|
5
|
+
- **You can now arm a watch: a background poll that stays out of the chat and only wakes me when its condition is actually met.** A watch is just a cron carrying a shell `check` command — each tick runs the check with output discarded, and *only* an exit 0 wakes the agent (with a `🔔 Watch "<label>" triggered` notice and a normal reply). A non-zero exit is the idle case: nothing hits the chat, the poll's activity goes to an append-only `watch.log`, and the job stays pending for the next tick. Watches default to **one-shot** (fire once, then remove) so a matched condition can't spam you; add `--repeat` to keep polling. Create/manage from the CLI (`open-claudia watch-add "<check>" "<wake-prompt>" [--every "<cron>"] [--repeat]`, `watch-list`, `watch-remove`), and the owner can flip the global master switch with **`/watch on|off`** (watches are owner-gated because the check is raw shell). Rides the migration-verified jobs store untouched — `check`/`repeat` survive as extra fields, zero schema change.
|
|
6
|
+
- **Pasting a credential into the chat now stores it out-of-band regardless of which provider is active.** Codex refuses secrets in its prompt, so a pasted key under the Codex backend used to be rejected — and being told to re-paste it through keyring commands *in the same chat* was no safer and plain annoying. A loose paste is now classified (`core/credential-detect.js`) and captured **before** the turn reaches the model: a Claude OAuth token → `.env`+vault, an OpenAI key → handed to the Codex CLI, a recognised service token → the operational keyring, an ambiguous long token → scrubbed from chat. Every captured secret is registered for redaction and the original message deleted. The capture is provider-agnostic and never forwards the secret to any model.
|
|
7
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds FROM the existing `:base`. Covered by new `test-credential-capture.js` and watch probes in `test-provider-scheduler.js`. Existing pods pick this up on their next approved upgrade.
|
|
8
|
+
|
|
9
|
+
## v3.0.36 — Picking a Claude model no longer forces a re-login
|
|
10
|
+
|
|
11
|
+
- **Selecting any Claude model claimed Claude wasn't connected and started a login — even while Claude was working fine.** The model-button handler (and the `/login` picker) decided "connected?" from a shallow token probe (`getClaudeOAuthToken().value`) that only sees an OAuth token in `.env`, the process env, or an unlocked vault. Anyone whose Claude login lives in the Claude CLI's own credential store (the normal `claude` login, no bot token) therefore read as *disconnected*, so every model tap answered "Claude isn't connected yet. Starting login…". The re-login it launched (`claude auth login --claudeai`) never stores a bot token, so the shallow check stayed false and the prompt returned on the very next tap — an unbreakable loop.
|
|
12
|
+
- **Both surfaces now resolve Claude's real auth state via `claudeProviderAuthStatus()`** — the same probe agent runs rely on, which counts a bot OAuth token *and* a `claude auth status` CLI-store login as authenticated. A model tap only starts a login when Claude is genuinely unauthenticated, mirroring the Codex branch beside it. Codex was never affected (it already checked `codex login status`). Cost: one `claude auth status` (~1s) on a model tap when no bot token exists — fine for a settings screen, and skipped entirely when a bot token is present.
|
|
13
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds FROM the existing `:base`. Existing pods pick this up on their next approved upgrade.
|
|
14
|
+
|
|
3
15
|
## v3.0.35 — Spaces comments are readable rich text, not a run-on wall
|
|
4
16
|
|
|
5
17
|
- **The bot's Spaces replies rendered as unreadable walls.** `buildCommentContent` posted the entire reply as one text node inside one paragraph. Spaces comments are TipTap/ProseMirror docs: inside a single text node newlines don't render, so paragraphs, numbered lists and `---` separators collapsed into one run-on blob — and the model's markdown (`**bold**`, `*italic*`, backticks) printed its asterisks literally.
|
package/bin/cli.js
CHANGED
|
@@ -306,6 +306,26 @@ switch (command) {
|
|
|
306
306
|
require("./schedule").runCronRemove(args.slice(1));
|
|
307
307
|
break;
|
|
308
308
|
}
|
|
309
|
+
case "watch-add": {
|
|
310
|
+
require("./schedule").runWatchAdd(args.slice(1));
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "watch-list": {
|
|
314
|
+
require("./schedule").runWatchList();
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "watch-remove": {
|
|
318
|
+
require("./schedule").runWatchRemove(args.slice(1));
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "watch-on": {
|
|
322
|
+
require("./schedule").runWatchToggle(true);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case "watch-off": {
|
|
326
|
+
require("./schedule").runWatchToggle(false);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
309
329
|
|
|
310
330
|
case "task": {
|
|
311
331
|
require("./task").run(args.slice(1));
|
|
@@ -440,6 +460,10 @@ Background work (only inside an active bot-spawned task):
|
|
|
440
460
|
open-claudia cron-add "<sched>" "<prompt>" Recurring job pinned to the selected provider session
|
|
441
461
|
open-claudia cron-list List all wakeups + crons on this channel
|
|
442
462
|
open-claudia cron-remove <id> Remove a scheduled job
|
|
463
|
+
open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake only when it exits 0
|
|
464
|
+
open-claudia watch-list List watches on this channel + master switch state
|
|
465
|
+
open-claudia watch-remove <id> Remove a watch
|
|
466
|
+
open-claudia watch-on | watch-off Global master switch for all watches (owner)
|
|
443
467
|
open-claudia task add|list|start|done|remove Per-channel todo (survives restarts)
|
|
444
468
|
open-claudia agent "<prompt>" [--role X] [--provider active|claude|codex] Spawn a provider-isolated sub-agent
|
|
445
469
|
|
package/bin/schedule.js
CHANGED
|
@@ -15,8 +15,19 @@ Crons (recurring):
|
|
|
15
15
|
open-claudia cron-list # all wakeups + crons for this channel
|
|
16
16
|
open-claudia cron-remove <id> # remove by id from cron-list
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Watches (silent background monitor — polls a shell check, wakes you only on a hit):
|
|
19
|
+
open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat]
|
|
20
|
+
<check-cmd>: a shell command polled on a schedule (default every minute).
|
|
21
|
+
exit 0 = condition met → wake the agent with <wake-prompt>.
|
|
22
|
+
non-zero = still idle → nothing happens, nothing hits the chat.
|
|
23
|
+
--repeat: keep polling after it fires (default: fire once, then remove).
|
|
24
|
+
open-claudia watch-list # watches on this channel + master switch state
|
|
25
|
+
open-claudia watch-remove <id> # remove a watch by id
|
|
26
|
+
open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
|
|
27
|
+
|
|
28
|
+
The poll itself never touches the chat — only a real trigger wakes the agent
|
|
29
|
+
and produces a normal reply. Each fire resumes the selected provider session
|
|
30
|
+
captured when the schedule/watch was created. Provider/project switches made
|
|
20
31
|
later do not retarget already-scheduled work.
|
|
21
32
|
`;
|
|
22
33
|
|
|
@@ -123,10 +134,68 @@ async function runCronRemove(args) {
|
|
|
123
134
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
async function runWatchAdd(args) {
|
|
138
|
+
if (args.length < 2 || args[0] === "--help") { console.log(HELP); process.exit(args[0] === "--help" ? 0 : 2); }
|
|
139
|
+
const rest = args.slice();
|
|
140
|
+
const label = takeFlag(rest, "label");
|
|
141
|
+
const schedule = takeFlag(rest, "every");
|
|
142
|
+
const project = takeFlag(rest, "project");
|
|
143
|
+
const repeatIdx = rest.indexOf("--repeat");
|
|
144
|
+
const repeat = repeatIdx >= 0;
|
|
145
|
+
if (repeat) rest.splice(repeatIdx, 1);
|
|
146
|
+
const check = rest[0];
|
|
147
|
+
const prompt = rest.slice(1).join(" ");
|
|
148
|
+
if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat]"); process.exit(2); }
|
|
149
|
+
try {
|
|
150
|
+
const request = { check, prompt, repeat, label: label || prompt.slice(0, 60) };
|
|
151
|
+
if (schedule) request.schedule = schedule;
|
|
152
|
+
if (project) request.project = project;
|
|
153
|
+
const res = await postJson("watch-add", request);
|
|
154
|
+
console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"). Silent until the check passes.`);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runWatchList() {
|
|
160
|
+
try {
|
|
161
|
+
const res = await postJson("watch-list", {});
|
|
162
|
+
const list = res.watches || [];
|
|
163
|
+
console.log(`Watches: master switch is ${res.enabled ? "ON" : "OFF"}.`);
|
|
164
|
+
if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
|
|
165
|
+
for (const w of list) {
|
|
166
|
+
const mode = w.repeat ? "repeating" : "one-shot";
|
|
167
|
+
console.log(`${w.id} ${mode} polls "${w.schedule}" check: ${w.check} → ${w.label || "(no label)"}`);
|
|
168
|
+
}
|
|
169
|
+
process.exit(0);
|
|
170
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function runWatchRemove(args) {
|
|
174
|
+
const id = args[0];
|
|
175
|
+
if (!id) { console.error("Usage: watch-remove <id>"); process.exit(2); }
|
|
176
|
+
try {
|
|
177
|
+
const res = await postJson("watch-remove", { id });
|
|
178
|
+
console.log(res.ok ? `Removed watch ${id}.` : `Not found: ${id}.`);
|
|
179
|
+
process.exit(res.ok ? 0 : 1);
|
|
180
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runWatchToggle(enabled) {
|
|
184
|
+
try {
|
|
185
|
+
const res = await postJson("watch-toggle", { enabled });
|
|
186
|
+
console.log(`Watches ${res.enabled ? "ENABLED" : "DISABLED"} globally.`);
|
|
187
|
+
process.exit(0);
|
|
188
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
189
|
+
}
|
|
190
|
+
|
|
126
191
|
module.exports = {
|
|
127
192
|
HELP,
|
|
128
193
|
runScheduleWakeup,
|
|
129
194
|
runCronAdd,
|
|
130
195
|
runCronList,
|
|
131
196
|
runCronRemove,
|
|
197
|
+
runWatchAdd,
|
|
198
|
+
runWatchList,
|
|
199
|
+
runWatchRemove,
|
|
200
|
+
runWatchToggle,
|
|
132
201
|
};
|
package/core/actions.js
CHANGED
|
@@ -28,7 +28,7 @@ const jobs = require("./jobs");
|
|
|
28
28
|
const scheduler = require("./scheduler");
|
|
29
29
|
const { sideChatCoordinator } = require("./side-chat");
|
|
30
30
|
const {
|
|
31
|
-
|
|
31
|
+
claudeProviderAuthStatus, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
|
|
32
32
|
} = require("./auth-flow");
|
|
33
33
|
|
|
34
34
|
const REMOVED_PROVIDER_CALLBACK_MESSAGE = "This button is from an older Open Claudia release. Cursor Agent support has been removed. Open /backend to choose Claude Code or OpenAI Codex.";
|
|
@@ -335,7 +335,7 @@ async function handleAction(envelope) {
|
|
|
335
335
|
const beLabel = provider.label;
|
|
336
336
|
// Auth-aware: if the chosen provider isn't connected, start its login flow
|
|
337
337
|
// now. The model is already set, so once auth completes the user is on it.
|
|
338
|
-
if (be === "claude" &&
|
|
338
|
+
if (be === "claude" && claudeProviderAuthStatus().state !== "authenticated") {
|
|
339
339
|
if (!isChatOwner(envelope.channelId)) { await send("Claude isn't connected and only the owner can sign it in."); return; }
|
|
340
340
|
await send(`Set to ${model}, but Claude isn't connected yet. Starting login — open the URL I send, then paste the code here.`);
|
|
341
341
|
await runClaudeAuthCommand(["auth", "login", "--claudeai"], "Claude login");
|
package/core/auth-flow.js
CHANGED
|
@@ -393,12 +393,14 @@ function codexAuthHelp(reason = "Codex authentication failed") {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
// Quick "is this provider usable non-interactively" check for the unified
|
|
396
|
-
// /login model picker.
|
|
397
|
-
//
|
|
396
|
+
// /login model picker. Each provider resolves its real auth state once (Claude
|
|
397
|
+
// via a bot token or the CLI credential store; Codex via `codex login status`)
|
|
398
|
+
// — fine for a setup screen, and it stops a CLI-store login from looking
|
|
399
|
+
// disconnected.
|
|
398
400
|
function providerAuthStatus() {
|
|
399
401
|
const binary = codexExecutable();
|
|
400
402
|
return {
|
|
401
|
-
claude:
|
|
403
|
+
claude: claudeProviderAuthStatus().state === "authenticated",
|
|
402
404
|
codex: binary ? runCodexLoginStatus().ok : false,
|
|
403
405
|
};
|
|
404
406
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Pure classification of a pasted chat message into a credential disposition.
|
|
4
|
+
// No side effects — the router performs the actual store/delete/confirm. Kept
|
|
5
|
+
// standalone (no requires) so it can be unit-tested without loading the
|
|
6
|
+
// router's dependency graph, and so the ordering rules live in one place.
|
|
7
|
+
//
|
|
8
|
+
// Disposition kinds:
|
|
9
|
+
// claude — a Claude Code OAuth token → saved to .env + vault
|
|
10
|
+
// openai — an OpenAI API key → handed to the Codex CLI
|
|
11
|
+
// keyring — another recognised secret → operational keyring (name provided)
|
|
12
|
+
// scrub — an ambiguous long token → delete from chat but still answer
|
|
13
|
+
// none — ordinary text — leave the message alone
|
|
14
|
+
//
|
|
15
|
+
// A recognised service prefix is claimed BEFORE the generic long-opaque Claude
|
|
16
|
+
// heuristic, otherwise a long GitHub/GitLab token would be mistaken for a
|
|
17
|
+
// Claude token. sk-ant- is claimed before the generic OpenAI sk- shape.
|
|
18
|
+
|
|
19
|
+
function classifyPastedCredential(raw) {
|
|
20
|
+
const text = String(raw || "").trim();
|
|
21
|
+
if (!text) return { kind: "none" };
|
|
22
|
+
|
|
23
|
+
if (/^sk-ant-[A-Za-z0-9._-]+$/.test(text)) return { kind: "claude" };
|
|
24
|
+
if (/^sk-(?:proj-)?[A-Za-z0-9._-]{20,}$/.test(text)) return { kind: "openai" };
|
|
25
|
+
|
|
26
|
+
const byPrefix = [
|
|
27
|
+
[/^glpat-[A-Za-z0-9._-]{8,}$/, "gitlab_token"],
|
|
28
|
+
[/^(?:ghp_|gho_|github_pat_)[A-Za-z0-9._-]{8,}$/, "github_token"],
|
|
29
|
+
[/^(?:xoxb-|xoxp-)[A-Za-z0-9-]{8,}$/, "slack_token"],
|
|
30
|
+
[/^AKIA[0-9A-Z]{8,}$/, "aws_access_key_id"],
|
|
31
|
+
[/^AIza[0-9A-Za-z._-]{10,}$/, "google_api_key"],
|
|
32
|
+
];
|
|
33
|
+
for (const [re, name] of byPrefix) if (re.test(text)) return { kind: "keyring", name, value: text };
|
|
34
|
+
|
|
35
|
+
// A labelled secret ("token: <value>", "Bearer <value>") — only when the
|
|
36
|
+
// value is a single secret-shaped token, so "key: the answer is 42" is left
|
|
37
|
+
// as ordinary text rather than swallowed.
|
|
38
|
+
const labeled = text.match(/^(Bearer)\s+(\S{12,})$/i)
|
|
39
|
+
|| text.match(/^(token|key|secret|password)\s*:\s*(\S{12,})$/i);
|
|
40
|
+
if (labeled) {
|
|
41
|
+
const name = labeled[1].toLowerCase() === "bearer" ? "bearer_token" : labeled[1].toLowerCase();
|
|
42
|
+
return { kind: "keyring", name, value: labeled[2] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Long opaque token with no sk- prefix → Claude OAuth token shape.
|
|
46
|
+
if (!/^sk-/.test(text) && text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text)) return { kind: "claude" };
|
|
47
|
+
|
|
48
|
+
// Ambiguous long single token: scrub it from the chat (it may be a secret)
|
|
49
|
+
// but let the turn proceed so the agent can still respond.
|
|
50
|
+
if (/^[A-Za-z0-9_-]{20,}$/.test(text)) return { kind: "scrub" };
|
|
51
|
+
|
|
52
|
+
return { kind: "none" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { classifyPastedCredential };
|
package/core/handlers.js
CHANGED
|
@@ -2105,6 +2105,79 @@ register({
|
|
|
2105
2105
|
},
|
|
2106
2106
|
});
|
|
2107
2107
|
|
|
2108
|
+
// ── Watch (silent background monitor) ──────────────────────────────
|
|
2109
|
+
// A watch is a cron carrying a shell `check`. The scheduler polls the check
|
|
2110
|
+
// silently — nothing reaches the chat — and wakes the agent with `prompt`
|
|
2111
|
+
// only when the check exits 0. Owner-only: the check is arbitrary shell on
|
|
2112
|
+
// the bot host.
|
|
2113
|
+
|
|
2114
|
+
register({
|
|
2115
|
+
name: "watch", description: "Silent background monitor: poll a shell check, ping you only on a hit", args: "[add \"<check>\" \"<prompt>\" [repeat] | remove <#> | on | off]", ownerOnly: true,
|
|
2116
|
+
handler: async (env, { tail }) => {
|
|
2117
|
+
if (!ownerEnv(env)) return send("Owner only — a watch runs shell commands on my host.");
|
|
2118
|
+
const channelWatches = () => jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => !!j.check);
|
|
2119
|
+
|
|
2120
|
+
if (!tail) {
|
|
2121
|
+
const list = channelWatches();
|
|
2122
|
+
const master = scheduler.watchesEnabled() ? "ON" : "OFF";
|
|
2123
|
+
if (list.length === 0) {
|
|
2124
|
+
return send([
|
|
2125
|
+
`No watches. Master switch: ${master}.`,
|
|
2126
|
+
"",
|
|
2127
|
+
"A watch silently polls a shell check and only pings you when it passes — nothing hits the chat until then.",
|
|
2128
|
+
"",
|
|
2129
|
+
'Add: /watch add "<check-cmd>" "<wake-prompt>"',
|
|
2130
|
+
'e.g. /watch add "curl -sf https://myapp/health || exit 1" "myapp health check failed — take a look"',
|
|
2131
|
+
"Add `repeat` at the end to keep polling after it fires (default: one-shot).",
|
|
2132
|
+
].join("\n"));
|
|
2133
|
+
}
|
|
2134
|
+
return send(
|
|
2135
|
+
`Watches (master switch: ${master}):\n\n` +
|
|
2136
|
+
list.map((w, i) => `${i + 1}. ${w.label} — polls "${w.schedule}"${w.repeat ? " (repeating)" : " (one-shot)"}\n check: ${w.check}`).join("\n") +
|
|
2137
|
+
"\n\nRemove: /watch remove <#>\nToggle all: /watch on | /watch off",
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
if (/^on$/i.test(tail)) {
|
|
2142
|
+
scheduler.setWatchesEnabled(true);
|
|
2143
|
+
return send("✅ Watches ENABLED. Existing watches resume polling.");
|
|
2144
|
+
}
|
|
2145
|
+
if (/^off$/i.test(tail)) {
|
|
2146
|
+
scheduler.setWatchesEnabled(false);
|
|
2147
|
+
return send("⏸️ Watches DISABLED globally. Polling is paused and nothing will fire until /watch on. Your watches are kept.");
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
const addMatch = tail.match(/^add\s+"([^"]+)"\s+"([^"]+)"(?:\s+(repeat))?$/i);
|
|
2151
|
+
if (addMatch) {
|
|
2152
|
+
const job = scheduler.addWatch({
|
|
2153
|
+
adapter: env.adapter.id,
|
|
2154
|
+
adapterType: env.adapter.type,
|
|
2155
|
+
channelId: String(env.channelId),
|
|
2156
|
+
canonicalUserId: env.canonicalUserId,
|
|
2157
|
+
project: "Workspace",
|
|
2158
|
+
projectDir: WORKSPACE,
|
|
2159
|
+
check: addMatch[1],
|
|
2160
|
+
prompt: addMatch[2],
|
|
2161
|
+
label: addMatch[2].slice(0, 50),
|
|
2162
|
+
source: "user",
|
|
2163
|
+
repeat: !!addMatch[3],
|
|
2164
|
+
});
|
|
2165
|
+
return send(`👁️ Watching (${addMatch[3] ? "repeating" : "one-shot"}, polls "${job.schedule}"). I'll stay quiet until the check passes, then ping you.`);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const removeMatch = tail.match(/^remove\s+(\d+)$/);
|
|
2169
|
+
if (removeMatch) {
|
|
2170
|
+
const list = channelWatches();
|
|
2171
|
+
const idx = parseInt(removeMatch[1], 10) - 1;
|
|
2172
|
+
if (idx < 0 || idx >= list.length) return send("Invalid number.");
|
|
2173
|
+
const removed = scheduler.removeJob(list[idx].id);
|
|
2174
|
+
return send(`Removed watch: ${removed ? removed.label : "(unknown)"}`);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
send('Usage: /watch | /watch add "<check-cmd>" "<wake-prompt>" [repeat] | /watch remove <#> | /watch on | /watch off');
|
|
2178
|
+
},
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2108
2181
|
// ── Channels ───────────────────────────────────────────────────────
|
|
2109
2182
|
|
|
2110
2183
|
register({
|
package/core/loopback.js
CHANGED
|
@@ -74,6 +74,7 @@ function readBodyToFile(req, dest) {
|
|
|
74
74
|
const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
|
|
75
75
|
const JSON_KINDS = new Set([
|
|
76
76
|
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
77
|
+
"watch-add", "watch-list", "watch-remove", "watch-toggle",
|
|
77
78
|
"task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
|
|
78
79
|
"people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
|
|
79
80
|
"people-remove", "people-set-primary",
|
|
@@ -238,6 +239,57 @@ async function handleJson(req, res, url, kind) {
|
|
|
238
239
|
return reply(res, 200, { ok: true, jobs: list, archived });
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
// A watch is a cron carrying a shell `check`; the scheduler polls the check
|
|
243
|
+
// silently and only wakes the agent (with `prompt`) when it exits 0.
|
|
244
|
+
if (kind === "watch-add") {
|
|
245
|
+
if (!payload.check || !payload.prompt) return reply(res, 400, { error: "missing check/prompt" });
|
|
246
|
+
const job = scheduler.addWatch({
|
|
247
|
+
adapter: adapterId,
|
|
248
|
+
adapterType: adapter.type,
|
|
249
|
+
channelId,
|
|
250
|
+
canonicalUserId: payload.canonicalUserId || null,
|
|
251
|
+
userId: payload.userId || null,
|
|
252
|
+
provider: payload.provider || null,
|
|
253
|
+
providerSettings: payload.providerSettings || {},
|
|
254
|
+
project: payload.project || null,
|
|
255
|
+
projectDir: payload.projectDir || null,
|
|
256
|
+
check: String(payload.check),
|
|
257
|
+
prompt: String(payload.prompt),
|
|
258
|
+
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
259
|
+
schedule: payload.schedule ? String(payload.schedule) : undefined,
|
|
260
|
+
repeat: !!payload.repeat,
|
|
261
|
+
source: payload.source || "agent",
|
|
262
|
+
sessionId: payload.sessionId || null,
|
|
263
|
+
previousSessionId: payload.previousSessionId || null,
|
|
264
|
+
originRunId: payload.originRunId || null,
|
|
265
|
+
sessionLineage: {
|
|
266
|
+
originRunId: payload.originRunId || null,
|
|
267
|
+
originSessionId: payload.sessionId || null,
|
|
268
|
+
previousSessionId: payload.previousSessionId || null,
|
|
269
|
+
originProject: payload.originProject || payload.project || null,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
return reply(res, 200, { ok: true, job });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (kind === "watch-list") {
|
|
276
|
+
const list = jobsStore.listForChannel(adapterId, channelId).filter((job) => !!job.check);
|
|
277
|
+
return reply(res, 200, { ok: true, watches: list, enabled: scheduler.watchesEnabled() });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (kind === "watch-remove") {
|
|
281
|
+
const id = payload.id || url.searchParams.get("id");
|
|
282
|
+
if (!id) return reply(res, 400, { error: "missing id" });
|
|
283
|
+
const removed = scheduler.removeJob(id);
|
|
284
|
+
return reply(res, removed ? 200 : 404, { ok: !!removed, removed });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (kind === "watch-toggle") {
|
|
288
|
+
if (typeof payload.enabled !== "boolean") return reply(res, 400, { error: "missing enabled boolean" });
|
|
289
|
+
const enabled = scheduler.setWatchesEnabled(payload.enabled);
|
|
290
|
+
return reply(res, 200, { ok: true, enabled });
|
|
291
|
+
}
|
|
292
|
+
|
|
241
293
|
if (kind === "task-add") {
|
|
242
294
|
if (!payload.content) return reply(res, 400, { error: "missing content" });
|
|
243
295
|
try {
|
package/core/router.js
CHANGED
|
@@ -22,8 +22,10 @@ const {
|
|
|
22
22
|
clearPendingClaudeAuth, clearPendingCodexAuth,
|
|
23
23
|
sendClaudeAuthStatusSummary, sendCodexAuthStatusSummary,
|
|
24
24
|
} = require("./auth-flow");
|
|
25
|
-
const { redactSensitive } = require("./redact");
|
|
25
|
+
const { redactSensitive, registerSecrets } = require("./redact");
|
|
26
26
|
const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
|
|
27
|
+
const keyring = require("./keyring");
|
|
28
|
+
const { classifyPastedCredential } = require("./credential-detect");
|
|
27
29
|
const channelWizard = require("./channel-wizard");
|
|
28
30
|
require("./handlers"); // side-effect: register slash commands
|
|
29
31
|
|
|
@@ -272,6 +274,51 @@ async function handleDocument(envelope) {
|
|
|
272
274
|
} catch (err) { await send(`Failed: ${err.message}`); }
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
// Provider-agnostic capture of a credential pasted into chat with no auth
|
|
278
|
+
// flow active. Before v3.0.37 such a paste was merely deleted and then still
|
|
279
|
+
// forwarded to the model — which, under Codex, refused it as a policy
|
|
280
|
+
// violation. Now the router classifies it and stores it out-of-band (Claude
|
|
281
|
+
// token → .env/vault, OpenAI key → Codex CLI, other recognised secrets →
|
|
282
|
+
// operational keyring), deletes the message, confirms, and stops. The model
|
|
283
|
+
// never sees the secret. Returns true when it fully handled the message
|
|
284
|
+
// (caller must stop), false to let the turn proceed normally.
|
|
285
|
+
async function captureLooseCredential(text, envelope) {
|
|
286
|
+
const disposition = classifyPastedCredential(text);
|
|
287
|
+
switch (disposition.kind) {
|
|
288
|
+
case "claude": {
|
|
289
|
+
await deleteMessage(envelope.messageId);
|
|
290
|
+
registerSecrets(text);
|
|
291
|
+
saveClaudeOAuthToken(text);
|
|
292
|
+
await send(`Saved your Claude token to .env${vault.isUnlocked() ? " and vault" : ""} and deleted your message so it isn't left in chat. Restart with /restart so I pick it up.`);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
case "openai": {
|
|
296
|
+
await deleteMessage(envelope.messageId);
|
|
297
|
+
registerSecrets(text);
|
|
298
|
+
const result = await saveCodexApiKeyWithCli(text);
|
|
299
|
+
await send(result.ok
|
|
300
|
+
? "Saved your OpenAI API key via the Codex CLI and deleted your message. I never printed it."
|
|
301
|
+
: `Deleted your message, but the Codex CLI couldn't store that key: ${redactSensitive(result.output).slice(-400)}`);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
case "keyring": {
|
|
305
|
+
await deleteMessage(envelope.messageId);
|
|
306
|
+
registerSecrets(disposition.value);
|
|
307
|
+
keyring.set(disposition.name, disposition.value);
|
|
308
|
+
await send(`Stored that secret in the operational keyring as \`${disposition.name}\` and deleted your message. Manage it with /keyring.`);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
case "scrub": {
|
|
312
|
+
// Ambiguous long token: strip it from the chat but let the turn proceed
|
|
313
|
+
// (it may be an id/arg the agent legitimately needs to act on).
|
|
314
|
+
await deleteMessage(envelope.messageId);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
default:
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
275
322
|
async function handleText(envelope) {
|
|
276
323
|
if (isDuplicate(envelope)) return;
|
|
277
324
|
const state = currentState();
|
|
@@ -448,17 +495,14 @@ async function handleText(envelope) {
|
|
|
448
495
|
}
|
|
449
496
|
|
|
450
497
|
const text = envelope.text || "";
|
|
498
|
+
|
|
499
|
+
// Loose credential paste with no auth flow active: store it out-of-band and
|
|
500
|
+
// stop, instead of forwarding a secret to the model (Codex refuses those).
|
|
501
|
+
if (await captureLooseCredential(text.trim(), envelope)) return;
|
|
502
|
+
|
|
451
503
|
const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
|
|
452
504
|
const attachments = [];
|
|
453
505
|
const imagePaths = [];
|
|
454
|
-
const credPatterns = [
|
|
455
|
-
/^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/,
|
|
456
|
-
/^[A-Za-z0-9_-]{20,}$/,
|
|
457
|
-
/^(Bearer |token:|key:|secret:|password:)/i,
|
|
458
|
-
];
|
|
459
|
-
if (credPatterns.some((p) => p.test(text.trim()))) {
|
|
460
|
-
await deleteMessage(envelope.messageId);
|
|
461
|
-
}
|
|
462
506
|
|
|
463
507
|
let prompt = text;
|
|
464
508
|
const reply = envelope.reply;
|
package/core/scheduler.js
CHANGED
|
@@ -10,6 +10,8 @@ const crypto = require("crypto");
|
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const cron = require("node-cron");
|
|
13
|
+
const { spawn } = require("child_process");
|
|
14
|
+
const CONFIG_DIR = require("../config-dir");
|
|
13
15
|
|
|
14
16
|
const {
|
|
15
17
|
CRONS_FILE,
|
|
@@ -34,6 +36,10 @@ const { redactSensitive } = require("./redact");
|
|
|
34
36
|
const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
|
|
35
37
|
const RETRY_DELAY_MS = 30 * 1000;
|
|
36
38
|
const BUSY_DELAY_MS = 30 * 1000;
|
|
39
|
+
// A watch's `check` command is capped so a hung poll can't wedge the tick.
|
|
40
|
+
const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
41
|
+
const WATCH_LOG_FILE = process.env.OPEN_CLAUDIA_WATCH_LOG_FILE || path.join(CONFIG_DIR, "watch.log");
|
|
42
|
+
const WATCHES_ENABLED_FILE = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE || path.join(CONFIG_DIR, "watches-enabled");
|
|
37
43
|
|
|
38
44
|
function uniqueId(prefix = "occ") {
|
|
39
45
|
return typeof crypto.randomUUID === "function"
|
|
@@ -53,6 +59,13 @@ function terminalRunSucceeded(result) {
|
|
|
53
59
|
return true;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
// A one-shot fires exactly once then is removed after a successful run: a
|
|
63
|
+
// wakeup, or a watch (a cron carrying a shell `check`) created without repeat
|
|
64
|
+
// polling. A repeat watch stays pending and keeps polling like any cron.
|
|
65
|
+
function isOneShot(job) {
|
|
66
|
+
return job.kind === "wakeup" || (!!job.check && !job.repeat);
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
function providerIsRunnable(status) {
|
|
57
70
|
if (!status || status.availability?.state !== "available") return false;
|
|
58
71
|
if (status.compatibility?.state === "incompatible") return false;
|
|
@@ -96,6 +109,30 @@ function createSchedulerService(dependencies = {}) {
|
|
|
96
109
|
const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
|
|
97
110
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
98
111
|
const timers = new Map();
|
|
112
|
+
const checksInFlight = new Set();
|
|
113
|
+
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
114
|
+
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
115
|
+
// dir with the process env, output discarded (exit code is the contract),
|
|
116
|
+
// hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
|
|
117
|
+
const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
|
|
118
|
+
const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (code, timedOut) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
try { clearTimer(timer); } catch (_) {}
|
|
124
|
+
resolve({ code, timedOut: !!timedOut });
|
|
125
|
+
};
|
|
126
|
+
let child;
|
|
127
|
+
try {
|
|
128
|
+
child = spawn("sh", ["-c", String(command)], { cwd, env: process.env, stdio: "ignore" });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return resolve({ code: 127, timedOut: false });
|
|
131
|
+
}
|
|
132
|
+
const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
|
|
133
|
+
child.on("close", (code) => finish(code == null ? 1 : code, false));
|
|
134
|
+
child.on("error", () => finish(127, false));
|
|
135
|
+
}));
|
|
99
136
|
let adapters = Array.isArray(dependencies.adapters) ? dependencies.adapters.slice() : [];
|
|
100
137
|
|
|
101
138
|
function setAdapters(list) {
|
|
@@ -186,7 +223,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
186
223
|
|
|
187
224
|
function wrappedPrompt(job, target) {
|
|
188
225
|
const firedAt = new Date(now()).toISOString();
|
|
189
|
-
const header = job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
|
|
226
|
+
const header = job.check ? "Watch triggered" : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
|
|
190
227
|
if (target.fallbackFrom) {
|
|
191
228
|
return [
|
|
192
229
|
`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
|
|
@@ -270,7 +307,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
270
307
|
catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
|
|
271
308
|
if (!marked) return { ok: false, status: "failed", error: { code: "JOB_PERSIST_FAILED", message: "Scheduled success marker was not persisted" }, result };
|
|
272
309
|
|
|
273
|
-
if (job
|
|
310
|
+
if (isOneShot(job)) {
|
|
274
311
|
try {
|
|
275
312
|
const removed = jobsStore.remove(job.id);
|
|
276
313
|
if (!removed) throw new Error("successful one-shot could not be removed after its durable success marker");
|
|
@@ -308,12 +345,34 @@ function createSchedulerService(dependencies = {}) {
|
|
|
308
345
|
if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
|
|
309
346
|
return { ok: false, status: "running", skipped: true, inFlight: true };
|
|
310
347
|
}
|
|
311
|
-
if (job.status === "succeeded" && job
|
|
348
|
+
if (job.status === "succeeded" && isOneShot(job)) {
|
|
312
349
|
try { jobsStore.remove(job.id); } catch (_) {}
|
|
313
350
|
unscheduleJob(job.id);
|
|
314
351
|
return { ok: true, status: "succeeded", finalized: true };
|
|
315
352
|
}
|
|
316
353
|
|
|
354
|
+
// Watch poll gate. A watch is a cron carrying a shell `check`; each tick
|
|
355
|
+
// runs the check and only wakes the agent when it exits 0. A non-zero exit
|
|
356
|
+
// is the normal idle case: skip silently, mark nothing, leave the job
|
|
357
|
+
// pending so the next tick polls again. Never marks failure; never chats.
|
|
358
|
+
if (job.check) {
|
|
359
|
+
if (!watchesEnabled()) {
|
|
360
|
+
logWatch(job, "skipped (watches disabled globally)");
|
|
361
|
+
return { ok: false, status: "watch-disabled", skipped: true };
|
|
362
|
+
}
|
|
363
|
+
if (checksInFlight.has(job.id)) return { ok: false, status: "watch-checking", skipped: true };
|
|
364
|
+
checksInFlight.add(job.id);
|
|
365
|
+
let check;
|
|
366
|
+
try { check = await runWatchCheck(job.check, job); }
|
|
367
|
+
catch (_) { check = { code: 1, timedOut: false }; }
|
|
368
|
+
finally { checksInFlight.delete(job.id); }
|
|
369
|
+
if (!check || check.code !== 0) {
|
|
370
|
+
logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
|
|
371
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
372
|
+
}
|
|
373
|
+
logWatch(job, "triggered (check passed) — waking agent");
|
|
374
|
+
}
|
|
375
|
+
|
|
317
376
|
const adapter = resolveAdapter(job);
|
|
318
377
|
if (!adapter) {
|
|
319
378
|
try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
|
|
@@ -385,7 +444,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
385
444
|
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
386
445
|
}
|
|
387
446
|
|
|
388
|
-
try { await send(`Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
|
|
447
|
+
try { await send(job.check ? `🔔 Watch "${job.label}" triggered` : `Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
|
|
389
448
|
let result;
|
|
390
449
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
391
450
|
catch (error) {
|
|
@@ -403,7 +462,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
403
462
|
function scheduleJob(job) {
|
|
404
463
|
unscheduleJob(job.id);
|
|
405
464
|
if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
|
|
406
|
-
if (job.status === "succeeded" && job
|
|
465
|
+
if (job.status === "succeeded" && isOneShot(job)) {
|
|
407
466
|
try { jobsStore.remove(job.id); } catch (_) {}
|
|
408
467
|
return false;
|
|
409
468
|
}
|
|
@@ -514,6 +573,41 @@ function createSchedulerService(dependencies = {}) {
|
|
|
514
573
|
return job;
|
|
515
574
|
}
|
|
516
575
|
|
|
576
|
+
// Global master switch for all watches (owner's /watch off). Absent file →
|
|
577
|
+
// enabled (default ON). Only the poll gate consults it; wakeups/crons are
|
|
578
|
+
// untouched.
|
|
579
|
+
function watchesEnabled() {
|
|
580
|
+
try { return fs.readFileSync(WATCHES_ENABLED_FILE, "utf8").trim() !== "off"; }
|
|
581
|
+
catch (_) { return true; }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function setWatchesEnabled(on) {
|
|
585
|
+
try { fs.writeFileSync(WATCHES_ENABLED_FILE, on ? "on" : "off"); } catch (_) {}
|
|
586
|
+
return !!on;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Append-only audit of watch poll activity. NEVER sent to chat — this is the
|
|
590
|
+
// whole point of a watch: the polling stays out of the conversation, only a
|
|
591
|
+
// real trigger wakes the agent and surfaces in chat.
|
|
592
|
+
function logWatch(job, event) {
|
|
593
|
+
const line = `[${new Date(now()).toISOString()}] job=${job.id} label=${JSON.stringify(job.label || "")} ${event}\n`;
|
|
594
|
+
try { fs.appendFileSync(WATCH_LOG_FILE, line); } catch (_) {}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// A watch is a cron carrying a shell `check`; it rides the migration-verified
|
|
598
|
+
// jobs store untouched (check/repeat survive normalizeRunnableJob as extra
|
|
599
|
+
// fields). Default schedule polls every minute; default is one-shot.
|
|
600
|
+
function addWatch(spec) {
|
|
601
|
+
return addJob({
|
|
602
|
+
...spec,
|
|
603
|
+
kind: "cron",
|
|
604
|
+
schedule: spec.schedule || "* * * * *",
|
|
605
|
+
check: String(spec.check || "").trim(),
|
|
606
|
+
repeat: !!spec.repeat,
|
|
607
|
+
label: spec.label || String(spec.prompt || "watch").slice(0, 60),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
517
611
|
function removeJob(id) {
|
|
518
612
|
unscheduleJob(id);
|
|
519
613
|
return jobsStore.remove(id);
|
|
@@ -529,6 +623,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
529
623
|
scheduleJob,
|
|
530
624
|
unscheduleJob,
|
|
531
625
|
addJob,
|
|
626
|
+
addWatch,
|
|
627
|
+
watchesEnabled,
|
|
628
|
+
setWatchesEnabled,
|
|
532
629
|
removeJob,
|
|
533
630
|
stopAll,
|
|
534
631
|
fireJob,
|
|
@@ -561,6 +658,9 @@ module.exports = {
|
|
|
561
658
|
scheduleJob: service.scheduleJob,
|
|
562
659
|
unscheduleJob: service.unscheduleJob,
|
|
563
660
|
addJob: service.addJob,
|
|
661
|
+
addWatch: service.addWatch,
|
|
662
|
+
watchesEnabled: service.watchesEnabled,
|
|
663
|
+
setWatchesEnabled: service.setWatchesEnabled,
|
|
564
664
|
removeJob: service.removeJob,
|
|
565
665
|
stopAll: service.stopAll,
|
|
566
666
|
fireJob: service.fireJob,
|
package/core/system-prompt.js
CHANGED
|
@@ -241,6 +241,12 @@ Wake-ups and crons (real schedulers, not hallucinations — use them instead of
|
|
|
241
241
|
- \`open-claudia cron-list\` — list all wakeups + crons on this channel.
|
|
242
242
|
- \`open-claudia cron-remove <id>\` — cancel one.
|
|
243
243
|
|
|
244
|
+
Watches (a silent background monitor — poll a condition WITHOUT waking every tick; wake only when it actually happens). Use this instead of a minute-by-minute cron whenever you'd otherwise fire a turn just to check "is it done yet?" and stay quiet most times:
|
|
245
|
+
- \`open-claudia watch-add "<check-cmd>" "<wake-prompt>"\` — the scheduler polls \`<check-cmd>\` as a shell command (default every minute). The poll is SILENT: nothing reaches the chat while the check keeps failing. The instant \`<check-cmd>\` exits 0, you wake in this same conversation with \`<wake-prompt>\`, exactly like a wakeup. Non-zero exit = still waiting = nothing happens. Default is ONE-SHOT (fires once, then removes itself); add \`--repeat\` to keep polling after each hit. \`--every "<5-field cron>"\` changes the poll cadence.
|
|
246
|
+
- \`open-claudia watch-list\` / \`open-claudia watch-remove <id>\` — inspect/cancel watches on this channel.
|
|
247
|
+
- The owner has a global master switch: \`/watch off\` pauses ALL watch polling (and \`/watch on\` resumes). If \`watch-list\` shows the switch OFF, watches won't fire until it's back on.
|
|
248
|
+
- Example: after kicking off a long external job, \`open-claudia watch-add "test -f /tmp/build-done || exit 1" "the build finished — verify and report"\` beats a wakeup guess, because it fires the moment it's actually done, not on a fixed timer. (Never use a watch to poll harness-tracked work you started — that notifies you automatically. Watches are for EXTERNAL state the harness can't see.)
|
|
249
|
+
|
|
244
250
|
The selected coding-agent provider may expose native scheduling tools. Do NOT use those for Open Claudia wakeups or crons: a provider runs as a per-turn subprocess, so its timers do not belong to the long-running bot. Only the \`open-claudia\` CLI commands above are authoritative and survive.
|
|
245
251
|
|
|
246
252
|
Persistent todo list with plans + subtasks (per channel; survives compaction and restart).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.37",
|
|
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": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
|
|
12
|
+
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
|
|
13
13
|
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"test-provider-session-commands.js",
|
|
96
96
|
"test-provider-compaction.js",
|
|
97
97
|
"test-provider-scheduler.js",
|
|
98
|
+
"test-credential-capture.js",
|
|
98
99
|
"test-cursor-removal.js",
|
|
99
100
|
"test-utility-provider-policy.js",
|
|
100
101
|
"test-provider-subagent.js",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
// Loose credential capture: a pasted secret must be stored out-of-band and the
|
|
6
|
+
// turn stopped BEFORE the message reaches the model — regardless of which
|
|
7
|
+
// provider is active. This is the Codex complaint: Codex refuses secrets in its
|
|
8
|
+
// prompt, so the harness has to intercept the paste itself rather than forward
|
|
9
|
+
// it. Two layers are covered here:
|
|
10
|
+
// 1. the pure classifier (core/credential-detect.js) — the decision logic
|
|
11
|
+
// 2. the router wiring (core/router.js) — capture precedes admitRunContext and
|
|
12
|
+
// each disposition routes to the right sink, provider-agnostically.
|
|
13
|
+
|
|
14
|
+
const assert = require("assert");
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
|
|
18
|
+
const root = __dirname;
|
|
19
|
+
const { classifyPastedCredential } = require("./core/credential-detect");
|
|
20
|
+
|
|
21
|
+
function classifierProbe() {
|
|
22
|
+
const kind = (text) => classifyPastedCredential(text).kind;
|
|
23
|
+
|
|
24
|
+
// Claude OAuth: the sk-ant- prefix and the long opaque no-sk shape.
|
|
25
|
+
assert.strictEqual(kind("sk-ant-oat01-" + "A".repeat(40)), "claude");
|
|
26
|
+
assert.strictEqual(kind("x".repeat(80)), "claude", "long opaque token with no sk- prefix reads as a Claude token");
|
|
27
|
+
assert.strictEqual(kind("A1b2C3d4_" + "e".repeat(80)), "claude");
|
|
28
|
+
|
|
29
|
+
// OpenAI API keys go to the Codex CLI — the whole point of the fix.
|
|
30
|
+
assert.strictEqual(kind("sk-" + "a".repeat(40)), "openai");
|
|
31
|
+
assert.strictEqual(kind("sk-proj-" + "b".repeat(40)), "openai");
|
|
32
|
+
|
|
33
|
+
// Recognised service prefixes are claimed before the generic Claude heuristic.
|
|
34
|
+
const keyring = (text) => {
|
|
35
|
+
const d = classifyPastedCredential(text);
|
|
36
|
+
return `${d.kind}:${d.name}`;
|
|
37
|
+
};
|
|
38
|
+
assert.strictEqual(keyring("glpat-" + "x".repeat(20)), "keyring:gitlab_token");
|
|
39
|
+
assert.strictEqual(keyring("ghp_" + "x".repeat(36)), "keyring:github_token");
|
|
40
|
+
assert.strictEqual(keyring("github_pat_" + "x".repeat(40)), "keyring:github_token");
|
|
41
|
+
assert.strictEqual(keyring("xoxb-" + "1".repeat(20)), "keyring:slack_token");
|
|
42
|
+
assert.strictEqual(keyring("AKIA" + "ABCDEFGH1234"), "keyring:aws_access_key_id");
|
|
43
|
+
assert.strictEqual(keyring("AIza" + "a".repeat(30)), "keyring:google_api_key");
|
|
44
|
+
|
|
45
|
+
// A long sk-ant- token must win over the generic long-opaque Claude branch,
|
|
46
|
+
// and over the OpenAI sk- shape.
|
|
47
|
+
assert.strictEqual(kind("sk-ant-" + "z".repeat(90)), "claude");
|
|
48
|
+
|
|
49
|
+
// Labelled single-token secrets land in the keyring with a derived name.
|
|
50
|
+
const labelled = classifyPastedCredential("token: " + "s".repeat(24));
|
|
51
|
+
assert.strictEqual(labelled.kind, "keyring");
|
|
52
|
+
assert.strictEqual(labelled.name, "token");
|
|
53
|
+
assert.strictEqual(labelled.value, "s".repeat(24));
|
|
54
|
+
assert.strictEqual(classifyPastedCredential("Bearer " + "t".repeat(24)).name, "bearer_token");
|
|
55
|
+
|
|
56
|
+
// A label followed by prose is ordinary text, not a swallowed secret.
|
|
57
|
+
assert.strictEqual(kind("key: the answer is 42"), "none");
|
|
58
|
+
|
|
59
|
+
// Ambiguous medium-length single token → scrub from chat, but let the turn run.
|
|
60
|
+
assert.strictEqual(kind("A1b2C3d4E5f6G7h8I9j0"), "scrub");
|
|
61
|
+
|
|
62
|
+
// Ordinary conversation and empties are left completely alone.
|
|
63
|
+
assert.strictEqual(kind("hey can you check the deploy status"), "none");
|
|
64
|
+
assert.strictEqual(kind("what colour are the fish?"), "none");
|
|
65
|
+
assert.strictEqual(kind(""), "none");
|
|
66
|
+
assert.strictEqual(kind(" "), "none");
|
|
67
|
+
assert.strictEqual(kind("short"), "none");
|
|
68
|
+
assert.strictEqual(kind(null), "none");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function routerWiringProbe() {
|
|
72
|
+
const source = fs.readFileSync(path.join(root, "core", "router.js"), "utf8");
|
|
73
|
+
|
|
74
|
+
// The classifier is imported and driven through a single capture funnel.
|
|
75
|
+
assert.match(source, /classifyPastedCredential/, "router must use the pure classifier");
|
|
76
|
+
assert.match(source, /async function captureLooseCredential/, "router must define the capture funnel");
|
|
77
|
+
|
|
78
|
+
// Capture must run — and short-circuit the turn — BEFORE the model is admitted,
|
|
79
|
+
// so a pasted secret never becomes part of a provider prompt.
|
|
80
|
+
const captureIdx = source.indexOf("if (await captureLooseCredential(");
|
|
81
|
+
const admitIdx = source.indexOf("admitRunContext(text,");
|
|
82
|
+
assert.ok(captureIdx > 0, "handleText must call captureLooseCredential");
|
|
83
|
+
assert.ok(admitIdx > 0, "handleText must still admit ordinary turns");
|
|
84
|
+
assert.ok(captureIdx < admitIdx, "credential capture must precede admitRunContext so secrets never reach the model");
|
|
85
|
+
|
|
86
|
+
// Capture is provider-agnostic: it must not branch on the active backend/provider.
|
|
87
|
+
const fnStart = source.indexOf("async function captureLooseCredential");
|
|
88
|
+
const fnEnd = source.indexOf("\nasync function handleText", fnStart);
|
|
89
|
+
const fnBody = source.slice(fnStart, fnEnd);
|
|
90
|
+
assert.ok(fnEnd > fnStart, "captureLooseCredential body must be locatable");
|
|
91
|
+
assert.ok(!/settings\.backend|state\.settings|activeProvider|currentProvider/.test(fnBody),
|
|
92
|
+
"credential capture must not depend on which provider is active");
|
|
93
|
+
|
|
94
|
+
// Each disposition routes to its sink, and every stored secret is redaction-
|
|
95
|
+
// registered and deleted from the chat.
|
|
96
|
+
assert.match(fnBody, /saveClaudeOAuthToken\(/, "claude paste → save Claude OAuth token");
|
|
97
|
+
assert.match(fnBody, /saveCodexApiKeyWithCli\(/, "openai paste → hand to Codex CLI");
|
|
98
|
+
assert.match(fnBody, /keyring\.set\(/, "recognised secret → operational keyring");
|
|
99
|
+
assert.match(fnBody, /registerSecrets\(/, "captured secrets are registered for redaction");
|
|
100
|
+
assert.match(fnBody, /deleteMessage\(/, "captured secrets are deleted from the chat");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function main() {
|
|
104
|
+
classifierProbe();
|
|
105
|
+
routerWiringProbe();
|
|
106
|
+
console.log("credential capture OK");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
main();
|
|
@@ -32,15 +32,21 @@ 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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
35
|
+
// 3.0.36 approved by Sumeet 2026-07-17 ("One bug is when you change model to a
|
|
36
|
+
// Claude it asks you to sign in again" → "Go ahead"): picking a Claude model no
|
|
37
|
+
// longer forces a re-login. The model-button handler and the /login picker
|
|
38
|
+
// judged "connected?" from a shallow token probe (getClaudeOAuthToken) that
|
|
39
|
+
// only sees .env/process-env/vault tokens, so a Claude CLI-store login read as
|
|
40
|
+
// disconnected and every tap restarted login — a loop, since that login stores
|
|
41
|
+
// no bot token. Both now use claudeProviderAuthStatus() (honours a bot token
|
|
42
|
+
// AND a `claude auth status` CLI-store login), matching the Codex branch.
|
|
43
|
+
// 3.0.37 approved by Sumeet 2026-07-17 ("when done please push out the new
|
|
44
|
+
// version"): adds silent background watches (a cron carrying a shell `check`
|
|
45
|
+
// that only wakes the agent on exit 0, owner-gated via /watch) and
|
|
46
|
+
// provider-agnostic loose-credential capture (a pasted secret is stored
|
|
47
|
+
// out-of-band and the turn stopped before the model — Codex refuses secrets in
|
|
48
|
+
// its prompt, so the harness intercepts the paste itself).
|
|
49
|
+
assert.strictEqual(pkg.version, "3.0.37", "release version must remain unchanged without explicit approval");
|
|
44
50
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
45
51
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
46
52
|
}
|
|
@@ -10,6 +10,14 @@ const path = require("path");
|
|
|
10
10
|
const root = __dirname;
|
|
11
11
|
const fixturesRoot = path.join(root, "test-fixtures", "migrations");
|
|
12
12
|
|
|
13
|
+
// Keep watch state off the real ~/.open-claudia during the run. scheduler.js
|
|
14
|
+
// reads these paths from env at require time, so they must be set before main()
|
|
15
|
+
// pulls in ./core/scheduler.
|
|
16
|
+
const watchTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-watch-test-"));
|
|
17
|
+
process.env.OPEN_CLAUDIA_WATCH_LOG_FILE = path.join(watchTmpDir, "watch.log");
|
|
18
|
+
process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE = path.join(watchTmpDir, "watches-enabled");
|
|
19
|
+
const watchesEnabledFile = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE;
|
|
20
|
+
|
|
13
21
|
function readFixture(name, file = "jobs.json") {
|
|
14
22
|
return JSON.parse(fs.readFileSync(path.join(fixturesRoot, name, file), "utf8"));
|
|
15
23
|
}
|
|
@@ -423,6 +431,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
|
|
|
423
431
|
retryDelayMs: 1000,
|
|
424
432
|
busyDelayMs: 250,
|
|
425
433
|
missedGraceMs: 60 * 60 * 1000,
|
|
434
|
+
...(options.runWatchCheck ? { runWatchCheck: options.runWatchCheck } : {}),
|
|
426
435
|
});
|
|
427
436
|
return { service, jobsStore, scheduled, admissions, runCalls, sends, state };
|
|
428
437
|
}
|
|
@@ -727,6 +736,78 @@ function loopbackMetadataProbe(runner, loopbackClient) {
|
|
|
727
736
|
}
|
|
728
737
|
}
|
|
729
738
|
|
|
739
|
+
async function watchGateProbe(createSchedulerService) {
|
|
740
|
+
const successOutcome = () => ({
|
|
741
|
+
ok: true,
|
|
742
|
+
status: "succeeded",
|
|
743
|
+
persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
|
|
744
|
+
delivery: { ok: true },
|
|
745
|
+
});
|
|
746
|
+
const watchJob = (overrides = {}) => baseJob({
|
|
747
|
+
id: "watch-1",
|
|
748
|
+
kind: "cron",
|
|
749
|
+
schedule: "* * * * *",
|
|
750
|
+
fireAt: undefined,
|
|
751
|
+
occurrenceId: "watch-occurrence",
|
|
752
|
+
check: "true",
|
|
753
|
+
repeat: false,
|
|
754
|
+
label: "disk watch",
|
|
755
|
+
...overrides,
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Failing check → idle skip: agent never wakes, nothing hits chat, job stays pending.
|
|
759
|
+
const idle = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-idle" })], {
|
|
760
|
+
runWatchCheck: async () => ({ code: 1, timedOut: false }),
|
|
761
|
+
outcomes: [successOutcome()],
|
|
762
|
+
});
|
|
763
|
+
const idleResult = await idle.service.fireJob("watch-idle");
|
|
764
|
+
assert.strictEqual(idleResult.status, "watch-idle");
|
|
765
|
+
assert.strictEqual(idleResult.skipped, true);
|
|
766
|
+
assert.strictEqual(idle.runCalls.length, 0, "failed check must not wake the agent");
|
|
767
|
+
assert.strictEqual(idle.sends.length, 0, "idle poll never touches chat");
|
|
768
|
+
const idleJob = idle.jobsStore.get("watch-idle");
|
|
769
|
+
assert.ok(idleJob, "idle watch is left in the store");
|
|
770
|
+
assert.strictEqual(idleJob.status, "pending", "idle watch stays pending for the next tick");
|
|
771
|
+
|
|
772
|
+
// Passing check → wakes agent once with the Watch header; one-shot watch is removed after firing.
|
|
773
|
+
const fired = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-fire" })], {
|
|
774
|
+
runWatchCheck: async () => ({ code: 0, timedOut: false }),
|
|
775
|
+
outcomes: [successOutcome()],
|
|
776
|
+
});
|
|
777
|
+
const firedResult = await fired.service.fireJob("watch-fire");
|
|
778
|
+
assert.strictEqual(firedResult.ok, true);
|
|
779
|
+
assert.strictEqual(firedResult.status, "succeeded");
|
|
780
|
+
assert.strictEqual(fired.runCalls.length, 1, "passing check wakes the agent exactly once");
|
|
781
|
+
assert.match(fired.runCalls[0].prompt, /Watch triggered/, "watch fire carries the Watch triggered header");
|
|
782
|
+
assert.strictEqual(fired.jobsStore.get("watch-fire"), null, "one-shot watch is removed after firing");
|
|
783
|
+
|
|
784
|
+
// Repeat watch → fires but survives with a fresh occurrence so the next tick polls again.
|
|
785
|
+
const repeating = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-repeat", repeat: true })], {
|
|
786
|
+
runWatchCheck: async () => ({ code: 0, timedOut: false }),
|
|
787
|
+
outcomes: [successOutcome()],
|
|
788
|
+
});
|
|
789
|
+
await repeating.service.fireJob("watch-repeat");
|
|
790
|
+
assert.strictEqual(repeating.runCalls.length, 1);
|
|
791
|
+
const repeatJob = repeating.jobsStore.get("watch-repeat");
|
|
792
|
+
assert.ok(repeatJob, "repeat watch survives firing");
|
|
793
|
+
assert.strictEqual(repeatJob.status, "pending");
|
|
794
|
+
assert.strictEqual(repeatJob.occurrenceId, "next-occ-1", "repeat watch advances to a new occurrence");
|
|
795
|
+
|
|
796
|
+
// Global master switch OFF → skip before the check even runs.
|
|
797
|
+
fs.writeFileSync(watchesEnabledFile, "off");
|
|
798
|
+
let checkRan = false;
|
|
799
|
+
const disabled = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-off" })], {
|
|
800
|
+
runWatchCheck: async () => { checkRan = true; return { code: 0, timedOut: false }; },
|
|
801
|
+
outcomes: [successOutcome()],
|
|
802
|
+
});
|
|
803
|
+
const disabledResult = await disabled.service.fireJob("watch-off");
|
|
804
|
+
assert.strictEqual(disabledResult.status, "watch-disabled");
|
|
805
|
+
assert.strictEqual(disabledResult.skipped, true);
|
|
806
|
+
assert.strictEqual(checkRan, false, "disabled watches never run their check");
|
|
807
|
+
assert.strictEqual(disabled.runCalls.length, 0);
|
|
808
|
+
fs.rmSync(watchesEnabledFile, { force: true });
|
|
809
|
+
}
|
|
810
|
+
|
|
730
811
|
function staticSchedulingProbe() {
|
|
731
812
|
const files = [
|
|
732
813
|
"core/jobs.js",
|
|
@@ -742,6 +823,13 @@ function staticSchedulingProbe() {
|
|
|
742
823
|
assert.ok(!/\bsessionKey\b/.test(source[file]), `${file} must not use the removed sessionKey scheduling field`);
|
|
743
824
|
}
|
|
744
825
|
assert.match(source["bin/cli.js"], /selected provider session/i);
|
|
826
|
+
|
|
827
|
+
// Watch feature must be wired end to end: gate → loopback → CLI surfaces.
|
|
828
|
+
assert.match(source["core/scheduler.js"], /job\.check/, "scheduler must gate firing on a watch check");
|
|
829
|
+
assert.match(source["core/scheduler.js"], /watchesEnabled/, "scheduler must consult the global watch switch");
|
|
830
|
+
assert.match(source["core/loopback.js"], /watch-add/, "loopback must route watch-add");
|
|
831
|
+
assert.match(source["bin/schedule.js"], /runWatchAdd/, "schedule CLI must expose watch-add");
|
|
832
|
+
assert.match(source["bin/cli.js"], /watch-add/, "cli must dispatch watch-add");
|
|
745
833
|
}
|
|
746
834
|
|
|
747
835
|
async function main() {
|
|
@@ -762,9 +850,11 @@ async function main() {
|
|
|
762
850
|
await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
|
|
763
851
|
await unavailableAndFallbackProbe(schedulerApi.createSchedulerService);
|
|
764
852
|
await cronOccurrenceProbe(schedulerApi.createSchedulerService);
|
|
853
|
+
await watchGateProbe(schedulerApi.createSchedulerService);
|
|
765
854
|
creationEnrichmentProbe(schedulerApi.createSchedulerService);
|
|
766
855
|
loopbackMetadataProbe(require("./core/runner"), require("./bin/loopback-client"));
|
|
767
856
|
staticSchedulingProbe();
|
|
857
|
+
try { fs.rmSync(watchTmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
768
858
|
console.log("provider scheduler OK");
|
|
769
859
|
}
|
|
770
860
|
|