agent-relay 4.0.2 → 4.0.3

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 +3 -0
  21. package/dist/src/cli/commands/on/start.d.ts.map +1 -1
  22. package/dist/src/cli/commands/on/start.js +113 -84
  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,284 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import test, { afterEach, mock } from 'node:test';
6
+
7
+ import { RelayFileClient } from '@relayfile/sdk';
8
+
9
+ import { seedAclRules, seedWorkflowAcls, seedWorkspace } from '../seeder.js';
10
+
11
+ interface FetchResponseShape {
12
+ ok: boolean;
13
+ status: number;
14
+ text(): Promise<string>;
15
+ }
16
+
17
+ const originalFetch = globalThis.fetch;
18
+
19
+ async function createWorkspace(
20
+ files: Record<string, string | Buffer>
21
+ ): Promise<{ dir: string; cleanup: () => Promise<void> }> {
22
+ const dir = await mkdtemp(path.join(tmpdir(), 'relay-seeder-'));
23
+
24
+ for (const [relativePath, content] of Object.entries(files)) {
25
+ const filePath = path.join(dir, relativePath);
26
+ await mkdir(path.dirname(filePath), { recursive: true });
27
+ await writeFile(filePath, content);
28
+ }
29
+
30
+ return {
31
+ dir,
32
+ cleanup: () => rm(dir, { recursive: true, force: true }),
33
+ };
34
+ }
35
+
36
+ function createFetchResponse(body: string, status = 200): FetchResponseShape {
37
+ return {
38
+ ok: status >= 200 && status < 300,
39
+ status,
40
+ async text(): Promise<string> {
41
+ return body;
42
+ },
43
+ };
44
+ }
45
+
46
+ function parseFetchBody(fetchMock: ReturnType<typeof mock.method>): { files: unknown[] } {
47
+ assert.equal(fetchMock.mock.calls.length, 1);
48
+ const [, options] = fetchMock.mock.calls[0]!.arguments as [string, RequestInit];
49
+ assert.equal(typeof options.body, 'string');
50
+ return JSON.parse(options.body as string) as { files: unknown[] };
51
+ }
52
+
53
+ afterEach(() => {
54
+ mock.restoreAll();
55
+ globalThis.fetch = originalFetch;
56
+ });
57
+
58
+ test('seedWorkspace posts the expected HTTP payload after SDK fallback', async () => {
59
+ const workspace = await createWorkspace({
60
+ 'alpha.txt': 'alpha payload',
61
+ 'binary.bin': Buffer.from([0xff, 0x00, 0x61]),
62
+ '.relay/ignored.txt': 'skip',
63
+ '.git/config': 'skip',
64
+ 'node_modules/pkg/index.js': 'skip',
65
+ 'custom-skip/ignored.txt': 'skip',
66
+ '.relayfile-mount-state.json': '{"skip":true}',
67
+ });
68
+
69
+ try {
70
+ mock.method(RelayFileClient.prototype, 'bulkWrite', async () => {
71
+ throw new Error('fall back to HTTP');
72
+ });
73
+
74
+ const fetchMock = mock.method(globalThis, 'fetch', async () =>
75
+ createFetchResponse(JSON.stringify({ written: 2, errorCount: 0, errors: [] }))
76
+ );
77
+
78
+ const seededCount = await seedWorkspace(
79
+ 'https://relay.example///',
80
+ 'admin-token',
81
+ ' workspace-123 ',
82
+ workspace.dir,
83
+ ['custom-skip']
84
+ );
85
+
86
+ assert.equal(seededCount, 2);
87
+ assert.equal(fetchMock.mock.calls.length, 1);
88
+
89
+ const [url, options] = fetchMock.mock.calls[0]!.arguments as [string, RequestInit];
90
+ assert.equal(url, 'https://relay.example/v1/workspaces/workspace-123/fs/bulk');
91
+ assert.equal(options.method, 'POST');
92
+ assert.deepEqual(options.headers, {
93
+ Authorization: 'Bearer admin-token',
94
+ 'Content-Type': 'application/json',
95
+ 'X-Correlation-Id': options.headers && (options.headers as Record<string, string>)['X-Correlation-Id'],
96
+ });
97
+ assert.match(
98
+ (options.headers as Record<string, string>)['X-Correlation-Id'],
99
+ /^seed-workspace-workspace-123-\d+-0$/u
100
+ );
101
+
102
+ const body = parseFetchBody(fetchMock);
103
+ assert.deepEqual(body.files, [
104
+ {
105
+ path: '/alpha.txt',
106
+ content: 'alpha payload',
107
+ encoding: 'utf-8',
108
+ },
109
+ {
110
+ path: '/binary.bin',
111
+ content: Buffer.from([0xff, 0x00, 0x61]).toString('base64'),
112
+ encoding: 'base64',
113
+ },
114
+ ]);
115
+ } finally {
116
+ await workspace.cleanup();
117
+ }
118
+ });
119
+
120
+ test('seedAclRules formats ACL files for root and nested directories', async () => {
121
+ mock.method(RelayFileClient.prototype, 'bulkWrite', async () => {
122
+ throw new Error('fall back to HTTP');
123
+ });
124
+
125
+ const fetchMock = mock.method(globalThis, 'fetch', async () =>
126
+ createFetchResponse(JSON.stringify({ written: 2, errorCount: 0, errors: [] }))
127
+ );
128
+
129
+ await seedAclRules('https://relay.example/', 'acl-token', 'workspace-acl', {
130
+ '/': ['allow:agent:lead:read'],
131
+ '/docs/': ['allow:agent:writer:write', 'deny:agent:reader'],
132
+ });
133
+
134
+ const body = parseFetchBody(fetchMock);
135
+ assert.deepEqual(body.files, [
136
+ {
137
+ path: '/.relayfile.acl',
138
+ content: JSON.stringify({
139
+ semantics: { permissions: ['allow:agent:lead:read'] },
140
+ }),
141
+ encoding: 'utf-8',
142
+ },
143
+ {
144
+ path: '/docs/.relayfile.acl',
145
+ content: JSON.stringify({
146
+ semantics: {
147
+ permissions: ['allow:agent:writer:write', 'deny:agent:reader'],
148
+ },
149
+ }),
150
+ encoding: 'utf-8',
151
+ },
152
+ ]);
153
+ });
154
+
155
+ test('seedWorkflowAcls merges multiple agents onto shared directories', async () => {
156
+ mock.method(RelayFileClient.prototype, 'bulkWrite', async () => {
157
+ throw new Error('fall back to HTTP');
158
+ });
159
+
160
+ const fetchMock = mock.method(globalThis, 'fetch', async () =>
161
+ createFetchResponse(JSON.stringify({ written: 2, errorCount: 0, errors: [] }))
162
+ );
163
+
164
+ await seedWorkflowAcls({
165
+ relayfileUrl: 'https://relay.example',
166
+ adminToken: 'workflow-token',
167
+ workspace: 'workflow-merge',
168
+ agents: [
169
+ { name: 'qa-reviewer', acl: { src: ['read'] } },
170
+ { name: 'builder', acl: { 'src\\': ['write'], '/docs/': ['read'] } },
171
+ { name: 'analyst', acl: { docs: ['read'] } },
172
+ ],
173
+ });
174
+
175
+ const body = parseFetchBody(fetchMock);
176
+ assert.deepEqual(body.files, [
177
+ {
178
+ path: '/docs/.relayfile.acl',
179
+ content: JSON.stringify({
180
+ semantics: {
181
+ permissions: [
182
+ 'allow:agent:analyst:read',
183
+ 'allow:agent:builder:read',
184
+ 'allow:agent:qa-reviewer:read',
185
+ ],
186
+ },
187
+ }),
188
+ encoding: 'utf-8',
189
+ },
190
+ {
191
+ path: '/src/.relayfile.acl',
192
+ content: JSON.stringify({
193
+ semantics: {
194
+ permissions: [
195
+ 'allow:agent:builder:read',
196
+ 'allow:agent:builder:write',
197
+ 'allow:agent:qa-reviewer:read',
198
+ 'deny:agent:analyst',
199
+ ],
200
+ },
201
+ }),
202
+ encoding: 'utf-8',
203
+ },
204
+ ]);
205
+ });
206
+
207
+ test('seedWorkflowAcls unions deny rules for agents missing directory access', async () => {
208
+ mock.method(RelayFileClient.prototype, 'bulkWrite', async () => {
209
+ throw new Error('fall back to HTTP');
210
+ });
211
+
212
+ const fetchMock = mock.method(globalThis, 'fetch', async () =>
213
+ createFetchResponse(JSON.stringify({ written: 2, errorCount: 0, errors: [] }))
214
+ );
215
+
216
+ await seedWorkflowAcls({
217
+ relayfileUrl: 'https://relay.example',
218
+ adminToken: 'workflow-token',
219
+ workspace: 'workflow-deny',
220
+ agents: [
221
+ { name: 'alpha', acl: { src: ['read'] } },
222
+ { name: 'beta', acl: { docs: ['write'] } },
223
+ ],
224
+ });
225
+
226
+ const body = parseFetchBody(fetchMock);
227
+ assert.deepEqual(body.files, [
228
+ {
229
+ path: '/docs/.relayfile.acl',
230
+ content: JSON.stringify({
231
+ semantics: {
232
+ permissions: ['allow:agent:beta:read', 'allow:agent:beta:write', 'deny:agent:alpha'],
233
+ },
234
+ }),
235
+ encoding: 'utf-8',
236
+ },
237
+ {
238
+ path: '/src/.relayfile.acl',
239
+ content: JSON.stringify({
240
+ semantics: {
241
+ permissions: ['allow:agent:alpha:read', 'deny:agent:beta'],
242
+ },
243
+ }),
244
+ encoding: 'utf-8',
245
+ },
246
+ ]);
247
+ });
248
+
249
+ test('seedWorkflowAcls is a no-op when there are no ACL directories to seed', async () => {
250
+ const bulkWriteMock = mock.method(RelayFileClient.prototype, 'bulkWrite', async () => {
251
+ throw new Error('bulkWrite should not be called');
252
+ });
253
+ const fetchMock = mock.method(globalThis, 'fetch', async () => {
254
+ throw new Error('fetch should not be called');
255
+ });
256
+
257
+ await seedWorkflowAcls({
258
+ relayfileUrl: 'https://relay.example',
259
+ adminToken: 'workflow-token',
260
+ workspace: 'workflow-empty',
261
+ agents: [
262
+ { name: 'builder', acl: {} },
263
+ { name: 'qa-reviewer', acl: {} },
264
+ ],
265
+ });
266
+
267
+ assert.equal(bulkWriteMock.mock.calls.length, 0);
268
+ assert.equal(fetchMock.mock.calls.length, 0);
269
+ });
270
+
271
+ test('seedAclRules surfaces HTTP failures from the fallback API', async () => {
272
+ mock.method(RelayFileClient.prototype, 'bulkWrite', async () => {
273
+ throw new Error('fall back to HTTP');
274
+ });
275
+
276
+ mock.method(globalThis, 'fetch', async () => createFetchResponse('relay unavailable', 503));
277
+
278
+ await assert.rejects(
279
+ seedAclRules('https://relay.example', 'acl-token', 'workspace-http', {
280
+ '/': ['allow:agent:builder:read'],
281
+ }),
282
+ new Error('failed to seed workspace workspace-http: HTTP 503 relay unavailable')
283
+ );
284
+ });
@@ -0,0 +1,249 @@
1
+ import fs from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import * as tar from 'tar';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ const bulkWriteMock = vi.hoisted(() => vi.fn());
8
+ const relayFileClientMock = vi.hoisted(() => vi.fn());
9
+ const execSyncMock = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock('@relayfile/sdk', () => ({
12
+ RelayFileClient: relayFileClientMock.mockImplementation(() => ({
13
+ bulkWrite: bulkWriteMock,
14
+ })),
15
+ }));
16
+
17
+ vi.mock('node:child_process', () => ({
18
+ execSync: execSyncMock,
19
+ }));
20
+
21
+ import { seedWorkspaceTar } from '../seeder.js';
22
+
23
+ const tempDirs: string[] = [];
24
+
25
+ function makeTempDir(prefix: string): string {
26
+ const dir = fs.mkdtempSync(path.join(tmpdir(), prefix));
27
+ tempDirs.push(dir);
28
+ return dir;
29
+ }
30
+
31
+ function jsonResponse(payload: unknown, status = 200): Response {
32
+ return new Response(JSON.stringify(payload), {
33
+ status,
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
37
+
38
+ function listRelativeFiles(rootDir: string, currentDir = rootDir): string[] {
39
+ const files: string[] = [];
40
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
41
+
42
+ for (const entry of entries) {
43
+ const absolutePath = path.join(currentDir, entry.name);
44
+ if (entry.isDirectory()) {
45
+ files.push(...listRelativeFiles(rootDir, absolutePath));
46
+ continue;
47
+ }
48
+ if (entry.isFile()) {
49
+ files.push(path.relative(rootDir, absolutePath).split(path.sep).join('/'));
50
+ }
51
+ }
52
+
53
+ return files.sort((left, right) => left.localeCompare(right));
54
+ }
55
+
56
+ async function extractTarballEntries(body: unknown): Promise<string[]> {
57
+ const archiveDir = makeTempDir('relay-tar-archive-');
58
+ const extractDir = makeTempDir('relay-tar-extract-');
59
+ const archivePath = path.join(archiveDir, 'seed.tar.gz');
60
+
61
+ fs.writeFileSync(archivePath, Buffer.from(body as Uint8Array));
62
+ await tar.extract({ file: archivePath, cwd: extractDir, gzip: true });
63
+
64
+ return listRelativeFiles(extractDir);
65
+ }
66
+
67
+ afterEach(() => {
68
+ bulkWriteMock.mockReset();
69
+ relayFileClientMock.mockClear();
70
+ execSyncMock.mockReset();
71
+ vi.restoreAllMocks();
72
+ vi.unstubAllGlobals();
73
+ for (const dir of tempDirs.splice(0)) {
74
+ fs.rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ describe('seedWorkspaceTar', () => {
79
+ it('creates and uploads a tar.gz to the import endpoint and respects excludeDirs', async () => {
80
+ const projectDir = makeTempDir('relay-seed-project-');
81
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
82
+ fs.mkdirSync(path.join(projectDir, 'ignored'), { recursive: true });
83
+ fs.mkdirSync(path.join(projectDir, 'node_modules', 'left-pad'), { recursive: true });
84
+
85
+ fs.writeFileSync(path.join(projectDir, 'src', 'hello.txt'), 'hello world\n');
86
+ fs.writeFileSync(path.join(projectDir, 'src', 'data.bin'), Buffer.from([0xff, 0x00, 0xaa]));
87
+ fs.writeFileSync(path.join(projectDir, 'ignored', 'skip.txt'), 'skip me\n');
88
+ fs.writeFileSync(path.join(projectDir, 'node_modules', 'left-pad', 'index.js'), 'module.exports = 1;\n');
89
+ fs.writeFileSync(path.join(projectDir, '.relayfile-mount-state.json'), '{}\n');
90
+
91
+ execSyncMock.mockReturnValue(
92
+ [
93
+ 'src/hello.txt',
94
+ 'src/data.bin',
95
+ 'ignored/skip.txt',
96
+ 'node_modules/left-pad/index.js',
97
+ '.relayfile-mount-state.json',
98
+ ].join('\0')
99
+ );
100
+
101
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ imported: 2 }));
102
+ vi.stubGlobal('fetch', fetchMock);
103
+
104
+ const imported = await seedWorkspaceTar('https://relayfile.example/', 'token', 'rw_demo', projectDir, [
105
+ 'ignored',
106
+ ]);
107
+
108
+ expect(imported).toBe(2);
109
+ expect(fetchMock).toHaveBeenCalledTimes(1);
110
+
111
+ const [url, init] = fetchMock.mock.calls[0];
112
+ expect(String(url)).toContain('/v1/workspaces/rw_demo/fs/import');
113
+ expect(init.method).toBe('POST');
114
+ expect(init.headers).toMatchObject({
115
+ Authorization: 'Bearer token',
116
+ 'Content-Type': 'application/gzip',
117
+ 'X-Correlation-Id': expect.stringMatching(/^seed-tar-rw_demo-/),
118
+ });
119
+ expect(init.body).toBeInstanceOf(Uint8Array);
120
+
121
+ const entries = await extractTarballEntries(init.body);
122
+ expect(entries).toEqual(expect.arrayContaining(['src/data.bin', 'src/hello.txt']));
123
+ expect(entries).not.toContain('ignored/skip.txt');
124
+ expect(entries).not.toContain('node_modules/left-pad/index.js');
125
+ expect(entries).not.toContain('.relayfile-mount-state.json');
126
+ });
127
+
128
+ it('falls back to seedWorkspace when the import endpoint returns 404', async () => {
129
+ const projectDir = makeTempDir('relay-seed-project-');
130
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
131
+ fs.writeFileSync(path.join(projectDir, 'src', 'hello.txt'), 'hello fallback\n');
132
+
133
+ execSyncMock.mockReturnValue('src/hello.txt\0');
134
+ bulkWriteMock.mockRejectedValue({ status: undefined });
135
+
136
+ const fetchMock = vi
137
+ .fn()
138
+ .mockResolvedValueOnce(new Response('missing', { status: 404 }))
139
+ .mockResolvedValueOnce(jsonResponse({ written: 1, errorCount: 0, errors: [] }));
140
+ vi.stubGlobal('fetch', fetchMock);
141
+
142
+ const imported = await seedWorkspaceTar('https://relayfile.example/', 'token', 'rw_demo', projectDir, []);
143
+
144
+ expect(imported).toBe(1);
145
+ expect(fetchMock).toHaveBeenCalledTimes(2);
146
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/v1/workspaces/rw_demo/fs/import');
147
+ expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/v1/workspaces/rw_demo/fs/bulk');
148
+
149
+ const payload = JSON.parse(String(fetchMock.mock.calls[1]?.[1].body));
150
+ expect(payload.files).toEqual([
151
+ { path: '/src/hello.txt', content: 'hello fallback\n', encoding: 'utf-8' },
152
+ ]);
153
+ });
154
+
155
+ it('throws on non-404 HTTP errors', async () => {
156
+ const projectDir = makeTempDir('relay-seed-project-');
157
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
158
+ fs.writeFileSync(path.join(projectDir, 'src', 'hello.txt'), 'hello\n');
159
+
160
+ execSyncMock.mockReturnValue('src/hello.txt\0');
161
+
162
+ const fetchMock = vi.fn().mockResolvedValue(new Response('boom', { status: 500 }));
163
+ vi.stubGlobal('fetch', fetchMock);
164
+
165
+ await expect(
166
+ seedWorkspaceTar('https://relayfile.example/', 'token', 'rw_demo', projectDir, [])
167
+ ).rejects.toThrow('tar import failed for workspace rw_demo: HTTP 500 boom');
168
+ });
169
+
170
+ it('works for non-git directories via the directory-walk fallback path', async () => {
171
+ const projectDir = makeTempDir('relay-seed-project-');
172
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
173
+ fs.mkdirSync(path.join(projectDir, 'nested', 'docs'), { recursive: true });
174
+ fs.mkdirSync(path.join(projectDir, 'custom-ignore'), { recursive: true });
175
+ fs.mkdirSync(path.join(projectDir, 'node_modules', 'left-pad'), { recursive: true });
176
+
177
+ fs.writeFileSync(path.join(projectDir, 'src', 'app.ts'), 'export const app = true;\n');
178
+ fs.writeFileSync(path.join(projectDir, 'nested', 'docs', 'readme.md'), '# hello\n');
179
+ fs.writeFileSync(path.join(projectDir, 'custom-ignore', 'skip.txt'), 'skip\n');
180
+ fs.writeFileSync(path.join(projectDir, 'node_modules', 'left-pad', 'index.js'), 'module.exports = 1;\n');
181
+ fs.writeFileSync(path.join(projectDir, '.relayfile-mount-state.json'), '{}\n');
182
+
183
+ execSyncMock.mockImplementation(() => {
184
+ throw new Error('not a git repo');
185
+ });
186
+
187
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ imported: 2 }));
188
+ vi.stubGlobal('fetch', fetchMock);
189
+
190
+ const imported = await seedWorkspaceTar('https://relayfile.example/', 'token', 'rw_demo', projectDir, [
191
+ 'custom-ignore',
192
+ ]);
193
+
194
+ expect(imported).toBe(2);
195
+ expect(fetchMock).toHaveBeenCalledTimes(1);
196
+
197
+ const [, init] = fetchMock.mock.calls[0];
198
+ const entries = await extractTarballEntries(init.body);
199
+ expect(entries).toEqual(expect.arrayContaining(['nested/docs/readme.md', 'src/app.ts']));
200
+ expect(entries).not.toContain('custom-ignore/skip.txt');
201
+ expect(entries).not.toContain('node_modules/left-pad/index.js');
202
+ expect(entries).not.toContain('.relayfile-mount-state.json');
203
+ });
204
+
205
+ it('includes untracked files returned by git ls-files and preserves gitignore filtering', async () => {
206
+ const projectDir = makeTempDir('relay-seed-project-');
207
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
208
+ fs.mkdirSync(path.join(projectDir, 'ignored-by-git'), { recursive: true });
209
+
210
+ fs.writeFileSync(path.join(projectDir, 'src', 'tracked.ts'), 'export const tracked = true;\n');
211
+ fs.writeFileSync(path.join(projectDir, 'src', 'draft.ts'), 'export const draft = true;\n');
212
+ fs.writeFileSync(path.join(projectDir, 'ignored-by-git', 'skip.txt'), 'skip\n');
213
+
214
+ execSyncMock.mockReturnValue(['src/tracked.ts', 'src/draft.ts'].join('\0'));
215
+
216
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ imported: 2 }));
217
+ vi.stubGlobal('fetch', fetchMock);
218
+
219
+ const imported = await seedWorkspaceTar('https://relayfile.example/', 'token', 'rw_demo', projectDir, []);
220
+
221
+ expect(imported).toBe(2);
222
+ expect(execSyncMock).toHaveBeenCalledWith(
223
+ 'git ls-files -z --cached --others --exclude-standard',
224
+ expect.objectContaining({ cwd: path.resolve(projectDir), encoding: 'utf-8' })
225
+ );
226
+
227
+ const [, init] = fetchMock.mock.calls[0];
228
+ const entries = await extractTarballEntries(init.body);
229
+ expect(entries).toEqual(['src/draft.ts', 'src/tracked.ts']);
230
+ expect(entries).not.toContain('ignored-by-git/skip.txt');
231
+ });
232
+
233
+ it('does not fall back to a directory walk when git ls-files succeeds with no files', async () => {
234
+ const projectDir = makeTempDir('relay-seed-project-');
235
+ fs.mkdirSync(path.join(projectDir, 'ignored-by-git'), { recursive: true });
236
+ fs.writeFileSync(path.join(projectDir, 'ignored-by-git', 'skip.txt'), 'skip\n');
237
+
238
+ execSyncMock.mockReturnValue('');
239
+
240
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ imported: 0 }));
241
+ vi.stubGlobal('fetch', fetchMock);
242
+
243
+ const imported = await seedWorkspaceTar('https://relayfile.example/', 'token', 'rw_demo', projectDir, []);
244
+
245
+ expect(imported).toBe(0);
246
+
247
+ expect(fetchMock).not.toHaveBeenCalled();
248
+ });
249
+ });