Skip to content

Commit 9a14230

Browse files
authored
Merge branch 'main' into feat/add-clidash-skill
2 parents e856e92 + 3f39f57 commit 9a14230

11 files changed

Lines changed: 362 additions & 32 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<a href="https://docs.nanoclaw.dev">docs</a>&nbsp;&nbsp;
1212
<a href="README_zh.md">中文</a>&nbsp;&nbsp;
1313
<a href="README_ja.md">日本語</a>&nbsp;&nbsp;
14+
<a href="README_ko.md">한국어</a>&nbsp;&nbsp;
1415
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp;&nbsp;
1516
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
1617
</p>

README_ja.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<a href="https://docs.nanoclaw.dev">ドキュメント</a>&nbsp;&nbsp;
1212
<a href="README.md">English</a>&nbsp;&nbsp;
1313
<a href="README_zh.md">中文</a>&nbsp;&nbsp;
14+
<a href="README_ko.md">한국어</a>&nbsp;&nbsp;
1415
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp;&nbsp;
1516
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
1617
</p>

README_ko.md

Lines changed: 228 additions & 0 deletions
Large diffs are not rendered by default.

README_zh.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<a href="https://docs.nanoclaw.dev">文档</a>&nbsp;&nbsp;
1212
<a href="README.md">English</a>&nbsp;&nbsp;
1313
<a href="README_ja.md">日本語</a>&nbsp;&nbsp;
14+
<a href="README_ko.md">한국어</a>&nbsp;&nbsp;
1415
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp;&nbsp;
1516
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
1617
</p>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "nanoclaw",
3-
"version": "2.1.17",
3+
"version": "2.1.18",
44
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
55
"type": "module",
66
"packageManager": "pnpm@10.33.0",

setup/lib/captured-token.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { extractClaudeOAuthToken } from './captured-token.js';
4+
5+
// A syntactically valid token: sk-ant-oat + 93 token chars + AA.
6+
const TOKEN = `sk-ant-oat01-${'a'.repeat(90)}AA`;
7+
8+
describe('extractClaudeOAuthToken', () => {
9+
it('extracts the token from clean single-line output (normal terminal)', () => {
10+
const raw = `Login successful.\nYour token:\n${TOKEN}\n`;
11+
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
12+
});
13+
14+
// The actual sbx failure shape: the real token wrapped across two lines AND
15+
// the `export CLAUDE_CODE_OAUTH_TOKEN=<token>` placeholder in the same
16+
// capture. The old parser returned null (matched only the first fragment);
17+
// the normalizer must un-wrap the real token and never mistake the
18+
// placeholder for it.
19+
it('extracts the real wrapped token from sbx capture and ignores the placeholder export', () => {
20+
const head = TOKEN.slice(0, 72);
21+
const tail = TOKEN.slice(72);
22+
const raw = `
23+
\x1b[?2026h✓ Long-lived authentication token created successfully!
24+
25+
Your OAuth token (valid for 1 year):
26+
27+
${head}
28+
${tail}
29+
30+
Store this token securely. You won't be able to see it again.
31+
32+
Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token>
33+
`;
34+
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
35+
});
36+
37+
it('returns null for the placeholder env-var line, not a real token', () => {
38+
expect(extractClaudeOAuthToken('export CLAUDE_CODE_OAUTH_TOKEN=<token>\n')).toBeNull();
39+
});
40+
41+
it('returns null when no token is present', () => {
42+
expect(extractClaudeOAuthToken('claude: authentication cancelled\n')).toBeNull();
43+
});
44+
});

setup/lib/captured-token.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Parse a provider auth token out of interactive CLI output captured through
3+
* a PTY (`script(1)`).
4+
*
5+
* Secret this module hides: the menagerie of PTY-capture artifacts that
6+
* corrupt an otherwise whitespace-free secret. A real terminal wraps long
7+
* lines, pads with spaces, and interleaves ANSI/control sequences, so a token
8+
* the CLI printed as one string lands in the capture split across lines with
9+
* escape codes embedded. Provider login itself succeeds — only our parse of
10+
* the human-oriented output fails.
11+
*
12+
* A normalize step strips the capture artifacts; the extractor matches the
13+
* token shape against the clean string. A future provider adds its own
14+
* extractor here rather than regexing raw `script(1)` output.
15+
*
16+
* Runnable as a CLI for the bash callers that can't import TS:
17+
* tsx setup/lib/captured-token.ts claude <capture-file>
18+
* Prints the token and exits 0, or exits 1 with nothing on stdout.
19+
*/
20+
import fs from 'fs';
21+
import { pathToFileURL } from 'url';
22+
23+
/* eslint-disable no-control-regex -- these patterns exist precisely to match
24+
the ESC/control bytes a PTY capture is full of. */
25+
// CSI sequences (colors, cursor moves): ESC [ , optional private '?' /
26+
// parameter bytes, optional intermediate bytes, one final byte. Stripped
27+
// explicitly because a colour reset mid-token (sk…\x1b[0m…AA) would otherwise
28+
// leave a `[` that breaks the token's character run.
29+
const CSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
30+
// Everything <= space (control bytes incl. any stray ESC, CR/LF, tabs, and the
31+
// wrap-padding spaces inserted mid-token) plus DEL. Tokens contain none of these.
32+
const CONTROL_AND_SPACE = /[\x00-\x20\x7f]/g;
33+
/* eslint-enable no-control-regex */
34+
35+
/**
36+
* Collapse PTY-capture artifacts so a whitespace-free secret printed across
37+
* wrapped lines becomes a single contiguous string. Drops ALL whitespace by
38+
* design — these captures exist only to recover a token, never prose.
39+
*/
40+
function normalizeCapturedTerminalOutput(raw: string): string {
41+
return raw.replace(CSI, '').replace(CONTROL_AND_SPACE, '');
42+
}
43+
44+
// Claude subscription OAuth tokens: sk-ant-oat<base64url>AA. Bounded length
45+
// keeps a greedy match from running off the end of the token.
46+
const CLAUDE_OAUTH_TOKEN = /sk-ant-oat[A-Za-z0-9_-]{80,500}AA/g;
47+
48+
/**
49+
* Extract the Claude OAuth token from a PTY capture of `claude setup-token`,
50+
* or `null` if none is present. Returns the LAST match — setup-token can echo
51+
* partial/intermediate output before the final token. Placeholder strings like
52+
* `<token>` never match (they lack the `sk-ant-oat` prefix).
53+
*/
54+
export function extractClaudeOAuthToken(raw: string): string | null {
55+
const matches = normalizeCapturedTerminalOutput(raw).match(CLAUDE_OAUTH_TOKEN);
56+
return matches ? matches[matches.length - 1] : null;
57+
}
58+
59+
function runCli(argv: string[]): number {
60+
const [provider, file] = argv;
61+
if (provider !== 'claude' || !file) {
62+
process.stderr.write('usage: captured-token.ts claude <capture-file>\n');
63+
return 2;
64+
}
65+
const token = extractClaudeOAuthToken(fs.readFileSync(file, 'utf-8'));
66+
if (!token) return 1;
67+
process.stdout.write(token);
68+
return 0;
69+
}
70+
71+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
72+
process.exit(runCli(process.argv.slice(2)));
73+
}

setup/lib/claude-assist.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import path from 'path';
2727
import * as p from '@clack/prompts';
2828
import k from 'kleur';
2929

30+
import { extractClaudeOAuthToken } from './captured-token.js';
3031
import { ensureAnswer } from './runner.js';
3132
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
3233

@@ -207,16 +208,11 @@ export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
207208
});
208209

209210
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
210-
const raw = fs.readFileSync(tmpfile, 'utf-8');
211-
const stripped = raw
212-
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
213-
.replace(/[\n\r]/g, '');
214-
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
215-
if (matches) {
216-
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
217-
}
211+
const token = extractClaudeOAuthToken(fs.readFileSync(tmpfile, 'utf-8'));
212+
if (token) process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
218213
}
219214
} finally {
215+
// eslint-disable-next-line no-empty -- best-effort temp cleanup
220216
try { fs.unlinkSync(tmpfile); } catch {}
221217
}
222218

setup/register-claude-token.sh

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ set -euo pipefail
99
# Flow:
1010
# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser
1111
# OAuth dance works and its token is captured into a tempfile.
12-
# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture.
12+
# 2. Parse the sk-ant-oat…AA token out of the capture via the shared
13+
# PTY-capture parser (setup/lib/captured-token.ts).
1314
# 3. Register it with OneCLI.
1415
#
1516
# Env overrides:
@@ -99,12 +100,11 @@ else
99100
script -q "$tmpfile" $cmd
100101
fi
101102

102-
# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match
103-
# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255.
104-
token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \
105-
| tr -d '\n\r' \
106-
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
107-
| tail -1 || true)
103+
# Extract the token via the shared PTY-capture parser (setup/lib/captured-token.ts),
104+
# so this script and setup/lib/claude-assist.ts stay in lockstep on the
105+
# normalization rules (ANSI/control stripping, un-wrapping the token).
106+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
107+
token=$(pnpm exec tsx "$SCRIPT_DIR/lib/captured-token.ts" claude "$tmpfile" || true)
108108

109109
if [ -z "$token" ]; then
110110
keep=$(mktemp -t claude-setup-token-log.XXXXXX)

src/group-folder.test.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import path from 'path';
22

33
import { describe, expect, it } from 'vitest';
44

5-
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
5+
import { isValidGroupFolder, resolveGroupFolderPath } from './group-folder.js';
66

77
describe('group folder validation', () => {
88
it('accepts normal group folder names', () => {
@@ -23,13 +23,7 @@ describe('group folder validation', () => {
2323
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true);
2424
});
2525

26-
it('resolves safe paths under data ipc directory', () => {
27-
const resolved = resolveGroupIpcPath('family-chat');
28-
expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true);
29-
});
30-
3126
it('throws for unsafe folder names', () => {
3227
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
33-
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
3428
});
3529
});

0 commit comments

Comments
 (0)