mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
feat(setup): effect:check predicate gate — precondition checks that bounce/defer/gate
Add an `effect:check` branch to the run-case in the apply engine: it runs the
body as a shell predicate, mutating nothing (no journal, no capture). A non-zero
exit bounces to an agent and latches the run-health `blocked` flag, so a failed
precondition gates the dangerous side effects (restart, pairing/QR step, wire)
that follow; an unresolved {{var}} defers. Document the new effect in the
directive grammar.
Author the four lost safety preconditions as one-line nc:run effect:check blocks
beside their prose: add-imessage (non-Mac local hard-fail), add-whatsapp (empty
captured bot_phone guard), add-teams (Azure app_id set before manifest build),
add-signal (signal-cli presence probe before linking).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,15 @@ How should iMessage run — `local` (this Mac, needs Full Disk Access) or `remot
|
||||
Requirements: macOS, with Full Disk Access granted to the Node binary. Without
|
||||
it the adapter can't read `chat.db` and inbound messages never arrive.
|
||||
|
||||
Local mode only works on a Mac — it reads this machine's iMessage `chat.db`
|
||||
directly, and there is no such database off macOS. On any other OS, stop here and
|
||||
use remote (Photon) mode instead; otherwise you'd write a local config that can
|
||||
never receive a message:
|
||||
|
||||
```nc:run effect:check when:mode=local
|
||||
[ "$(uname)" = Darwin ]
|
||||
```
|
||||
|
||||
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`),
|
||||
so open its folder in Finder to make the drag-and-drop target obvious. Harmless
|
||||
off a desktop (SSH/headless) — it just no-ops:
|
||||
|
||||
@@ -84,7 +84,17 @@ Signal account is verified manually once the service runs.
|
||||
This is the whole credential step. signal-cli opens a device-link handshake,
|
||||
prints a `sgnl://linkdevice…` URL, and renders it as a scannable QR. You scan it
|
||||
once from the phone that already runs Signal; that phone's number becomes the
|
||||
account NanoClaw sends and receives as — no number is registered. Tell the user:
|
||||
account NanoClaw sends and receives as — no number is registered.
|
||||
|
||||
The device-link runs signal-cli, so it must be reachable first — on `PATH`, or at
|
||||
`$SIGNAL_CLI_PATH`. If step 1's install didn't land, the link step has nothing to
|
||||
drive; confirm it's present before linking (re-run step 1 if this fails):
|
||||
|
||||
```nc:run effect:check
|
||||
command -v signal-cli >/dev/null 2>&1 || [ -x "$SIGNAL_CLI_PATH" ]
|
||||
```
|
||||
|
||||
Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Link NanoClaw to your Signal account:
|
||||
|
||||
@@ -176,6 +176,14 @@ Enable the Microsoft Teams channel on the bot:
|
||||
|
||||
### Build the Teams app package
|
||||
|
||||
The manifest bakes in the Application (client) ID, so the Azure app registration
|
||||
above must be done first — building with a blank `app_id` produces a package that
|
||||
no bot can claim. Confirm it's set before generating the zip:
|
||||
|
||||
```nc:run effect:check
|
||||
[ -n "{{app_id}}" ]
|
||||
```
|
||||
|
||||
Generate the zip you'll sideload into Teams (manifest + icons, written to
|
||||
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
|
||||
is safe to repeat.
|
||||
|
||||
@@ -121,6 +121,14 @@ pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --pho
|
||||
If the handshake fails (`logged_out` or a timeout), the code expired — clear
|
||||
`store/auth/` and run the step again for a fresh one. See Troubleshooting.
|
||||
|
||||
A successful link reports the number back as `bot_phone`. If it came back empty,
|
||||
the device never confirmed (an expired QR or pairing code), so don't restart or
|
||||
wire against a blank number — clear `store/auth/` and re-run the link step first:
|
||||
|
||||
```nc:run effect:check
|
||||
[ -n "{{bot_phone}}" ]
|
||||
```
|
||||
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the WhatsApp adapter and picks up the
|
||||
|
||||
@@ -683,6 +683,100 @@ describe('run-health gate (a bounce blocks later side effects)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// effect:check runs the body as a shell PREDICATE — a precondition gate that
|
||||
// mutates NOTHING (no journal, no capture). A non-zero exit bounces to an agent
|
||||
// AND latches `blocked`, so a following dangerous side effect (a restart) is
|
||||
// gated. An unresolved {{var}} defers (a headless rebuild before the value is
|
||||
// collected). A zero exit is a silent pass.
|
||||
const CHECK_GATE_SKILL = `# check gate demo
|
||||
|
||||
## Require macOS for local mode
|
||||
\`\`\`nc:run effect:check
|
||||
[ "$(uname)" = Darwin ]
|
||||
\`\`\`
|
||||
|
||||
## Restart the service
|
||||
\`\`\`nc:run effect:restart
|
||||
bash restart.sh
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const CHECK_VAR_SKILL = `# check var demo
|
||||
|
||||
## Collect the linked number
|
||||
\`\`\`nc:prompt bot_phone
|
||||
The linked number.
|
||||
\`\`\`
|
||||
|
||||
## Guard the captured value before using it
|
||||
\`\`\`nc:run effect:check
|
||||
[ -n "{{bot_phone}}" ]
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const CHECK_PASS_SKILL = `# check pass demo
|
||||
|
||||
## A precondition that passes
|
||||
\`\`\`nc:run effect:check
|
||||
true
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
describe('nc:run effect:check (precondition gate)', () => {
|
||||
let chkSkill: string;
|
||||
let chkRoot: string;
|
||||
beforeEach(() => {
|
||||
chkSkill = mkdtempSync(join(tmpdir(), 'nc-check-skill-'));
|
||||
chkRoot = mkdtempSync(join(tmpdir(), 'nc-check-proj-'));
|
||||
writeFileSync(join(chkRoot, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(join(chkRoot, '.env'), '');
|
||||
});
|
||||
|
||||
it('a non-zero check bounces to an agent and gates a following effect:restart', async () => {
|
||||
writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_GATE_SKILL);
|
||||
const cmds: string[] = [];
|
||||
const exec = (c: string): string | void => {
|
||||
cmds.push(c);
|
||||
if (c.startsWith('[')) throw new Error('exit 1'); // predicate failed (non-zero)
|
||||
};
|
||||
const res = await applySkill(chkSkill, chkRoot, { inputs: {}, exec });
|
||||
|
||||
expect(cmds).toContain('[ "$(uname)" = Darwin ]'); // the predicate actually ran
|
||||
expect(cmds).not.toContain('bash restart.sh'); // restart never executed — gated by the failed check
|
||||
// two agent tasks: the failed check itself + the gated restart
|
||||
expect(res.agentTasks).toHaveLength(2);
|
||||
expect(res.agentTasks[0].kind).toBe('run');
|
||||
expect(res.agentTasks.some((t) => /an earlier step did not complete/.test(t.reason))).toBe(true);
|
||||
expect(res.journal).toEqual([]); // a check mutates nothing
|
||||
});
|
||||
|
||||
it('an unresolved {{var}} in a check defers (headless rebuild) — not a bounce', async () => {
|
||||
writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_VAR_SKILL);
|
||||
const cmds: string[] = [];
|
||||
const res = await applySkill(chkSkill, chkRoot, { prompter: headless({}), exec: (c) => void cmds.push(c) });
|
||||
|
||||
expect(res.deferred).toContain('bot_phone'); // the prompt deferred (no headless answer)
|
||||
expect(res.deferred.some((d) => /unresolved \{\{bot_phone\}\}/.test(d))).toBe(true); // the check deferred on it
|
||||
expect(res.agentTasks).toEqual([]); // a deferred check is NOT a failure — no bounce
|
||||
expect(cmds.some((c) => c.startsWith('['))).toBe(false); // the predicate never ran (var unresolved)
|
||||
});
|
||||
|
||||
it('a zero-exit check is a no-op — no journal entry, no bounce, no defer', async () => {
|
||||
writeFileSync(join(chkSkill, 'SKILL.md'), CHECK_PASS_SKILL);
|
||||
const cmds: string[] = [];
|
||||
const res = await applySkill(chkSkill, chkRoot, { inputs: {}, exec: (c) => void cmds.push(c) });
|
||||
|
||||
expect(cmds).toContain('true'); // the predicate ran
|
||||
expect(res.journal).toEqual([]); // mutated nothing — no 'ran' entry
|
||||
expect(res.agentTasks).toEqual([]);
|
||||
expect(res.deferred).toEqual([]);
|
||||
});
|
||||
|
||||
it('lint accepts a check that guards an earlier-defined var', () => {
|
||||
expect(validate(parseDirectives(CHECK_VAR_SKILL))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// The apply-lifecycle reporter brackets each real mutation (applyOne) with
|
||||
// stepStart/stepEnd so the setup driver can spin on the slow ones. An effectful
|
||||
// step (a run, a dep, a branch-fetch copy) carries a human label (the nearest
|
||||
|
||||
@@ -418,6 +418,19 @@ async function applyOne(
|
||||
// API (e.g. Slack conversations.open → the DM channel id) and feed it to a
|
||||
// later directive, so a flow that validates/resolves stays pure directives.
|
||||
const capture = typeof d.attrs.capture === 'string' ? d.attrs.capture : undefined;
|
||||
// effect:check runs the body as a shell PREDICATE — a precondition gate
|
||||
// that mutates NOTHING. It pushes no journal entry and binds no capture: a
|
||||
// zero exit is a silent pass; a non-zero exit throws → the outer catch
|
||||
// bounces it to an agent (which reads the prose and decides); an unresolved
|
||||
// {{var}} throws from substitute first → deferred (like any other run, e.g.
|
||||
// a headless rebuild before the value is collected). Because a bounce here
|
||||
// latches `blocked`, a failed precondition gates the dangerous side effects
|
||||
// (a restart, a pairing/QR step, a wire) that follow — a broken local
|
||||
// config or an un-registered app never reaches a doomed restart/QR.
|
||||
if (d.attrs.effect === 'check') {
|
||||
for (const cmd of d.body) await exec(substitute(cmd, vars));
|
||||
break;
|
||||
}
|
||||
// effect:step runs a long-running, operator-interactive step (a pairing
|
||||
// code, a QR device-link) through the streaming exec and binds the terminal
|
||||
// status block's named fields via capture:<var>=<FIELD>[,…] — the structured,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
// copy [from-branch:<b>] body: `PATH` (src==dst) or `SRC -> DST` overwrite
|
||||
// append to:<file> [at:<marker>] body: line(s) to add skip if present
|
||||
// dep [manager:pnpm] body: `pkg@<exact-semver>` line(s) reinstall no-op
|
||||
// run [effect:build|test|fetch|external|wire|restart|step] [capture:<spec>] re-runnable
|
||||
// run [effect:build|test|fetch|external|wire|restart|step|check] [capture:<spec>] re-runnable
|
||||
// body: shell command(s). {{vars}} are substituted in. effect:wire runs
|
||||
// `ncl …` to wire collected input (no undo — the rows it creates are user
|
||||
// runtime data, not reversed on skill remove). effect:restart restarts the
|
||||
@@ -34,6 +34,11 @@
|
||||
// `capture:<var>=<FIELD>[,<var2>=<FIELD2>…]` binds the terminal block's
|
||||
// named fields into {{vars}} (multi-valued, structured twin of stdout
|
||||
// capture). Degrades to an agent when no streaming exec is wired.
|
||||
// effect:check runs the body as a shell PREDICATE (a precondition gate):
|
||||
// it mutates nothing (no journal, no capture). A zero exit passes silently;
|
||||
// a non-zero exit bounces to an agent (degrade, not crash) and, via the
|
||||
// run-health gate, blocks the dangerous side effects that follow it (a
|
||||
// restart, a pairing/QR step, a wire). An unresolved {{var}} defers.
|
||||
// prompt <var> [secret] [validate:<re>] body: the question → binds {{var}} skip if satisfied
|
||||
// validate:<re> is a regex the interactive prompter enforces (e.g.
|
||||
// validate:^xoxb- to require a Slack bot token); `inputs` bypass it.
|
||||
|
||||
Reference in New Issue
Block a user