Compare commits

...

54 Commits

Author SHA1 Message Date
glifocat 1512e3f19e fix(setup): re-probe systemd user session with derived env on su- entry
`setup/service.ts` decides between a real systemd user unit and the
nohup fallback by running `systemctl --user daemon-reload` and watching
for the call to succeed. In contexts that bypass pam_systemd —
`su -`, `pct enter`, headless containers — `XDG_RUNTIME_DIR` and
`DBUS_SESSION_BUS_ADDRESS` are not exported, the probe fails with
`Failed to connect to bus: No medium found`, and we install nohup
despite the user manager being healthy on disk.

Before the probe, check whether linger is enabled for the current user
and `/run/user/<uid>` exists; if so, re-derive the env vars from disk
and let them propagate to subsequent `systemctl --user` calls via
`process.env` (execSync inherits by default). If the probe still fails
after that, the existing nohup fallback runs unchanged — and the
warning log now records *which* precondition failed so the cause is
visible without grepping setup.log.

The pure decision function lives in `setup/systemd-user-env.ts` so it
can be tested without execSync. New regression test in
`setup/systemd-user-env.test.ts` covers the #2482 repro plus the
already_set / no_linger / no_runtime_dir / no_user / no_uid branches.

Closes #1981
Closes #2482
2026-05-15 17:16:12 +02:00
github-actions[bot] fa945a1d0c chore: bump version to 2.0.62 2026-05-14 17:22:20 +00:00
Daniel M bec10fe4e3 Merge pull request #2473 from nanocoai/fix/destinations-remove-scratchpad-clause
fix(destinations): remove misleading scratchpad clause from internal-tag description
2026-05-14 20:22:07 +03:00
Daniel Milliner cbdebe55fc fix(destinations): remove misleading scratchpad clause from internal-tag description
Follow-up to #2467. The trailing "anything outside these tags is also
treated as scratchpad" clause contradicted the rest of the system prompt,
which requires bare text to be wrapped in `<message>` blocks. Removing it
keeps the description focused on what `<internal>` actually does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 20:20:43 +03:00
github-actions[bot] 8f30a7aad3 chore: bump version to 2.0.61 2026-05-14 11:58:02 +00:00
Daniel M b2894bf44c Merge pull request #2467 from nanocoai/Koshkoshinsk/fix/welcome-duplicate-message
fix(welcome): stop emitting the greeting twice
2026-05-14 14:57:46 +03:00
Koshkoshinsk ca52d2c6c1 fix(welcome): stop emitting the greeting twice
The welcome skill told the agent to send the greeting via `send_message`,
but the destinations system prompt also requires the final response to
be wrapped in `<message to="…">` blocks (since 1d4d920). The agent
followed both, sending the greeting once via the MCP tool and once via
the wrapped final output.

- welcome/SKILL.md: drop the mechanism — "send a short, warm greeting"
  lets the system prompt steer how it's delivered.
- destinations.ts: reframe `<message>` blocks and `send_message` as the
  same delivery surface, with the explicit note that each call/block
  lands as its own message — so they compose into a sequence rather than
  reading as additive duplicates of the same content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:12:38 +00:00
glifocat b779a0b5c6 Merge pull request #2460 from madevizslove183/madevizslove183/setup/slack-files-scope
setup: add files:read and files:write to Slack scope checklist
2026-05-13 17:51:06 +02:00
madevizslove183 4d81dc4e0e setup: add files:read and files:write to Slack scope checklist
Without files:read, @chat-adapter/slack cannot download attachments —
Slack returns an HTML login page in place of file bytes and the adapter
throws a NetworkError. Bundles files:write for symmetric outbound
(files.uploadV2).

Closes #2457

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:43:15 +02:00
github-actions[bot] e263352aed chore: bump version to 2.0.60 2026-05-13 07:43:11 +00:00
Gabi Simons d27b1bb291 Merge pull request #2442 from Koshkoshinsk/fix/core-instructions-message-wrapping
fix(core-instructions): require message wrapping for single-destination agents
2026-05-13 00:42:57 -07:00
Koshkoshinsk 1d4d920629 fix(core-instructions): require message wrapping for single-destination agents
The parenthetical "(single-destination: just write)" was stale after
9db39b2 removed the bare-text routing fallback. Agents following this
hint had their responses silently dropped to scratchpad.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 07:27:07 +00:00
gavrielc c9c5ffadc9 fix(setup): pin OneCLI gateway version to 1.23.0
The upstream install script supports ONECLI_VERSION; use it to avoid
pulling an untested gateway release during setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-13 01:16:33 +03:00
github-actions[bot] 001c62c2e4 docs: update token count to 174k tokens · 87% of context window 2026-05-12 17:17:43 +00:00
github-actions[bot] 7334feb8dc chore: bump version to 2.0.59 2026-05-12 17:17:38 +00:00
gavrielc 2eb6a1c62e fix(permissions): skip channel-type prefix for userIds that already contain a colon
Platforms like Teams send userIds in "29:xxx" format which already
include a colon. Blindly prefixing with channelType produced double-
namespaced ids (e.g. "teams:29:xxx") that never matched the users
table, causing all approval clicks to be rejected. Mirror the
resolveOrCreateUser logic: only prefix when the raw id has no colon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 20:17:17 +03:00
github-actions[bot] 61d7ca6bba chore: bump version to 2.0.58 2026-05-11 21:44:24 +00:00
gavrielc 1baea6b9e9 Merge pull request #2414 from nanocoai/fix/unwrapped-output-nudge
fix(poll-loop): nudge agent when output lacks message wrapping
2026-05-12 00:44:10 +03:00
gavrielc 7f4fa65f3c fix(poll-loop): nudge agent when output lacks message wrapping
When the agent outputs bare text without <message to="..."> blocks,
nothing gets delivered — silent failure. Now the poll-loop pushes a
one-shot correction back into the active query telling the agent to
re-send with proper wrapping. Capped at once per user turn to avoid
loops; resets when a new follow-up message arrives.

Also updates destination instructions to require explicit <internal>
wrapping for scratchpad instead of treating bare text as implicit
scratchpad.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 00:30:23 +03:00
github-actions[bot] e0f5967128 docs: update token count to 173k tokens · 87% of context window 2026-05-11 21:25:29 +00:00
github-actions[bot] c1fd830add chore: bump version to 2.0.57 2026-05-11 21:25:10 +00:00
gavrielc 74744599d3 Merge pull request #2413 from nanocoai/fix/compact-instructions-reminder
fix(compact): place destination reminder at end of compaction summary
2026-05-12 00:25:05 +03:00
gavrielc fcbc204a24 Merge pull request #2412 from nanocoai/revert/compaction-destination-reminder
revert: remove compaction destination reminder (PR #2327)
2026-05-12 00:24:50 +03:00
gavrielc 00ddb3b169 fix(compact): place destination reminder at end of compaction summary
Tell the compactor to include the <message to="name"> wrapping reminder
verbatim at the END of the summary so it's the last thing the agent sees
after compaction. Previously the instruction just asked to "preserve"
routing info, which the compactor could place anywhere or summarize away.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 12:49:28 +03:00
gavrielc a760da7fef revert: remove compaction destination reminder (PR #2327)
The compacted event handler injected a system-tagged reminder into the
live query after SDK auto-compaction, which caused the agent to send
an unintended message. Reverts the four changes from #2327:

- Remove `compacted` variant from ProviderEvent union
- Restore `result` yield for compact_boundary in ClaudeProvider
- Remove compacted event handler and getAllDestinations import in poll-loop
- Remove compaction integration tests and CompactingProvider helper

Closes #2325 differently — the reminder approach is not viable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 12:38:49 +03:00
github-actions[bot] 48dfb1b1e0 chore: bump version to 2.0.56 2026-05-11 08:19:03 +00:00
gavrielc 9dfd68d14a Merge pull request #2410 from nanocoai/fix/on-wake-graceful-degrade
fix(container): gracefully handle missing on_wake column
2026-05-11 11:18:48 +03:00
gavrielc 8ac3cf2912 fix(container): gracefully handle missing on_wake column in pre-migration session DBs
The container opens inbound.db read-only, so it can't ALTER TABLE.
If the host hasn't run migrateMessagesInTable yet (e.g., container
rebuilt before host restart), the on_wake column won't exist and
the query crashes, causing a restart loop.

Detect the column via PRAGMA table_info and conditionally include
the filter clause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 11:08:02 +03:00
github-actions[bot] 0a1b396d12 docs: update token count to 175k tokens · 87% of context window 2026-05-11 07:05:10 +00:00
github-actions[bot] cf7da26c34 chore: bump version to 2.0.55 2026-05-11 07:04:57 +00:00
glifocat 6e3c60ce94 Merge pull request #2408 from glifocat/chore/rename-qwibitai-references 2026-05-11 09:04:44 +02:00
glifocat bda72a4bf4 chore: rename remaining qwibitai/nanoclaw references to nanocoai/nanoclaw
Sweep of outbound strings, doc URLs, comments, and clone instructions
that were missed in the original org rename. One both-match case in
setup/lib/channels-remote.sh (URL detection) accepts either name so
existing forks with a `qwibitai` remote continue to resolve cleanly;
everywhere else is a straight rename.

Historical mentions left intact:
- CHANGELOG.md (v2.0.0 entry, frozen history)
- .claude/skills/add-gmail-tool/SKILL.md (pre-v2 qwibitai skill — historical)
- repo-tokens/badge.svg (auto-regenerated by update-tokens.yml)
2026-05-11 08:40:09 +02:00
glifocat 35d667c3ae Merge pull request #2400 from dvirarad/docs/fix-contributing-repo-urls
docs: update CONTRIBUTING.md repo references after nanocoai migration
2026-05-10 23:58:14 +02:00
glifocat a98ce59374 Merge pull request #2402 from glifocat/fix/workflow-repo-guards
fix(ci): workflows no-op after repo rename — update repository guards
2026-05-10 23:29:04 +02:00
glifocat 069928a445 fix(ci): update update-tokens repo guard 2026-05-10 23:24:56 +02:00
glifocat 45189abaf1 fix(ci): update bump-version repo guard 2026-05-10 23:24:46 +02:00
Dvir Arad 43d69a9966 docs: update CONTRIBUTING.md repo references after nanocoai migration 2026-05-10 22:37:26 +03:00
gavrielc e185bb8bad Merge pull request #2392 from glifocat/fix/cli-scope-hardening
fix(cli-scope): fail-closed scopeField enforcement + sessions-get oracle guard
2026-05-10 22:24:46 +03:00
glifocat c6d5cd7d02 fixup(cli-scope): build error, false-positive on custom ops, tests, drop FORK.md
Addresses review feedback on this branch:

- Fix TS2352 build error in dispatch.ts: `getSession()` returns `Session`,
  which has no index signature, so `(s as Record<string, unknown>)` is rejected
  by tsc. `Session.agent_group_id` exists — read it directly.

- Fix a regression introduced by dropping the `groupField in data` guard:
  the post-handler scope check now runs for *every* command under a whitelisted
  resource, including custom ops, which return ad-hoc shapes. `ncl groups config
  get` (access:open, reachable by a group-scoped agent) returns a config object
  with no `id` field → `data['id'] !== ctx.agentGroupId` → `forbidden`, even on
  the agent's own config. Fix: tag the auto-generated list/get handlers with
  `generic: 'list' | 'get'` on `CommandDef` (set in `registerResource`) and run
  the post-handler check only when `cmd.generic` is set. Generic handlers return
  raw DB rows that carry `scopeField`; custom ops are already pinned to the
  caller's group by the pre-handler `--id` auto-fill or the approval gate.
  Fail-closed-when-`scopeField`-missing is preserved (now scoped to generic
  list/get).

- Tests: `dispatch.test.ts` mocks `getResource` (the real resources aren't
  registered in this unit), tags the two post-handler test commands as `generic`,
  and adds coverage for: custom op returning a non-row object not being rejected;
  `sessions-get` pre-handler returning "session not found" for foreign and
  non-existent UUIDs (no existence oracle) and allowing the caller's own session;
  generic list/get failing closed when a resource declares no `scopeField`.
  Full suite: 323 passing.

- Remove FORK.md from the PR diff — it's the fork's personal README, carried in
  because the branch was cut from the fork's `main` rather than upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:47:51 +02:00
glifocat b323b55efe fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:41 +02:00
glifocat bf34857d11 fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:41 +02:00
glifocat d8aa46c0a7 fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:40 +02:00
glifocat 610a692519 fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:30 +02:00
glifocat 8a8ec84ef1 fix(cli-scope): fail-closed scopeField enforcement and sessions-get oracle guard 2026-05-10 20:30:25 +02:00
glifocat 47c85d0985 fix(cli-scope): add scopeField to ResourceDef for fail-closed group scope 2026-05-10 20:30:15 +02:00
glifocat f338bd47ea Merge branch 'nanocoai:main' into main 2026-05-10 20:27:30 +02:00
Gabi Simons 0de46f8b38 Merge pull request #2384 from johnnyfish/fix/mcp-install-credential-instructions
fix: teach agent to use OneCLI gateway credentials after MCP server install
2026-05-10 21:12:25 +03:00
johnnyfish f49de0fb01 fix: teach agent to use OneCLI gateway credentials after MCP server install 2026-05-10 19:23:22 +03:00
glifocat bdb8cf559c Merge branch 'qwibitai:main' into main 2026-05-06 16:25:59 +02:00
glifocat ff90c8f565 Merge branch 'qwibitai:main' into main 2026-05-05 17:29:57 +02:00
glifocat 295275df69 Merge branch 'qwibitai:main' into main 2026-05-05 00:19:11 +02:00
glifocat b92fdb5771 Merge remote-tracking branch 'upstream/main' 2026-04-24 17:12:34 +02:00
glifocat d3581bc65e Merge remote-tracking branch 'upstream/main' 2026-04-24 13:11:51 +02:00
glifocat ae2c09cbde docs: add fork-specific notes in FORK.md 2026-04-23 10:33:54 +02:00
50 changed files with 438 additions and 232 deletions
+2 -2
View File
@@ -228,5 +228,5 @@ Common signals:
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
- **Addresses:** [issue #1500](https://github.com/nanocoai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/nanocoai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
+1 -1
View File
@@ -54,7 +54,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
git remote add upstream https://github.com/nanocoai/nanoclaw.git
```
### Merge the skill branch
+1 -1
View File
@@ -60,7 +60,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
@@ -58,7 +58,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
git remote add upstream https://github.com/nanocoai/nanoclaw.git
```
### Merge the skill branch
+1 -1
View File
@@ -34,7 +34,7 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/nanocoai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
+2 -2
View File
@@ -11,7 +11,7 @@ Run `/update-nanoclaw` in Claude Code.
## How it works
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/nanocoai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.
@@ -69,7 +69,7 @@ If output is non-empty:
Confirm remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Add it: `git remote add upstream <user-provided-url>`
- Then: `git fetch upstream --prune`
+1 -1
View File
@@ -42,7 +42,7 @@ Check remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- `git remote add upstream <url>`
Fetch:
@@ -40,7 +40,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
git remote add upstream https://github.com/nanocoai/nanoclaw.git
```
### Merge the skill branch
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
bump-version:
if: github.repository == 'qwibitai/nanoclaw'
if: github.repository == 'nanocoai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
update-tokens:
if: github.repository == 'qwibitai/nanoclaw'
if: github.repository == 'nanocoai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+3 -3
View File
@@ -4,8 +4,8 @@
1. **Check for existing work.** Search open PRs and issues before starting:
```bash
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
gh pr list --repo nanocoai/nanoclaw --search "<your feature>"
gh issue list --repo nanocoai/nanoclaw --search "<your feature>"
```
If a related PR or issue exists, build on it rather than duplicating effort.
@@ -43,7 +43,7 @@ Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setu
3. Claude walks through interactive setup (env vars, bot creation, etc.)
**Contributing a feature skill:**
1. Fork `qwibitai/nanoclaw` and branch from `main`
1. Fork `nanocoai/nanoclaw` and branch from `main`
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
4. Open a PR. We'll create the `skill/<name>` branch from your work
+2 -2
View File
@@ -26,7 +26,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
## Quick Start
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
@@ -39,7 +39,7 @@ bash nanoclaw.sh
Run from a fresh v2 checkout next to your v1 install:
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClawは同じコア機能を提供しますが、理解できる規模のコ
## クイックスタート
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能
## 快速开始
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
@@ -26,9 +26,9 @@ const instructions = [
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. The `from` attribute identifies which destination sent the message.',
' The agent MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
'3. At the END of the compaction summary, include this verbatim reminder:',
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
];
console.log(instructions.join('\n'));
+15 -1
View File
@@ -10,6 +10,19 @@
import { getConfig } from '../config.js';
import { openInboundDb, getOutboundDb } from './connection.js';
// Cache whether inbound.db has the on_wake column (added in v2.0.48).
// The container opens inbound.db read-only, so it can't ALTER —
// gracefully degrade when running against an older session DB.
let _hasOnWake: boolean | null = null;
function hasOnWakeColumn(db: ReturnType<typeof openInboundDb>): boolean {
if (_hasOnWake !== null) return _hasOnWake;
const cols = new Set(
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
);
_hasOnWake = cols.has('on_wake');
return _hasOnWake;
}
export interface MessageInRow {
id: string;
seq: number | null;
@@ -54,12 +67,13 @@ export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
const outbound = getOutboundDb();
try {
const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : '';
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
AND (on_wake = 0 OR ?1 = 1)
${onWakeFilter}
ORDER BY seq DESC
LIMIT ?2`,
)
@@ -27,18 +27,18 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('from="name"');
expect(prompt).toContain('`casa`');
expect(prompt).toContain('`whatsapp-mg-17780`');
});
it('requires explicit wrapping even for a single destination', () => {
it('describes message wrapping for a single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('`casa`');
});
@@ -47,7 +47,7 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('no configured destinations');
expect(prompt).not.toContain('Default routing');
expect(prompt).not.toContain('default to addressing');
});
it('includes default-routing and wrapping instructions for single destination', () => {
@@ -55,9 +55,9 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('`casa`');
});
});
+6 -7
View File
@@ -115,17 +115,16 @@ function buildDestinationsSection(): string {
}
}
lines.push('');
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
lines.push('');
lines.push(
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
);
lines.push('');
lines.push(
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `<message>` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").',
);
lines.push('');
lines.push(
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
);
return lines.join('\n');
}
@@ -295,115 +295,8 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('should inject destination reminder after a compacted event', async () => {
// Two destinations — required for the reminder to fire (single-destination
// groups have a fallback path that works without <message to="…"> wrapping).
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
)
.run();
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
expect(reminder).toBeDefined();
expect(reminder).toContain('2 destinations');
expect(reminder).toContain('discord-test');
expect(reminder).toContain('discord-second');
expect(reminder).toContain('<message to="name">');
await loopPromise.catch(() => {});
});
it('should NOT inject destination reminder with a single destination', async () => {
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
// Only the original prompt push (if any) — no reminder, since beforeEach
// seeds exactly one destination.
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
expect(reminders).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that emits a single compacted event mid-stream, then returns a
* result. Captures every push() call so tests can assert on the injected
* reminder content.
*/
class CompactingProvider {
readonly supportsNativeSlashCommands = false;
readonly pushes: string[] = [];
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const pushes = this.pushes;
let ended = false;
let aborted = false;
let resolveWaiter: (() => void) | null = null;
async function* events() {
yield { type: 'activity' as const };
yield { type: 'init' as const, continuation: 'compaction-test-session' };
yield { type: 'activity' as const };
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
// Wait for poll-loop to push the reminder (or end / abort)
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
// Belt-and-braces: don't hang forever if the reminder never arrives
setTimeout(resolve, 200);
});
yield { type: 'activity' as const };
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
while (!ended && !aborted) {
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
setTimeout(resolve, 50);
});
}
}
return {
push(message: string) {
pushes.push(message);
resolveWaiter?.();
},
end() {
ended = true;
resolveWaiter?.();
},
abort() {
aborted = true;
resolveWaiter?.();
},
events: events(),
};
}
}
// Helper: run poll loop until aborted or timeout
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
return Promise.race([
@@ -1,6 +1,6 @@
## Sending messages
Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `<message to="name">...</message>` blocks). See that section for the current destination list.
**Every response** must be wrapped in `<message to="name">...</message>` blocks — even if you only have one destination. Bare text outside of `<message>` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names.
### Mid-turn updates (`send_message`)
@@ -22,4 +22,4 @@ Use **`add_mcp_server`** to add an MCP server to your configuration. Browse avai
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
```
Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential.
Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery).
+18 -23
View File
@@ -265,6 +265,7 @@ async function processQuery(
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
let unwrappedNudged = false;
// Concurrent polling: push follow-ups into the active query as they arrive.
// We do NOT force-end the stream on silence — keeping the query open avoids
@@ -338,6 +339,7 @@ async function processQuery(
const keptIds = keep.map((m) => m.id);
const prompt = formatMessages(keep);
log(`Pushing ${keep.length} follow-up message(s) into active query`);
unwrappedNudged = false;
query.push(prompt);
markCompleted(keptIds);
} catch (err) {
@@ -376,24 +378,18 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
dispatchResultText(event.text, routing);
}
} else if (event.type === 'compacted') {
// The SDK auto-compacted the conversation. After compaction the
// model often drops the learned `<message to="…">` wrapping
// discipline (the destinations are still in the system prompt,
// but the behavioral pattern is summarized away). Inject a
// reminder back into the live query so the next turn re-anchors
// on the destination model. Only do this when there's >1
// destination — single-destination groups have a fallback that
// works without wrapping. See qwibitai/nanoclaw#2325.
const destinations = getAllDestinations();
if (destinations.length > 1) {
const names = destinations.map((d) => d.name).join(', ');
query.push(
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
);
const { hasUnwrapped } = dispatchResultText(event.text, routing);
if (hasUnwrapped && !unwrappedNudged) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
}
}
}
@@ -421,9 +417,6 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
case 'progress':
log(`Progress: ${event.message}`);
break;
case 'compacted':
log(`Compacted: ${event.text}`);
break;
}
}
@@ -435,7 +428,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
* The agent must always wrap output in <message to="name">...</message>
* blocks, even with a single destination. Bare text is scratchpad only.
*/
function dispatchResultText(text: string, routing: RoutingContext): void {
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
let match: RegExpExecArray | null;
@@ -470,9 +463,11 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
}
if (sent === 0 && text.trim()) {
const hasUnwrapped = sent === 0 && !!scratchpad;
if (hasUnwrapped) {
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
}
return { sent, hasUnwrapped };
}
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
@@ -336,7 +336,7 @@ export class ClaudeProvider implements AgentProvider {
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
yield { type: 'compacted', text: `Context compacted${detail}.` };
yield { type: 'result', text: `Context compacted${detail}.` };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { summary?: string };
yield { type: 'progress', message: tn.summary || 'Task notification' };
@@ -89,12 +89,4 @@ export type ProviderEvent =
* event (tool call, thinking, partial message, anything) so the
* poll-loop's idle timer stays honest during long tool runs.
*/
| { type: 'activity' }
/**
* The provider's underlying SDK auto-compacted the conversation context.
* The poll-loop reacts by injecting a destination reminder back into
* the live query so the agent doesn't drop `<message to="…">` wrapping
* after compaction. Distinct from `result` so it doesn't mark the turn
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
*/
| { type: 'compacted'; text: string };
| { type: 'activity' };
@@ -4,4 +4,4 @@ Your HTTP requests go through the OneCLI proxy, which injects real credentials a
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI.
If you get a `401`/`403`/`app_not_connected`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.
+1 -1
View File
@@ -9,7 +9,7 @@ You've just been connected to a new user. This your time to shine and make a str
## What to do
1. Send a short, warm greeting using `send_message`
1. Send a short, warm greeting
2. State your name (from your system prompt / CLAUDE.md)
3. Signal that you're capable of a lot — but don't list everything upfront. Be intriguing, not encyclopedic
4. Ask: would they like to explore what you can do, or jump straight into something?
+1 -1
View File
@@ -2,7 +2,7 @@
## Structure
**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**`nanocoai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**Channel forks** (`nanoclaw-whatsapp`, `nanoclaw-telegram`, `nanoclaw-slack`, etc.) — each fork = upstream + one channel's code applied. Users clone upstream, then merge a fork into their clone to add a channel.
+2 -2
View File
@@ -77,7 +77,7 @@ NanoClaw must live inside the workspace directory — Docker-in-Docker can only
```bash
# Clone to home first (virtiofs can corrupt git pack files during clone)
cd ~
git clone https://github.com/qwibitai/nanoclaw.git
git clone https://github.com/nanocoai/nanoclaw.git
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
WORKSPACE=/Users/you/nanoclaw-workspace
@@ -347,7 +347,7 @@ docker sandbox network proxy <sandbox-name> \
### Git clone fails with "inflate: data stream error"
Clone to a non-workspace path first, then move:
```bash
cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
```
### WhatsApp QR code doesn't display
+22 -22
View File
@@ -23,7 +23,7 @@ This replaces the previous `skills-engine/` system (three-way file merging, `.na
### Repository structure
The upstream repo (`qwibitai/nanoclaw`) maintains:
The upstream repo (`nanocoai/nanoclaw`) maintains:
- `main` — core NanoClaw (no skill code)
- `skill/discord` — main + Discord integration
@@ -46,7 +46,7 @@ Skills are split into two categories:
**Feature skills** (in marketplace, installed on demand):
- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc.
- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code
- Live in the marketplace repo (`qwibitai/nanoclaw-skills`)
- Live in the marketplace repo (`nanocoai/nanoclaw-skills`)
Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently:
@@ -78,7 +78,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
"repo": "nanocoai/nanoclaw-skills"
}
}
}
@@ -88,7 +88,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
The marketplace repo uses Claude Code's plugin structure:
```
qwibitai/nanoclaw-skills/
nanocoai/nanoclaw-skills/
.claude-plugin/
marketplace.json # Plugin catalog
plugins/
@@ -213,7 +213,7 @@ A GitHub Action runs on every push to `main`:
### New users (recommended)
1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button)
1. Fork `nanocoai/nanoclaw` on GitHub (click the Fork button)
2. Clone your fork:
```bash
git clone https://github.com/<you>/nanoclaw.git
@@ -229,9 +229,9 @@ Forking is recommended because it gives users a remote to push their customizati
### Existing users migrating from clone
Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations:
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
1. Fork `qwibitai/nanoclaw` on GitHub
1. Fork `nanocoai/nanoclaw` on GitHub
2. Reroute remotes:
```bash
git remote rename origin upstream
@@ -239,7 +239,7 @@ Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` an
git push --force origin main
```
The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose.
3. From this point, `origin` = their fork, `upstream` = qwibitai/nanoclaw
3. From this point, `origin` = their fork, `upstream` = nanocoai/nanoclaw
### Existing users migrating from the old skills engine
@@ -316,7 +316,7 @@ git fetch upstream main
git checkout -b my-fix upstream/main
# Make changes
git push origin my-fix
# Create PR from my-fix to qwibitai/nanoclaw:main
# Create PR from my-fix to nanocoai/nanoclaw:main
```
Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR.
@@ -327,7 +327,7 @@ The flow below is for **feature skills** (branch-based). For utility skills (sel
### Contributor flow (feature skills)
1. Fork `qwibitai/nanoclaw`
1. Fork `nanocoai/nanoclaw`
2. Branch from `main`
3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.)
4. Open a PR to `main`
@@ -345,7 +345,7 @@ When a skill PR is reviewed and approved:
```
2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes)
3. Merge the slimmed PR into `main` (just the contributor addition)
4. Add the skill's SKILL.md to the marketplace repo (`qwibitai/nanoclaw-skills`)
4. Add the skill's SKILL.md to the marketplace repo (`nanocoai/nanoclaw-skills`)
This way:
- The contributor gets merge credit (their PR is merged)
@@ -388,7 +388,7 @@ If the community contributor is trusted, they can open a PR to add their marketp
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
"repo": "nanocoai/nanoclaw-skills"
}
},
"alice-nanoclaw-skills": {
@@ -434,7 +434,7 @@ A flavor is a curated fork of NanoClaw — a combination of skills, custom chang
### Creating a flavor
1. Fork `qwibitai/nanoclaw`
1. Fork `nanocoai/nanoclaw`
2. Merge in the skills you want
3. Make custom changes (trigger word, prompts, integrations, etc.)
4. Your fork's `main` IS the flavor
@@ -462,7 +462,7 @@ Then setup continues normally (dependencies, auth, container, service).
After installation, the user's fork has three remotes:
- `origin` — their fork (push customizations here)
- `upstream``qwibitai/nanoclaw` (core updates)
- `upstream``nanocoai/nanoclaw` (core updates)
- `<flavor-name>` — the flavor fork (flavor updates)
### Updating a flavor
@@ -538,14 +538,14 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
Before:
```bash
git clone https://github.com/qwibitai/NanoClaw.git
git clone https://github.com/nanocoai/NanoClaw.git
cd NanoClaw
claude
```
After:
```
1. Fork qwibitai/nanoclaw on GitHub
1. Fork nanocoai/nanoclaw on GitHub
2. git clone https://github.com/<you>/nanoclaw.git
3. cd nanoclaw
4. claude
@@ -556,8 +556,8 @@ After:
Updates to the setup flow:
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git`
- Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration.
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/nanocoai/nanoclaw.git`
- Check if `origin` points to the user's fork (not nanocoai). If it points to nanocoai, guide them through the fork migration.
- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart)
- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels
- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp)
@@ -573,7 +573,7 @@ Marketplace configuration so the official marketplace is auto-registered:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
"repo": "nanocoai/nanoclaw-skills"
}
}
}
@@ -601,7 +601,7 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
### New infrastructure
- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **Marketplace repo** (`nanocoai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution
- **`/update-skills` skill** — checks for and applies skill branch updates using git history
- **`CONTRIBUTORS.md`** — tracks skill contributors
@@ -650,7 +650,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to.
>
> **If you currently have a clone with local changes**, migrate to a fork:
> 1. Fork `qwibitai/nanoclaw` on GitHub
> 1. Fork `nanocoai/nanoclaw` on GitHub
> 2. Run:
> ```
> git remote rename origin upstream
@@ -668,7 +668,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **Contributing skills**
>
> To contribute a skill:
> 1. Fork `qwibitai/nanoclaw`
> 1. Fork `nanocoai/nanoclaw`
> 2. Branch from `main` and make your code changes
> 3. Open a regular PR
>
+1 -1
View File
@@ -240,7 +240,7 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
printf ' %s\n' "$(dim '4. Log out: exit')"
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/nanocoai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
exit 1
;;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.54",
"version": "2.0.62",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+3 -3
View File
@@ -12,7 +12,7 @@ A GitHub Action that calculates the size of your codebase in terms of tokens and
## Usage
```yaml
- uses: qwibitai/nanoclaw/repo-tokens@v1
- uses: nanocoai/nanoclaw/repo-tokens@v1
with:
include: 'src/**/*.ts'
exclude: 'src/**/*.test.ts'
@@ -34,7 +34,7 @@ Repos using repo-tokens:
| Repo | Badge |
|------|-------|
| [NanoClaw](https://github.com/qwibitai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/qwibitai/NanoClaw/main/repo-tokens/badge.svg) |
| [NanoClaw](https://github.com/nanocoai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/nanocoai/NanoClaw/main/repo-tokens/badge.svg) |
### Full workflow example
@@ -59,7 +59,7 @@ jobs:
with:
python-version: '3.12'
- uses: qwibitai/nanoclaw/repo-tokens@v1
- uses: nanocoai/nanoclaw/repo-tokens@v1
id: tokens
with:
include: 'src/**/*.ts'
+2 -2
View File
@@ -114,7 +114,7 @@ runs:
with open(readme_path, "r", encoding="utf-8") as f:
content = f.read()
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
linked_badge = f'<a href="{repo_tokens_url}">{badge}</a>'
new_content = marker_re.sub(rf"\1{linked_badge}\2", content)
@@ -148,7 +148,7 @@ runs:
lx = label_w // 2
vx = label_w + value_w // 2
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total_w}" height="20" role="img" aria-label="{full_desc}">
<title>{full_desc}</title>
+1 -1
View File
@@ -7,7 +7,7 @@
<clipPath id="r">
<rect width="90" height="20" rx="3" fill="#fff"/>
</clipPath>
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
<a xlink:href="https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens">
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#e05d44"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -146,6 +146,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
' • chat:write',
' • users:read',
' • reactions:write',
' • files:read, files:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
Regular → Executable
+4 -4
View File
@@ -6,10 +6,10 @@
# `upstream`, with `origin` pointing at the user's fork. The channels branch
# only lives upstream, so a hardcoded `git fetch origin channels` fails for
# forks. This helper walks `git remote -v`, picks the remote whose URL points
# at qwibitai/nanoclaw, and prints its name.
# at nanocoai/nanoclaw, and prints its name.
#
# Fallback: if no existing remote matches, add `upstream` pointing at
# github.com/qwibitai/nanoclaw and return that — keeps forks without an
# github.com/nanocoai/nanoclaw and return that — keeps forks without an
# explicit upstream configured working on the first try.
#
# Explicit override: set NANOCLAW_CHANNELS_REMOTE=<name> to skip detection.
@@ -23,7 +23,7 @@ resolve_channels_remote() {
local remote url
while IFS=$'\t' read -r remote url; do
case "$url" in
*qwibitai/nanoclaw*)
*qwibitai/nanoclaw*|*nanocoai/nanoclaw*)
printf '%s' "$remote"
return 0
;;
@@ -33,6 +33,6 @@ resolve_channels_remote() {
# No matching remote — add `upstream` and use it. Silent on failure so
# callers see the eventual `git fetch` error rather than a cryptic
# remote-add failure.
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
git remote add upstream https://github.com/nanocoai/nanoclaw.git 2>/dev/null || true
printf '%s' "upstream"
}
+1 -1
View File
@@ -66,7 +66,7 @@ async function getJson<T>(url: string, token: string, fetchImpl: FetchFn): Promi
const res = await fetchImpl(url, {
headers: {
Authorization: `Bot ${token}`,
'User-Agent': 'NanoClaw-Migration (https://github.com/qwibitai/nanoclaw, 2.x)',
'User-Agent': 'NanoClaw-Migration (https://github.com/nanocoai/nanoclaw, 2.x)',
},
});
if (!res.ok) {
+2 -1
View File
@@ -105,6 +105,7 @@ function writeEnvOnecliUrl(url: string): void {
// Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships.
const ONECLI_GATEWAY_VERSION = '1.23.0';
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
@@ -153,7 +154,7 @@ function installOnecli(): { stdout: string; ok: boolean } {
if (cleanup) stdout += cleanup + '\n';
// Gateway install (docker-compose based, no rate-limit concerns).
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
const gw = runInstall(`export ONECLI_VERSION=${ONECLI_GATEWAY_VERSION} && curl -fsSL onecli.sh/install | sh`);
stdout += gw.stdout;
if (!gw.ok) {
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
+23
View File
@@ -22,6 +22,7 @@ import {
isWSL,
} from './platform.js';
import { emitStatus } from './status.js';
import { computeUserSystemdEnv } from './systemd-user-env.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
@@ -291,12 +292,34 @@ function setupSystemd(
systemctlPrefix = 'systemctl';
log.info('Running as root — installing system-level systemd unit');
} else {
// pam_systemd normally exports XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS
// on login, but invocations via `su -`, `pct enter`, and other non-pam
// entry points skip that step. The user systemd manager is still running
// (linger keeps it alive across sessions), but the daemon-reload probe
// below can't reach it without the env vars. Re-derive them from on-disk
// state before probing so we don't false-negative into the nohup path
// when a real systemd user session is available. See #2482.
const envResult = computeUserSystemdEnv({
uid: process.getuid?.(),
user: process.env.USER || process.env.LOGNAME,
env: { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR },
exists: fs.existsSync,
});
if (envResult.reason === 'populated') {
process.env.XDG_RUNTIME_DIR = envResult.XDG_RUNTIME_DIR;
process.env.DBUS_SESSION_BUS_ADDRESS = envResult.DBUS_SESSION_BUS_ADDRESS;
log.info('Populated systemd user env from linger state', {
XDG_RUNTIME_DIR: envResult.XDG_RUNTIME_DIR,
});
}
// Check if user-level systemd session is available
try {
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
} catch {
log.warn(
'systemd user session not available — falling back to nohup wrapper',
{ envProbeReason: envResult.reason },
);
setupNohupFallback(projectRoot, nodePath, homeDir);
return;
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { computeUserSystemdEnv } from './systemd-user-env.js';
function existsFrom(paths: Set<string>) {
return (p: string) => paths.has(p);
}
describe('computeUserSystemdEnv', () => {
it('populates env when linger is on and the runtime dir exists (#2482 repro)', () => {
const result = computeUserSystemdEnv({
uid: 1000,
user: 'nanoclaw',
env: {},
exists: existsFrom(
new Set(['/var/lib/systemd/linger/nanoclaw', '/run/user/1000']),
),
});
expect(result).toEqual({
reason: 'populated',
XDG_RUNTIME_DIR: '/run/user/1000',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
});
});
it('no-ops when XDG_RUNTIME_DIR is already set (SSH login path)', () => {
const result = computeUserSystemdEnv({
uid: 1000,
user: 'nanoclaw',
env: { XDG_RUNTIME_DIR: '/run/user/1000' },
exists: existsFrom(
new Set(['/var/lib/systemd/linger/nanoclaw', '/run/user/1000']),
),
});
expect(result.reason).toBe('already_set');
expect(result.XDG_RUNTIME_DIR).toBeUndefined();
expect(result.DBUS_SESSION_BUS_ADDRESS).toBeUndefined();
});
it('returns no_linger when the linger marker is absent', () => {
const result = computeUserSystemdEnv({
uid: 1000,
user: 'nanoclaw',
env: {},
exists: existsFrom(new Set(['/run/user/1000'])),
});
expect(result.reason).toBe('no_linger');
expect(result.XDG_RUNTIME_DIR).toBeUndefined();
});
it('returns no_runtime_dir when linger is on but /run/user/<uid> is missing', () => {
// The defensive guard from #2482: without this we would point env vars
// at a non-existent socket and the daemon-reload probe would fail with a
// less recoverable error than the bare "No medium found".
const result = computeUserSystemdEnv({
uid: 1000,
user: 'nanoclaw',
env: {},
exists: existsFrom(new Set(['/var/lib/systemd/linger/nanoclaw'])),
});
expect(result.reason).toBe('no_runtime_dir');
expect(result.XDG_RUNTIME_DIR).toBeUndefined();
});
it('returns no_user when USER and LOGNAME are both missing', () => {
const result = computeUserSystemdEnv({
uid: 1000,
user: undefined,
env: {},
exists: existsFrom(new Set()),
});
expect(result.reason).toBe('no_user');
});
it('returns no_uid when process.getuid is unavailable', () => {
const result = computeUserSystemdEnv({
uid: undefined,
user: 'nanoclaw',
env: {},
exists: existsFrom(new Set()),
});
expect(result.reason).toBe('no_uid');
});
});
+38
View File
@@ -0,0 +1,38 @@
export interface SystemdUserEnvDeps {
uid: number | undefined;
user: string | undefined;
env: { XDG_RUNTIME_DIR?: string };
exists: (path: string) => boolean;
}
export type SystemdUserEnvReason =
| 'already_set'
| 'no_user'
| 'no_uid'
| 'no_linger'
| 'no_runtime_dir'
| 'populated';
export interface SystemdUserEnvResult {
reason: SystemdUserEnvReason;
XDG_RUNTIME_DIR?: string;
DBUS_SESSION_BUS_ADDRESS?: string;
}
export function computeUserSystemdEnv(
deps: SystemdUserEnvDeps,
): SystemdUserEnvResult {
if (deps.env.XDG_RUNTIME_DIR) return { reason: 'already_set' };
if (!deps.user) return { reason: 'no_user' };
if (typeof deps.uid !== 'number') return { reason: 'no_uid' };
if (!deps.exists(`/var/lib/systemd/linger/${deps.user}`)) {
return { reason: 'no_linger' };
}
const runtimeDir = `/run/user/${deps.uid}`;
if (!deps.exists(runtimeDir)) return { reason: 'no_runtime_dir' };
return {
reason: 'populated',
XDG_RUNTIME_DIR: runtimeDir,
DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDir}/bus`,
};
}
+8
View File
@@ -52,6 +52,12 @@ export interface ResourceDef {
description: string;
/** Primary key column name. */
idColumn: string;
/**
* Column that carries the agent group ID for group-scope enforcement.
* Required on every resource in the CLI whitelist (groups, sessions,
* destinations, members). When absent, post-handler filtering fails closed.
*/
scopeField?: string;
columns: ColumnDef[];
/** Which standard CRUD operations are enabled. */
operations: {
@@ -226,6 +232,7 @@ export function registerResource(def: ResourceDef): void {
description: `List all ${def.plural}.`,
access: def.operations.list,
resource: def.plural,
generic: 'list',
parseArgs: (raw) => normalizeArgs(raw),
handler: genericList(def),
});
@@ -237,6 +244,7 @@ export function registerResource(def: ResourceDef): void {
description: `Get a ${def.name} by ID.`,
access: def.operations.get,
resource: def.plural,
generic: 'get',
parseArgs: (raw) => normalizeArgs(raw),
handler: genericGet(def),
});
+109
View File
@@ -21,6 +21,13 @@ vi.mock('../db/sessions.js', () => ({
getSession: (...args: unknown[]) => mockGetSession(...args),
}));
// dispatch's post-handler looks up the resource's `scopeField` via getResource.
// The real resources aren't registered in this unit test, so mock it.
const mockGetResource = vi.fn();
vi.mock('./crud.js', () => ({
getResource: (...args: unknown[]) => mockGetResource(...args),
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
@@ -97,6 +104,7 @@ register({
description: 'returns mock group rows',
resource: 'groups',
access: 'open',
generic: 'list',
parseArgs: (raw) => raw,
handler: async () => [
{ id: 'g1', name: 'my-group' },
@@ -109,6 +117,7 @@ register({
description: 'returns a mock session row',
resource: 'sessions',
access: 'open',
generic: 'get',
parseArgs: (raw) => raw,
handler: async (args) => ({
id: args.id,
@@ -116,11 +125,43 @@ register({
}),
});
// A custom op under the `groups` resource that returns a config-shaped object
// (no `id` key). The post-handler must not touch this — only `generic` handlers.
register({
name: 'groups-config-get',
description: 'custom op returning a config object (no id)',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => ({ agent_group_id: 'g1', model: 'opus' }),
});
// The real `sessions-get` name — triggers the pre-handler ownership check.
register({
name: 'sessions-get',
description: 'generic sessions get',
resource: 'sessions',
access: 'open',
generic: 'get',
parseArgs: (raw) => raw,
handler: async (args) => ({ id: (args as Record<string, unknown>).id, agent_group_id: 'g1' }),
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
// Default: the four CLI-whitelisted resources with their real scopeFields.
const scopeFields: Record<string, string> = {
groups: 'id',
sessions: 'agent_group_id',
destinations: 'agent_group_id',
members: 'agent_group_id',
};
mockGetResource.mockImplementation((plural: string) =>
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
);
});
// --- Helpers ---
@@ -402,4 +443,72 @@ describe('CLI scope enforcement', () => {
expect(data).toHaveLength(2); // both groups returned
}
});
// --- Custom ops bypass post-handler row filtering (regression: #2392 review) ---
it('group: a custom op returning a non-row object is not falsely rejected', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
// groups-config-get is access:open and reachable by a group-scoped agent;
// it returns { agent_group_id, model } with no `id` field. Before this fix
// the post-handler compared data['id'] (undefined) and returned forbidden.
const resp = await dispatch({ id: '1', command: 'groups-config-get', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
expect((resp.data as { model: string }).model).toBe('opus');
}
});
// --- sessions-get pre-handler ownership check (no existence oracle) ---
it('group: sessions-get returns "session not found" for a foreign session UUID', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's-x', agent_group_id: 'other-group' });
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-x' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('handler-error');
expect(resp.error.message).toContain('session not found');
}
});
it('group: sessions-get returns "session not found" for a non-existent UUID', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue(undefined);
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-nope' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('handler-error');
expect(resp.error.message).toContain('session not found');
}
});
it('group: sessions-get allows the callers own session', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's-mine', agent_group_id: 'g1' });
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-mine' } }, agentCtx());
expect(resp.ok).toBe(true);
});
// --- Fail-closed regression guard for a missing scopeField ---
it('group: generic list/get fails closed when the resource declares no scopeField', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetResource.mockReturnValue(undefined); // a whitelisted resource that forgot scopeField
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('not available in group scope');
}
});
});
+32 -7
View File
@@ -11,6 +11,7 @@ import { getAgentGroup } from '../db/agent-groups.js';
import { getSession } from '../db/sessions.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { getResource } from './crud.js';
import { lookup } from './registry.js';
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
@@ -87,6 +88,16 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
fill.id = req.args.id ?? ctx.agentGroupId;
}
req = { ...req, args: { ...req.args, ...fill } };
// Fail-closed pre-handler check for sessions-get: returns "not found"
// regardless of whether the UUID exists in another group, preventing an
// existence oracle across group boundaries.
if (cmd.resource === 'sessions' && req.command === 'sessions-get' && req.args.id) {
const s = getSession(req.args.id as string);
if (!s || s.agent_group_id !== ctx.agentGroupId) {
return err(req.id, 'handler-error', `session not found: ${req.args.id}`);
}
}
}
}
@@ -124,14 +135,28 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
try {
let data = await cmd.handler(parsed, ctx);
// Post-handler group scope enforcement: filter/verify results belong
// to the caller's agent group. Catches leaks that pre-handler auto-fill
// can't prevent (e.g. `groups list` where the id arg is skipped by the
// generic list handler, or `sessions get` by UUID).
if (ctx.caller === 'agent' && cmd.resource) {
// Post-handler group-scope enforcement. Applies only to the auto-generated
// `list` / `get` handlers (`cmd.generic`), which return raw DB rows carrying
// the resource's `scopeField`:
// - `list` → drop rows that don't belong to the caller's agent group
// (covers `groups list`, where the generic list handler ignores
// the auto-filled `--id`)
// - `get` → reject if the single row belongs to another group
// Custom operations return ad-hoc shapes (e.g. `groups config get` → a config
// object with no `id`) and are NOT checked here — they would be falsely
// rejected, and they're already pinned to the caller's group by the
// pre-handler `--id` auto-fill (groups/destinations) or gated behind approval,
// so they can't reach another group's data anyway.
if (ctx.caller === 'agent' && cmd.resource && cmd.generic) {
const configRow = getContainerConfig(ctx.agentGroupId);
if ((configRow?.cli_scope ?? 'group') === 'group') {
const groupField = cmd.resource === 'groups' ? 'id' : 'agent_group_id';
const def = getResource(cmd.resource);
const groupField = def?.scopeField;
if (!groupField) {
// Fail closed: a whitelisted resource exposing list/get must declare
// `scopeField` so its rows can be filtered.
return err(req.id, 'forbidden', `"${cmd.resource}" is not available in group scope.`);
}
if (Array.isArray(data)) {
data = data.filter(
(row) =>
@@ -139,7 +164,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
row !== null &&
(row as Record<string, unknown>)[groupField] === ctx.agentGroupId,
);
} else if (data && typeof data === 'object' && groupField in (data as Record<string, unknown>)) {
} else if (data && typeof data === 'object') {
if ((data as Record<string, unknown>)[groupField] !== ctx.agentGroupId) {
return err(req.id, 'forbidden', 'Resource belongs to a different agent group.');
}
+7
View File
@@ -15,6 +15,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
access: Access;
/** Resource this command belongs to (for help grouping). */
resource?: string;
/**
* Set on the auto-generated `list` / `get` handlers (see `registerResource`).
* These return raw DB rows that carry the resource's `scopeField`, so the
* dispatcher applies post-handler group-scope filtering to their output.
* Custom operations return ad-hoc shapes and leave this undefined.
*/
generic?: 'list' | 'get';
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
parseArgs: (raw: Record<string, unknown>) => TArgs;
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
+1
View File
@@ -8,6 +8,7 @@ registerResource({
description:
'Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.',
idColumn: 'agent_group_id',
scopeField: 'agent_group_id',
columns: [
{
name: 'agent_group_id',
+1
View File
@@ -38,6 +38,7 @@ registerResource({
description:
'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.',
idColumn: 'id',
scopeField: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{
+1
View File
@@ -8,6 +8,7 @@ registerResource({
description:
'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".',
idColumn: 'user_id',
scopeField: 'agent_group_id',
columns: [
{
name: 'user_id',
+1
View File
@@ -7,6 +7,7 @@ registerResource({
description:
'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.',
idColumn: 'id',
scopeField: 'agent_group_id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{ name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' },
+13 -6
View File
@@ -227,11 +227,14 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
if (!row) return false;
// payload.userId is the raw platform userId (e.g. "6037840640"); namespace it
// with the channel type so it matches users(id) format. Then verify the
// clicker is the designated approver OR has owner/admin privilege over this
// agent group — any other click is rejected so random users can't self-admit
// via stolen card forwarding.
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
// with the channel type so it matches users(id) format. Some platforms
// (e.g. Teams "29:xxx") already include a colon — mirror resolveOrCreateUser
// logic and only prefix when the raw id has no colon.
const clickerId = payload.userId
? payload.userId.includes(':')
? payload.userId
: `${payload.channelType}:${payload.userId}`
: null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
if (!isAuthorized) {
@@ -308,7 +311,11 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
const clickerId = payload.userId
? payload.userId.includes(':')
? payload.userId
: `${payload.channelType}:${payload.userId}`
: null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
if (!isAuthorized) {