mstro-app 0.4.39 → 0.4.44
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.
- package/PRIVACY.md +1 -3
- package/bin/commands/login.js +17 -7
- package/bin/commands/logout.js +14 -6
- package/bin/commands/status.js +9 -3
- package/bin/commands/whoami.js +10 -4
- package/bin/mstro.js +11 -1
- package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
- package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
- package/dist/server/cli/headless/index.d.ts +1 -0
- package/dist/server/cli/headless/index.d.ts.map +1 -1
- package/dist/server/cli/headless/index.js +2 -0
- package/dist/server/cli/headless/index.js.map +1 -1
- package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
- package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
- package/dist/server/cli/headless/resilient-runner.js +234 -0
- package/dist/server/cli/headless/resilient-runner.js.map +1 -0
- package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
- package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
- package/dist/server/cli/headless/retry-strategies.js +262 -0
- package/dist/server/cli/headless/retry-strategies.js.map +1 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +5 -0
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +31 -4
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +1 -30
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +16 -3
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/prompt-builders.d.ts.map +1 -1
- package/dist/server/cli/prompt-builders.js +31 -13
- package/dist/server/cli/prompt-builders.js.map +1 -1
- package/dist/server/index.js +1 -9
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-cli.js +5 -4
- package/dist/server/mcp/bouncer-cli.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +1 -1
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +14 -8
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-patterns.js +1 -1
- package/dist/server/mcp/security-patterns.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +19 -9
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +6 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +163 -77
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/front-matter.d.ts +1 -0
- package/dist/server/services/plan/front-matter.d.ts.map +1 -1
- package/dist/server/services/plan/front-matter.js +6 -0
- package/dist/server/services/plan/front-matter.js.map +1 -1
- package/dist/server/services/plan/issue-classification.d.ts +11 -0
- package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
- package/dist/server/services/plan/issue-classification.js +20 -0
- package/dist/server/services/plan/issue-classification.js.map +1 -0
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.js +7 -4
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
- package/dist/server/services/plan/issue-retry.d.ts +0 -5
- package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
- package/dist/server/services/plan/issue-retry.js +12 -241
- package/dist/server/services/plan/issue-retry.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/review-gate.d.ts.map +1 -1
- package/dist/server/services/plan/review-gate.js +9 -6
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +1 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/platform-credentials.d.ts.map +1 -1
- package/dist/server/services/platform-credentials.js +11 -4
- package/dist/server/services/platform-credentials.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +7 -1
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-search-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/file-search-handlers.js +4 -0
- package/dist/server/services/websocket/file-search-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler-context.d.ts +2 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +2 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +18 -7
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
- package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-fix-agent.js +90 -42
- package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +48 -7
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +48 -1
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +74 -32
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +18 -18
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
- package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/skill-handlers.js +52 -41
- package/dist/server/services/websocket/skill-handlers.js.map +1 -1
- package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
- package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
- package/dist/server/services/websocket/skill-watcher.js +85 -0
- package/dist/server/services/websocket/skill-watcher.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +2 -268
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +0 -4
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/claude-invoker-stream.ts +1 -0
- package/server/cli/headless/index.ts +2 -0
- package/server/cli/headless/resilient-runner.ts +354 -0
- package/server/cli/headless/retry-strategies.ts +330 -0
- package/server/cli/headless/stall-assessor.ts +5 -0
- package/server/cli/headless/tool-watchdog.ts +40 -4
- package/server/cli/improvisation-retry.ts +1 -32
- package/server/cli/improvisation-session-manager.ts +17 -3
- package/server/cli/prompt-builders.ts +33 -12
- package/server/index.ts +1 -9
- package/server/mcp/bouncer-cli.ts +5 -4
- package/server/mcp/bouncer-haiku.ts +1 -1
- package/server/mcp/bouncer-integration.ts +15 -8
- package/server/mcp/security-patterns.ts +1 -1
- package/server/services/plan/agents/code-review.md +109 -0
- package/server/services/plan/agents/commit-message.md +26 -0
- package/server/services/plan/agents/fix-quality.md +24 -0
- package/server/services/plan/agents/pr-description.md +28 -0
- package/server/services/plan/composer.ts +20 -9
- package/server/services/plan/executor.ts +165 -77
- package/server/services/plan/front-matter.ts +7 -0
- package/server/services/plan/issue-classification.ts +21 -0
- package/server/services/plan/issue-prompt-builder.ts +8 -4
- package/server/services/plan/issue-retry.ts +15 -330
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/review-gate.ts +9 -6
- package/server/services/plan/types.ts +3 -0
- package/server/services/platform-credentials.ts +10 -4
- package/server/services/terminal/pty-manager.ts +7 -1
- package/server/services/websocket/file-search-handlers.ts +2 -0
- package/server/services/websocket/handler-context.ts +2 -0
- package/server/services/websocket/handler.ts +18 -8
- package/server/services/websocket/plan-execution-handlers.ts +7 -7
- package/server/services/websocket/quality-fix-agent.ts +86 -44
- package/server/services/websocket/quality-handlers.ts +48 -7
- package/server/services/websocket/quality-persistence.ts +75 -1
- package/server/services/websocket/quality-review-agent.ts +70 -31
- package/server/services/websocket/quality-tools.ts +16 -14
- package/server/services/websocket/skill-handlers.ts +50 -40
- package/server/services/websocket/skill-watcher.ts +79 -0
- package/server/services/websocket/types.ts +0 -311
- package/dist/server/services/deploy/ai-broker.d.ts +0 -63
- package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
- package/dist/server/services/deploy/ai-broker.js +0 -360
- package/dist/server/services/deploy/ai-broker.js.map +0 -1
- package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
- package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
- package/dist/server/services/deploy/board-execution-handler.js +0 -621
- package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
- package/dist/server/services/deploy/credentials.d.ts +0 -35
- package/dist/server/services/deploy/credentials.d.ts.map +0 -1
- package/dist/server/services/deploy/credentials.js +0 -177
- package/dist/server/services/deploy/credentials.js.map +0 -1
- package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
- package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
- package/dist/server/services/deploy/deploy-ai-service.js +0 -294
- package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
- package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
- package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
- package/dist/server/services/deploy/headless-session-handler.js +0 -266
- package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
- package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
- package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
- package/dist/server/services/websocket/deploy-handlers.js +0 -409
- package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
- package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
- package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
- package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
- package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
- package/server/cli/headless/RESEARCH.md +0 -627
- package/server/services/deploy/ai-broker.ts +0 -512
- package/server/services/deploy/board-execution-handler.ts +0 -847
- package/server/services/deploy/credentials.ts +0 -200
- package/server/services/deploy/deploy-ai-service.ts +0 -401
- package/server/services/deploy/headless-session-handler.ts +0 -414
- package/server/services/websocket/deploy-handlers.ts +0 -544
- package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
-
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Deploy Credentials — AES-256-GCM encrypted API key storage.
|
|
6
|
-
*
|
|
7
|
-
* Stores the developer's Anthropic API key at ~/.mstro/deploy-credentials.json,
|
|
8
|
-
* encrypted with a machine-specific secret (hostname + username).
|
|
9
|
-
*
|
|
10
|
-
* The key derivation and storage format must remain compatible with
|
|
11
|
-
* deploy-ai-service.ts which reads credentials at execution time.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
createCipheriv,
|
|
16
|
-
createDecipheriv,
|
|
17
|
-
randomBytes,
|
|
18
|
-
scryptSync,
|
|
19
|
-
} from 'node:crypto';
|
|
20
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import { homedir, hostname, userInfo } from 'node:os';
|
|
22
|
-
import { dirname, join } from 'node:path';
|
|
23
|
-
|
|
24
|
-
const DEPLOY_CREDENTIALS_PATH = join(homedir(), '.mstro', 'deploy-credentials.json');
|
|
25
|
-
|
|
26
|
-
// ── Validation rate limiter (max 10 attempts per minute) ────
|
|
27
|
-
|
|
28
|
-
const RATE_LIMIT_MAX = 10;
|
|
29
|
-
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
30
|
-
const validationTimestamps: number[] = [];
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if a validation attempt is allowed under the rate limit.
|
|
34
|
-
* Returns true if allowed, false if rate-limited.
|
|
35
|
-
*/
|
|
36
|
-
export function checkValidationRateLimit(): boolean {
|
|
37
|
-
const now = Date.now();
|
|
38
|
-
// Prune timestamps outside the window
|
|
39
|
-
while (validationTimestamps.length > 0 && validationTimestamps[0]! < now - RATE_LIMIT_WINDOW_MS) {
|
|
40
|
-
validationTimestamps.shift();
|
|
41
|
-
}
|
|
42
|
-
if (validationTimestamps.length >= RATE_LIMIT_MAX) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
validationTimestamps.push(now);
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface StoredDeployCredentials {
|
|
50
|
-
iv: string;
|
|
51
|
-
authTag: string;
|
|
52
|
-
encrypted: string;
|
|
53
|
-
lastFour: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Derive the encryption key from machine-specific secret.
|
|
58
|
-
* Must match the derivation used by deploy-ai-service.ts.
|
|
59
|
-
*/
|
|
60
|
-
function deriveEncryptionKey(salt: string): Buffer {
|
|
61
|
-
const machineSecret = `${hostname()}${userInfo().username}`;
|
|
62
|
-
return scryptSync(machineSecret, salt, 32);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Encrypt an API key with AES-256-GCM.
|
|
67
|
-
*/
|
|
68
|
-
function encryptKey(apiKey: string): StoredDeployCredentials {
|
|
69
|
-
const iv = randomBytes(16);
|
|
70
|
-
const key = deriveEncryptionKey(iv.toString('hex')); // salt = iv hex for simplicity
|
|
71
|
-
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
72
|
-
let encrypted = cipher.update(apiKey, 'utf-8', 'hex');
|
|
73
|
-
encrypted += cipher.final('hex');
|
|
74
|
-
const authTag = cipher.getAuthTag();
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
iv: iv.toString('hex'),
|
|
78
|
-
authTag: authTag.toString('hex'),
|
|
79
|
-
encrypted,
|
|
80
|
-
lastFour: apiKey.slice(-4),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Decrypt a stored API key.
|
|
86
|
-
*/
|
|
87
|
-
function decryptKey(stored: StoredDeployCredentials): string | null {
|
|
88
|
-
try {
|
|
89
|
-
const iv = Buffer.from(stored.iv, 'hex');
|
|
90
|
-
const authTag = Buffer.from(stored.authTag, 'hex');
|
|
91
|
-
const key = deriveEncryptionKey(stored.iv); // salt = iv for simplicity
|
|
92
|
-
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
93
|
-
decipher.setAuthTag(authTag);
|
|
94
|
-
let decrypted = decipher.update(stored.encrypted, 'hex', 'utf-8');
|
|
95
|
-
decrypted += decipher.final('utf-8');
|
|
96
|
-
return decrypted;
|
|
97
|
-
} catch {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Store an encrypted API key to ~/.mstro/deploy-credentials.json.
|
|
104
|
-
* Creates the directory if it doesn't exist. File is mode 0600.
|
|
105
|
-
*/
|
|
106
|
-
export function storeApiKey(apiKey: string): { lastFour: string } {
|
|
107
|
-
const stored = encryptKey(apiKey);
|
|
108
|
-
const dir = dirname(DEPLOY_CREDENTIALS_PATH);
|
|
109
|
-
if (!existsSync(dir)) {
|
|
110
|
-
mkdirSync(dir, { recursive: true });
|
|
111
|
-
}
|
|
112
|
-
writeFileSync(DEPLOY_CREDENTIALS_PATH, JSON.stringify(stored, null, 2), {
|
|
113
|
-
mode: 0o600,
|
|
114
|
-
});
|
|
115
|
-
return { lastFour: stored.lastFour };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Read and decrypt the stored API key. Returns null if missing or corrupted.
|
|
120
|
-
*/
|
|
121
|
-
export function readApiKey(): string | null {
|
|
122
|
-
if (!existsSync(DEPLOY_CREDENTIALS_PATH)) {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
const raw = readFileSync(DEPLOY_CREDENTIALS_PATH, 'utf-8');
|
|
127
|
-
const stored: StoredDeployCredentials = JSON.parse(raw);
|
|
128
|
-
if (!stored.encrypted || !stored.iv || !stored.authTag) {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
return decryptKey(stored);
|
|
132
|
-
} catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Get API key status without exposing the key.
|
|
139
|
-
* Checks env var first (highest priority), then stored credentials.
|
|
140
|
-
*/
|
|
141
|
-
export function getApiKeyStatus(): { status: 'valid' | 'missing'; lastFour?: string; source?: 'env' | 'stored' } {
|
|
142
|
-
// 1. Check environment variable (highest priority)
|
|
143
|
-
const envKey = process.env.ANTHROPIC_API_KEY;
|
|
144
|
-
if (envKey && envKey.trim().length > 0) {
|
|
145
|
-
return {
|
|
146
|
-
status: 'valid',
|
|
147
|
-
lastFour: envKey.trim().slice(-4),
|
|
148
|
-
source: 'env',
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// 2. Check stored credentials
|
|
153
|
-
if (!existsSync(DEPLOY_CREDENTIALS_PATH)) {
|
|
154
|
-
return { status: 'missing' };
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
const raw = readFileSync(DEPLOY_CREDENTIALS_PATH, 'utf-8');
|
|
158
|
-
const stored: StoredDeployCredentials = JSON.parse(raw);
|
|
159
|
-
if (!stored.encrypted || !stored.iv || !stored.authTag) {
|
|
160
|
-
return { status: 'missing' };
|
|
161
|
-
}
|
|
162
|
-
// Verify we can still decrypt (machine identity unchanged)
|
|
163
|
-
const decrypted = decryptKey(stored);
|
|
164
|
-
if (!decrypted) {
|
|
165
|
-
return { status: 'missing' };
|
|
166
|
-
}
|
|
167
|
-
return { status: 'valid', lastFour: stored.lastFour, source: 'stored' };
|
|
168
|
-
} catch {
|
|
169
|
-
return { status: 'missing' };
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Delete stored API key.
|
|
175
|
-
*/
|
|
176
|
-
export function deleteApiKey(): void {
|
|
177
|
-
if (existsSync(DEPLOY_CREDENTIALS_PATH)) {
|
|
178
|
-
unlinkSync(DEPLOY_CREDENTIALS_PATH);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Validate an Anthropic API key by calling GET /v1/models.
|
|
184
|
-
* Returns true if the key is valid, false otherwise.
|
|
185
|
-
*/
|
|
186
|
-
export async function validateAnthropicKey(apiKey: string): Promise<boolean> {
|
|
187
|
-
try {
|
|
188
|
-
const response = await fetch('https://api.anthropic.com/v1/models', {
|
|
189
|
-
method: 'GET',
|
|
190
|
-
headers: {
|
|
191
|
-
'x-api-key': apiKey,
|
|
192
|
-
'anthropic-version': '2023-06-01',
|
|
193
|
-
},
|
|
194
|
-
signal: AbortSignal.timeout(10_000),
|
|
195
|
-
});
|
|
196
|
-
return response.ok;
|
|
197
|
-
} catch {
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
@@ -1,401 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
-
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Deploy AI Service
|
|
6
|
-
*
|
|
7
|
-
* Core service managing Deploy AI execution. Every execution is gated on the
|
|
8
|
-
* developer's Anthropic API key — subscription (OAuth) auth is rejected.
|
|
9
|
-
*
|
|
10
|
-
* The API key is injected into each spawned Claude Code process via `extraEnv`
|
|
11
|
-
* and is never logged or sent to the platform server.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { createDecipheriv, scryptSync } from 'node:crypto';
|
|
15
|
-
import { EventEmitter } from 'node:events';
|
|
16
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
-
import { homedir, hostname, userInfo } from 'node:os';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import { HeadlessRunner } from '../../cli/headless/runner.js';
|
|
20
|
-
import type { HeadlessConfig, SessionResult, ToolUseEvent } from '../../cli/headless/types.js';
|
|
21
|
-
import { getCredentials } from '../platform-credentials.js';
|
|
22
|
-
|
|
23
|
-
// ========== Credential Detection ==========
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Credential types returned by readOwnerApiCredential().
|
|
27
|
-
*
|
|
28
|
-
* - 'api-key': Developer has an explicit Anthropic API key (env var or stored).
|
|
29
|
-
* This is the ONLY type that allows Deploy execution.
|
|
30
|
-
* - 'oauth': Developer is authenticated via Claude subscription (platform creds
|
|
31
|
-
* exist but no API key). Deploy rejects this.
|
|
32
|
-
* - null: No authentication found at all. Deploy rejects this.
|
|
33
|
-
*/
|
|
34
|
-
export type OwnerCredentialResult =
|
|
35
|
-
| { type: 'api-key'; key: string; source: 'env' | 'stored' }
|
|
36
|
-
| { type: 'oauth' }
|
|
37
|
-
| null;
|
|
38
|
-
|
|
39
|
-
const DEPLOY_CREDENTIALS_PATH = join(homedir(), '.mstro', 'deploy-credentials.json');
|
|
40
|
-
|
|
41
|
-
interface StoredDeployCredentials {
|
|
42
|
-
iv: string;
|
|
43
|
-
authTag: string;
|
|
44
|
-
encrypted: string;
|
|
45
|
-
lastFour: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Derive the encryption key from machine-specific secret.
|
|
50
|
-
* Must match the derivation used by IS-026 (credential storage).
|
|
51
|
-
*/
|
|
52
|
-
function deriveEncryptionKey(salt: string): Buffer {
|
|
53
|
-
const machineSecret = `${hostname()}${userInfo().username}`;
|
|
54
|
-
return scryptSync(machineSecret, salt, 32);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Decrypt a stored API key from ~/.mstro/deploy-credentials.json.
|
|
59
|
-
*/
|
|
60
|
-
function decryptStoredKey(stored: StoredDeployCredentials): string | null {
|
|
61
|
-
try {
|
|
62
|
-
const iv = Buffer.from(stored.iv, 'hex');
|
|
63
|
-
const authTag = Buffer.from(stored.authTag, 'hex');
|
|
64
|
-
const key = deriveEncryptionKey(stored.iv); // salt = iv for simplicity
|
|
65
|
-
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
66
|
-
decipher.setAuthTag(authTag);
|
|
67
|
-
let decrypted = decipher.update(stored.encrypted, 'hex', 'utf-8');
|
|
68
|
-
decrypted += decipher.final('utf-8');
|
|
69
|
-
return decrypted;
|
|
70
|
-
} catch {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Read the owner's API credential. Resolution order:
|
|
77
|
-
* 1. ANTHROPIC_API_KEY environment variable
|
|
78
|
-
* 2. Locally encrypted storage (~/.mstro/deploy-credentials.json)
|
|
79
|
-
* 3. Platform credentials exist (OAuth/subscription) -> { type: 'oauth' }
|
|
80
|
-
* 4. Nothing -> null
|
|
81
|
-
*
|
|
82
|
-
* Deploy ONLY proceeds if result.type === 'api-key'.
|
|
83
|
-
*/
|
|
84
|
-
export function readOwnerApiCredential(): OwnerCredentialResult {
|
|
85
|
-
// 1. Check environment variable (highest priority)
|
|
86
|
-
const envKey = process.env.ANTHROPIC_API_KEY;
|
|
87
|
-
if (envKey && envKey.trim().length > 0) {
|
|
88
|
-
return { type: 'api-key', key: envKey.trim(), source: 'env' };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 2. Check locally encrypted storage
|
|
92
|
-
if (existsSync(DEPLOY_CREDENTIALS_PATH)) {
|
|
93
|
-
try {
|
|
94
|
-
const raw = readFileSync(DEPLOY_CREDENTIALS_PATH, 'utf-8');
|
|
95
|
-
const stored: StoredDeployCredentials = JSON.parse(raw);
|
|
96
|
-
if (stored.encrypted && stored.iv && stored.authTag) {
|
|
97
|
-
const decrypted = decryptStoredKey(stored);
|
|
98
|
-
if (decrypted) {
|
|
99
|
-
return { type: 'api-key', key: decrypted, source: 'stored' };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
} catch {
|
|
103
|
-
// Corrupted credentials file — fall through
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// 3. Platform credentials exist -> user has subscription (OAuth) but no API key
|
|
108
|
-
const platformCreds = getCredentials();
|
|
109
|
-
if (platformCreds) {
|
|
110
|
-
return { type: 'oauth' };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// 4. No authentication at all
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ========== Deploy Error ==========
|
|
118
|
-
|
|
119
|
-
const DEPLOY_API_KEY_ERROR =
|
|
120
|
-
'Deploy requires an Anthropic API key. Add your key in Deploy \u2192 AI Config or set ANTHROPIC_API_KEY in your environment.';
|
|
121
|
-
|
|
122
|
-
export class DeployApiKeyError extends Error {
|
|
123
|
-
constructor() {
|
|
124
|
-
super(DEPLOY_API_KEY_ERROR);
|
|
125
|
-
this.name = 'DeployApiKeyError';
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ========== Deploy Session Types ==========
|
|
130
|
-
|
|
131
|
-
export interface DeployExecutionOptions {
|
|
132
|
-
/** Unique identifier for this deployment */
|
|
133
|
-
deploymentId: string;
|
|
134
|
-
/** The prompt/task to execute */
|
|
135
|
-
prompt: string;
|
|
136
|
-
/** Working directory for the Claude Code instance */
|
|
137
|
-
workingDir: string;
|
|
138
|
-
/** Optional model override */
|
|
139
|
-
model?: string;
|
|
140
|
-
/** Callback for streaming output */
|
|
141
|
-
outputCallback?: (text: string) => void;
|
|
142
|
-
/** Callback for thinking output */
|
|
143
|
-
thinkingCallback?: (text: string) => void;
|
|
144
|
-
/** Callback for tool use events */
|
|
145
|
-
toolUseCallback?: (event: ToolUseEvent) => void;
|
|
146
|
-
/** Tools to disallow in this session */
|
|
147
|
-
disallowedTools?: string[];
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export interface DeploySession {
|
|
151
|
-
id: string;
|
|
152
|
-
deploymentId: string;
|
|
153
|
-
runner: HeadlessRunner;
|
|
154
|
-
startedAt: number;
|
|
155
|
-
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export interface DeployExecutionResult {
|
|
159
|
-
sessionId: string;
|
|
160
|
-
completed: boolean;
|
|
161
|
-
error?: string;
|
|
162
|
-
totalTokens: number;
|
|
163
|
-
assistantResponse?: string;
|
|
164
|
-
durationMs: number;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ========== Deploy AI Service (Singleton) ==========
|
|
168
|
-
|
|
169
|
-
const MAX_CONCURRENT_SESSIONS_PER_DEPLOYMENT = 3;
|
|
170
|
-
const MAX_TOTAL_SESSIONS = 10;
|
|
171
|
-
|
|
172
|
-
export class DeployAiService extends EventEmitter {
|
|
173
|
-
private static instance: DeployAiService | null = null;
|
|
174
|
-
private sessions: Map<string, DeploySession> = new Map();
|
|
175
|
-
|
|
176
|
-
private constructor() {
|
|
177
|
-
super();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
static getInstance(): DeployAiService {
|
|
181
|
-
if (!DeployAiService.instance) {
|
|
182
|
-
DeployAiService.instance = new DeployAiService();
|
|
183
|
-
}
|
|
184
|
-
return DeployAiService.instance;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Execute a deploy AI task. Hard-gates on API key authentication.
|
|
189
|
-
*
|
|
190
|
-
* @throws DeployApiKeyError if credential type is 'oauth' or null
|
|
191
|
-
*/
|
|
192
|
-
async execute(options: DeployExecutionOptions): Promise<DeployExecutionResult> {
|
|
193
|
-
const startTime = Date.now();
|
|
194
|
-
const sessionId = `deploy-${options.deploymentId}-${Date.now()}`;
|
|
195
|
-
|
|
196
|
-
// ===== CRITICAL: API key auth enforcement =====
|
|
197
|
-
const credential = readOwnerApiCredential();
|
|
198
|
-
|
|
199
|
-
if (!credential || credential.type !== 'api-key') {
|
|
200
|
-
throw new DeployApiKeyError();
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const deployApiKey = credential.key;
|
|
204
|
-
// ===== END auth gate =====
|
|
205
|
-
|
|
206
|
-
// Enforce concurrency limits
|
|
207
|
-
this.enforceSessionLimits(options.deploymentId);
|
|
208
|
-
|
|
209
|
-
// Create isolated HeadlessRunner with the developer's API key injected.
|
|
210
|
-
// deployMode activates additional Security Bouncer patterns for end-user-driven sessions.
|
|
211
|
-
const runnerConfig: Partial<HeadlessConfig> = {
|
|
212
|
-
workingDir: options.workingDir,
|
|
213
|
-
directPrompt: options.prompt,
|
|
214
|
-
model: options.model,
|
|
215
|
-
outputCallback: options.outputCallback,
|
|
216
|
-
thinkingCallback: options.thinkingCallback,
|
|
217
|
-
toolUseCallback: options.toolUseCallback,
|
|
218
|
-
disallowedTools: options.disallowedTools,
|
|
219
|
-
extraEnv: { ANTHROPIC_API_KEY: deployApiKey },
|
|
220
|
-
verbose: false,
|
|
221
|
-
deployMode: true,
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const runner = new HeadlessRunner(runnerConfig);
|
|
225
|
-
|
|
226
|
-
const session: DeploySession = {
|
|
227
|
-
id: sessionId,
|
|
228
|
-
deploymentId: options.deploymentId,
|
|
229
|
-
runner,
|
|
230
|
-
startedAt: startTime,
|
|
231
|
-
status: 'running',
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
this.sessions.set(sessionId, session);
|
|
235
|
-
this.emit('sessionStart', { sessionId, deploymentId: options.deploymentId });
|
|
236
|
-
|
|
237
|
-
try {
|
|
238
|
-
const result: SessionResult = await runner.run();
|
|
239
|
-
|
|
240
|
-
session.status = result.completed ? 'completed' : 'failed';
|
|
241
|
-
|
|
242
|
-
const executionResult: DeployExecutionResult = {
|
|
243
|
-
sessionId,
|
|
244
|
-
completed: result.completed,
|
|
245
|
-
error: result.error,
|
|
246
|
-
totalTokens: result.totalTokens,
|
|
247
|
-
assistantResponse: result.assistantResponse,
|
|
248
|
-
durationMs: Date.now() - startTime,
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
this.emit('sessionComplete', {
|
|
252
|
-
sessionId,
|
|
253
|
-
deploymentId: options.deploymentId,
|
|
254
|
-
completed: result.completed,
|
|
255
|
-
durationMs: executionResult.durationMs,
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
return executionResult;
|
|
259
|
-
} catch (error: unknown) {
|
|
260
|
-
session.status = 'failed';
|
|
261
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
262
|
-
|
|
263
|
-
this.emit('sessionError', {
|
|
264
|
-
sessionId,
|
|
265
|
-
deploymentId: options.deploymentId,
|
|
266
|
-
error: errorMessage,
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
sessionId,
|
|
271
|
-
completed: false,
|
|
272
|
-
error: errorMessage,
|
|
273
|
-
totalTokens: 0,
|
|
274
|
-
durationMs: Date.now() - startTime,
|
|
275
|
-
};
|
|
276
|
-
} finally {
|
|
277
|
-
// Cleanup: remove from active sessions after a brief delay
|
|
278
|
-
// to allow status queries on recently-completed sessions
|
|
279
|
-
setTimeout(() => {
|
|
280
|
-
this.sessions.delete(sessionId);
|
|
281
|
-
}, 30_000);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Stop a specific deploy session.
|
|
287
|
-
*/
|
|
288
|
-
stopSession(sessionId: string): boolean {
|
|
289
|
-
const session = this.sessions.get(sessionId);
|
|
290
|
-
if (!session || session.status !== 'running') {
|
|
291
|
-
return false;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
session.runner.cleanup();
|
|
295
|
-
session.status = 'cancelled';
|
|
296
|
-
|
|
297
|
-
this.emit('sessionCancelled', {
|
|
298
|
-
sessionId,
|
|
299
|
-
deploymentId: session.deploymentId,
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
return true;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Stop all sessions for a specific deployment.
|
|
307
|
-
*/
|
|
308
|
-
stopDeployment(deploymentId: string): number {
|
|
309
|
-
let stopped = 0;
|
|
310
|
-
for (const [, session] of this.sessions) {
|
|
311
|
-
if (session.deploymentId === deploymentId && session.status === 'running') {
|
|
312
|
-
session.runner.cleanup();
|
|
313
|
-
session.status = 'cancelled';
|
|
314
|
-
stopped++;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return stopped;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Get the current status of a deploy session.
|
|
322
|
-
*/
|
|
323
|
-
getSession(sessionId: string): DeploySession | undefined {
|
|
324
|
-
return this.sessions.get(sessionId);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Get all active sessions for a deployment.
|
|
329
|
-
*/
|
|
330
|
-
getDeploymentSessions(deploymentId: string): DeploySession[] {
|
|
331
|
-
const result: DeploySession[] = [];
|
|
332
|
-
for (const session of this.sessions.values()) {
|
|
333
|
-
if (session.deploymentId === deploymentId) {
|
|
334
|
-
result.push(session);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return result;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Count active (running) sessions for a deployment.
|
|
342
|
-
*/
|
|
343
|
-
getActiveSessionCount(deploymentId: string): number {
|
|
344
|
-
let count = 0;
|
|
345
|
-
for (const session of this.sessions.values()) {
|
|
346
|
-
if (session.deploymentId === deploymentId && session.status === 'running') {
|
|
347
|
-
count++;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return count;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Cleanup all sessions — call on process exit.
|
|
355
|
-
*/
|
|
356
|
-
cleanup(): void {
|
|
357
|
-
for (const session of this.sessions.values()) {
|
|
358
|
-
if (session.status === 'running') {
|
|
359
|
-
session.runner.cleanup();
|
|
360
|
-
session.status = 'cancelled';
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
this.sessions.clear();
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Sweep completed/failed sessions that have exceeded the retention window.
|
|
368
|
-
*/
|
|
369
|
-
sweepStaleSessions(): number {
|
|
370
|
-
let swept = 0;
|
|
371
|
-
const now = Date.now();
|
|
372
|
-
for (const [sessionId, session] of this.sessions) {
|
|
373
|
-
if (session.status !== 'running' && now - session.startedAt > 60_000) {
|
|
374
|
-
this.sessions.delete(sessionId);
|
|
375
|
-
swept++;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return swept;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// ========== Internal ==========
|
|
382
|
-
|
|
383
|
-
private enforceSessionLimits(deploymentId: string): void {
|
|
384
|
-
const activeForDeployment = this.getActiveSessionCount(deploymentId);
|
|
385
|
-
if (activeForDeployment >= MAX_CONCURRENT_SESSIONS_PER_DEPLOYMENT) {
|
|
386
|
-
throw new Error(
|
|
387
|
-
`Deployment ${deploymentId} has reached the maximum of ${MAX_CONCURRENT_SESSIONS_PER_DEPLOYMENT} concurrent sessions.`
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
let totalActive = 0;
|
|
392
|
-
for (const session of this.sessions.values()) {
|
|
393
|
-
if (session.status === 'running') totalActive++;
|
|
394
|
-
}
|
|
395
|
-
if (totalActive >= MAX_TOTAL_SESSIONS) {
|
|
396
|
-
throw new Error(
|
|
397
|
-
`Maximum total concurrent deploy sessions (${MAX_TOTAL_SESSIONS}) reached. Wait for existing sessions to complete.`
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|