mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1512e3f19e | |||
| fa945a1d0c | |||
| bec10fe4e3 | |||
| cbdebe55fc | |||
| 8f30a7aad3 | |||
| b2894bf44c | |||
| ca52d2c6c1 | |||
| b779a0b5c6 | |||
| 4d81dc4e0e | |||
| e263352aed | |||
| d27b1bb291 | |||
| 1d4d920629 | |||
| c9c5ffadc9 | |||
| 001c62c2e4 | |||
| 7334feb8dc | |||
| 2eb6a1c62e | |||
| 61d7ca6bba | |||
| 1baea6b9e9 |
@@ -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`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user