claude-code-session-manager 0.27.0 → 0.29.0
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/.claude-plugin/marketplace.json +21 -0
- package/dist/assets/{TiptapBody-BsNr6F0B.js → TiptapBody-DyiZVN0v.js} +1 -1
- package/dist/assets/index-DMIi9YZH.css +32 -0
- package/dist/assets/{index-DU1pkhIQ.js → index-qS7GdCsL.js} +391 -389
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +19 -0
- package/plugins/session-manager-dev/skills/develop/SKILL.md +112 -0
- package/plugins/session-manager-dev/skills/develop/standards.md +67 -0
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +192 -0
- package/plugins/session-manager-dev/skills/explain-to-me/assets/style-reference.html +163 -0
- package/plugins/session-manager-dev/skills/local-project-health/SKILL.md +58 -0
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +132 -0
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +289 -0
- package/plugins/session-manager-dev/skills/prd/SKILL.md +134 -0
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +183 -0
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +244 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +105 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/code-reviewer.md +146 -0
- package/plugins/session-manager-dev/skills/security-review/SKILL.md +105 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +95 -0
- package/src/main/__tests__/scheduler-autopromote.test.cjs +33 -0
- package/src/main/index.cjs +7 -0
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/credentials.cjs +96 -11
- package/src/main/pluginInstall.cjs +114 -32
- package/src/main/scheduler.cjs +109 -5
- package/src/main/seedDevPlugin.cjs +95 -0
- package/src/main/templates/PRD_AUTHORING.md +316 -0
- package/src/main/usage.cjs +34 -1
- package/src/preload/api.d.ts +8 -2
- package/dist/assets/index-Dwb94Uxm.css +0 -32
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-review
|
|
3
|
+
description: >-
|
|
4
|
+
Check for common security vulnerabilities before shipping code — focus on what
|
|
5
|
+
actually gets exploited (OWASP Top 10: injection, auth, secrets, access control),
|
|
6
|
+
not compliance theater. Use before committing code that handles user input, auth,
|
|
7
|
+
or data storage; when adding API endpoints or external integrations; when
|
|
8
|
+
reviewing dependencies; or when asked to do a security review. Keywords: security,
|
|
9
|
+
vulnerability, OWASP, injection, auth, secrets, review.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Security Review
|
|
13
|
+
|
|
14
|
+
## Overview
|
|
15
|
+
|
|
16
|
+
Check for common security vulnerabilities before shipping code. Focus on what actually gets exploited, not compliance theater.
|
|
17
|
+
|
|
18
|
+
## When to Use
|
|
19
|
+
|
|
20
|
+
- Before committing code that handles user input, auth, or data storage
|
|
21
|
+
- When adding new API endpoints or external integrations
|
|
22
|
+
- When reviewing dependencies or updating packages
|
|
23
|
+
- When asked to do a security review
|
|
24
|
+
|
|
25
|
+
## OWASP Top 10 Checklist
|
|
26
|
+
|
|
27
|
+
### Injection (SQL, NoSQL, OS Command, LDAP)
|
|
28
|
+
- All user input parameterized or escaped before use in queries
|
|
29
|
+
- No string concatenation in SQL/NoSQL queries
|
|
30
|
+
- No `eval()`, `exec()`, `shell=True`, or `child_process.exec()` with user input
|
|
31
|
+
- ORM/query builder used instead of raw queries where possible
|
|
32
|
+
|
|
33
|
+
### Broken Authentication
|
|
34
|
+
- Passwords hashed with bcrypt/argon2 (never MD5/SHA1)
|
|
35
|
+
- Session tokens are random, long, and expire
|
|
36
|
+
- MFA available for sensitive operations
|
|
37
|
+
- No credentials in URLs, logs, or error messages
|
|
38
|
+
|
|
39
|
+
### Sensitive Data Exposure
|
|
40
|
+
- No secrets in code, config files, or git history (.env, API keys, tokens)
|
|
41
|
+
- Data encrypted at rest (AES-256) and in transit (TLS)
|
|
42
|
+
- Sensitive fields excluded from logs and API responses
|
|
43
|
+
- PII handled according to data classification
|
|
44
|
+
|
|
45
|
+
### XSS (Cross-Site Scripting)
|
|
46
|
+
- All output HTML-encoded by default (React JSX handles this)
|
|
47
|
+
- No `dangerouslySetInnerHTML` or `v-html` without sanitization
|
|
48
|
+
- Content-Security-Policy headers set
|
|
49
|
+
- User input never inserted into `<script>` tags or event handlers
|
|
50
|
+
|
|
51
|
+
### Broken Access Control
|
|
52
|
+
- Every API endpoint checks authentication AND authorization
|
|
53
|
+
- No direct object references without ownership verification
|
|
54
|
+
- CORS configured to specific allowed origins (not `*`)
|
|
55
|
+
- Rate limiting on auth and sensitive endpoints
|
|
56
|
+
|
|
57
|
+
### Security Misconfiguration
|
|
58
|
+
- No default credentials or debug modes in production
|
|
59
|
+
- Error messages don't leak stack traces or internal details
|
|
60
|
+
- HTTP security headers set (HSTS, X-Frame-Options, X-Content-Type-Options)
|
|
61
|
+
- Unnecessary features and endpoints disabled
|
|
62
|
+
|
|
63
|
+
### CSRF (Cross-Site Request Forgery)
|
|
64
|
+
- State-changing requests use CSRF tokens or SameSite cookies
|
|
65
|
+
- POST/PUT/DELETE endpoints reject requests without valid tokens
|
|
66
|
+
|
|
67
|
+
## Dependency Check
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Node.js
|
|
71
|
+
npm audit
|
|
72
|
+
# Check for known vulnerabilities
|
|
73
|
+
npx audit-ci --moderate
|
|
74
|
+
|
|
75
|
+
# Python
|
|
76
|
+
pip-audit
|
|
77
|
+
safety check
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Flag: outdated packages with known CVEs, unmaintained dependencies, packages with suspiciously few downloads or recent ownership transfers.
|
|
81
|
+
|
|
82
|
+
## Secrets Scan
|
|
83
|
+
|
|
84
|
+
Look for accidentally committed secrets:
|
|
85
|
+
- API keys, tokens, passwords in source files
|
|
86
|
+
- `.env` files not in `.gitignore`
|
|
87
|
+
- Hardcoded connection strings
|
|
88
|
+
- Private keys or certificates
|
|
89
|
+
|
|
90
|
+
## Input Validation Rules
|
|
91
|
+
|
|
92
|
+
- Validate at system boundaries (API endpoints, form handlers, file uploads)
|
|
93
|
+
- Reject unexpected input types and sizes
|
|
94
|
+
- Whitelist over blacklist
|
|
95
|
+
- Validate on the server, never trust client-only validation
|
|
96
|
+
|
|
97
|
+
## When Reporting Issues
|
|
98
|
+
|
|
99
|
+
Classify findings:
|
|
100
|
+
- **Critical**: Exploitable now, data at risk (injection, auth bypass, exposed secrets)
|
|
101
|
+
- **High**: Exploitable with effort (XSS, CSRF, broken access control)
|
|
102
|
+
- **Medium**: Defense-in-depth gap (missing headers, weak config)
|
|
103
|
+
- **Low**: Best practice deviation (could become a problem later)
|
|
104
|
+
|
|
105
|
+
Always provide the fix, not just the finding.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-autofix-select.test.cjs — unit tests for selectAutoFixTargets.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 node --test src/main/__tests__/scheduler-autofix-select.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const { selectAutoFixTargets } = require('../scheduler.cjs');
|
|
12
|
+
|
|
13
|
+
const noSiblingOnDisk = () => false;
|
|
14
|
+
|
|
15
|
+
function makeJob(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
slug: '05-my-feature',
|
|
18
|
+
status: 'needs_review',
|
|
19
|
+
runId: '2026-06-16T10-00-00-000Z',
|
|
20
|
+
parallelGroup: 5,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('selects a fresh needs_review job', () => {
|
|
26
|
+
const jobs = [makeJob()];
|
|
27
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
28
|
+
assert.strictEqual(result.length, 1);
|
|
29
|
+
assert.strictEqual(result[0].slug, '05-my-feature');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('excludes job with autoFixAttempted: true', () => {
|
|
33
|
+
const jobs = [makeJob({ autoFixAttempted: true })];
|
|
34
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
35
|
+
assert.strictEqual(result.length, 0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('excludes a fix-plan slug (05-fix-foo)', () => {
|
|
39
|
+
const jobs = [makeJob({ slug: '05-fix-foo' })];
|
|
40
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
41
|
+
assert.strictEqual(result.length, 0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('excludes a failed job', () => {
|
|
45
|
+
const jobs = [makeJob({ status: 'failed' })];
|
|
46
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
47
|
+
assert.strictEqual(result.length, 0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('excludes a completed job', () => {
|
|
51
|
+
const jobs = [makeJob({ status: 'completed' })];
|
|
52
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
53
|
+
assert.strictEqual(result.length, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('excludes a job missing runId', () => {
|
|
57
|
+
const jobs = [makeJob({ runId: null })];
|
|
58
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
59
|
+
assert.strictEqual(result.length, 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('excludes when fix sibling exists on disk', () => {
|
|
63
|
+
const jobs = [makeJob()];
|
|
64
|
+
// fixSlug = '05-fix-my-feature'
|
|
65
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: (s) => s === '05-fix-my-feature' });
|
|
66
|
+
assert.strictEqual(result.length, 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('excludes when fix sibling already in the queue', () => {
|
|
70
|
+
const sibling = { slug: '05-fix-my-feature', status: 'pending', runId: null };
|
|
71
|
+
const jobs = [makeJob(), sibling];
|
|
72
|
+
const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
|
|
73
|
+
assert.strictEqual(result.length, 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('fixSlug uses padded parallelGroup and strips leading digits from slug', () => {
|
|
77
|
+
const jobs = [makeJob({ slug: '07-some-task', parallelGroup: 7 })];
|
|
78
|
+
// Expected fixSlug: '07-fix-some-task'
|
|
79
|
+
const seen = [];
|
|
80
|
+
selectAutoFixTargets(jobs, {
|
|
81
|
+
fixSlugExists: (s) => { seen.push(s); return false; },
|
|
82
|
+
});
|
|
83
|
+
assert.strictEqual(seen[0], '07-fix-some-task');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('defaults parallelGroup to 99 when absent', () => {
|
|
87
|
+
const jobs = [makeJob({ slug: '05-my-feature', parallelGroup: undefined })];
|
|
88
|
+
const seen = [];
|
|
89
|
+
selectAutoFixTargets(jobs, {
|
|
90
|
+
fixSlugExists: (s) => { seen.push(s); return false; },
|
|
91
|
+
});
|
|
92
|
+
assert.strictEqual(seen[0], '99-fix-my-feature');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
console.log('scheduler-autofix-select tests: PASS');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-autopromote.test.cjs — unit tests for isPromotableOriginal.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 node --test src/main/__tests__/scheduler-autopromote.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const { isPromotableOriginal } = require('../scheduler.cjs');
|
|
12
|
+
|
|
13
|
+
test('isPromotableOriginal: failed → true', () => {
|
|
14
|
+
assert.strictEqual(isPromotableOriginal('failed'), true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('isPromotableOriginal: needs_review → true', () => {
|
|
18
|
+
assert.strictEqual(isPromotableOriginal('needs_review'), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('isPromotableOriginal: completed → false', () => {
|
|
22
|
+
assert.strictEqual(isPromotableOriginal('completed'), false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('isPromotableOriginal: running → false', () => {
|
|
26
|
+
assert.strictEqual(isPromotableOriginal('running'), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('isPromotableOriginal: pending → false', () => {
|
|
30
|
+
assert.strictEqual(isPromotableOriginal('pending'), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
console.log('scheduler-autopromote tests: PASS');
|
package/src/main/index.cjs
CHANGED
|
@@ -26,6 +26,7 @@ const watchers = require('./watchers.cjs');
|
|
|
26
26
|
const teams = require('./teams.cjs');
|
|
27
27
|
const queueOps = require('./queueOps.cjs');
|
|
28
28
|
const pluginInstall = require('./pluginInstall.cjs');
|
|
29
|
+
const { seedDevPlugin } = require('./seedDevPlugin.cjs');
|
|
29
30
|
const otel = require('./otel.cjs');
|
|
30
31
|
const otelSettings = require('./otelSettings.cjs');
|
|
31
32
|
const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs');
|
|
@@ -948,6 +949,12 @@ app.whenReady().then(async () => {
|
|
|
948
949
|
scheduler.init().catch((e) => {
|
|
949
950
|
logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
950
951
|
});
|
|
952
|
+
// First-boot default: install the bundled session-manager-dev plugin (its 10
|
|
953
|
+
// dev skills) from the app's own marketplace. One-shot + idempotent; never
|
|
954
|
+
// throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
|
|
955
|
+
seedDevPlugin({ logger: console }).catch((e) => {
|
|
956
|
+
logs.writeLine({ scope: 'seed-dev-plugin', level: 'error', message: 'seed failed', meta: { error: e?.message } });
|
|
957
|
+
});
|
|
951
958
|
webRemote.init().catch((e) => {
|
|
952
959
|
logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
953
960
|
});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -358,8 +358,18 @@ const repoAnalyze = z.object({
|
|
|
358
358
|
// Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
|
|
359
359
|
// depth — install() re-checks; the schema rejects earlier.
|
|
360
360
|
const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
|
|
361
|
+
const PLUGIN_MKT_ADD_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9._\-]+$/;
|
|
361
362
|
const pluginsInstall = z.object({
|
|
362
363
|
slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
|
|
364
|
+
// Optional non-official marketplace: registered via `plugin marketplace add`
|
|
365
|
+
// before install. pluginInstall.cjs re-validates (defense in depth).
|
|
366
|
+
marketplace: z.object({
|
|
367
|
+
// `owner/repo`, or the literal `bundled` sentinel (app's own packaged
|
|
368
|
+
// marketplace — pluginInstall.cjs resolves it to an absolute path).
|
|
369
|
+
add: z.string().min(1).max(200)
|
|
370
|
+
.refine((v) => v === 'bundled' || PLUGIN_MKT_ADD_RE.test(v), 'invalid marketplace source'),
|
|
371
|
+
name: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
|
|
372
|
+
}).optional(),
|
|
363
373
|
}).passthrough();
|
|
364
374
|
const pluginsAbort = z.object({
|
|
365
375
|
slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
|
|
@@ -4,10 +4,55 @@ const fsp = require('node:fs/promises');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const os = require('node:os');
|
|
7
|
-
const { spawn } = require('node:child_process');
|
|
7
|
+
const { spawn, execFileSync } = require('node:child_process');
|
|
8
8
|
const { cleanChildEnv } = require('./cleanEnv.cjs');
|
|
9
9
|
|
|
10
10
|
const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
11
|
+
|
|
12
|
+
// macOS stores Claude Code credentials in the login Keychain, not on disk —
|
|
13
|
+
// there is no ~/.claude/.credentials.json there. The Keychain item is a
|
|
14
|
+
// generic password under this service whose secret is the same JSON blob
|
|
15
|
+
// ({ claudeAiOauth: { accessToken, … } }) the Linux/WSL file holds.
|
|
16
|
+
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
17
|
+
|
|
18
|
+
/** Read the raw credential JSON string from the macOS Keychain, or null. */
|
|
19
|
+
function readKeychainRaw() {
|
|
20
|
+
if (process.platform !== 'darwin') return null;
|
|
21
|
+
try {
|
|
22
|
+
const out = execFileSync(
|
|
23
|
+
'security',
|
|
24
|
+
['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'],
|
|
25
|
+
{ encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
26
|
+
);
|
|
27
|
+
const trimmed = out.trim();
|
|
28
|
+
return trimmed.length ? trimmed : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null; // not found / locked — caller treats as "no creds here"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Discover the account the Keychain item is stored under (for write-back). */
|
|
35
|
+
function keychainAccount() {
|
|
36
|
+
try {
|
|
37
|
+
const out = execFileSync(
|
|
38
|
+
'security',
|
|
39
|
+
['find-generic-password', '-s', KEYCHAIN_SERVICE],
|
|
40
|
+
{ encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
41
|
+
);
|
|
42
|
+
const m = out.match(/"acct"<blob>="([^"]*)"/);
|
|
43
|
+
if (m && m[1]) return m[1];
|
|
44
|
+
} catch { /* fall through to login user */ }
|
|
45
|
+
return os.userInfo().username;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write the credential JSON back into the Keychain (-U upserts in place). */
|
|
49
|
+
function writeKeychainRaw(value) {
|
|
50
|
+
execFileSync(
|
|
51
|
+
'security',
|
|
52
|
+
['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', keychainAccount(), '-w', value],
|
|
53
|
+
{ timeout: 10_000, stdio: 'ignore' },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
11
56
|
const REFRESH_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'credential-refresh.log');
|
|
12
57
|
const REFRESH_LOG_MAX_BYTES = 100 * 1024;
|
|
13
58
|
|
|
@@ -16,17 +61,42 @@ const REFRESH_LOG_MAX_BYTES = 100 * 1024;
|
|
|
16
61
|
// allowing the caller to fall back gracefully.
|
|
17
62
|
const OAUTH_TOKEN_URL = 'https://claude.ai/api/auth/oauth/token';
|
|
18
63
|
|
|
64
|
+
/** Parse a raw credential JSON blob (from file or Keychain) into a result. */
|
|
65
|
+
function parseCredsRaw(raw, source) {
|
|
66
|
+
let data;
|
|
67
|
+
try {
|
|
68
|
+
data = JSON.parse(raw);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { kind: 'config', message: `cannot parse ${source} credentials: ${e.message}` };
|
|
71
|
+
}
|
|
72
|
+
const oa = data?.claudeAiOauth;
|
|
73
|
+
if (!oa?.accessToken) return { kind: 'config', message: `missing accessToken in ${source} credentials` };
|
|
74
|
+
return { kind: 'ok', creds: oa, raw: data, source };
|
|
75
|
+
}
|
|
76
|
+
|
|
19
77
|
async function readCredentials() {
|
|
78
|
+
// 1) File — Linux / WSL (and macOS in the rare case a file exists).
|
|
20
79
|
try {
|
|
21
80
|
const raw = await fsp.readFile(CREDS_PATH, 'utf8');
|
|
22
|
-
|
|
23
|
-
const oa = data?.claudeAiOauth;
|
|
24
|
-
if (!oa?.accessToken) return { kind: 'config', message: 'missing accessToken in credentials file' };
|
|
25
|
-
return { kind: 'ok', creds: oa, raw: data };
|
|
81
|
+
return parseCredsRaw(raw, 'file');
|
|
26
82
|
} catch (e) {
|
|
27
|
-
if (e?.code
|
|
28
|
-
|
|
83
|
+
if (e?.code !== 'ENOENT') {
|
|
84
|
+
return { kind: 'config', message: `cannot read credentials: ${e.message}` };
|
|
85
|
+
}
|
|
86
|
+
// ENOENT — fall through to the macOS Keychain before giving up.
|
|
29
87
|
}
|
|
88
|
+
|
|
89
|
+
// 2) macOS Keychain — the canonical store on darwin (no file there).
|
|
90
|
+
if (process.platform === 'darwin') {
|
|
91
|
+
const kc = readKeychainRaw();
|
|
92
|
+
if (kc) return parseCredsRaw(kc, 'keychain');
|
|
93
|
+
return {
|
|
94
|
+
kind: 'config',
|
|
95
|
+
message: `credentials not found (no ${CREDS_PATH}; no Keychain item "${KEYCHAIN_SERVICE}" — run \`claude\` to log in)`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { kind: 'config', message: 'credentials file not found' };
|
|
30
100
|
}
|
|
31
101
|
|
|
32
102
|
function expiresAtMs(creds) {
|
|
@@ -49,8 +119,14 @@ function isExpiringSoon(creds, withinMs = 5 * 60_000) {
|
|
|
49
119
|
return ms !== null && ms - Date.now() < withinMs;
|
|
50
120
|
}
|
|
51
121
|
|
|
52
|
-
async function writeCredentials(rawData, freshOauth) {
|
|
122
|
+
async function writeCredentials(rawData, freshOauth, source = 'file') {
|
|
53
123
|
const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
|
|
124
|
+
if (source === 'keychain') {
|
|
125
|
+
// macOS: upsert back into the Keychain. Sync + may throw — caller catches
|
|
126
|
+
// and falls back to the `claude --version` CLI refresh path.
|
|
127
|
+
writeKeychainRaw(JSON.stringify(next));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
54
130
|
const tmp = `${CREDS_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
55
131
|
await fsp.writeFile(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
56
132
|
try { await fsp.chmod(tmp, 0o600); } catch { /* umask may have already set it */ }
|
|
@@ -130,7 +206,7 @@ function tryCliFallback() {
|
|
|
130
206
|
async function refreshIfNeeded(forceRefresh = false) {
|
|
131
207
|
const cr = await readCredentials();
|
|
132
208
|
if (cr.kind !== 'ok') return cr;
|
|
133
|
-
const { creds, raw } = cr;
|
|
209
|
+
const { creds, raw, source } = cr;
|
|
134
210
|
|
|
135
211
|
if (!forceRefresh && !isExpiringSoon(creds)) {
|
|
136
212
|
return { kind: 'ok', creds };
|
|
@@ -144,7 +220,7 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
144
220
|
|
|
145
221
|
if (oauthResult.kind === 'ok') {
|
|
146
222
|
try {
|
|
147
|
-
await writeCredentials(raw, oauthResult.fresh);
|
|
223
|
+
await writeCredentials(raw, oauthResult.fresh, source);
|
|
148
224
|
const freshCr = await readCredentials();
|
|
149
225
|
if (freshCr.kind === 'ok') {
|
|
150
226
|
appendRefreshLog({ event: 'oauth_refresh_written_ok' });
|
|
@@ -188,4 +264,13 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
188
264
|
return { kind: 'unsupported', message: 'Auto-refresh failed; token still valid for now', creds };
|
|
189
265
|
}
|
|
190
266
|
|
|
191
|
-
module.exports = {
|
|
267
|
+
module.exports = {
|
|
268
|
+
readCredentials,
|
|
269
|
+
expiresAtMs,
|
|
270
|
+
isExpired,
|
|
271
|
+
isExpiringSoon,
|
|
272
|
+
refreshIfNeeded,
|
|
273
|
+
parseCredsRaw,
|
|
274
|
+
KEYCHAIN_SERVICE,
|
|
275
|
+
CREDS_PATH,
|
|
276
|
+
};
|
|
@@ -20,13 +20,29 @@ const { ipcMain } = require('electron');
|
|
|
20
20
|
const pty = require('node-pty');
|
|
21
21
|
const path = require('node:path');
|
|
22
22
|
const os = require('node:os');
|
|
23
|
+
const fs = require('node:fs');
|
|
23
24
|
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
24
25
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
25
26
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
26
27
|
const { schemas } = require('./ipcSchemas.cjs');
|
|
27
28
|
|
|
28
29
|
const SLUG_RE = /^[a-z0-9\-/]+$/;
|
|
30
|
+
// Marketplace name: same shape as a plugin slug. Marketplace source (`add`):
|
|
31
|
+
// a GitHub `owner/repo` (case-sensitive) or a dotted/hyphenated path segment.
|
|
32
|
+
// Deliberately excludes whitespace, flags (leading `-`), and shell metachars.
|
|
33
|
+
const MKT_NAME_RE = /^[a-z0-9\-/]+$/;
|
|
34
|
+
const MKT_ADD_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9._\-]+$/;
|
|
35
|
+
// Sentinel: register the marketplace that ships inside this app's own files
|
|
36
|
+
// (the npx distribution). Resolved to an absolute path in-process so the
|
|
37
|
+
// renderer never supplies a filesystem path. Works offline — no GitHub/npm.
|
|
38
|
+
const BUNDLED_ADD = 'bundled';
|
|
29
39
|
const MAX_LINE_BYTES = 16 * 1024;
|
|
40
|
+
|
|
41
|
+
/** Absolute path to the packaged marketplace root (package dir holding
|
|
42
|
+
* `.claude-plugin/marketplace.json`). src/main/ → ../../ */
|
|
43
|
+
function bundledMarketplaceDir() {
|
|
44
|
+
return path.join(__dirname, '..', '..');
|
|
45
|
+
}
|
|
30
46
|
const KILL_AFTER_MS = 5 * 60 * 1000; // 5 min hard ceiling per install
|
|
31
47
|
const KILL_GRACE_MS = 5_000; // SIGTERM → SIGKILL escalation window
|
|
32
48
|
|
|
@@ -41,52 +57,51 @@ function send(channel, payload) {
|
|
|
41
57
|
sendIfAlive(mainWindow, channel, payload);
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
function childEnv() {
|
|
61
|
+
const home = os.homedir();
|
|
62
|
+
const extraPath = [
|
|
63
|
+
path.join(home, '.local', 'bin'),
|
|
64
|
+
path.join(home, '.npm-global', 'bin'),
|
|
65
|
+
'/usr/local/bin',
|
|
66
|
+
'/usr/bin',
|
|
67
|
+
'/bin',
|
|
68
|
+
].join(':');
|
|
69
|
+
return cleanChildEnv({
|
|
70
|
+
PATH: `${extraPath}:${process.env.PATH || ''}`,
|
|
71
|
+
TERM: 'xterm-256color',
|
|
72
|
+
FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
|
|
73
|
+
});
|
|
74
|
+
}
|
|
51
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Run one `claude plugin …` pty step, streaming output under `slug` and
|
|
78
|
+
* resolving with the exit code. `register`/`unregister` thread the live proc
|
|
79
|
+
* into the inFlight map so plugins:abort can kill whichever step is current.
|
|
80
|
+
*/
|
|
81
|
+
function runStep({ slug, args, register, unregister }) {
|
|
52
82
|
return new Promise((resolve) => {
|
|
53
|
-
const home = os.homedir();
|
|
54
|
-
const extraPath = [
|
|
55
|
-
path.join(home, '.local', 'bin'),
|
|
56
|
-
path.join(home, '.npm-global', 'bin'),
|
|
57
|
-
'/usr/local/bin',
|
|
58
|
-
'/usr/bin',
|
|
59
|
-
'/bin',
|
|
60
|
-
].join(':');
|
|
61
|
-
const env = cleanChildEnv({
|
|
62
|
-
PATH: `${extraPath}:${process.env.PATH || ''}`,
|
|
63
|
-
TERM: 'xterm-256color',
|
|
64
|
-
FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
|
|
65
|
-
});
|
|
66
|
-
|
|
67
83
|
const claudeBin = resolveClaudeBin();
|
|
68
84
|
let proc;
|
|
69
85
|
try {
|
|
70
|
-
proc = pty.spawn(claudeBin,
|
|
86
|
+
proc = pty.spawn(claudeBin, args, {
|
|
71
87
|
name: 'xterm-256color',
|
|
72
88
|
cols: 120,
|
|
73
89
|
rows: 30,
|
|
74
|
-
cwd:
|
|
75
|
-
env,
|
|
90
|
+
cwd: os.homedir(),
|
|
91
|
+
env: childEnv(),
|
|
76
92
|
});
|
|
77
93
|
} catch (err) {
|
|
78
94
|
resolve({ ok: false, exitCode: -1, error: `spawn failed: ${err?.message ?? String(err)}` });
|
|
79
95
|
return;
|
|
80
96
|
}
|
|
81
97
|
|
|
82
|
-
|
|
98
|
+
register(proc);
|
|
83
99
|
|
|
84
100
|
let lineBuf = '';
|
|
85
101
|
let settled = false;
|
|
86
102
|
|
|
87
103
|
const killTimer = setTimeout(() => {
|
|
88
104
|
try { proc.kill('SIGTERM'); } catch { /* */ }
|
|
89
|
-
// Escalate to SIGKILL after KILL_GRACE_MS if the pty hasn't exited.
|
|
90
105
|
const escalate = setTimeout(() => {
|
|
91
106
|
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
92
107
|
}, KILL_GRACE_MS);
|
|
@@ -96,11 +111,11 @@ function install({ slug }) {
|
|
|
96
111
|
|
|
97
112
|
// Belt-and-suspenders: if onExit never fires (broken pty event path after
|
|
98
113
|
// SIGKILL — analogous to anthropics/claude-code #61735's unreachable pts),
|
|
99
|
-
// force-
|
|
114
|
+
// force-resolve so the step (and its inFlight lock) isn't permanently stuck.
|
|
100
115
|
const deadman = setTimeout(() => {
|
|
101
116
|
if (settled) return;
|
|
102
117
|
settled = true;
|
|
103
|
-
|
|
118
|
+
unregister();
|
|
104
119
|
resolve({ ok: false, exitCode: -1, error: 'install hung — pty onExit never fired' });
|
|
105
120
|
}, KILL_AFTER_MS + KILL_GRACE_MS + 30_000);
|
|
106
121
|
if (deadman.unref) deadman.unref();
|
|
@@ -113,7 +128,6 @@ function install({ slug }) {
|
|
|
113
128
|
lineBuf = lineBuf.slice(nl + 1);
|
|
114
129
|
send('plugins:install-progress', { slug, line });
|
|
115
130
|
}
|
|
116
|
-
// Guard runaway buffers without newlines.
|
|
117
131
|
if (lineBuf.length > MAX_LINE_BYTES) {
|
|
118
132
|
send('plugins:install-progress', { slug, line: lineBuf });
|
|
119
133
|
lineBuf = '';
|
|
@@ -129,13 +143,81 @@ function install({ slug }) {
|
|
|
129
143
|
send('plugins:install-progress', { slug, line: lineBuf });
|
|
130
144
|
lineBuf = '';
|
|
131
145
|
}
|
|
132
|
-
|
|
146
|
+
unregister();
|
|
133
147
|
const code = typeof exitCode === 'number' ? exitCode : -1;
|
|
134
148
|
resolve({ ok: code === 0, exitCode: code });
|
|
135
149
|
});
|
|
136
150
|
});
|
|
137
151
|
}
|
|
138
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Install a plugin. Without `marketplace`, runs `claude plugin install <slug>`
|
|
155
|
+
* (official catalog is pre-registered). With `marketplace: { add, name }`,
|
|
156
|
+
* first runs `claude plugin marketplace add <add>` to register the source, then
|
|
157
|
+
* installs `<slug>@<name>` — this is what a non-official plugin (e.g. the
|
|
158
|
+
* bundled session-manager-dev) needs on a fresh machine.
|
|
159
|
+
*/
|
|
160
|
+
async function install({ slug, marketplace }) {
|
|
161
|
+
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > 128) {
|
|
162
|
+
return { ok: false, exitCode: -1, error: 'invalid slug' };
|
|
163
|
+
}
|
|
164
|
+
let mkt = null;
|
|
165
|
+
if (marketplace != null) {
|
|
166
|
+
const { add, name } = marketplace;
|
|
167
|
+
const addValid = typeof add === 'string' && add.length <= 200 &&
|
|
168
|
+
(add === BUNDLED_ADD || MKT_ADD_RE.test(add));
|
|
169
|
+
if (!addValid || typeof name !== 'string' || !MKT_NAME_RE.test(name) || name.length > 128) {
|
|
170
|
+
return { ok: false, exitCode: -1, error: 'invalid marketplace' };
|
|
171
|
+
}
|
|
172
|
+
// Resolve the `bundled` sentinel to the app's own packaged marketplace dir.
|
|
173
|
+
let addArg = add;
|
|
174
|
+
if (add === BUNDLED_ADD) {
|
|
175
|
+
const dir = bundledMarketplaceDir();
|
|
176
|
+
if (!fs.existsSync(path.join(dir, '.claude-plugin', 'marketplace.json'))) {
|
|
177
|
+
return { ok: false, exitCode: -1, error: 'bundled marketplace not found' };
|
|
178
|
+
}
|
|
179
|
+
addArg = dir;
|
|
180
|
+
}
|
|
181
|
+
mkt = { add: addArg, name };
|
|
182
|
+
}
|
|
183
|
+
if (inFlight.has(slug)) {
|
|
184
|
+
return { ok: false, exitCode: -1, error: 'install already in progress' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Reserve the slug for the whole sequence; each step swaps in its live proc.
|
|
188
|
+
inFlight.set(slug, null);
|
|
189
|
+
const register = (proc) => inFlight.set(slug, proc);
|
|
190
|
+
const release = () => inFlight.delete(slug);
|
|
191
|
+
// Between steps the slug stays reserved (set to null) so a concurrent call
|
|
192
|
+
// can't slip in; runStep's unregister only clears the per-step proc handle.
|
|
193
|
+
const unregisterStep = () => inFlight.set(slug, null);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
if (mkt) {
|
|
197
|
+
const added = await runStep({
|
|
198
|
+
slug,
|
|
199
|
+
args: ['plugin', 'marketplace', 'add', mkt.add],
|
|
200
|
+
register,
|
|
201
|
+
unregister: unregisterStep,
|
|
202
|
+
});
|
|
203
|
+
// Tolerate "already added" — the add command exits non-zero when the
|
|
204
|
+
// marketplace is already registered; fall through to install regardless.
|
|
205
|
+
if (!added.ok) {
|
|
206
|
+
send('plugins:install-progress', { slug, line: `[marketplace add exit ${added.exitCode} — continuing to install]` });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const installTarget = mkt ? `${slug}@${mkt.name}` : slug;
|
|
210
|
+
return await runStep({
|
|
211
|
+
slug,
|
|
212
|
+
args: ['plugin', 'install', installTarget],
|
|
213
|
+
register,
|
|
214
|
+
unregister: unregisterStep,
|
|
215
|
+
});
|
|
216
|
+
} finally {
|
|
217
|
+
release();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
139
221
|
function registerPluginInstallHandlers() {
|
|
140
222
|
ipcMain.handle('plugins:install', async (_e, payload) => {
|
|
141
223
|
// safeParse to preserve the existing `{ ok:false, exitCode:-1, error }`
|
|
@@ -145,7 +227,7 @@ function registerPluginInstallHandlers() {
|
|
|
145
227
|
if (!parsed.success) {
|
|
146
228
|
return { ok: false, exitCode: -1, error: 'invalid slug' };
|
|
147
229
|
}
|
|
148
|
-
return install({ slug: parsed.data.slug });
|
|
230
|
+
return install({ slug: parsed.data.slug, marketplace: parsed.data.marketplace });
|
|
149
231
|
});
|
|
150
232
|
|
|
151
233
|
// plugins:abort — send SIGKILL to a stuck install and release the inFlight
|
|
@@ -164,4 +246,4 @@ function registerPluginInstallHandlers() {
|
|
|
164
246
|
});
|
|
165
247
|
}
|
|
166
248
|
|
|
167
|
-
module.exports = { registerPluginInstallHandlers, attachWindow };
|
|
249
|
+
module.exports = { registerPluginInstallHandlers, attachWindow, install, bundledMarketplaceDir };
|