Compare commits

..

18 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
13 changed files with 187 additions and 27 deletions
+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**
@@ -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('All output 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('All output 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 -6
View File
@@ -115,16 +115,16 @@ function buildDestinationsSection(): string {
}
}
lines.push('');
lines.push('**All output must be wrapped.** Use `<message to="name">...</message>` for content to send, or `<internal>...</internal>` for scratchpad.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Bare text (outside of `<message>` or `<internal>` blocks) is not allowed and will not be delivered.');
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');
}
@@ -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`)
+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
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.57",
"version": "2.0.62",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="173k tokens, 87% of context window">
<title>173k tokens, 87% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="174k tokens, 87% of context window">
<title>174k tokens, 87% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">173k</text>
<text x="71" y="14">173k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">174k</text>
<text x="71" y="14">174k</text>
</g>
</g>
</a>

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"',
+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`,
};
}
+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) {