agent-relay 4.0.2 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +7906 -2084
  6. package/dist/packages/sdk/src/provisioner/seeder.d.ts +17 -0
  7. package/dist/packages/sdk/src/provisioner/seeder.d.ts.map +1 -0
  8. package/dist/packages/sdk/src/provisioner/seeder.js +419 -0
  9. package/dist/packages/sdk/src/provisioner/seeder.js.map +1 -0
  10. package/dist/packages/sdk/src/provisioner/token.d.ts +38 -0
  11. package/dist/packages/sdk/src/provisioner/token.d.ts.map +1 -0
  12. package/dist/packages/sdk/src/provisioner/token.js +74 -0
  13. package/dist/packages/sdk/src/provisioner/token.js.map +1 -0
  14. package/dist/src/cli/commands/core.d.ts.map +1 -1
  15. package/dist/src/cli/commands/core.js +7 -3
  16. package/dist/src/cli/commands/core.js.map +1 -1
  17. package/dist/src/cli/commands/on/provision.d.ts.map +1 -1
  18. package/dist/src/cli/commands/on/provision.js +8 -3
  19. package/dist/src/cli/commands/on/provision.js.map +1 -1
  20. package/dist/src/cli/commands/on/start.d.ts +5 -0
  21. package/dist/src/cli/commands/on/start.d.ts.map +1 -1
  22. package/dist/src/cli/commands/on/start.js +126 -88
  23. package/dist/src/cli/commands/on/start.js.map +1 -1
  24. package/dist/src/cli/commands/on/symlink-mount.d.ts +12 -0
  25. package/dist/src/cli/commands/on/symlink-mount.d.ts.map +1 -0
  26. package/dist/src/cli/commands/on/symlink-mount.js +304 -0
  27. package/dist/src/cli/commands/on/symlink-mount.js.map +1 -0
  28. package/dist/src/cli/commands/on.d.ts.map +1 -1
  29. package/dist/src/cli/commands/on.js +3 -0
  30. package/dist/src/cli/commands/on.js.map +1 -1
  31. package/install.sh +4 -0
  32. package/package.json +9 -9
  33. package/packages/acp-bridge/package.json +2 -2
  34. package/packages/brand/package.json +1 -1
  35. package/packages/cloud/package.json +2 -2
  36. package/packages/config/package.json +1 -1
  37. package/packages/hooks/package.json +4 -4
  38. package/packages/memory/package.json +2 -2
  39. package/packages/openclaw/package.json +2 -2
  40. package/packages/policy/package.json +2 -2
  41. package/packages/sdk/dist/client.d.ts +3 -10
  42. package/packages/sdk/dist/client.d.ts.map +1 -1
  43. package/packages/sdk/dist/client.js +2 -0
  44. package/packages/sdk/dist/client.js.map +1 -1
  45. package/packages/sdk/dist/provisioner/__tests__/audit.test.d.ts +2 -0
  46. package/packages/sdk/dist/provisioner/__tests__/audit.test.d.ts.map +1 -0
  47. package/packages/sdk/dist/provisioner/__tests__/audit.test.js +45 -0
  48. package/packages/sdk/dist/provisioner/__tests__/audit.test.js.map +1 -0
  49. package/packages/sdk/dist/provisioner/__tests__/compiler.test.d.ts +2 -0
  50. package/packages/sdk/dist/provisioner/__tests__/compiler.test.d.ts.map +1 -0
  51. package/packages/sdk/dist/provisioner/__tests__/compiler.test.js +345 -0
  52. package/packages/sdk/dist/provisioner/__tests__/compiler.test.js.map +1 -0
  53. package/packages/sdk/dist/provisioner/__tests__/presets.test.d.ts +2 -0
  54. package/packages/sdk/dist/provisioner/__tests__/presets.test.d.ts.map +1 -0
  55. package/packages/sdk/dist/provisioner/__tests__/presets.test.js +23 -0
  56. package/packages/sdk/dist/provisioner/__tests__/presets.test.js.map +1 -0
  57. package/packages/sdk/dist/provisioner/__tests__/seeder.test.d.ts +2 -0
  58. package/packages/sdk/dist/provisioner/__tests__/seeder.test.d.ts.map +1 -0
  59. package/packages/sdk/dist/provisioner/__tests__/seeder.test.js +224 -0
  60. package/packages/sdk/dist/provisioner/__tests__/seeder.test.js.map +1 -0
  61. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.d.ts +2 -0
  62. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.d.ts.map +1 -0
  63. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.js +191 -0
  64. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.js.map +1 -0
  65. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.d.ts +2 -0
  66. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.d.ts.map +1 -0
  67. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.js +127 -0
  68. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.js.map +1 -0
  69. package/packages/sdk/dist/provisioner/__tests__/token.test.d.ts +2 -0
  70. package/packages/sdk/dist/provisioner/__tests__/token.test.d.ts.map +1 -0
  71. package/packages/sdk/dist/provisioner/__tests__/token.test.js +44 -0
  72. package/packages/sdk/dist/provisioner/__tests__/token.test.js.map +1 -0
  73. package/packages/sdk/dist/provisioner/audit.d.ts +19 -0
  74. package/packages/sdk/dist/provisioner/audit.d.ts.map +1 -0
  75. package/packages/sdk/dist/provisioner/audit.js +74 -0
  76. package/packages/sdk/dist/provisioner/audit.js.map +1 -0
  77. package/packages/sdk/dist/provisioner/compiler.d.ts +23 -0
  78. package/packages/sdk/dist/provisioner/compiler.d.ts.map +1 -0
  79. package/packages/sdk/dist/provisioner/compiler.js +355 -0
  80. package/packages/sdk/dist/provisioner/compiler.js.map +1 -0
  81. package/packages/sdk/dist/provisioner/index.d.ts +9 -0
  82. package/packages/sdk/dist/provisioner/index.d.ts.map +1 -0
  83. package/packages/sdk/dist/provisioner/index.js +266 -0
  84. package/packages/sdk/dist/provisioner/index.js.map +1 -0
  85. package/packages/sdk/dist/provisioner/mount.d.ts +14 -0
  86. package/packages/sdk/dist/provisioner/mount.d.ts.map +1 -0
  87. package/packages/sdk/dist/provisioner/mount.js +329 -0
  88. package/packages/sdk/dist/provisioner/mount.js.map +1 -0
  89. package/packages/sdk/dist/provisioner/seeder.d.ts +17 -0
  90. package/packages/sdk/dist/provisioner/seeder.d.ts.map +1 -0
  91. package/packages/sdk/dist/provisioner/seeder.js +419 -0
  92. package/packages/sdk/dist/provisioner/seeder.js.map +1 -0
  93. package/packages/sdk/dist/provisioner/token.d.ts +38 -0
  94. package/packages/sdk/dist/provisioner/token.d.ts.map +1 -0
  95. package/packages/sdk/dist/provisioner/token.js +74 -0
  96. package/packages/sdk/dist/provisioner/token.js.map +1 -0
  97. package/packages/sdk/dist/provisioner/types.d.ts +133 -0
  98. package/packages/sdk/dist/provisioner/types.d.ts.map +1 -0
  99. package/packages/sdk/dist/provisioner/types.js +2 -0
  100. package/packages/sdk/dist/provisioner/types.js.map +1 -0
  101. package/packages/sdk/dist/relay.d.ts +6 -0
  102. package/packages/sdk/dist/relay.d.ts.map +1 -1
  103. package/packages/sdk/dist/relay.js +17 -5
  104. package/packages/sdk/dist/relay.js.map +1 -1
  105. package/packages/sdk/dist/types.d.ts +9 -0
  106. package/packages/sdk/dist/types.d.ts.map +1 -1
  107. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.d.ts +2 -0
  108. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.d.ts.map +1 -0
  109. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.js +331 -0
  110. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.js.map +1 -0
  111. package/packages/sdk/dist/workflows/__tests__/permission-types.test.d.ts +2 -0
  112. package/packages/sdk/dist/workflows/__tests__/permission-types.test.d.ts.map +1 -0
  113. package/packages/sdk/dist/workflows/__tests__/permission-types.test.js +124 -0
  114. package/packages/sdk/dist/workflows/__tests__/permission-types.test.js.map +1 -0
  115. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.d.ts +2 -0
  116. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.d.ts.map +1 -0
  117. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.js +526 -0
  118. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.js.map +1 -0
  119. package/packages/sdk/dist/workflows/dry-run-format.d.ts.map +1 -1
  120. package/packages/sdk/dist/workflows/dry-run-format.js +8 -0
  121. package/packages/sdk/dist/workflows/dry-run-format.js.map +1 -1
  122. package/packages/sdk/dist/workflows/runner.d.ts +14 -0
  123. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  124. package/packages/sdk/dist/workflows/runner.js +455 -6
  125. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  126. package/packages/sdk/dist/workflows/types.d.ts +190 -0
  127. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  128. package/packages/sdk/dist/workflows/types.js +29 -0
  129. package/packages/sdk/dist/workflows/types.js.map +1 -1
  130. package/packages/sdk/package.json +6 -2
  131. package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +123 -1
  132. package/packages/sdk/src/__tests__/provisioner-mount.test.ts +126 -0
  133. package/packages/sdk/src/__tests__/spawn-token.test.ts +41 -0
  134. package/packages/sdk/src/__tests__/workflow-runner.test.ts +77 -45
  135. package/packages/sdk/src/client.ts +4 -8
  136. package/packages/sdk/src/provisioner/__tests__/audit.test.ts +62 -0
  137. package/packages/sdk/src/provisioner/__tests__/compiler.test.ts +369 -0
  138. package/packages/sdk/src/provisioner/__tests__/presets.test.ts +25 -0
  139. package/packages/sdk/src/provisioner/__tests__/seeder.test.ts +284 -0
  140. package/packages/sdk/src/provisioner/__tests__/tar-seeder.test.ts +249 -0
  141. package/packages/sdk/src/provisioner/__tests__/token-factory.test.ts +172 -0
  142. package/packages/sdk/src/provisioner/__tests__/token.test.ts +53 -0
  143. package/packages/sdk/src/provisioner/audit.ts +104 -0
  144. package/packages/sdk/src/provisioner/compiler.ts +498 -0
  145. package/packages/sdk/src/provisioner/index.ts +332 -0
  146. package/packages/sdk/src/provisioner/mount.ts +419 -0
  147. package/packages/sdk/src/provisioner/seeder.ts +571 -0
  148. package/packages/sdk/src/provisioner/token.ts +112 -0
  149. package/packages/sdk/src/provisioner/types.ts +188 -0
  150. package/packages/sdk/src/relay.ts +31 -9
  151. package/packages/sdk/src/types.ts +9 -0
  152. package/packages/sdk/src/workflows/__tests__/e2e-permissions.test.ts +407 -0
  153. package/packages/sdk/src/workflows/__tests__/fixtures/.agentignore +2 -0
  154. package/packages/sdk/src/workflows/__tests__/fixtures/.reader.agentreadonly +2 -0
  155. package/packages/sdk/src/workflows/__tests__/fixtures/permission-test.yaml +42 -0
  156. package/packages/sdk/src/workflows/__tests__/permission-types.test.ts +154 -0
  157. package/packages/sdk/src/workflows/__tests__/permissions-integration.test.ts +649 -0
  158. package/packages/sdk/src/workflows/builtin-templates/bug-fix.yaml +13 -9
  159. package/packages/sdk/src/workflows/builtin-templates/code-review.yaml +12 -8
  160. package/packages/sdk/src/workflows/builtin-templates/competitive.yaml +11 -7
  161. package/packages/sdk/src/workflows/builtin-templates/documentation.yaml +16 -8
  162. package/packages/sdk/src/workflows/builtin-templates/feature-dev.yaml +13 -9
  163. package/packages/sdk/src/workflows/builtin-templates/refactor.yaml +13 -9
  164. package/packages/sdk/src/workflows/builtin-templates/review-loop.yaml +14 -10
  165. package/packages/sdk/src/workflows/builtin-templates/security-audit.yaml +19 -9
  166. package/packages/sdk/src/workflows/dry-run-format.ts +14 -1
  167. package/packages/sdk/src/workflows/runner.ts +559 -6
  168. package/packages/sdk/src/workflows/schema.json +204 -114
  169. package/packages/sdk/src/workflows/types.ts +266 -1
  170. package/packages/sdk/vitest.config.ts +5 -1
  171. package/packages/sdk-py/pyproject.toml +1 -1
  172. package/packages/telemetry/package.json +1 -1
  173. package/packages/trajectory/package.json +2 -2
  174. package/packages/user-directory/package.json +2 -2
  175. package/packages/utils/package.json +2 -2
@@ -0,0 +1,407 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import type { WorkflowDb } from '../runner.js';
8
+ import type { RelayYamlConfig, WorkflowRunRow, WorkflowStepRow } from '../types.js';
9
+ import type { ProvisionResult, WorkflowProvisionConfig } from '../../provisioner/types.js';
10
+
11
+ const fixturePath = fileURLToPath(new URL('./fixtures/permission-test.yaml', import.meta.url));
12
+
13
+ const permissionProfiles = {
14
+ reader: {
15
+ access: 'readonly',
16
+ scopes: ['relayfile:fs:read:/**'],
17
+ summary: { readonly: 4, readwrite: 0, denied: 0, customScopes: 0 },
18
+ },
19
+ writer: {
20
+ access: 'readwrite',
21
+ scopes: ['relayfile:fs:read:/src/tests/**', 'relayfile:fs:write:/src/tests/**'],
22
+ summary: { readonly: 0, readwrite: 1, denied: 2, customScopes: 0 },
23
+ },
24
+ 'admin-lead': {
25
+ access: 'full',
26
+ scopes: ['relayfile:fs:read:/**', 'relayfile:fs:write:/**'],
27
+ summary: { readonly: 0, readwrite: 6, denied: 0, customScopes: 0 },
28
+ },
29
+ } as const;
30
+
31
+ type PermissionProfile = (typeof permissionProfiles)[keyof typeof permissionProfiles];
32
+
33
+ function buildCompiledPermissions(agentName: string, workspace: string, profile: PermissionProfile) {
34
+ return {
35
+ agentName,
36
+ workspace,
37
+ effectiveAccess: profile.access,
38
+ inherited: profile.access !== 'full',
39
+ sources: [{ type: 'yaml' as const, label: 'permissions', ruleCount: profile.scopes.length }],
40
+ readonlyPatterns: profile.access === 'readonly' ? ['**'] : [],
41
+ readwritePatterns:
42
+ profile.access === 'full'
43
+ ? ['**']
44
+ : profile.scopes
45
+ .filter((scope) => scope.startsWith('relayfile:fs:write:'))
46
+ .map((scope) => scope.split(':').slice(3).join(':')),
47
+ deniedPatterns: agentName === 'writer' ? ['.env', 'secrets/**'] : [],
48
+ readonlyPaths: Array.from({ length: profile.summary.readonly }, (_, index) => `readonly-${index}.txt`),
49
+ readwritePaths: Array.from({ length: profile.summary.readwrite }, (_, index) => `write-${index}.txt`),
50
+ deniedPaths: Array.from({ length: profile.summary.denied }, (_, index) => `denied-${index}.txt`),
51
+ scopes: [...profile.scopes],
52
+ network: undefined,
53
+ exec: undefined,
54
+ acl: {},
55
+ summary: { ...profile.summary },
56
+ };
57
+ }
58
+
59
+ let lastProvisionCall: WorkflowProvisionConfig | null = null;
60
+ let lastProvisionResult: ProvisionResult | null = null;
61
+
62
+ const mockProvisionWorkflowAgents = vi.fn(
63
+ async (input: WorkflowProvisionConfig): Promise<ProvisionResult> => {
64
+ lastProvisionCall = input;
65
+
66
+ const agentNames = Object.keys(input.agents ?? {});
67
+ const tokens = new Map<string, string>();
68
+ const scopes = new Map<string, string[]>();
69
+ const agents = Object.fromEntries(
70
+ agentNames.map((agentName) => {
71
+ const profile = permissionProfiles[agentName as keyof typeof permissionProfiles];
72
+ const token = `jwt-${agentName}`;
73
+ const compiled = buildCompiledPermissions(agentName, input.workspace, profile);
74
+
75
+ tokens.set(agentName, token);
76
+ scopes.set(agentName, [...profile.scopes]);
77
+
78
+ return [
79
+ agentName,
80
+ {
81
+ name: agentName,
82
+ tokenPath: path.join(input.projectDir, '.relay', 'tokens', `${agentName}.jwt`),
83
+ token,
84
+ scopes: [...profile.scopes],
85
+ compiled,
86
+ },
87
+ ];
88
+ })
89
+ );
90
+
91
+ const result: ProvisionResult = {
92
+ agents,
93
+ agentNames,
94
+ adminToken: 'jwt-admin',
95
+ seededFileCount: 0,
96
+ seededAclCount: 0,
97
+ summary: agentNames.reduce(
98
+ (acc, agentName) => {
99
+ const profile = permissionProfiles[agentName as keyof typeof permissionProfiles];
100
+ acc.readonly += profile.summary.readonly;
101
+ acc.readwrite += profile.summary.readwrite;
102
+ acc.denied += profile.summary.denied;
103
+ acc.customScopes += profile.summary.customScopes;
104
+ return acc;
105
+ },
106
+ { readonly: 0, readwrite: 0, denied: 0, customScopes: 0 }
107
+ ),
108
+ mounts: new Map(),
109
+ tokens,
110
+ scopes,
111
+ };
112
+
113
+ lastProvisionResult = result;
114
+ return result;
115
+ }
116
+ );
117
+
118
+ const mockResolveAgentPermissions = vi.fn(
119
+ (agentName: string, _permissions: unknown, _projectDir: string, workspace: string) =>
120
+ buildCompiledPermissions(
121
+ agentName,
122
+ workspace,
123
+ permissionProfiles[agentName as keyof typeof permissionProfiles]
124
+ )
125
+ );
126
+
127
+ vi.mock('../../provisioner/index.js', async (importOriginal) => {
128
+ const actual = await importOriginal<typeof import('../../provisioner/index.js')>();
129
+ return {
130
+ ...actual,
131
+ provisionWorkflowAgents: mockProvisionWorkflowAgents,
132
+ resolveAgentPermissions: mockResolveAgentPermissions,
133
+ };
134
+ });
135
+
136
+ const mockFetch = vi.fn().mockResolvedValue({
137
+ ok: true,
138
+ json: () => Promise.resolve({ data: { api_key: 'rk_live_test', workspace_id: 'ws-test' } }),
139
+ text: () => Promise.resolve(''),
140
+ });
141
+ vi.stubGlobal('fetch', mockFetch);
142
+
143
+ const mockRelaycastAgent = {
144
+ send: vi.fn().mockResolvedValue(undefined),
145
+ heartbeat: vi.fn().mockResolvedValue(undefined),
146
+ channels: {
147
+ create: vi.fn().mockResolvedValue(undefined),
148
+ join: vi.fn().mockResolvedValue(undefined),
149
+ invite: vi.fn().mockResolvedValue(undefined),
150
+ },
151
+ };
152
+
153
+ const mockRelaycast = {
154
+ agents: {
155
+ register: vi.fn().mockResolvedValue({ token: 'token-1' }),
156
+ },
157
+ as: vi.fn().mockReturnValue(mockRelaycastAgent),
158
+ };
159
+
160
+ class MockRelayError extends Error {
161
+ code: string;
162
+
163
+ constructor(code: string, message: string, status = 400) {
164
+ super(message);
165
+ this.code = code;
166
+ this.name = 'RelayError';
167
+ (this as any).status = status;
168
+ }
169
+ }
170
+
171
+ vi.mock('@relaycast/sdk', () => ({
172
+ RelayCast: vi.fn().mockImplementation(() => mockRelaycast),
173
+ RelayError: MockRelayError,
174
+ }));
175
+
176
+ let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>;
177
+ let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>;
178
+ let mockSpawnOutputs: string[] = [];
179
+
180
+ const mockAgent = {
181
+ name: 'test-agent-abc',
182
+ get waitForExit() {
183
+ return waitForExitFn;
184
+ },
185
+ get waitForIdle() {
186
+ return waitForIdleFn;
187
+ },
188
+ release: vi.fn().mockResolvedValue(undefined),
189
+ };
190
+
191
+ const mockHuman = {
192
+ name: 'WorkflowRunner',
193
+ sendMessage: vi.fn().mockResolvedValue(undefined),
194
+ };
195
+
196
+ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; task?: string }) => {
197
+ const queued = mockSpawnOutputs.shift();
198
+ const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim();
199
+ const output = queued ?? (stepComplete ? `STEP_COMPLETE:${stepComplete}\n` : 'STEP_COMPLETE:unknown\n');
200
+
201
+ queueMicrotask(() => {
202
+ if (typeof mockRelayInstance.onWorkerOutput === 'function') {
203
+ mockRelayInstance.onWorkerOutput({ name, chunk: output });
204
+ }
205
+ });
206
+
207
+ return { ...mockAgent, name };
208
+ };
209
+
210
+ const mockRelayInstance = {
211
+ spawnPty: vi.fn().mockImplementation(defaultSpawnPtyImplementation),
212
+ human: vi.fn().mockReturnValue(mockHuman),
213
+ shutdown: vi.fn().mockResolvedValue(undefined),
214
+ onBrokerStderr: vi.fn().mockReturnValue(() => {}),
215
+ onWorkerOutput: null as ((frame: { name: string; chunk: string }) => void) | null,
216
+ onMessageReceived: null as any,
217
+ onAgentSpawned: null as any,
218
+ onAgentReleased: null as any,
219
+ onAgentExited: null as any,
220
+ onAgentIdle: null as any,
221
+ onDeliveryUpdate: null as any,
222
+ listAgentsRaw: vi.fn().mockResolvedValue([]),
223
+ };
224
+
225
+ vi.mock('../../relay.js', () => ({
226
+ AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance),
227
+ }));
228
+
229
+ const { WorkflowRunner } = await import('../runner.js');
230
+ const { formatDryRunReport } = await import('../dry-run-format.js');
231
+
232
+ function makeDb(): WorkflowDb {
233
+ const runs = new Map<string, WorkflowRunRow>();
234
+ const steps = new Map<string, WorkflowStepRow>();
235
+
236
+ return {
237
+ insertRun: vi.fn(async (run: WorkflowRunRow) => {
238
+ runs.set(run.id, { ...run });
239
+ }),
240
+ updateRun: vi.fn(async (id: string, patch: Partial<WorkflowRunRow>) => {
241
+ const existing = runs.get(id);
242
+ if (existing) runs.set(id, { ...existing, ...patch });
243
+ }),
244
+ getRun: vi.fn(async (id: string) => {
245
+ const run = runs.get(id);
246
+ return run ? { ...run } : null;
247
+ }),
248
+ insertStep: vi.fn(async (step: WorkflowStepRow) => {
249
+ steps.set(step.id, { ...step });
250
+ }),
251
+ updateStep: vi.fn(async (id: string, patch: Partial<WorkflowStepRow>) => {
252
+ const existing = steps.get(id);
253
+ if (existing) steps.set(id, { ...existing, ...patch });
254
+ }),
255
+ getStepsByRunId: vi.fn(async (runId: string) => {
256
+ return [...steps.values()].filter((step) => step.runId === runId);
257
+ }),
258
+ };
259
+ }
260
+
261
+ function never<T>(): Promise<T> {
262
+ return new Promise(() => {});
263
+ }
264
+
265
+ function createWorkspace(): string {
266
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'relay-workflow-permissions-'));
267
+ mkdirSync(path.join(dir, 'src', 'tests'), { recursive: true });
268
+ mkdirSync(path.join(dir, 'src'), { recursive: true });
269
+ mkdirSync(path.join(dir, 'secrets'), { recursive: true });
270
+ writeFileSync(path.join(dir, 'README.md'), '# workspace\n');
271
+ writeFileSync(path.join(dir, 'src', 'index.ts'), 'export const value = 1;\n');
272
+ writeFileSync(path.join(dir, 'src', 'tests', 'fixture.txt'), 'fixture\n');
273
+ writeFileSync(path.join(dir, '.env'), 'TOKEN=secret\n');
274
+ writeFileSync(path.join(dir, 'secrets', 'prod.txt'), 'top-secret\n');
275
+ return dir;
276
+ }
277
+
278
+ async function loadPermissionFixture(
279
+ runner: InstanceType<typeof WorkflowRunner>,
280
+ options: { includeLeadStep?: boolean } = {}
281
+ ): Promise<RelayYamlConfig> {
282
+ const config = await runner.parseYamlFile(fixturePath);
283
+ config.trajectories = false;
284
+
285
+ if (options.includeLeadStep) {
286
+ const workflow = config.workflows?.find((entry) => entry.name === 'test');
287
+ workflow?.steps.push({
288
+ name: 'lead-step',
289
+ agent: 'admin-lead',
290
+ dependsOn: ['read-step', 'write-step'],
291
+ task: 'Verify admin lead permissions are available and conclude the workflow.',
292
+ });
293
+ }
294
+
295
+ return config;
296
+ }
297
+
298
+ describe('WorkflowRunner permissions integration', () => {
299
+ let db: WorkflowDb;
300
+ let runner: InstanceType<typeof WorkflowRunner>;
301
+ let workspaceDir: string;
302
+
303
+ beforeEach(() => {
304
+ vi.clearAllMocks();
305
+ waitForExitFn = vi.fn().mockResolvedValue('exited');
306
+ waitForIdleFn = vi.fn().mockImplementation(() => never());
307
+ mockSpawnOutputs = [];
308
+ mockAgent.release.mockResolvedValue(undefined);
309
+ mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation);
310
+ mockRelayInstance.onWorkerOutput = null;
311
+ lastProvisionCall = null;
312
+ lastProvisionResult = null;
313
+ workspaceDir = createWorkspace();
314
+ db = makeDb();
315
+ runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: workspaceDir });
316
+ });
317
+
318
+ afterEach(() => {
319
+ vi.restoreAllMocks();
320
+ rmSync(workspaceDir, { recursive: true, force: true });
321
+ });
322
+
323
+ it('provisions permissions, propagates agent tokens, and clears workflow tokens after completion', async () => {
324
+ const config = await loadPermissionFixture(runner, { includeLeadStep: true });
325
+ const provisionSpy = vi.spyOn(runner as any, 'provisionAgents');
326
+ const nonInteractiveCommandSpy = vi
327
+ .spyOn(WorkflowRunner, 'buildNonInteractiveCommand')
328
+ .mockImplementation(() => ({
329
+ cmd: 'sh',
330
+ args: ['-c', 'printf "RELAY_AGENT_TOKEN=%s" "$RELAY_AGENT_TOKEN"'],
331
+ }));
332
+
333
+ const run = await runner.execute(config, 'test');
334
+ const steps = await db.getStepsByRunId(run.id);
335
+ const stepByName = new Map(steps.map((step) => [step.stepName, step]));
336
+ const provisionedScopes = lastProvisionResult?.scopes;
337
+ const spawnCalls = (mockRelayInstance.spawnPty as any).mock.calls.map(
338
+ ([input]: [{ agentToken?: string; name: string }]) => input
339
+ );
340
+
341
+ expect(run.status).toBe('completed');
342
+ expect(provisionSpy).toHaveBeenCalledTimes(1);
343
+ expect(mockProvisionWorkflowAgents).toHaveBeenCalledTimes(1);
344
+ expect(lastProvisionCall?.workspace).toBe('ws-test');
345
+ expect(lastProvisionCall?.projectDir).toBe(workspaceDir);
346
+ expect(Object.keys(lastProvisionCall?.agents ?? {})).toEqual(['reader', 'writer', 'admin-lead']);
347
+
348
+ expect(provisionedScopes?.get('reader')).toEqual(['relayfile:fs:read:/**']);
349
+ expect(provisionedScopes?.get('reader')?.some((scope) => scope.includes(':write:'))).toBe(false);
350
+ expect(provisionedScopes?.get('writer')).toEqual([
351
+ 'relayfile:fs:read:/src/tests/**',
352
+ 'relayfile:fs:write:/src/tests/**',
353
+ ]);
354
+ expect(provisionedScopes?.get('writer')?.filter((scope) => scope.includes(':write:'))).toEqual([
355
+ 'relayfile:fs:write:/src/tests/**',
356
+ ]);
357
+ expect(provisionedScopes?.get('admin-lead')).toEqual(['relayfile:fs:read:/**', 'relayfile:fs:write:/**']);
358
+
359
+ expect(nonInteractiveCommandSpy).toHaveBeenCalledTimes(2);
360
+ expect(stepByName.get('read-step')?.output).toBe('RELAY_AGENT_TOKEN=jwt-reader');
361
+ expect(stepByName.get('write-step')?.output).toBe('RELAY_AGENT_TOKEN=jwt-writer');
362
+
363
+ expect(spawnCalls.length).toBeGreaterThan(0);
364
+ expect(
365
+ spawnCalls.every(
366
+ (call: { agentToken: string }) => typeof call.agentToken === 'string' && call.agentToken.length > 0
367
+ )
368
+ ).toBe(true);
369
+ expect(spawnCalls[0]?.agentToken).toBe('jwt-admin-lead');
370
+
371
+ expect((runner as any).agentTokens.size).toBe(0);
372
+ expect((runner as any).agentMounts.size).toBe(0);
373
+ }, 20_000);
374
+
375
+ it('shows a permissions summary in dry-run mode', async () => {
376
+ const config = await loadPermissionFixture(runner);
377
+ const report = runner.dryRun(config, 'test');
378
+ const formatted = formatDryRunReport(report);
379
+
380
+ expect(report.permissions).toEqual(
381
+ expect.arrayContaining([
382
+ expect.objectContaining({
383
+ agent: 'reader',
384
+ access: 'readonly',
385
+ writePaths: 0,
386
+ }),
387
+ expect.objectContaining({
388
+ agent: 'writer',
389
+ access: 'readwrite',
390
+ writePaths: 1,
391
+ }),
392
+ expect.objectContaining({
393
+ agent: 'admin-lead',
394
+ access: 'full',
395
+ }),
396
+ ])
397
+ );
398
+
399
+ expect(formatted).toContain('Permissions');
400
+ expect(formatted).toContain('reader');
401
+ expect(formatted).toContain('writer');
402
+ expect(formatted).toContain('admin-lead');
403
+ expect(formatted).toContain('readonly');
404
+ expect(formatted).toContain('readwrite');
405
+ expect(formatted).toContain('full');
406
+ }, 20_000);
407
+ });
@@ -0,0 +1,2 @@
1
+ .env
2
+ secrets/**
@@ -0,0 +1,42 @@
1
+ version: '1.0'
2
+ name: permission-e2e-test
3
+ swarm:
4
+ pattern: dag
5
+ channel: wf-perm-e2e
6
+ agents:
7
+ - name: reader
8
+ cli: claude
9
+ preset: reviewer
10
+ permissions:
11
+ access: readonly
12
+ - name: writer
13
+ cli: codex
14
+ preset: worker
15
+ permissions:
16
+ access: readwrite
17
+ files:
18
+ write: ['src/tests/**']
19
+ deny: ['.env', 'secrets/**']
20
+ - name: admin-lead
21
+ cli: claude
22
+ preset: lead
23
+ permissions:
24
+ access: full
25
+ workflows:
26
+ - name: test
27
+ steps:
28
+ - name: check-env
29
+ type: deterministic
30
+ command: 'echo "checking env"'
31
+ - name: read-step
32
+ agent: reader
33
+ dependsOn: [check-env]
34
+ task: 'Verify you have read access. Check RELAY_AGENT_TOKEN is set.'
35
+ verification:
36
+ type: exit_code
37
+ - name: write-step
38
+ agent: writer
39
+ dependsOn: [check-env]
40
+ task: 'Verify you can write to src/tests/. Check RELAY_AGENT_TOKEN is set.'
41
+ verification:
42
+ type: exit_code
@@ -0,0 +1,154 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { compileAgentScopes, resolveAgentPermissions } from '../../provisioner/compiler.js';
8
+ import type {
9
+ AccessPreset,
10
+ AgentDefinition,
11
+ AgentWorkflowStep,
12
+ DeterministicWorkflowStep,
13
+ } from '../types.js';
14
+ import { isAgentStep, isDeterministicStep, isRestrictedAgent } from '../types.js';
15
+
16
+ const tempDirs: string[] = [];
17
+
18
+ async function createWorkspace(files: Record<string, string>) {
19
+ const dir = await mkdtemp(path.join(tmpdir(), 'relay-workflow-permission-types-'));
20
+ tempDirs.push(dir);
21
+
22
+ for (const [relativePath, content] of Object.entries(files)) {
23
+ const filePath = path.join(dir, relativePath);
24
+ await mkdir(path.dirname(filePath), { recursive: true });
25
+ await writeFile(filePath, content);
26
+ }
27
+
28
+ return dir;
29
+ }
30
+
31
+ afterEach(async () => {
32
+ while (tempDirs.length > 0) {
33
+ await rm(tempDirs.pop()!, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ describe('workflow permission types', () => {
38
+ it('allows agents to omit permissions without becoming restricted', () => {
39
+ const agent: AgentDefinition = {
40
+ name: 'worker',
41
+ cli: 'codex',
42
+ task: 'Write tests',
43
+ };
44
+
45
+ expect(agent.permissions).toBeUndefined();
46
+ expect(isRestrictedAgent(agent)).toBe(false);
47
+ });
48
+
49
+ it.each(['readonly', 'readwrite', 'restricted', 'full'] as const satisfies readonly AccessPreset[])(
50
+ 'accepts the %s access preset',
51
+ async (access) => {
52
+ const workspace = await createWorkspace({
53
+ 'src/index.ts': 'export const value = 1;\n',
54
+ });
55
+
56
+ const compiled = compileAgentScopes({
57
+ agentName: `${access}-agent`,
58
+ workspace: 'relay-test',
59
+ projectDir: workspace,
60
+ permissions: {
61
+ access,
62
+ inherit: false,
63
+ },
64
+ });
65
+
66
+ expect(compiled.effectiveAccess).toBe(access);
67
+ expect(isRestrictedAgent({ name: 'agent', cli: 'codex', permissions: { access } })).toBe(
68
+ access === 'readonly' || access === 'restricted'
69
+ );
70
+ }
71
+ );
72
+
73
+ it('compiles full permissions with read/write access for all files', async () => {
74
+ const workspace = await createWorkspace({
75
+ '.agentignore': 'secret.txt\n',
76
+ '.agentreadonly': 'locked.txt\n',
77
+ 'locked.txt': 'lock me\n',
78
+ 'secret.txt': 'classified\n',
79
+ 'src/index.ts': 'export const value = 1;\n',
80
+ });
81
+
82
+ const compiled = compileAgentScopes({
83
+ agentName: 'lead',
84
+ workspace: 'relay-test',
85
+ projectDir: workspace,
86
+ permissions: {
87
+ access: 'full',
88
+ network: false,
89
+ exec: ['npm test'],
90
+ scopes: ['custom:relay:debug'],
91
+ },
92
+ });
93
+
94
+ expect(compiled.effectiveAccess).toBe('full');
95
+ expect(compiled.inherited).toBe(false);
96
+ expect(compiled.readonlyPaths).toEqual([]);
97
+ expect(compiled.deniedPaths).toEqual([]);
98
+ expect(compiled.readwritePaths).toEqual([
99
+ '.agentignore',
100
+ '.agentreadonly',
101
+ 'locked.txt',
102
+ 'secret.txt',
103
+ 'src/index.ts',
104
+ ]);
105
+ expect(compiled.scopes).toContain('relayfile:fs:read:/secret.txt');
106
+ expect(compiled.scopes).toContain('relayfile:fs:write:/secret.txt');
107
+ expect(compiled.scopes).toContain('relayfile:fs:read:/src/index.ts');
108
+ expect(compiled.scopes).toContain('relayfile:fs:write:/src/index.ts');
109
+ expect(compiled.scopes).toContain('custom:relay:debug');
110
+ expect(compiled.network).toBe(false);
111
+ expect(compiled.exec).toEqual(['npm test']);
112
+ expect(compiled.summary).toEqual({
113
+ readonly: 0,
114
+ readwrite: 5,
115
+ denied: 0,
116
+ customScopes: 1,
117
+ });
118
+ });
119
+
120
+ it('preserves backwards-compatible default resolution when permissions are undefined', async () => {
121
+ const workspace = await createWorkspace({
122
+ '.agentignore': 'blocked.txt\n',
123
+ '.agentreadonly': 'locked.txt\n',
124
+ 'blocked.txt': 'do not read\n',
125
+ 'locked.txt': 'read only\n',
126
+ 'writable.txt': 'can edit\n',
127
+ });
128
+
129
+ const compiled = resolveAgentPermissions('legacy-worker', undefined, workspace, 'relay-test');
130
+
131
+ expect(compiled.effectiveAccess).toBe('readwrite');
132
+ expect(compiled.inherited).toBe(true);
133
+ expect(compiled.readonlyPaths).toEqual(['locked.txt']);
134
+ expect(compiled.readwritePaths).toEqual(['.agentignore', '.agentreadonly', 'writable.txt']);
135
+ expect(compiled.deniedPaths).toEqual(['blocked.txt']);
136
+ });
137
+
138
+ it('keeps legacy workflow step aliases compatible with WorkflowStep guards', () => {
139
+ const agentStep: AgentWorkflowStep = {
140
+ name: 'draft',
141
+ agent: 'worker',
142
+ task: 'Draft the summary',
143
+ };
144
+ const deterministicStep: DeterministicWorkflowStep = {
145
+ name: 'check',
146
+ type: 'deterministic',
147
+ command: 'npm test',
148
+ };
149
+
150
+ expect(isAgentStep(agentStep)).toBe(true);
151
+ expect(isDeterministicStep(agentStep)).toBe(false);
152
+ expect(isDeterministicStep(deterministicStep)).toBe(true);
153
+ });
154
+ });