runwork 0.10.4 → 0.11.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/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/types.d.ts +31 -2
- package/dist/commands/sync.js +146 -59
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/types.d.ts +23 -0
- package/dist/ui/banner.js +1 -1
- package/package.json +1 -1
package/dist/agents/cursor.js
CHANGED
|
@@ -5,6 +5,39 @@ import { execFileSync } from '../utils/subprocess.js';
|
|
|
5
5
|
import { querySqlite, openWritableSqlite } from '../utils/sqlite.js';
|
|
6
6
|
import { whichBinary } from '../utils/which.js';
|
|
7
7
|
import { mergeJsonMcpServers, removeRunworkMcpServers, readJsonConfig, writeJsonConfig } from './utils/json-config.js';
|
|
8
|
+
/**
|
|
9
|
+
* Merge a new managed list into a markerless on-disk array while preserving
|
|
10
|
+
* user-added entries.
|
|
11
|
+
*
|
|
12
|
+
* existing = what's currently on disk
|
|
13
|
+
* baseline = what we wrote on the previous sync (state.lastInjected). Items
|
|
14
|
+
* present here are assumed to be ours; subtracting them from
|
|
15
|
+
* existing leaves entries the user added themselves.
|
|
16
|
+
* incoming = the new managed set to inject (defaults already filtered for
|
|
17
|
+
* opt-outs, merged with team rules)
|
|
18
|
+
*
|
|
19
|
+
* If baseline is empty (bootstrap or first run with this CLI version), we
|
|
20
|
+
* assume nothing currently on disk is ours and preserve all existing entries.
|
|
21
|
+
*/
|
|
22
|
+
export function mergeMarkerless(existing, baseline, incoming) {
|
|
23
|
+
const baselineSet = new Set(baseline);
|
|
24
|
+
const userEntries = existing.filter((item) => !baselineSet.has(item));
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const result = [];
|
|
27
|
+
for (const item of userEntries) {
|
|
28
|
+
if (!seen.has(item)) {
|
|
29
|
+
seen.add(item);
|
|
30
|
+
result.push(item);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const item of incoming) {
|
|
34
|
+
if (!seen.has(item)) {
|
|
35
|
+
seen.add(item);
|
|
36
|
+
result.push(item);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
8
41
|
export class CursorAdapter {
|
|
9
42
|
name = 'Cursor';
|
|
10
43
|
slug = 'cursor';
|
|
@@ -68,7 +101,7 @@ ${skill.content}`;
|
|
|
68
101
|
writeFileSync(filePath, mdcContent);
|
|
69
102
|
}
|
|
70
103
|
// ── Agent config (model, permissions via SQLite) ────────────────────
|
|
71
|
-
async writeAgentConfig(config, scope) {
|
|
104
|
+
async writeAgentConfig(config, scope, baseline) {
|
|
72
105
|
if (scope !== 'user')
|
|
73
106
|
return;
|
|
74
107
|
// Sandbox network allowlist: merge into ~/.cursor/cli-config.json
|
|
@@ -108,10 +141,10 @@ ${skill.content}`;
|
|
|
108
141
|
}
|
|
109
142
|
if (config.permissionRules && data.composerState) {
|
|
110
143
|
if (config.permissionRules.allow) {
|
|
111
|
-
data.composerState.yoloCommandAllowlist = config.permissionRules.allow;
|
|
144
|
+
data.composerState.yoloCommandAllowlist = mergeMarkerless(data.composerState.yoloCommandAllowlist ?? [], baseline?.allow ?? [], config.permissionRules.allow);
|
|
112
145
|
}
|
|
113
146
|
if (config.permissionRules.deny) {
|
|
114
|
-
data.composerState.yoloCommandDenylist = config.permissionRules.deny;
|
|
147
|
+
data.composerState.yoloCommandDenylist = mergeMarkerless(data.composerState.yoloCommandDenylist ?? [], baseline?.deny ?? [], config.permissionRules.deny);
|
|
115
148
|
}
|
|
116
149
|
}
|
|
117
150
|
try {
|
|
@@ -125,6 +158,12 @@ ${skill.content}`;
|
|
|
125
158
|
db.close();
|
|
126
159
|
}
|
|
127
160
|
}
|
|
161
|
+
async readManagedBlock(_scope) {
|
|
162
|
+
// Cursor's yoloCommandAllowlist has no marker — there's no way to read a
|
|
163
|
+
// managed-subset from disk. The sync loop relies on the `baseline` arg
|
|
164
|
+
// (state.lastInjected) passed into writeAgentConfig instead.
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
128
167
|
/**
|
|
129
168
|
* Merge domains into Cursor's sandbox network policy (~/.cursor/sandbox.json).
|
|
130
169
|
* Schema per https://cursor.com/docs/reference/sandbox:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baked-in default permission rules that `runwork sync` injects into each
|
|
3
|
+
* supported agent's configuration.
|
|
4
|
+
*
|
|
5
|
+
* Defaults are user-scope only. They merge with team-managed rules from the
|
|
6
|
+
* workspace admin and are tracked in `~/.runwork/setup.json` under
|
|
7
|
+
* `agentDefaults[slug]` so that:
|
|
8
|
+
* 1. Removals stick: if a user deletes an injected entry, it never returns.
|
|
9
|
+
* 2. Newly added defaults flow in: CLI upgrades that ship more defaults
|
|
10
|
+
* auto-apply on the next sync.
|
|
11
|
+
* 3. Removed defaults disappear: shrinking the set here also removes the
|
|
12
|
+
* entries from users' on-disk config on next sync.
|
|
13
|
+
* 4. Tool removal resets state: `runwork agents remove <slug>` clears the
|
|
14
|
+
* per-agent state, so re-adding the tool starts from a clean bootstrap.
|
|
15
|
+
*
|
|
16
|
+
* Only agents whose permission model maps cleanly to command-pattern allow/deny
|
|
17
|
+
* lists appear here. Codex, Gemini, Cline, and Claude Desktop are intentionally
|
|
18
|
+
* absent (their config formats are toggle-based or tool-name-based and do not
|
|
19
|
+
* fit). Network domain allowlisting for those agents is handled separately via
|
|
20
|
+
* AgentConfigOverride.networkAllowlist in sync.ts.
|
|
21
|
+
*/
|
|
22
|
+
export interface BakedDefaults {
|
|
23
|
+
allow: string[];
|
|
24
|
+
deny: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare const RUNWORK_AGENT_DEFAULTS: Record<string, BakedDefaults>;
|
|
27
|
+
/** Schema version recorded in setup.json. Bump when default-injection semantics change. */
|
|
28
|
+
export declare const AGENT_DEFAULTS_SCHEMA_VERSION = 1;
|
|
29
|
+
/** Slugs that have baked-in defaults. */
|
|
30
|
+
export declare function hasBakedDefaults(slug: string): boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baked-in default permission rules that `runwork sync` injects into each
|
|
3
|
+
* supported agent's configuration.
|
|
4
|
+
*
|
|
5
|
+
* Defaults are user-scope only. They merge with team-managed rules from the
|
|
6
|
+
* workspace admin and are tracked in `~/.runwork/setup.json` under
|
|
7
|
+
* `agentDefaults[slug]` so that:
|
|
8
|
+
* 1. Removals stick: if a user deletes an injected entry, it never returns.
|
|
9
|
+
* 2. Newly added defaults flow in: CLI upgrades that ship more defaults
|
|
10
|
+
* auto-apply on the next sync.
|
|
11
|
+
* 3. Removed defaults disappear: shrinking the set here also removes the
|
|
12
|
+
* entries from users' on-disk config on next sync.
|
|
13
|
+
* 4. Tool removal resets state: `runwork agents remove <slug>` clears the
|
|
14
|
+
* per-agent state, so re-adding the tool starts from a clean bootstrap.
|
|
15
|
+
*
|
|
16
|
+
* Only agents whose permission model maps cleanly to command-pattern allow/deny
|
|
17
|
+
* lists appear here. Codex, Gemini, Cline, and Claude Desktop are intentionally
|
|
18
|
+
* absent (their config formats are toggle-based or tool-name-based and do not
|
|
19
|
+
* fit). Network domain allowlisting for those agents is handled separately via
|
|
20
|
+
* AgentConfigOverride.networkAllowlist in sync.ts.
|
|
21
|
+
*/
|
|
22
|
+
export const RUNWORK_AGENT_DEFAULTS = {
|
|
23
|
+
'claude-code': {
|
|
24
|
+
allow: [
|
|
25
|
+
'Bash(runwork *)',
|
|
26
|
+
'Bash(runwork-cli *)',
|
|
27
|
+
'Bash(git status)',
|
|
28
|
+
'Bash(git diff:*)',
|
|
29
|
+
'Bash(git log:*)',
|
|
30
|
+
'Bash(git branch:*)',
|
|
31
|
+
'Bash(git show:*)',
|
|
32
|
+
'Bash(ls:*)',
|
|
33
|
+
'Bash(pwd)',
|
|
34
|
+
'Bash(cat:*)',
|
|
35
|
+
],
|
|
36
|
+
deny: [
|
|
37
|
+
'Bash(rm -rf *)',
|
|
38
|
+
'Bash(rm -rf /*)',
|
|
39
|
+
'Bash(sudo rm *)',
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
'cursor': {
|
|
43
|
+
allow: [
|
|
44
|
+
'runwork',
|
|
45
|
+
'runwork *',
|
|
46
|
+
'runwork-cli',
|
|
47
|
+
'runwork-cli *',
|
|
48
|
+
'git status',
|
|
49
|
+
'git diff',
|
|
50
|
+
'git log',
|
|
51
|
+
'git branch',
|
|
52
|
+
'ls',
|
|
53
|
+
'pwd',
|
|
54
|
+
'cat',
|
|
55
|
+
],
|
|
56
|
+
deny: [
|
|
57
|
+
'rm -rf *',
|
|
58
|
+
'sudo *',
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
/** Schema version recorded in setup.json. Bump when default-injection semantics change. */
|
|
63
|
+
export const AGENT_DEFAULTS_SCHEMA_VERSION = 1;
|
|
64
|
+
/** Slugs that have baked-in defaults. */
|
|
65
|
+
export function hasBakedDefaults(slug) {
|
|
66
|
+
return slug in RUNWORK_AGENT_DEFAULTS;
|
|
67
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions for the default-rules merge algorithm.
|
|
3
|
+
*
|
|
4
|
+
* The sync loop (commands/sync.ts) wires these together with per-adapter I/O.
|
|
5
|
+
* Keeping the logic side-effect-free here makes it cheap to unit-test the
|
|
6
|
+
* tricky lifecycle (bootstrap, opt-out detection, set shrinking) without
|
|
7
|
+
* mocking filesystem layers.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentDefaultsState } from '../types.js';
|
|
10
|
+
import type { BakedDefaults } from './default-config.js';
|
|
11
|
+
/** Items present in `lastInjected` but not in `onDisk`. These are entries the user removed since the previous sync. */
|
|
12
|
+
export declare function detectRemovals(lastInjected: string[], onDisk: string[]): string[];
|
|
13
|
+
/** Defaults filtered to exclude anything the user has opted out of. */
|
|
14
|
+
export declare function computeApplicable(baked: string[], optOuts: string[]): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Dedupe-silently merge of defaults and team rules. Team rules appear after
|
|
17
|
+
* defaults in the output (the marker block's team-managed half), and dedupe
|
|
18
|
+
* keeps the team-positioned copy when a default and a team rule are identical
|
|
19
|
+
* strings. Stable ordering is preserved.
|
|
20
|
+
*/
|
|
21
|
+
export declare function mergeAllow(applicableDefaults: string[], teamRules: string[]): string[];
|
|
22
|
+
export declare function unique(items: string[]): string[];
|
|
23
|
+
/** True if no per-agent state has ever been recorded — first sync after upgrade or fresh install. */
|
|
24
|
+
export declare function isBootstrap(state: AgentDefaultsState | undefined): boolean;
|
|
25
|
+
export interface ResolveResult {
|
|
26
|
+
/**
|
|
27
|
+
* Final merged list to write into the managed block.
|
|
28
|
+
* For marker-based agents (Claude Code): goes after the marker.
|
|
29
|
+
* For markerless agents (Cursor): becomes part of the array,
|
|
30
|
+
* with `baseline` used by the adapter to identify and preserve user entries.
|
|
31
|
+
*/
|
|
32
|
+
managedAllow: string[];
|
|
33
|
+
managedDeny: string[];
|
|
34
|
+
/** Defaults that we actually injected this sync. Stored as next sync's lastInjected. */
|
|
35
|
+
applicableAllow: string[];
|
|
36
|
+
applicableDeny: string[];
|
|
37
|
+
/** New sticky opt-out set. Stored as next sync's userOptOuts. */
|
|
38
|
+
newOptOutsAllow: string[];
|
|
39
|
+
newOptOutsDeny: string[];
|
|
40
|
+
/** Diagnostic counts for the sync log. */
|
|
41
|
+
removalsThisSync: {
|
|
42
|
+
allow: number;
|
|
43
|
+
deny: number;
|
|
44
|
+
};
|
|
45
|
+
/** True when this sync is the bootstrap pass and skipped default injection. */
|
|
46
|
+
bootstrapped: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface ResolveInput {
|
|
49
|
+
baked: BakedDefaults | undefined;
|
|
50
|
+
state: AgentDefaultsState | undefined;
|
|
51
|
+
onDisk: {
|
|
52
|
+
allow?: string[];
|
|
53
|
+
deny?: string[];
|
|
54
|
+
} | undefined;
|
|
55
|
+
team: {
|
|
56
|
+
allow?: string[];
|
|
57
|
+
deny?: string[];
|
|
58
|
+
} | undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Single-call resolver for one agent. Consumes the full input set and emits
|
|
62
|
+
* the merged managed lists plus the next-sync state values. Pure: no I/O.
|
|
63
|
+
*
|
|
64
|
+
* Cases:
|
|
65
|
+
* 1. No baked defaults for this agent → emits team rules only (no defaults
|
|
66
|
+
* logic kicks in). State stays undefined.
|
|
67
|
+
* 2. Bootstrap (state undefined) → applies team rules but does NOT inject
|
|
68
|
+
* defaults this sync. Caller initializes empty state for next time.
|
|
69
|
+
* 3. Normal → detects removals against lastInjected, accumulates opt-outs,
|
|
70
|
+
* filters baked by opt-outs, merges with team rules.
|
|
71
|
+
*/
|
|
72
|
+
export declare function resolveAgentDefaults(input: ResolveInput): ResolveResult;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions for the default-rules merge algorithm.
|
|
3
|
+
*
|
|
4
|
+
* The sync loop (commands/sync.ts) wires these together with per-adapter I/O.
|
|
5
|
+
* Keeping the logic side-effect-free here makes it cheap to unit-test the
|
|
6
|
+
* tricky lifecycle (bootstrap, opt-out detection, set shrinking) without
|
|
7
|
+
* mocking filesystem layers.
|
|
8
|
+
*/
|
|
9
|
+
/** Items present in `lastInjected` but not in `onDisk`. These are entries the user removed since the previous sync. */
|
|
10
|
+
export function detectRemovals(lastInjected, onDisk) {
|
|
11
|
+
const onDiskSet = new Set(onDisk);
|
|
12
|
+
return lastInjected.filter((item) => !onDiskSet.has(item));
|
|
13
|
+
}
|
|
14
|
+
/** Defaults filtered to exclude anything the user has opted out of. */
|
|
15
|
+
export function computeApplicable(baked, optOuts) {
|
|
16
|
+
const optOutSet = new Set(optOuts);
|
|
17
|
+
return baked.filter((item) => !optOutSet.has(item));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Dedupe-silently merge of defaults and team rules. Team rules appear after
|
|
21
|
+
* defaults in the output (the marker block's team-managed half), and dedupe
|
|
22
|
+
* keeps the team-positioned copy when a default and a team rule are identical
|
|
23
|
+
* strings. Stable ordering is preserved.
|
|
24
|
+
*/
|
|
25
|
+
export function mergeAllow(applicableDefaults, teamRules) {
|
|
26
|
+
const teamSet = new Set(teamRules);
|
|
27
|
+
const result = [];
|
|
28
|
+
for (const item of applicableDefaults) {
|
|
29
|
+
if (!teamSet.has(item))
|
|
30
|
+
result.push(item);
|
|
31
|
+
}
|
|
32
|
+
for (const item of teamRules) {
|
|
33
|
+
result.push(item);
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
export function unique(items) {
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
if (!seen.has(item)) {
|
|
42
|
+
seen.add(item);
|
|
43
|
+
out.push(item);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** True if no per-agent state has ever been recorded — first sync after upgrade or fresh install. */
|
|
49
|
+
export function isBootstrap(state) {
|
|
50
|
+
return state === undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Single-call resolver for one agent. Consumes the full input set and emits
|
|
54
|
+
* the merged managed lists plus the next-sync state values. Pure: no I/O.
|
|
55
|
+
*
|
|
56
|
+
* Cases:
|
|
57
|
+
* 1. No baked defaults for this agent → emits team rules only (no defaults
|
|
58
|
+
* logic kicks in). State stays undefined.
|
|
59
|
+
* 2. Bootstrap (state undefined) → applies team rules but does NOT inject
|
|
60
|
+
* defaults this sync. Caller initializes empty state for next time.
|
|
61
|
+
* 3. Normal → detects removals against lastInjected, accumulates opt-outs,
|
|
62
|
+
* filters baked by opt-outs, merges with team rules.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveAgentDefaults(input) {
|
|
65
|
+
const team = input.team ?? {};
|
|
66
|
+
const teamAllow = team.allow ?? [];
|
|
67
|
+
const teamDeny = team.deny ?? [];
|
|
68
|
+
// Case 1: no baked defaults — emit team rules unchanged, no state to track.
|
|
69
|
+
if (!input.baked) {
|
|
70
|
+
return {
|
|
71
|
+
managedAllow: unique(teamAllow),
|
|
72
|
+
managedDeny: unique(teamDeny),
|
|
73
|
+
applicableAllow: [],
|
|
74
|
+
applicableDeny: [],
|
|
75
|
+
newOptOutsAllow: [],
|
|
76
|
+
newOptOutsDeny: [],
|
|
77
|
+
removalsThisSync: { allow: 0, deny: 0 },
|
|
78
|
+
bootstrapped: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Case 2: bootstrap — do not inject defaults this pass.
|
|
82
|
+
if (isBootstrap(input.state)) {
|
|
83
|
+
return {
|
|
84
|
+
managedAllow: unique(teamAllow),
|
|
85
|
+
managedDeny: unique(teamDeny),
|
|
86
|
+
applicableAllow: [],
|
|
87
|
+
applicableDeny: [],
|
|
88
|
+
newOptOutsAllow: [],
|
|
89
|
+
newOptOutsDeny: [],
|
|
90
|
+
removalsThisSync: { allow: 0, deny: 0 },
|
|
91
|
+
bootstrapped: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Case 3: normal merge.
|
|
95
|
+
const state = input.state;
|
|
96
|
+
const lastInjectedAllow = state.lastInjected.allow ?? [];
|
|
97
|
+
const lastInjectedDeny = state.lastInjected.deny ?? [];
|
|
98
|
+
const existingOptOutsAllow = state.userOptOuts.allow ?? [];
|
|
99
|
+
const existingOptOutsDeny = state.userOptOuts.deny ?? [];
|
|
100
|
+
// Removal detection requires a reliable on-disk read. Markerless adapters
|
|
101
|
+
// (Cursor) cannot distinguish injected entries from user entries on disk, so
|
|
102
|
+
// they return `undefined` from readManagedBlock. Treating `undefined` as an
|
|
103
|
+
// empty array would fabricate a "user removed everything" signal on every
|
|
104
|
+
// subsequent sync and incorrectly convert every default into a sticky opt-out.
|
|
105
|
+
// When we cannot read managed state, we trust the baseline mechanism in the
|
|
106
|
+
// adapter and skip removal detection for this sync.
|
|
107
|
+
const canDetectRemovals = input.onDisk !== undefined;
|
|
108
|
+
const newRemovalsAllow = canDetectRemovals
|
|
109
|
+
? detectRemovals(lastInjectedAllow, input.onDisk?.allow ?? [])
|
|
110
|
+
: [];
|
|
111
|
+
const newRemovalsDeny = canDetectRemovals
|
|
112
|
+
? detectRemovals(lastInjectedDeny, input.onDisk?.deny ?? [])
|
|
113
|
+
: [];
|
|
114
|
+
const newOptOutsAllow = unique([...existingOptOutsAllow, ...newRemovalsAllow]);
|
|
115
|
+
const newOptOutsDeny = unique([...existingOptOutsDeny, ...newRemovalsDeny]);
|
|
116
|
+
const applicableAllow = computeApplicable(input.baked.allow, newOptOutsAllow);
|
|
117
|
+
const applicableDeny = computeApplicable(input.baked.deny, newOptOutsDeny);
|
|
118
|
+
return {
|
|
119
|
+
managedAllow: mergeAllow(applicableAllow, teamAllow),
|
|
120
|
+
managedDeny: mergeAllow(applicableDeny, teamDeny),
|
|
121
|
+
applicableAllow,
|
|
122
|
+
applicableDeny,
|
|
123
|
+
newOptOutsAllow,
|
|
124
|
+
newOptOutsDeny,
|
|
125
|
+
removalsThisSync: {
|
|
126
|
+
allow: newRemovalsAllow.length,
|
|
127
|
+
deny: newRemovalsDeny.length,
|
|
128
|
+
},
|
|
129
|
+
bootstrapped: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/dist/agents/types.d.ts
CHANGED
|
@@ -33,8 +33,37 @@ export interface AgentAdapter {
|
|
|
33
33
|
mcpProvidesSkills?: boolean;
|
|
34
34
|
/** Write team-managed instructions (separate marker block from auto-generated hint) */
|
|
35
35
|
writeTeamInstructions?(instructions: string, scope: 'project' | 'user'): Promise<void>;
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Write agent-specific config overrides from workspace admin (model, permissions).
|
|
38
|
+
*
|
|
39
|
+
* @param config - The full resolved config to apply this sync (already merged
|
|
40
|
+
* with baked defaults and network/minimum settings by the sync loop).
|
|
41
|
+
* @param scope - Project- or user-scope configuration target.
|
|
42
|
+
* @param baseline - Optional. The set of managed entries the previous sync
|
|
43
|
+
* wrote (lastInjected from setup.json). Adapters that lack on-disk markers
|
|
44
|
+
* to distinguish runwork-managed entries from user-added entries (e.g.
|
|
45
|
+
* Cursor's yoloCommandAllowlist) use this to subtract our previous
|
|
46
|
+
* contribution before re-injecting, preserving user edits. Marker-based
|
|
47
|
+
* adapters (Claude Code) can ignore this parameter.
|
|
48
|
+
*/
|
|
49
|
+
writeAgentConfig?(config: AgentConfigOverride, scope: 'project' | 'user', baseline?: {
|
|
50
|
+
allow?: string[];
|
|
51
|
+
deny?: string[];
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Read the currently-injected managed-block entries for this scope.
|
|
55
|
+
* Used by sync to detect when the user has removed an entry that we
|
|
56
|
+
* previously injected (so we can record it as a sticky opt-out).
|
|
57
|
+
*
|
|
58
|
+
* For marker-based adapters (Claude Code): returns items after the marker.
|
|
59
|
+
* For markerless adapters (Cursor): may return undefined; the sync loop
|
|
60
|
+
* falls back to the `baseline` mechanism (state.lastInjected) for those.
|
|
61
|
+
* Optional — adapters without a permission file at all (Cline) skip this.
|
|
62
|
+
*/
|
|
63
|
+
readManagedBlock?(scope: 'project' | 'user'): Promise<{
|
|
64
|
+
allow?: string[];
|
|
65
|
+
deny?: string[];
|
|
66
|
+
} | undefined>;
|
|
38
67
|
/** Remove all Runwork-managed config, skills, MCP entries, and instruction hints.
|
|
39
68
|
* The manifest lists exactly which resources were synced so only those are removed. */
|
|
40
69
|
cleanup?(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
|
package/dist/commands/sync.js
CHANGED
|
@@ -7,6 +7,8 @@ import { ApiClient } from '../api/client.js';
|
|
|
7
7
|
import { getAdapterBySlug, detectAgents } from '../agents/detect.js';
|
|
8
8
|
import { CodexAdapter } from '../agents/codex.js';
|
|
9
9
|
import { RUNWORK_MCP_PREFIX, RUNWORK_WORKSPACE_MCP_NAME } from '../agents/types.js';
|
|
10
|
+
import { RUNWORK_AGENT_DEFAULTS, AGENT_DEFAULTS_SCHEMA_VERSION } from '../agents/default-config.js';
|
|
11
|
+
import { resolveAgentDefaults } from '../agents/defaults-merge.js';
|
|
10
12
|
import { collectTelemetryEvents, printTelemetryVerbose, summarizeTelemetryForDryRun, } from './sync-telemetry.js';
|
|
11
13
|
import { generateIntroSkill, generateInstructionHint, buildAppSkillDescription } from '../agents/intro-skill.js';
|
|
12
14
|
import { computeSyncPlan } from '../sync/change-detect.js';
|
|
@@ -346,85 +348,170 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
346
348
|
}
|
|
347
349
|
}
|
|
348
350
|
}
|
|
349
|
-
// Pull
|
|
351
|
+
// Pull team config from server. A failure here must not stop the
|
|
352
|
+
// unified user-scope write below, which applies network and minimum-permission
|
|
353
|
+
// floors regardless of whether team config was fetched.
|
|
350
354
|
let teamInstructionsApplied = false;
|
|
351
355
|
let agentConfigsApplied = 0;
|
|
356
|
+
let teamInstructions;
|
|
357
|
+
let agentConfigs;
|
|
352
358
|
try {
|
|
353
359
|
const onboardingConfig = await client.getOnboardingConfig(state.workspaceId);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
360
|
+
teamInstructions = onboardingConfig?.config?.teamInstructions ?? undefined;
|
|
361
|
+
agentConfigs = onboardingConfig?.config?.agentConfigs ?? undefined;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Team config pull is non-fatal — proceed with no team-managed overrides.
|
|
365
|
+
}
|
|
366
|
+
// Write team instructions and/or per-agent extra instructions.
|
|
367
|
+
for (const adapter of adapters) {
|
|
368
|
+
if (!adapter.writeTeamInstructions)
|
|
369
|
+
continue;
|
|
370
|
+
const agentExtra = agentConfigs?.[adapter.slug]?.extraInstructions;
|
|
371
|
+
const parts = [teamInstructions, agentExtra].filter(Boolean);
|
|
372
|
+
if (parts.length === 0)
|
|
373
|
+
continue;
|
|
374
|
+
const fullInstructions = parts.join('\n\n');
|
|
375
|
+
for (const scope of scopes) {
|
|
376
|
+
try {
|
|
377
|
+
await adapter.writeTeamInstructions(fullInstructions, scope);
|
|
378
|
+
teamInstructionsApplied = true;
|
|
379
|
+
console.log(` [${adapter.name}] Updated team instructions (${scope})`);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Best-effort per adapter/scope
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Apply per-agent project-scope config (team overrides only).
|
|
387
|
+
// User scope is handled below in the unified defaults+network+minimum block.
|
|
388
|
+
if (agentConfigs) {
|
|
358
389
|
for (const adapter of adapters) {
|
|
359
|
-
|
|
390
|
+
const agentConfig = agentConfigs[adapter.slug];
|
|
391
|
+
if (!agentConfig || !adapter.writeAgentConfig)
|
|
360
392
|
continue;
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
if (parts.length === 0)
|
|
393
|
+
const { extraInstructions: _, ...configWithoutInstructions } = agentConfig;
|
|
394
|
+
if (Object.keys(configWithoutInstructions).length === 0)
|
|
364
395
|
continue;
|
|
365
|
-
const fullInstructions = parts.join('\n\n');
|
|
366
396
|
for (const scope of scopes) {
|
|
397
|
+
if (scope === 'user')
|
|
398
|
+
continue; // handled in unified block below
|
|
367
399
|
try {
|
|
368
|
-
await adapter.
|
|
369
|
-
|
|
370
|
-
|
|
400
|
+
await adapter.writeAgentConfig(configWithoutInstructions, scope);
|
|
401
|
+
agentConfigsApplied++;
|
|
402
|
+
const configKeys = Object.keys(configWithoutInstructions).join(', ');
|
|
403
|
+
console.log(` [${adapter.name}] Updated agent config: ${configKeys} (${scope})`);
|
|
371
404
|
}
|
|
372
405
|
catch {
|
|
373
406
|
// Best-effort per adapter/scope
|
|
374
407
|
}
|
|
375
408
|
}
|
|
376
409
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
410
|
+
}
|
|
411
|
+
// Unified user-scope write: merge baked defaults with team config, network
|
|
412
|
+
// allowlist, and minimum permissions into a single writeAgentConfig call
|
|
413
|
+
// per adapter. The merge layer tracks injected defaults in state.agentDefaults
|
|
414
|
+
// so user-removed entries become sticky opt-outs.
|
|
415
|
+
if (scopes.includes('user')) {
|
|
416
|
+
const networkDomains = ['runwork.ai', '*.runwork.ai'];
|
|
417
|
+
try {
|
|
418
|
+
const baseHost = new URL(baseUrl).hostname;
|
|
419
|
+
if (baseHost !== 'runwork.ai' && !baseHost.endsWith('.runwork.ai')) {
|
|
420
|
+
networkDomains.push(baseHost, `*.${baseHost}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch { /* use defaults */ }
|
|
424
|
+
// Order arrays list each agent's valid values from most restrictive to most
|
|
425
|
+
// permissive. The floor logic upgrades anything strictly below `minimum`,
|
|
426
|
+
// including unknown values (-1 < minimumIdx). That makes it critical to
|
|
427
|
+
// include the agent's most-permissive option in `order` — otherwise a user
|
|
428
|
+
// who explicitly set, say, `sandbox_mode = "danger-full-access"` gets
|
|
429
|
+
// silently downgraded to `workspace-write`.
|
|
430
|
+
const minimumPermissions = [
|
|
431
|
+
{ field: 'approval_policy', order: ['untrusted', 'on-request', 'on-failure', 'never'], minimum: 'on-request' },
|
|
432
|
+
{ field: 'sandbox_mode', order: ['read-only', 'workspace-write', 'danger-full-access'], minimum: 'workspace-write' },
|
|
433
|
+
];
|
|
434
|
+
// agentDefaults lifecycle: full `runwork uninstall` deletes ~/.runwork
|
|
435
|
+
// (state and all per-agent defaults state with it). Re-running `runwork
|
|
436
|
+
// setup` overwrites setup.json without agentDefaults so every remaining
|
|
437
|
+
// tool bootstraps fresh on the next sync. refreshConfiguredAgents only
|
|
438
|
+
// adds slugs (never removes), so we don't need orphan-entry pruning here.
|
|
439
|
+
if (!state.agentDefaults)
|
|
440
|
+
state.agentDefaults = {};
|
|
441
|
+
for (const adapter of adapters) {
|
|
442
|
+
if (!adapter.writeAgentConfig)
|
|
443
|
+
continue;
|
|
444
|
+
const slug = adapter.slug;
|
|
445
|
+
const baked = RUNWORK_AGENT_DEFAULTS[slug];
|
|
446
|
+
const agentState = state.agentDefaults[slug];
|
|
447
|
+
const team = agentConfigs?.[slug];
|
|
448
|
+
// onDisk = undefined signals "removal detection is not possible" — used for
|
|
449
|
+
// markerless adapters (Cursor) and for any adapter without readManagedBlock.
|
|
450
|
+
// The resolver treats undefined differently from an empty array: it skips
|
|
451
|
+
// opt-out fabrication entirely and trusts the baseline mechanism in the
|
|
452
|
+
// adapter to preserve user edits via subtraction.
|
|
453
|
+
let onDisk;
|
|
454
|
+
if (baked && adapter.readManagedBlock) {
|
|
455
|
+
try {
|
|
456
|
+
onDisk = await adapter.readManagedBlock('user');
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
onDisk = undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const resolved = resolveAgentDefaults({
|
|
463
|
+
baked,
|
|
464
|
+
state: agentState,
|
|
465
|
+
onDisk,
|
|
466
|
+
team: team?.permissionRules,
|
|
467
|
+
});
|
|
468
|
+
const mergedConfig = {
|
|
469
|
+
modelPreference: team?.modelPreference,
|
|
470
|
+
permissionRules: (resolved.managedAllow.length || resolved.managedDeny.length || team?.permissionRules?.defaultMode)
|
|
471
|
+
? {
|
|
472
|
+
...(resolved.managedAllow.length ? { allow: resolved.managedAllow } : {}),
|
|
473
|
+
...(resolved.managedDeny.length ? { deny: resolved.managedDeny } : {}),
|
|
474
|
+
...(team?.permissionRules?.defaultMode ? { defaultMode: team.permissionRules.defaultMode } : {}),
|
|
393
475
|
}
|
|
394
|
-
|
|
395
|
-
|
|
476
|
+
: undefined,
|
|
477
|
+
networkAllowlist: networkDomains,
|
|
478
|
+
minimumPermissions,
|
|
479
|
+
};
|
|
480
|
+
try {
|
|
481
|
+
const baseline = agentState?.lastInjected;
|
|
482
|
+
await adapter.writeAgentConfig(mergedConfig, 'user', baseline);
|
|
483
|
+
if (baked) {
|
|
484
|
+
const nextState = {
|
|
485
|
+
lastInjected: {
|
|
486
|
+
allow: resolved.applicableAllow,
|
|
487
|
+
deny: resolved.applicableDeny,
|
|
488
|
+
},
|
|
489
|
+
userOptOuts: {
|
|
490
|
+
allow: resolved.newOptOutsAllow,
|
|
491
|
+
deny: resolved.newOptOutsDeny,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
state.agentDefaults[slug] = nextState;
|
|
495
|
+
// Diagnostic log line (per "invisible / sync log only" policy)
|
|
496
|
+
if (resolved.bootstrapped) {
|
|
497
|
+
console.log(` [${adapter.name}] Bootstrapped default-rule tracking (defaults apply on next sync)`);
|
|
498
|
+
}
|
|
499
|
+
else if (resolved.applicableAllow.length || resolved.applicableDeny.length || resolved.removalsThisSync.allow || resolved.removalsThisSync.deny) {
|
|
500
|
+
const totalRemovals = resolved.removalsThisSync.allow + resolved.removalsThisSync.deny;
|
|
501
|
+
const removalNote = totalRemovals > 0
|
|
502
|
+
? ` (${totalRemovals} new opt-out${totalRemovals > 1 ? 's' : ''} honored)`
|
|
503
|
+
: '';
|
|
504
|
+
console.log(` [${adapter.name}] Applied defaults: ${resolved.applicableAllow.length} allow, ${resolved.applicableDeny.length} deny${removalNote}`);
|
|
396
505
|
}
|
|
397
506
|
}
|
|
507
|
+
if (team)
|
|
508
|
+
agentConfigsApplied++;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// Best-effort per adapter
|
|
398
512
|
}
|
|
399
513
|
}
|
|
400
|
-
|
|
401
|
-
catch {
|
|
402
|
-
// Team config pull is non-fatal
|
|
403
|
-
}
|
|
404
|
-
// Ensure sandbox network access for agents that run in sandboxed environments (e.g. Cursor)
|
|
405
|
-
const networkDomains = ['runwork.ai', '*.runwork.ai'];
|
|
406
|
-
try {
|
|
407
|
-
const baseHost = new URL(baseUrl).hostname;
|
|
408
|
-
if (baseHost !== 'runwork.ai' && !baseHost.endsWith('.runwork.ai')) {
|
|
409
|
-
networkDomains.push(baseHost, `*.${baseHost}`);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
catch { /* use defaults */ }
|
|
413
|
-
for (const adapter of adapters) {
|
|
414
|
-
if (!adapter.writeAgentConfig)
|
|
415
|
-
continue;
|
|
416
|
-
try {
|
|
417
|
-
await adapter.writeAgentConfig({
|
|
418
|
-
networkAllowlist: networkDomains,
|
|
419
|
-
minimumPermissions: [
|
|
420
|
-
{ field: 'approval_policy', order: ['always', 'untrusted', 'on-request', 'never'], minimum: 'on-request' },
|
|
421
|
-
{ field: 'sandbox_mode', order: ['full', 'read-only', 'workspace-write', 'off'], minimum: 'workspace-write' },
|
|
422
|
-
],
|
|
423
|
-
}, 'user');
|
|
424
|
-
}
|
|
425
|
-
catch {
|
|
426
|
-
// Best-effort
|
|
427
|
-
}
|
|
514
|
+
state.agentDefaultsVersion = AGENT_DEFAULTS_SCHEMA_VERSION;
|
|
428
515
|
}
|
|
429
516
|
// Register ~/.runwork as a project in the Codex desktop app (best-effort).
|
|
430
517
|
// Only attempts when Codex adapter is configured and the desktop app is closed.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.11.0";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-types.ts -- do not edit
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.11.0";
|