runwork 0.10.4 → 0.12.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/dist/agents/__tests__/claude-code-managed-block.test.d.ts +1 -0
- package/dist/agents/__tests__/claude-code-managed-block.test.js +97 -0
- package/dist/agents/__tests__/codex-minimum-permissions.test.d.ts +1 -0
- package/dist/agents/__tests__/codex-minimum-permissions.test.js +82 -0
- package/dist/agents/__tests__/cursor-merge.test.d.ts +1 -0
- package/dist/agents/__tests__/cursor-merge.test.js +66 -0
- package/dist/agents/__tests__/defaults-merge.test.d.ts +1 -0
- package/dist/agents/__tests__/defaults-merge.test.js +268 -0
- package/dist/agents/__tests__/intro-skill.test.js +32 -0
- package/dist/agents/claude-code.d.ts +5 -0
- package/dist/agents/claude-code.js +29 -0
- package/dist/agents/cursor.d.ts +23 -1
- package/dist/agents/cursor.js +42 -3
- package/dist/agents/default-config.d.ts +30 -0
- package/dist/agents/default-config.js +67 -0
- package/dist/agents/defaults-merge.d.ts +72 -0
- package/dist/agents/defaults-merge.js +131 -0
- package/dist/agents/intro-skill.d.ts +9 -0
- package/dist/agents/intro-skill.js +41 -0
- package/dist/agents/types.d.ts +31 -2
- package/dist/commands/__tests__/setup-persona.test.d.ts +1 -0
- package/dist/commands/__tests__/setup-persona.test.js +31 -0
- package/dist/commands/info.d.ts +1 -1
- package/dist/commands/info.js +7 -2
- package/dist/commands/setup.d.ts +7 -0
- package/dist/commands/setup.js +22 -0
- package/dist/commands/sync.js +147 -59
- package/dist/generated/bundled-types.js +33 -33
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/types.d.ts +36 -0
- package/dist/ui/banner.js +1 -1
- package/dist/utils/app-info.d.ts +4 -0
- package/dist/utils/app-info.js +17 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { ClaudeCodeAdapter } from '../claude-code.js';
|
|
6
|
+
let homeDir;
|
|
7
|
+
let originalHome;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
originalHome = process.env.HOME;
|
|
10
|
+
homeDir = join(tmpdir(), `runwork-cc-managed-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
11
|
+
mkdirSync(join(homeDir, '.claude'), { recursive: true });
|
|
12
|
+
process.env.HOME = homeDir;
|
|
13
|
+
// Reset the homedir() cache by mocking it via env (Node's os.homedir reads HOME on macOS/Linux).
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (originalHome)
|
|
17
|
+
process.env.HOME = originalHome;
|
|
18
|
+
else
|
|
19
|
+
delete process.env.HOME;
|
|
20
|
+
try {
|
|
21
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
catch { /* ignore */ }
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
function writeSettings(settings) {
|
|
27
|
+
writeFileSync(join(homeDir, '.claude', 'settings.json'), JSON.stringify(settings, null, 2));
|
|
28
|
+
}
|
|
29
|
+
describe('ClaudeCodeAdapter.readManagedBlock (user scope)', () => {
|
|
30
|
+
it('returns undefined when settings.json is missing', async () => {
|
|
31
|
+
const adapter = new ClaudeCodeAdapter();
|
|
32
|
+
const result = await adapter.readManagedBlock('user');
|
|
33
|
+
expect(result).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it('returns empty arrays when permissions key is absent', async () => {
|
|
36
|
+
writeSettings({ model: 'claude-sonnet-4-6' });
|
|
37
|
+
const adapter = new ClaudeCodeAdapter();
|
|
38
|
+
const result = await adapter.readManagedBlock('user');
|
|
39
|
+
expect(result).toEqual({ allow: [], deny: [] });
|
|
40
|
+
});
|
|
41
|
+
it('returns empty arrays when marker is absent', async () => {
|
|
42
|
+
writeSettings({ permissions: { allow: ['Bash(npm test)'], deny: ['Bash(rm)'] } });
|
|
43
|
+
const adapter = new ClaudeCodeAdapter();
|
|
44
|
+
const result = await adapter.readManagedBlock('user');
|
|
45
|
+
expect(result).toEqual({ allow: [], deny: [] });
|
|
46
|
+
});
|
|
47
|
+
it('returns entries after marker in allow', async () => {
|
|
48
|
+
writeSettings({
|
|
49
|
+
permissions: {
|
|
50
|
+
allow: ['Bash(npm test)', '# runwork-managed', 'Bash(runwork *)', 'Bash(git status)'],
|
|
51
|
+
deny: [],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const adapter = new ClaudeCodeAdapter();
|
|
55
|
+
const result = await adapter.readManagedBlock('user');
|
|
56
|
+
expect(result?.allow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
|
|
57
|
+
expect(result?.deny).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
it('returns entries after marker in deny', async () => {
|
|
60
|
+
writeSettings({
|
|
61
|
+
permissions: {
|
|
62
|
+
allow: [],
|
|
63
|
+
deny: ['Bash(curl evil.com)', '# runwork-managed', 'Bash(rm -rf *)'],
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const adapter = new ClaudeCodeAdapter();
|
|
67
|
+
const result = await adapter.readManagedBlock('user');
|
|
68
|
+
expect(result?.deny).toEqual(['Bash(rm -rf *)']);
|
|
69
|
+
});
|
|
70
|
+
it('returns empty when marker exists but nothing follows', async () => {
|
|
71
|
+
writeSettings({
|
|
72
|
+
permissions: {
|
|
73
|
+
allow: ['Bash(npm test)', '# runwork-managed'],
|
|
74
|
+
deny: [],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const adapter = new ClaudeCodeAdapter();
|
|
78
|
+
const result = await adapter.readManagedBlock('user');
|
|
79
|
+
expect(result?.allow).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
it('returns undefined on JSON parse error', async () => {
|
|
82
|
+
writeFileSync(join(homeDir, '.claude', 'settings.json'), '{ not valid json');
|
|
83
|
+
const adapter = new ClaudeCodeAdapter();
|
|
84
|
+
const result = await adapter.readManagedBlock('user');
|
|
85
|
+
expect(result).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
it('filters out non-string entries after marker (defensive)', async () => {
|
|
88
|
+
writeSettings({
|
|
89
|
+
permissions: {
|
|
90
|
+
allow: ['# runwork-managed', 'Bash(runwork *)', 42, null, 'Bash(git status)'],
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const adapter = new ClaudeCodeAdapter();
|
|
94
|
+
const result = await adapter.readManagedBlock('user');
|
|
95
|
+
expect(result?.allow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
let mockHomeDir = '/tmp/runwork-cli-codex-min-perms-tests';
|
|
6
|
+
vi.mock('os', async () => {
|
|
7
|
+
const actual = await vi.importActual('os');
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
homedir: vi.fn(() => mockHomeDir),
|
|
11
|
+
platform: vi.fn(() => 'darwin'),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
import { CodexAdapter } from '../codex.js';
|
|
15
|
+
// Matches the orderings used in sync.ts. Keep these in sync with that file —
|
|
16
|
+
// the whole point of these tests is to lock the floor semantics in place.
|
|
17
|
+
const APPROVAL_FLOOR = {
|
|
18
|
+
field: 'approval_policy',
|
|
19
|
+
order: ['untrusted', 'on-request', 'on-failure', 'never'],
|
|
20
|
+
minimum: 'on-request',
|
|
21
|
+
};
|
|
22
|
+
const SANDBOX_FLOOR = {
|
|
23
|
+
field: 'sandbox_mode',
|
|
24
|
+
order: ['read-only', 'workspace-write', 'danger-full-access'],
|
|
25
|
+
minimum: 'workspace-write',
|
|
26
|
+
};
|
|
27
|
+
describe('CodexAdapter minimumPermissions floors', () => {
|
|
28
|
+
let tempHomeDir;
|
|
29
|
+
let configPath;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tempHomeDir = mkdtempSync(join(tmpdir(), 'runwork-codex-floors-'));
|
|
32
|
+
mockHomeDir = tempHomeDir;
|
|
33
|
+
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true });
|
|
34
|
+
configPath = join(tempHomeDir, '.codex', 'config.toml');
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
rmSync(tempHomeDir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
function readConfig() {
|
|
40
|
+
return existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '';
|
|
41
|
+
}
|
|
42
|
+
it('preserves danger-full-access (more permissive than the floor)', async () => {
|
|
43
|
+
writeFileSync(configPath, `sandbox_mode = "danger-full-access"\n`);
|
|
44
|
+
const adapter = new CodexAdapter();
|
|
45
|
+
await adapter.writeAgentConfig({ minimumPermissions: [SANDBOX_FLOOR] }, 'user');
|
|
46
|
+
expect(readConfig()).toContain('sandbox_mode = "danger-full-access"');
|
|
47
|
+
});
|
|
48
|
+
it('preserves "never" approval_policy (more permissive than the floor)', async () => {
|
|
49
|
+
writeFileSync(configPath, `approval_policy = "never"\n`);
|
|
50
|
+
const adapter = new CodexAdapter();
|
|
51
|
+
await adapter.writeAgentConfig({ minimumPermissions: [APPROVAL_FLOOR] }, 'user');
|
|
52
|
+
expect(readConfig()).toContain('approval_policy = "never"');
|
|
53
|
+
});
|
|
54
|
+
it('preserves "on-failure" approval_policy (between floor and "never")', async () => {
|
|
55
|
+
// Regression for the case where on-failure was missing from the order
|
|
56
|
+
// array, making it look "unknown" and triggering an unwanted downgrade.
|
|
57
|
+
writeFileSync(configPath, `approval_policy = "on-failure"\n`);
|
|
58
|
+
const adapter = new CodexAdapter();
|
|
59
|
+
await adapter.writeAgentConfig({ minimumPermissions: [APPROVAL_FLOOR] }, 'user');
|
|
60
|
+
expect(readConfig()).toContain('approval_policy = "on-failure"');
|
|
61
|
+
});
|
|
62
|
+
it('upgrades restrictive "read-only" sandbox_mode to the floor', async () => {
|
|
63
|
+
writeFileSync(configPath, `sandbox_mode = "read-only"\n`);
|
|
64
|
+
const adapter = new CodexAdapter();
|
|
65
|
+
await adapter.writeAgentConfig({ minimumPermissions: [SANDBOX_FLOOR] }, 'user');
|
|
66
|
+
expect(readConfig()).toContain('sandbox_mode = "workspace-write"');
|
|
67
|
+
});
|
|
68
|
+
it('upgrades restrictive "untrusted" approval_policy to the floor', async () => {
|
|
69
|
+
writeFileSync(configPath, `approval_policy = "untrusted"\n`);
|
|
70
|
+
const adapter = new CodexAdapter();
|
|
71
|
+
await adapter.writeAgentConfig({ minimumPermissions: [APPROVAL_FLOOR] }, 'user');
|
|
72
|
+
expect(readConfig()).toContain('approval_policy = "on-request"');
|
|
73
|
+
});
|
|
74
|
+
it('sets the floor when the field is absent', async () => {
|
|
75
|
+
writeFileSync(configPath, `model = "gpt-5"\n`);
|
|
76
|
+
const adapter = new CodexAdapter();
|
|
77
|
+
await adapter.writeAgentConfig({ minimumPermissions: [APPROVAL_FLOOR, SANDBOX_FLOOR] }, 'user');
|
|
78
|
+
const content = readConfig();
|
|
79
|
+
expect(content).toContain('approval_policy = "on-request"');
|
|
80
|
+
expect(content).toContain('sandbox_mode = "workspace-write"');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mergeMarkerless } from '../cursor.js';
|
|
3
|
+
describe('mergeMarkerless (Cursor markerless merge helper)', () => {
|
|
4
|
+
it('bootstrap: empty baseline preserves all existing user entries', () => {
|
|
5
|
+
// First sync after upgrade. No baseline yet. The two existing entries
|
|
6
|
+
// are presumed to be the user's own personal yolo rules.
|
|
7
|
+
const existing = ['myPersonalRule', 'anotherUserRule'];
|
|
8
|
+
const baseline = [];
|
|
9
|
+
const incoming = ['Bash(runwork *)'];
|
|
10
|
+
expect(mergeMarkerless(existing, baseline, incoming)).toEqual([
|
|
11
|
+
'myPersonalRule',
|
|
12
|
+
'anotherUserRule',
|
|
13
|
+
'Bash(runwork *)',
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
it('normal: baseline entries are subtracted so they can be re-injected fresh', () => {
|
|
17
|
+
// Previous sync wrote ['Bash(runwork *)', 'Bash(git status)']. User added
|
|
18
|
+
// 'myPersonalRule' between syncs. Now we want to inject defaults again.
|
|
19
|
+
const existing = ['myPersonalRule', 'Bash(runwork *)', 'Bash(git status)'];
|
|
20
|
+
const baseline = ['Bash(runwork *)', 'Bash(git status)'];
|
|
21
|
+
const incoming = ['Bash(runwork *)', 'Bash(git status)', 'Bash(ls)'];
|
|
22
|
+
// User entry stays; baseline entries are removed first then re-added via incoming.
|
|
23
|
+
expect(mergeMarkerless(existing, baseline, incoming)).toEqual([
|
|
24
|
+
'myPersonalRule',
|
|
25
|
+
'Bash(runwork *)',
|
|
26
|
+
'Bash(git status)',
|
|
27
|
+
'Bash(ls)',
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
it('handles user removing a baseline entry: not re-added unless in incoming', () => {
|
|
31
|
+
// We wrote [a, b]. User removed 'a'. Sync passes baseline=[a,b] and incoming=[b]
|
|
32
|
+
// (because the sync layer detected the removal and stripped 'a' from defaults).
|
|
33
|
+
const existing = ['userRule', 'b'];
|
|
34
|
+
const baseline = ['a', 'b'];
|
|
35
|
+
const incoming = ['b'];
|
|
36
|
+
expect(mergeMarkerless(existing, baseline, incoming)).toEqual(['userRule', 'b']);
|
|
37
|
+
});
|
|
38
|
+
it('dedupes when a user entry happens to equal an incoming entry', () => {
|
|
39
|
+
const existing = ['Bash(runwork *)'];
|
|
40
|
+
const baseline = [];
|
|
41
|
+
const incoming = ['Bash(runwork *)', 'Bash(ls)'];
|
|
42
|
+
// The user already has 'Bash(runwork *)' (looks like their personal rule).
|
|
43
|
+
// We don't add a duplicate; the incoming one is skipped.
|
|
44
|
+
expect(mergeMarkerless(existing, baseline, incoming)).toEqual([
|
|
45
|
+
'Bash(runwork *)',
|
|
46
|
+
'Bash(ls)',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
it('empty incoming leaves user entries intact', () => {
|
|
50
|
+
expect(mergeMarkerless(['a', 'b'], [], [])).toEqual(['a', 'b']);
|
|
51
|
+
});
|
|
52
|
+
it('empty existing returns incoming as-is', () => {
|
|
53
|
+
expect(mergeMarkerless([], [], ['Bash(runwork *)'])).toEqual(['Bash(runwork *)']);
|
|
54
|
+
});
|
|
55
|
+
it('preserves order of user entries before incoming entries', () => {
|
|
56
|
+
expect(mergeMarkerless(['user1', 'user2'], [], ['inc1', 'inc2'])).toEqual([
|
|
57
|
+
'user1',
|
|
58
|
+
'user2',
|
|
59
|
+
'inc1',
|
|
60
|
+
'inc2',
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
it('dedupes within existing (defensive)', () => {
|
|
64
|
+
expect(mergeMarkerless(['a', 'a', 'b'], [], ['c'])).toEqual(['a', 'b', 'c']);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectRemovals, computeApplicable, mergeAllow, isBootstrap, resolveAgentDefaults, unique, } from '../defaults-merge.js';
|
|
3
|
+
describe('detectRemovals', () => {
|
|
4
|
+
it('returns lastInjected items missing from onDisk', () => {
|
|
5
|
+
expect(detectRemovals(['a', 'b', 'c'], ['a', 'c'])).toEqual(['b']);
|
|
6
|
+
});
|
|
7
|
+
it('returns empty when nothing was removed', () => {
|
|
8
|
+
expect(detectRemovals(['a', 'b'], ['a', 'b', 'c'])).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
it('returns all when onDisk is empty', () => {
|
|
11
|
+
expect(detectRemovals(['a', 'b'], [])).toEqual(['a', 'b']);
|
|
12
|
+
});
|
|
13
|
+
it('returns empty when lastInjected is empty', () => {
|
|
14
|
+
expect(detectRemovals([], ['a', 'b'])).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe('computeApplicable', () => {
|
|
18
|
+
it('filters out opted-out items', () => {
|
|
19
|
+
expect(computeApplicable(['a', 'b', 'c'], ['b'])).toEqual(['a', 'c']);
|
|
20
|
+
});
|
|
21
|
+
it('returns full set when no opt-outs', () => {
|
|
22
|
+
expect(computeApplicable(['a', 'b'], [])).toEqual(['a', 'b']);
|
|
23
|
+
});
|
|
24
|
+
it('returns empty when everything is opted out', () => {
|
|
25
|
+
expect(computeApplicable(['a', 'b'], ['a', 'b'])).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('mergeAllow', () => {
|
|
29
|
+
it('places defaults before team rules', () => {
|
|
30
|
+
expect(mergeAllow(['default1', 'default2'], ['team1'])).toEqual([
|
|
31
|
+
'default1',
|
|
32
|
+
'default2',
|
|
33
|
+
'team1',
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
it('dedupes silently when team rule equals default', () => {
|
|
37
|
+
expect(mergeAllow(['Bash(runwork *)', 'Bash(git status)'], ['Bash(runwork *)'])).toEqual([
|
|
38
|
+
'Bash(git status)',
|
|
39
|
+
'Bash(runwork *)',
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
it('preserves order within each segment', () => {
|
|
43
|
+
expect(mergeAllow(['a', 'b', 'c'], ['x', 'y'])).toEqual(['a', 'b', 'c', 'x', 'y']);
|
|
44
|
+
});
|
|
45
|
+
it('handles empty team rules', () => {
|
|
46
|
+
expect(mergeAllow(['a', 'b'], [])).toEqual(['a', 'b']);
|
|
47
|
+
});
|
|
48
|
+
it('handles empty defaults', () => {
|
|
49
|
+
expect(mergeAllow([], ['x', 'y'])).toEqual(['x', 'y']);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('unique', () => {
|
|
53
|
+
it('removes duplicates preserving first-seen order', () => {
|
|
54
|
+
expect(unique(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('isBootstrap', () => {
|
|
58
|
+
it('true when state is undefined', () => {
|
|
59
|
+
expect(isBootstrap(undefined)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
it('false when state is defined (even if empty)', () => {
|
|
62
|
+
const state = {
|
|
63
|
+
lastInjected: {},
|
|
64
|
+
userOptOuts: {},
|
|
65
|
+
};
|
|
66
|
+
expect(isBootstrap(state)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('resolveAgentDefaults', () => {
|
|
70
|
+
const baked = {
|
|
71
|
+
allow: ['Bash(runwork *)', 'Bash(git status)', 'Bash(ls:*)'],
|
|
72
|
+
deny: ['Bash(rm -rf *)'],
|
|
73
|
+
};
|
|
74
|
+
it('case 1: no baked defaults emits team rules unchanged', () => {
|
|
75
|
+
const result = resolveAgentDefaults({
|
|
76
|
+
baked: undefined,
|
|
77
|
+
state: undefined,
|
|
78
|
+
onDisk: undefined,
|
|
79
|
+
team: { allow: ['custom'], deny: [] },
|
|
80
|
+
});
|
|
81
|
+
expect(result.managedAllow).toEqual(['custom']);
|
|
82
|
+
expect(result.managedDeny).toEqual([]);
|
|
83
|
+
expect(result.applicableAllow).toEqual([]);
|
|
84
|
+
expect(result.bootstrapped).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
it('case 2: bootstrap does NOT inject defaults but applies team rules', () => {
|
|
87
|
+
const result = resolveAgentDefaults({
|
|
88
|
+
baked,
|
|
89
|
+
state: undefined,
|
|
90
|
+
onDisk: { allow: ['userPersonalRule'] },
|
|
91
|
+
team: { allow: ['teamRule'] },
|
|
92
|
+
});
|
|
93
|
+
expect(result.managedAllow).toEqual(['teamRule']);
|
|
94
|
+
expect(result.applicableAllow).toEqual([]);
|
|
95
|
+
expect(result.bootstrapped).toBe(true);
|
|
96
|
+
expect(result.newOptOutsAllow).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
it('case 3 normal: injects baked defaults when state has empty injection', () => {
|
|
99
|
+
const state = {
|
|
100
|
+
lastInjected: { allow: [], deny: [] },
|
|
101
|
+
userOptOuts: { allow: [], deny: [] },
|
|
102
|
+
};
|
|
103
|
+
const result = resolveAgentDefaults({
|
|
104
|
+
baked,
|
|
105
|
+
state,
|
|
106
|
+
onDisk: { allow: [], deny: [] },
|
|
107
|
+
team: undefined,
|
|
108
|
+
});
|
|
109
|
+
expect(result.managedAllow).toEqual(baked.allow);
|
|
110
|
+
expect(result.managedDeny).toEqual(baked.deny);
|
|
111
|
+
expect(result.applicableAllow).toEqual(baked.allow);
|
|
112
|
+
expect(result.bootstrapped).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
it('case 3 normal: sticky opt-out — removed entry stays out next sync', () => {
|
|
115
|
+
// Previous sync injected the 3 defaults. User removed 'Bash(ls:*)' from
|
|
116
|
+
// settings.json. On the next sync we should detect that and never
|
|
117
|
+
// re-inject it.
|
|
118
|
+
const state = {
|
|
119
|
+
lastInjected: { allow: baked.allow, deny: baked.deny },
|
|
120
|
+
userOptOuts: { allow: [], deny: [] },
|
|
121
|
+
};
|
|
122
|
+
const result = resolveAgentDefaults({
|
|
123
|
+
baked,
|
|
124
|
+
state,
|
|
125
|
+
onDisk: { allow: ['Bash(runwork *)', 'Bash(git status)'], deny: baked.deny },
|
|
126
|
+
team: undefined,
|
|
127
|
+
});
|
|
128
|
+
expect(result.newOptOutsAllow).toEqual(['Bash(ls:*)']);
|
|
129
|
+
expect(result.applicableAllow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
|
|
130
|
+
expect(result.managedAllow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
|
|
131
|
+
expect(result.removalsThisSync.allow).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
it('case 3 normal: opt-out persists even when on-disk equals lastInjected', () => {
|
|
134
|
+
// User opted out 'Bash(ls:*)' two syncs ago. Sync after that ran with
|
|
135
|
+
// applicable = [runwork *, git status]. Now another sync: on-disk has
|
|
136
|
+
// exactly that. We should NOT re-inject 'Bash(ls:*)'.
|
|
137
|
+
const state = {
|
|
138
|
+
lastInjected: { allow: ['Bash(runwork *)', 'Bash(git status)'] },
|
|
139
|
+
userOptOuts: { allow: ['Bash(ls:*)'] },
|
|
140
|
+
};
|
|
141
|
+
const result = resolveAgentDefaults({
|
|
142
|
+
baked,
|
|
143
|
+
state,
|
|
144
|
+
onDisk: { allow: ['Bash(runwork *)', 'Bash(git status)'] },
|
|
145
|
+
team: undefined,
|
|
146
|
+
});
|
|
147
|
+
expect(result.applicableAllow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
|
|
148
|
+
expect(result.newOptOutsAllow).toEqual(['Bash(ls:*)']);
|
|
149
|
+
expect(result.removalsThisSync.allow).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
it('case 3 normal: baked-default shrink auto-removes from managed block', () => {
|
|
152
|
+
// CLI used to inject ['a','b','c']. New CLI version only has ['a','b'].
|
|
153
|
+
// Previous sync recorded ['a','b','c'] as lastInjected. User did not
|
|
154
|
+
// touch settings.json — onDisk still has ['a','b','c']. Next sync:
|
|
155
|
+
// 'c' is no longer in baked → applicable = ['a','b'] → managed = ['a','b'].
|
|
156
|
+
// No opt-out should be created for 'c' (it was a baked-default removal,
|
|
157
|
+
// not a user removal).
|
|
158
|
+
const oldBaked = {
|
|
159
|
+
allow: ['a', 'b', 'c'],
|
|
160
|
+
deny: [],
|
|
161
|
+
};
|
|
162
|
+
const newBaked = {
|
|
163
|
+
allow: ['a', 'b'],
|
|
164
|
+
deny: [],
|
|
165
|
+
};
|
|
166
|
+
const state = {
|
|
167
|
+
lastInjected: { allow: oldBaked.allow },
|
|
168
|
+
userOptOuts: { allow: [] },
|
|
169
|
+
};
|
|
170
|
+
const result = resolveAgentDefaults({
|
|
171
|
+
baked: newBaked,
|
|
172
|
+
state,
|
|
173
|
+
onDisk: { allow: ['a', 'b', 'c'] },
|
|
174
|
+
team: undefined,
|
|
175
|
+
});
|
|
176
|
+
expect(result.applicableAllow).toEqual(['a', 'b']);
|
|
177
|
+
expect(result.managedAllow).toEqual(['a', 'b']);
|
|
178
|
+
// 'c' was on-disk so detectRemovals doesn't flag it; opt-outs stay empty.
|
|
179
|
+
expect(result.newOptOutsAllow).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
it('case 3 normal: team rules dedupe silently with defaults', () => {
|
|
182
|
+
const state = {
|
|
183
|
+
lastInjected: { allow: [] },
|
|
184
|
+
userOptOuts: { allow: [] },
|
|
185
|
+
};
|
|
186
|
+
const result = resolveAgentDefaults({
|
|
187
|
+
baked: { allow: ['Bash(runwork *)', 'Bash(ls:*)'], deny: [] },
|
|
188
|
+
state,
|
|
189
|
+
onDisk: { allow: [] },
|
|
190
|
+
team: { allow: ['Bash(runwork *)', 'Bash(npm test)'] },
|
|
191
|
+
});
|
|
192
|
+
// Default 'Bash(runwork *)' overlaps with team; team copy wins on position.
|
|
193
|
+
expect(result.managedAllow).toEqual([
|
|
194
|
+
'Bash(ls:*)',
|
|
195
|
+
'Bash(runwork *)',
|
|
196
|
+
'Bash(npm test)',
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
it('case 3 normal: simultaneous removal + new baked addition', () => {
|
|
200
|
+
// Previous sync injected ['a','b']. User removed 'a'. CLI added 'c'.
|
|
201
|
+
// Result: applicable = ['b','c'] (a opted out, c is new).
|
|
202
|
+
const state = {
|
|
203
|
+
lastInjected: { allow: ['a', 'b'] },
|
|
204
|
+
userOptOuts: { allow: [] },
|
|
205
|
+
};
|
|
206
|
+
const result = resolveAgentDefaults({
|
|
207
|
+
baked: { allow: ['a', 'b', 'c'], deny: [] },
|
|
208
|
+
state,
|
|
209
|
+
onDisk: { allow: ['b'] },
|
|
210
|
+
team: undefined,
|
|
211
|
+
});
|
|
212
|
+
expect(result.newOptOutsAllow).toEqual(['a']);
|
|
213
|
+
expect(result.applicableAllow).toEqual(['b', 'c']);
|
|
214
|
+
});
|
|
215
|
+
it('case 3 markerless: undefined onDisk does NOT fabricate opt-outs (Cursor)', () => {
|
|
216
|
+
// Cursor's readManagedBlock returns undefined because there's no marker
|
|
217
|
+
// on disk to identify our entries. The resolver must NOT treat that as
|
|
218
|
+
// "everything was removed" — otherwise every default becomes a sticky
|
|
219
|
+
// opt-out after the first injection, corrupting setup.json forever.
|
|
220
|
+
const state = {
|
|
221
|
+
lastInjected: { allow: ['runwork', 'git status', 'ls'] },
|
|
222
|
+
userOptOuts: { allow: [] },
|
|
223
|
+
};
|
|
224
|
+
const result = resolveAgentDefaults({
|
|
225
|
+
baked,
|
|
226
|
+
state,
|
|
227
|
+
onDisk: undefined,
|
|
228
|
+
team: undefined,
|
|
229
|
+
});
|
|
230
|
+
// No new opt-outs created from the missing read.
|
|
231
|
+
expect(result.newOptOutsAllow).toEqual([]);
|
|
232
|
+
expect(result.removalsThisSync.allow).toBe(0);
|
|
233
|
+
// Defaults are still applied (filtered by any existing opt-outs).
|
|
234
|
+
expect(result.applicableAllow).toEqual(baked.allow);
|
|
235
|
+
});
|
|
236
|
+
it('case 3 markerless: existing opt-outs are preserved when onDisk is undefined', () => {
|
|
237
|
+
// User had previously opted out of 'Bash(ls:*)' (recorded in state).
|
|
238
|
+
// This sync's onDisk read came back undefined. We must keep the existing
|
|
239
|
+
// opt-out, not silently re-inject the default.
|
|
240
|
+
const state = {
|
|
241
|
+
lastInjected: { allow: ['Bash(runwork *)', 'Bash(git status)'] },
|
|
242
|
+
userOptOuts: { allow: ['Bash(ls:*)'] },
|
|
243
|
+
};
|
|
244
|
+
const result = resolveAgentDefaults({
|
|
245
|
+
baked,
|
|
246
|
+
state,
|
|
247
|
+
onDisk: undefined,
|
|
248
|
+
team: undefined,
|
|
249
|
+
});
|
|
250
|
+
expect(result.newOptOutsAllow).toEqual(['Bash(ls:*)']);
|
|
251
|
+
expect(result.applicableAllow).not.toContain('Bash(ls:*)');
|
|
252
|
+
});
|
|
253
|
+
it('case 3 normal: deny side mirrors allow side', () => {
|
|
254
|
+
const state = {
|
|
255
|
+
lastInjected: { deny: ['Bash(rm -rf *)', 'Bash(sudo rm *)'] },
|
|
256
|
+
userOptOuts: { deny: [] },
|
|
257
|
+
};
|
|
258
|
+
const result = resolveAgentDefaults({
|
|
259
|
+
baked: { allow: [], deny: ['Bash(rm -rf *)', 'Bash(sudo rm *)'] },
|
|
260
|
+
state,
|
|
261
|
+
onDisk: { deny: ['Bash(rm -rf *)'] },
|
|
262
|
+
team: undefined,
|
|
263
|
+
});
|
|
264
|
+
expect(result.newOptOutsDeny).toEqual(['Bash(sudo rm *)']);
|
|
265
|
+
expect(result.applicableDeny).toEqual(['Bash(rm -rf *)']);
|
|
266
|
+
expect(result.managedDeny).toEqual(['Bash(rm -rf *)']);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -158,4 +158,36 @@ describe('generateInstructionHint', () => {
|
|
|
158
158
|
expect(hint).toContain('2 apps');
|
|
159
159
|
expect(hint).toContain('3 skills');
|
|
160
160
|
});
|
|
161
|
+
it('omits the persona block when no persona is provided', () => {
|
|
162
|
+
const hint = generateInstructionHint(baseCtx);
|
|
163
|
+
expect(hint).not.toContain('Communicating with this user');
|
|
164
|
+
});
|
|
165
|
+
it('omits the persona block for engineers (level 3)', () => {
|
|
166
|
+
const hint = generateInstructionHint({ ...baseCtx, persona: { level: 3, label: 'engineer' } });
|
|
167
|
+
expect(hint).not.toContain('Communicating with this user');
|
|
168
|
+
});
|
|
169
|
+
it('adds a novice persona block (level 1) telling agents to avoid dev tooling', () => {
|
|
170
|
+
const hint = generateInstructionHint({ ...baseCtx, persona: { level: 1, label: 'novice' } });
|
|
171
|
+
expect(hint).toContain('Communicating with this user');
|
|
172
|
+
expect(hint).toContain('not a software developer');
|
|
173
|
+
expect(hint).toContain('npm, bun, node');
|
|
174
|
+
// The persona block must stay inside the replaceable runwork markers.
|
|
175
|
+
const start = hint.indexOf('<!-- runwork:start -->');
|
|
176
|
+
const end = hint.indexOf('<!-- runwork:end -->');
|
|
177
|
+
expect(hint.indexOf('Communicating with this user')).toBeGreaterThan(start);
|
|
178
|
+
expect(hint.indexOf('Communicating with this user')).toBeLessThan(end);
|
|
179
|
+
});
|
|
180
|
+
it('adds a curious persona block (level 2)', () => {
|
|
181
|
+
const hint = generateInstructionHint({ ...baseCtx, persona: { level: 2, label: 'curious' } });
|
|
182
|
+
expect(hint).toContain('Communicating with this user');
|
|
183
|
+
expect(hint).toContain('some familiarity with AI tools');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('generateIntroSkill — environment variables', () => {
|
|
187
|
+
it('documents that apps have no custom env var system', () => {
|
|
188
|
+
const skill = generateIntroSkill(makeContext());
|
|
189
|
+
expect(skill.content).toContain('Environment variables and secrets');
|
|
190
|
+
expect(skill.content).toContain('do NOT have a custom environment variable');
|
|
191
|
+
expect(skill.content).toContain('.env');
|
|
192
|
+
});
|
|
161
193
|
});
|
|
@@ -13,6 +13,11 @@ export declare class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
13
13
|
writeAgentConfig(config: AgentConfigOverride, scope: 'project' | 'user'): Promise<void>;
|
|
14
14
|
/** Remove the managed marker and all entries after it from a permissions array */
|
|
15
15
|
private removeAfterMarker;
|
|
16
|
+
/** Return entries after the managed marker for both allow and deny lists. */
|
|
17
|
+
readManagedBlock(scope: 'project' | 'user'): Promise<{
|
|
18
|
+
allow?: string[];
|
|
19
|
+
deny?: string[];
|
|
20
|
+
} | undefined>;
|
|
16
21
|
cleanup(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
|
|
17
22
|
readUsageStats(lastSyncAt: string | null): Promise<AgentUsageStats | null>;
|
|
18
23
|
readSkillUsage(lastSyncAt: string | null): Promise<SkillUsageEntry[] | null>;
|
|
@@ -203,6 +203,35 @@ export class ClaudeCodeAdapter {
|
|
|
203
203
|
const idx = arr.indexOf(marker);
|
|
204
204
|
return idx >= 0 ? arr.slice(0, idx) : [...arr];
|
|
205
205
|
}
|
|
206
|
+
/** Return entries after the managed marker for both allow and deny lists. */
|
|
207
|
+
async readManagedBlock(scope) {
|
|
208
|
+
const settingsPath = scope === 'project'
|
|
209
|
+
? join(process.cwd(), '.claude', 'settings.json')
|
|
210
|
+
: join(homedir(), '.claude', 'settings.json');
|
|
211
|
+
if (!existsSync(settingsPath))
|
|
212
|
+
return undefined;
|
|
213
|
+
const MANAGED_MARKER = '# runwork-managed';
|
|
214
|
+
try {
|
|
215
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
216
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
217
|
+
const perms = settings?.permissions;
|
|
218
|
+
if (!perms || typeof perms !== 'object')
|
|
219
|
+
return { allow: [], deny: [] };
|
|
220
|
+
const extractAfter = (arr) => {
|
|
221
|
+
if (!Array.isArray(arr))
|
|
222
|
+
return [];
|
|
223
|
+
const idx = arr.indexOf(MANAGED_MARKER);
|
|
224
|
+
return idx >= 0 ? arr.slice(idx + 1).filter((x) => typeof x === 'string') : [];
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
allow: extractAfter(perms.allow),
|
|
228
|
+
deny: extractAfter(perms.deny),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
206
235
|
// ── Cleanup (uninstall) ────────────────────────────────────────────
|
|
207
236
|
async cleanup(scope, manifest) {
|
|
208
237
|
// 1. Remove MCP server entries
|
package/dist/agents/cursor.d.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Merge a new managed list into a markerless on-disk array while preserving
|
|
4
|
+
* user-added entries.
|
|
5
|
+
*
|
|
6
|
+
* existing = what's currently on disk
|
|
7
|
+
* baseline = what we wrote on the previous sync (state.lastInjected). Items
|
|
8
|
+
* present here are assumed to be ours; subtracting them from
|
|
9
|
+
* existing leaves entries the user added themselves.
|
|
10
|
+
* incoming = the new managed set to inject (defaults already filtered for
|
|
11
|
+
* opt-outs, merged with team rules)
|
|
12
|
+
*
|
|
13
|
+
* If baseline is empty (bootstrap or first run with this CLI version), we
|
|
14
|
+
* assume nothing currently on disk is ours and preserve all existing entries.
|
|
15
|
+
*/
|
|
16
|
+
export declare function mergeMarkerless(existing: string[], baseline: string[], incoming: string[]): string[];
|
|
2
17
|
export declare class CursorAdapter implements AgentAdapter {
|
|
3
18
|
name: string;
|
|
4
19
|
slug: string;
|
|
@@ -10,7 +25,14 @@ export declare class CursorAdapter implements AgentAdapter {
|
|
|
10
25
|
writeSkills(skills: SkillFile[], scope: 'project' | 'user'): Promise<void>;
|
|
11
26
|
writeInstructionHint(hint: string, scope: 'project' | 'user'): Promise<void>;
|
|
12
27
|
writeTeamInstructions(instructions: string, scope: 'project' | 'user'): Promise<void>;
|
|
13
|
-
writeAgentConfig(config: AgentConfigOverride, scope: 'project' | 'user'
|
|
28
|
+
writeAgentConfig(config: AgentConfigOverride, scope: 'project' | 'user', baseline?: {
|
|
29
|
+
allow?: string[];
|
|
30
|
+
deny?: string[];
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
readManagedBlock(_scope: 'project' | 'user'): Promise<{
|
|
33
|
+
allow?: string[];
|
|
34
|
+
deny?: string[];
|
|
35
|
+
} | undefined>;
|
|
14
36
|
/**
|
|
15
37
|
* Merge domains into Cursor's sandbox network policy (~/.cursor/sandbox.json).
|
|
16
38
|
* Schema per https://cursor.com/docs/reference/sandbox:
|