@rozek/nanoclaw 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/container/agent-runner/package-lock.json +1524 -0
  2. package/dist/cli.js +75 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +34 -0
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -1
  8. package/.claude/settings.json +0 -1
  9. package/.claude/skills/add-compact/SKILL.md +0 -135
  10. package/.claude/skills/add-discord/SKILL.md +0 -203
  11. package/.claude/skills/add-gmail/SKILL.md +0 -220
  12. package/.claude/skills/add-image-vision/SKILL.md +0 -94
  13. package/.claude/skills/add-ollama-tool/SKILL.md +0 -153
  14. package/.claude/skills/add-parallel/SKILL.md +0 -290
  15. package/.claude/skills/add-pdf-reader/SKILL.md +0 -104
  16. package/.claude/skills/add-reactions/SKILL.md +0 -117
  17. package/.claude/skills/add-slack/SKILL.md +0 -207
  18. package/.claude/skills/add-telegram/SKILL.md +0 -222
  19. package/.claude/skills/add-telegram-swarm/SKILL.md +0 -384
  20. package/.claude/skills/add-voice-transcription/SKILL.md +0 -148
  21. package/.claude/skills/add-whatsapp/SKILL.md +0 -372
  22. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -175
  23. package/.claude/skills/customize/SKILL.md +0 -110
  24. package/.claude/skills/debug/SKILL.md +0 -349
  25. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  26. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  27. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  28. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  29. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  30. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  31. package/.claude/skills/setup/SKILL.md +0 -218
  32. package/.claude/skills/update-nanoclaw/SKILL.md +0 -235
  33. package/.claude/skills/update-skills/SKILL.md +0 -130
  34. package/.claude/skills/use-local-whisper/SKILL.md +0 -152
  35. package/.claude/skills/x-integration/SKILL.md +0 -417
  36. package/.claude/skills/x-integration/agent.ts +0 -243
  37. package/.claude/skills/x-integration/host.ts +0 -159
  38. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  39. package/.claude/skills/x-integration/lib/config.ts +0 -62
  40. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  41. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  42. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  43. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  44. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  45. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  46. package/.env.example +0 -1
  47. package/.github/CODEOWNERS +0 -10
  48. package/.github/PULL_REQUEST_TEMPLATE.md +0 -14
  49. package/.github/workflows/bump-version.yml +0 -32
  50. package/.github/workflows/ci.yml +0 -25
  51. package/.github/workflows/merge-forward-skills.yml +0 -160
  52. package/.github/workflows/update-tokens.yml +0 -42
  53. package/.husky/pre-commit +0 -1
  54. package/.mcp.json +0 -3
  55. package/.nvmrc +0 -1
  56. package/.prettierrc +0 -3
  57. package/CHANGELOG.md +0 -8
  58. package/CONTRIBUTING.md +0 -23
  59. package/CONTRIBUTORS.md +0 -15
  60. package/NanoClaw_with_Web-Support.md +0 -325
  61. package/README_zh.md +0 -200
  62. package/assets/nanoclaw-favicon.png +0 -0
  63. package/assets/nanoclaw-icon.png +0 -0
  64. package/assets/nanoclaw-logo-dark.png +0 -0
  65. package/assets/nanoclaw-logo.png +0 -0
  66. package/assets/nanoclaw-profile.jpeg +0 -0
  67. package/assets/nanoclaw-sales.png +0 -0
  68. package/assets/social-preview.jpg +0 -0
  69. package/config-examples/mount-allowlist.json +0 -25
  70. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  71. package/docs/DEBUG_CHECKLIST.md +0 -143
  72. package/docs/REQUIREMENTS.md +0 -196
  73. package/docs/SDK_DEEP_DIVE.md +0 -643
  74. package/docs/SECURITY.md +0 -122
  75. package/docs/SPEC.md +0 -785
  76. package/docs/docker-sandboxes.md +0 -359
  77. package/docs/nanoclaw-architecture-final.md +0 -1063
  78. package/docs/nanorepo-architecture.md +0 -168
  79. package/docs/skills-as-branches.md +0 -662
  80. package/groups/global/CLAUDE.md +0 -58
  81. package/groups/main/CLAUDE.md +0 -246
  82. package/launchd/com.nanoclaw.plist +0 -32
  83. package/repo-tokens/README.md +0 -113
  84. package/repo-tokens/action.yml +0 -186
  85. package/repo-tokens/badge.svg +0 -23
  86. package/repo-tokens/examples/green.svg +0 -14
  87. package/repo-tokens/examples/red.svg +0 -14
  88. package/repo-tokens/examples/yellow-green.svg +0 -14
  89. package/repo-tokens/examples/yellow.svg +0 -14
  90. package/scripts/run-migrations.ts +0 -105
  91. package/setup.sh +0 -161
  92. package/src/channels/index.ts +0 -15
  93. package/src/channels/registry.test.ts +0 -42
  94. package/src/channels/registry.ts +0 -32
  95. package/src/channels/web.ts +0 -1931
  96. package/src/cli.ts +0 -210
  97. package/src/config.ts +0 -73
  98. package/src/container-runner.test.ts +0 -210
  99. package/src/container-runner.ts +0 -768
  100. package/src/container-runtime.test.ts +0 -149
  101. package/src/container-runtime.ts +0 -127
  102. package/src/credential-proxy.test.ts +0 -192
  103. package/src/credential-proxy.ts +0 -125
  104. package/src/db.test.ts +0 -484
  105. package/src/db.ts +0 -803
  106. package/src/env.ts +0 -42
  107. package/src/formatting.test.ts +0 -256
  108. package/src/group-folder.test.ts +0 -43
  109. package/src/group-folder.ts +0 -44
  110. package/src/group-queue.test.ts +0 -484
  111. package/src/group-queue.ts +0 -379
  112. package/src/index.ts +0 -854
  113. package/src/ipc-auth.test.ts +0 -679
  114. package/src/ipc.ts +0 -461
  115. package/src/logger.ts +0 -16
  116. package/src/mount-security.ts +0 -419
  117. package/src/remote-control.test.ts +0 -397
  118. package/src/remote-control.ts +0 -224
  119. package/src/router.ts +0 -52
  120. package/src/routing.test.ts +0 -170
  121. package/src/sender-allowlist.test.ts +0 -216
  122. package/src/sender-allowlist.ts +0 -128
  123. package/src/session-commands.test.ts +0 -247
  124. package/src/session-commands.ts +0 -163
  125. package/src/task-scheduler.test.ts +0 -129
  126. package/src/task-scheduler.ts +0 -328
  127. package/src/timezone.test.ts +0 -29
  128. package/src/timezone.ts +0 -16
  129. package/src/types.ts +0 -109
  130. package/tsconfig.json +0 -20
  131. package/vitest.config.ts +0 -7
  132. package/vitest.skills.config.ts +0 -7
@@ -1,149 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
-
3
- // Mock logger
4
- vi.mock('./logger.js', () => ({
5
- logger: {
6
- debug: vi.fn(),
7
- info: vi.fn(),
8
- warn: vi.fn(),
9
- error: vi.fn(),
10
- },
11
- }));
12
-
13
- // Mock child_process — store the mock fn so tests can configure it
14
- const mockExecSync = vi.fn();
15
- vi.mock('child_process', () => ({
16
- execSync: (...args: unknown[]) => mockExecSync(...args),
17
- }));
18
-
19
- import {
20
- CONTAINER_RUNTIME_BIN,
21
- readonlyMountArgs,
22
- stopContainer,
23
- ensureContainerRuntimeRunning,
24
- cleanupOrphans,
25
- } from './container-runtime.js';
26
- import { logger } from './logger.js';
27
-
28
- beforeEach(() => {
29
- vi.clearAllMocks();
30
- });
31
-
32
- // --- Pure functions ---
33
-
34
- describe('readonlyMountArgs', () => {
35
- it('returns -v flag with :ro suffix', () => {
36
- const args = readonlyMountArgs('/host/path', '/container/path');
37
- expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
38
- });
39
- });
40
-
41
- describe('stopContainer', () => {
42
- it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
43
- expect(stopContainer('nanoclaw-test-123')).toBe(
44
- `${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
45
- );
46
- });
47
- });
48
-
49
- // --- ensureContainerRuntimeRunning ---
50
-
51
- describe('ensureContainerRuntimeRunning', () => {
52
- it('does nothing when runtime is already running', () => {
53
- mockExecSync.mockReturnValueOnce('');
54
-
55
- ensureContainerRuntimeRunning();
56
-
57
- expect(mockExecSync).toHaveBeenCalledTimes(1);
58
- expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
59
- stdio: 'pipe',
60
- timeout: 10000,
61
- });
62
- expect(logger.debug).toHaveBeenCalledWith(
63
- 'Container runtime already running',
64
- );
65
- });
66
-
67
- it('throws when docker info fails', () => {
68
- mockExecSync.mockImplementationOnce(() => {
69
- throw new Error('Cannot connect to the Docker daemon');
70
- });
71
-
72
- expect(() => ensureContainerRuntimeRunning()).toThrow(
73
- 'Container runtime is required but failed to start',
74
- );
75
- expect(logger.error).toHaveBeenCalled();
76
- });
77
- });
78
-
79
- // --- cleanupOrphans ---
80
-
81
- describe('cleanupOrphans', () => {
82
- it('stops orphaned nanoclaw containers', () => {
83
- // docker ps returns container names, one per line
84
- mockExecSync.mockReturnValueOnce(
85
- 'nanoclaw-group1-111\nnanoclaw-group2-222\n',
86
- );
87
- // stop calls succeed
88
- mockExecSync.mockReturnValue('');
89
-
90
- cleanupOrphans();
91
-
92
- // ps + 2 stop calls
93
- expect(mockExecSync).toHaveBeenCalledTimes(3);
94
- expect(mockExecSync).toHaveBeenNthCalledWith(
95
- 2,
96
- `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
97
- { stdio: 'pipe' },
98
- );
99
- expect(mockExecSync).toHaveBeenNthCalledWith(
100
- 3,
101
- `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group2-222`,
102
- { stdio: 'pipe' },
103
- );
104
- expect(logger.info).toHaveBeenCalledWith(
105
- { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
106
- 'Stopped orphaned containers',
107
- );
108
- });
109
-
110
- it('does nothing when no orphans exist', () => {
111
- mockExecSync.mockReturnValueOnce('');
112
-
113
- cleanupOrphans();
114
-
115
- expect(mockExecSync).toHaveBeenCalledTimes(1);
116
- expect(logger.info).not.toHaveBeenCalled();
117
- });
118
-
119
- it('warns and continues when ps fails', () => {
120
- mockExecSync.mockImplementationOnce(() => {
121
- throw new Error('docker not available');
122
- });
123
-
124
- cleanupOrphans(); // should not throw
125
-
126
- expect(logger.warn).toHaveBeenCalledWith(
127
- expect.objectContaining({ err: expect.any(Error) }),
128
- 'Failed to clean up orphaned containers',
129
- );
130
- });
131
-
132
- it('continues stopping remaining containers when one stop fails', () => {
133
- mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
134
- // First stop fails
135
- mockExecSync.mockImplementationOnce(() => {
136
- throw new Error('already stopped');
137
- });
138
- // Second stop succeeds
139
- mockExecSync.mockReturnValueOnce('');
140
-
141
- cleanupOrphans(); // should not throw
142
-
143
- expect(mockExecSync).toHaveBeenCalledTimes(3);
144
- expect(logger.info).toHaveBeenCalledWith(
145
- { count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
146
- 'Stopped orphaned containers',
147
- );
148
- });
149
- });
@@ -1,127 +0,0 @@
1
- /**
2
- * Container runtime abstraction for NanoClaw.
3
- * All runtime-specific logic lives here so swapping runtimes means changing one file.
4
- */
5
- import { execSync } from 'child_process';
6
- import fs from 'fs';
7
- import os from 'os';
8
-
9
- import { logger } from './logger.js';
10
-
11
- /** The container runtime binary name. */
12
- export const CONTAINER_RUNTIME_BIN = 'docker';
13
-
14
- /** Hostname containers use to reach the host machine. */
15
- export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
16
-
17
- /**
18
- * Address the credential proxy binds to.
19
- * Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback.
20
- * Docker (Linux): bind to the docker0 bridge IP so only containers can reach it,
21
- * falling back to 0.0.0.0 if the interface isn't found.
22
- */
23
- export const PROXY_BIND_HOST =
24
- process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
25
-
26
- function detectProxyBindHost(): string {
27
- if (os.platform() === 'darwin') return '127.0.0.1';
28
-
29
- // WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct.
30
- // Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd.
31
- if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
32
-
33
- // Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0
34
- const ifaces = os.networkInterfaces();
35
- const docker0 = ifaces['docker0'];
36
- if (docker0) {
37
- const ipv4 = docker0.find((a) => a.family === 'IPv4');
38
- if (ipv4) return ipv4.address;
39
- }
40
- return '0.0.0.0';
41
- }
42
-
43
- /** CLI args needed for the container to resolve the host gateway. */
44
- export function hostGatewayArgs(): string[] {
45
- // On Linux, host.docker.internal isn't built-in — add it explicitly
46
- if (os.platform() === 'linux') {
47
- return ['--add-host=host.docker.internal:host-gateway'];
48
- }
49
- return [];
50
- }
51
-
52
- /** Returns CLI args for a readonly bind mount. */
53
- export function readonlyMountArgs(
54
- hostPath: string,
55
- containerPath: string,
56
- ): string[] {
57
- return ['-v', `${hostPath}:${containerPath}:ro`];
58
- }
59
-
60
- /** Returns the shell command to stop a container by name. */
61
- export function stopContainer(name: string): string {
62
- return `${CONTAINER_RUNTIME_BIN} stop ${name}`;
63
- }
64
-
65
- /** Ensure the container runtime is running, starting it if needed. */
66
- export function ensureContainerRuntimeRunning(): void {
67
- try {
68
- execSync(`${CONTAINER_RUNTIME_BIN} info`, {
69
- stdio: 'pipe',
70
- timeout: 10000,
71
- });
72
- logger.debug('Container runtime already running');
73
- } catch (err) {
74
- logger.error({ err }, 'Failed to reach container runtime');
75
- console.error(
76
- '\n╔════════════════════════════════════════════════════════════════╗',
77
- );
78
- console.error(
79
- '║ FATAL: Container runtime failed to start ║',
80
- );
81
- console.error(
82
- '║ ║',
83
- );
84
- console.error(
85
- '║ Agents cannot run without a container runtime. To fix: ║',
86
- );
87
- console.error(
88
- '║ 1. Ensure Docker is installed and running ║',
89
- );
90
- console.error(
91
- '║ 2. Run: docker info ║',
92
- );
93
- console.error(
94
- '║ 3. Restart NanoClaw ║',
95
- );
96
- console.error(
97
- '╚════════════════════════════════════════════════════════════════╝\n',
98
- );
99
- throw new Error('Container runtime is required but failed to start');
100
- }
101
- }
102
-
103
- /** Kill orphaned NanoClaw containers from previous runs. */
104
- export function cleanupOrphans(): void {
105
- try {
106
- const output = execSync(
107
- `${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
108
- { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
109
- );
110
- const orphans = output.trim().split('\n').filter(Boolean);
111
- for (const name of orphans) {
112
- try {
113
- execSync(stopContainer(name), { stdio: 'pipe' });
114
- } catch {
115
- /* already stopped */
116
- }
117
- }
118
- if (orphans.length > 0) {
119
- logger.info(
120
- { count: orphans.length, names: orphans },
121
- 'Stopped orphaned containers',
122
- );
123
- }
124
- } catch (err) {
125
- logger.warn({ err }, 'Failed to clean up orphaned containers');
126
- }
127
- }
@@ -1,192 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import http from 'http';
3
- import type { AddressInfo } from 'net';
4
-
5
- const mockEnv: Record<string, string> = {};
6
- vi.mock('./env.js', () => ({
7
- readEnvFile: vi.fn(() => ({ ...mockEnv })),
8
- }));
9
-
10
- vi.mock('./logger.js', () => ({
11
- logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
12
- }));
13
-
14
- import { startCredentialProxy } from './credential-proxy.js';
15
-
16
- function makeRequest(
17
- port: number,
18
- options: http.RequestOptions,
19
- body = '',
20
- ): Promise<{
21
- statusCode: number;
22
- body: string;
23
- headers: http.IncomingHttpHeaders;
24
- }> {
25
- return new Promise((resolve, reject) => {
26
- const req = http.request(
27
- { ...options, hostname: '127.0.0.1', port },
28
- (res) => {
29
- const chunks: Buffer[] = [];
30
- res.on('data', (c) => chunks.push(c));
31
- res.on('end', () => {
32
- resolve({
33
- statusCode: res.statusCode!,
34
- body: Buffer.concat(chunks).toString(),
35
- headers: res.headers,
36
- });
37
- });
38
- },
39
- );
40
- req.on('error', reject);
41
- req.write(body);
42
- req.end();
43
- });
44
- }
45
-
46
- describe('credential-proxy', () => {
47
- let proxyServer: http.Server;
48
- let upstreamServer: http.Server;
49
- let proxyPort: number;
50
- let upstreamPort: number;
51
- let lastUpstreamHeaders: http.IncomingHttpHeaders;
52
-
53
- beforeEach(async () => {
54
- lastUpstreamHeaders = {};
55
-
56
- upstreamServer = http.createServer((req, res) => {
57
- lastUpstreamHeaders = { ...req.headers };
58
- res.writeHead(200, { 'content-type': 'application/json' });
59
- res.end(JSON.stringify({ ok: true }));
60
- });
61
- await new Promise<void>((resolve) =>
62
- upstreamServer.listen(0, '127.0.0.1', resolve),
63
- );
64
- upstreamPort = (upstreamServer.address() as AddressInfo).port;
65
- });
66
-
67
- afterEach(async () => {
68
- await new Promise<void>((r) => proxyServer?.close(() => r()));
69
- await new Promise<void>((r) => upstreamServer?.close(() => r()));
70
- for (const key of Object.keys(mockEnv)) delete mockEnv[key];
71
- });
72
-
73
- async function startProxy(env: Record<string, string>): Promise<number> {
74
- Object.assign(mockEnv, env, {
75
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
76
- });
77
- proxyServer = await startCredentialProxy(0);
78
- return (proxyServer.address() as AddressInfo).port;
79
- }
80
-
81
- it('API-key mode injects x-api-key and strips placeholder', async () => {
82
- proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
83
-
84
- await makeRequest(
85
- proxyPort,
86
- {
87
- method: 'POST',
88
- path: '/v1/messages',
89
- headers: {
90
- 'content-type': 'application/json',
91
- 'x-api-key': 'placeholder',
92
- },
93
- },
94
- '{}',
95
- );
96
-
97
- expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
98
- });
99
-
100
- it('OAuth mode replaces Authorization when container sends one', async () => {
101
- proxyPort = await startProxy({
102
- CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
103
- });
104
-
105
- await makeRequest(
106
- proxyPort,
107
- {
108
- method: 'POST',
109
- path: '/api/oauth/claude_cli/create_api_key',
110
- headers: {
111
- 'content-type': 'application/json',
112
- authorization: 'Bearer placeholder',
113
- },
114
- },
115
- '{}',
116
- );
117
-
118
- expect(lastUpstreamHeaders['authorization']).toBe(
119
- 'Bearer real-oauth-token',
120
- );
121
- });
122
-
123
- it('OAuth mode does not inject Authorization when container omits it', async () => {
124
- proxyPort = await startProxy({
125
- CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
126
- });
127
-
128
- // Post-exchange: container uses x-api-key only, no Authorization header
129
- await makeRequest(
130
- proxyPort,
131
- {
132
- method: 'POST',
133
- path: '/v1/messages',
134
- headers: {
135
- 'content-type': 'application/json',
136
- 'x-api-key': 'temp-key-from-exchange',
137
- },
138
- },
139
- '{}',
140
- );
141
-
142
- expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange');
143
- expect(lastUpstreamHeaders['authorization']).toBeUndefined();
144
- });
145
-
146
- it('strips hop-by-hop headers', async () => {
147
- proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
148
-
149
- await makeRequest(
150
- proxyPort,
151
- {
152
- method: 'POST',
153
- path: '/v1/messages',
154
- headers: {
155
- 'content-type': 'application/json',
156
- connection: 'keep-alive',
157
- 'keep-alive': 'timeout=5',
158
- 'transfer-encoding': 'chunked',
159
- },
160
- },
161
- '{}',
162
- );
163
-
164
- // Proxy strips client hop-by-hop headers. Node's HTTP client may re-add
165
- // its own Connection header (standard HTTP/1.1 behavior), but the client's
166
- // custom keep-alive and transfer-encoding must not be forwarded.
167
- expect(lastUpstreamHeaders['keep-alive']).toBeUndefined();
168
- expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined();
169
- });
170
-
171
- it('returns 502 when upstream is unreachable', async () => {
172
- Object.assign(mockEnv, {
173
- ANTHROPIC_API_KEY: 'sk-ant-real-key',
174
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999',
175
- });
176
- proxyServer = await startCredentialProxy(0);
177
- proxyPort = (proxyServer.address() as AddressInfo).port;
178
-
179
- const res = await makeRequest(
180
- proxyPort,
181
- {
182
- method: 'POST',
183
- path: '/v1/messages',
184
- headers: { 'content-type': 'application/json' },
185
- },
186
- '{}',
187
- );
188
-
189
- expect(res.statusCode).toBe(502);
190
- expect(res.body).toBe('Bad Gateway');
191
- });
192
- });
@@ -1,125 +0,0 @@
1
- /**
2
- * Credential proxy for container isolation.
3
- * Containers connect here instead of directly to the Anthropic API.
4
- * The proxy injects real credentials so containers never see them.
5
- *
6
- * Two auth modes:
7
- * API key: Proxy injects x-api-key on every request.
8
- * OAuth: Container CLI exchanges its placeholder token for a temp
9
- * API key via /api/oauth/claude_cli/create_api_key.
10
- * Proxy injects real OAuth token on that exchange request;
11
- * subsequent requests carry the temp key which is valid as-is.
12
- */
13
- import { createServer, Server } from 'http';
14
- import { request as httpsRequest } from 'https';
15
- import { request as httpRequest, RequestOptions } from 'http';
16
-
17
- import { readEnvFile } from './env.js';
18
- import { logger } from './logger.js';
19
-
20
- export type AuthMode = 'api-key' | 'oauth';
21
-
22
- export interface ProxyConfig {
23
- authMode: AuthMode;
24
- }
25
-
26
- export function startCredentialProxy(
27
- port: number,
28
- host = '127.0.0.1',
29
- ): Promise<Server> {
30
- const secrets = readEnvFile([
31
- 'ANTHROPIC_API_KEY',
32
- 'CLAUDE_CODE_OAUTH_TOKEN',
33
- 'ANTHROPIC_AUTH_TOKEN',
34
- 'ANTHROPIC_BASE_URL',
35
- ]);
36
-
37
- const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
38
- const oauthToken =
39
- secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN;
40
-
41
- const upstreamUrl = new URL(
42
- secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
43
- );
44
- const isHttps = upstreamUrl.protocol === 'https:';
45
- const makeRequest = isHttps ? httpsRequest : httpRequest;
46
-
47
- return new Promise((resolve, reject) => {
48
- const server = createServer((req, res) => {
49
- const chunks: Buffer[] = [];
50
- req.on('data', (c) => chunks.push(c));
51
- req.on('end', () => {
52
- const body = Buffer.concat(chunks);
53
- const headers: Record<string, string | number | string[] | undefined> =
54
- {
55
- ...(req.headers as Record<string, string>),
56
- host: upstreamUrl.host,
57
- 'content-length': body.length,
58
- };
59
-
60
- // Strip hop-by-hop headers that must not be forwarded by proxies
61
- delete headers['connection'];
62
- delete headers['keep-alive'];
63
- delete headers['transfer-encoding'];
64
-
65
- if (authMode === 'api-key') {
66
- // API key mode: inject x-api-key on every request
67
- delete headers['x-api-key'];
68
- headers['x-api-key'] = secrets.ANTHROPIC_API_KEY;
69
- } else {
70
- // OAuth mode: replace placeholder Bearer token with the real one
71
- // only when the container actually sends an Authorization header
72
- // (exchange request + auth probes). Post-exchange requests use
73
- // x-api-key only, so they pass through without token injection.
74
- if (headers['authorization']) {
75
- delete headers['authorization'];
76
- if (oauthToken) {
77
- headers['authorization'] = `Bearer ${oauthToken}`;
78
- }
79
- }
80
- }
81
-
82
- const upstream = makeRequest(
83
- {
84
- hostname: upstreamUrl.hostname,
85
- port: upstreamUrl.port || (isHttps ? 443 : 80),
86
- path: req.url,
87
- method: req.method,
88
- headers,
89
- } as RequestOptions,
90
- (upRes) => {
91
- res.writeHead(upRes.statusCode!, upRes.headers);
92
- upRes.pipe(res);
93
- },
94
- );
95
-
96
- upstream.on('error', (err) => {
97
- logger.error(
98
- { err, url: req.url },
99
- 'Credential proxy upstream error',
100
- );
101
- if (!res.headersSent) {
102
- res.writeHead(502);
103
- res.end('Bad Gateway');
104
- }
105
- });
106
-
107
- upstream.write(body);
108
- upstream.end();
109
- });
110
- });
111
-
112
- server.listen(port, host, () => {
113
- logger.info({ port, host, authMode }, 'Credential proxy started');
114
- resolve(server);
115
- });
116
-
117
- server.on('error', reject);
118
- });
119
- }
120
-
121
- /** Detect which auth mode the host is configured for. */
122
- export function detectAuthMode(): AuthMode {
123
- const secrets = readEnvFile(['ANTHROPIC_API_KEY']);
124
- return secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
125
- }