@agentrhq/webcmd 0.3.0 → 0.3.2

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 (183) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +228 -24
  3. package/clis/chatgpt/ask.js +1 -1
  4. package/clis/chatgpt/ask.test.js +11 -0
  5. package/clis/chatgpt/commands.test.js +102 -1
  6. package/clis/chatgpt/deep-research-result.js +45 -3
  7. package/clis/chatgpt/image.js +22 -2
  8. package/clis/chatgpt/project-file-add.js +24 -1
  9. package/clis/chatgpt/utils.js +258 -52
  10. package/clis/chatgpt/utils.test.js +259 -1
  11. package/clis/claude/ask.js +21 -1
  12. package/clis/facebook/feed.js +110 -24
  13. package/clis/facebook/feed.test.js +62 -0
  14. package/clis/facebook/search.js +185 -38
  15. package/clis/facebook/search.test.js +95 -50
  16. package/clis/instagram/explore.js +30 -12
  17. package/clis/instagram/explore.test.js +77 -0
  18. package/clis/instagram/post.js +14 -1
  19. package/clis/instagram/reel.js +13 -1
  20. package/clis/linkedin/company.js +153 -0
  21. package/clis/linkedin/company.test.js +111 -0
  22. package/clis/linkedin/connections.js +135 -0
  23. package/clis/linkedin/connections.test.js +141 -0
  24. package/clis/mercury/reimbursement-draft.js +12 -1
  25. package/clis/producthunt/hot.js +1 -2
  26. package/clis/spotify/spotify.js +11 -11
  27. package/clis/twitter/article-evaluate.test.js +27 -0
  28. package/clis/twitter/article.js +73 -16
  29. package/clis/twitter/article.test.js +209 -0
  30. package/clis/twitter/download.js +6 -1
  31. package/clis/twitter/post.js +14 -1
  32. package/clis/twitter/profile.js +1 -1
  33. package/clis/twitter/profile.test.js +8 -0
  34. package/clis/twitter/quote.js +11 -1
  35. package/clis/twitter/reply.js +11 -1
  36. package/dist/src/browser/base-page.d.ts +26 -0
  37. package/dist/src/browser/base-page.js +23 -8
  38. package/dist/src/browser/base-page.test.js +45 -12
  39. package/dist/src/browser/command-catalog.d.ts +6 -0
  40. package/dist/src/browser/command-catalog.js +259 -0
  41. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  42. package/dist/src/browser/command-catalog.test.js +79 -0
  43. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  44. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  45. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  47. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
  48. package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
  49. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  50. package/dist/src/browser/target-resolver.d.ts +1 -0
  51. package/dist/src/browser/target-resolver.js +99 -4
  52. package/dist/src/browser/target-resolver.test.js +141 -1
  53. package/dist/src/build-manifest.d.ts +6 -0
  54. package/dist/src/build-manifest.js +18 -1
  55. package/dist/src/build-manifest.test.js +57 -1
  56. package/dist/src/builtin-command-surface.d.ts +9 -0
  57. package/dist/src/builtin-command-surface.js +16 -0
  58. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  59. package/dist/src/check-hosted-contract.test.js +56 -0
  60. package/dist/src/cli.js +58 -106
  61. package/dist/src/cli.test.js +71 -1
  62. package/dist/src/command-presentation.d.ts +95 -0
  63. package/dist/src/command-presentation.js +486 -0
  64. package/dist/src/command-presentation.test.d.ts +1 -0
  65. package/dist/src/command-presentation.test.js +97 -0
  66. package/dist/src/command-surface.d.ts +43 -0
  67. package/dist/src/command-surface.js +205 -0
  68. package/dist/src/command-surface.test.d.ts +1 -0
  69. package/dist/src/command-surface.test.js +406 -0
  70. package/dist/src/commanderAdapter.d.ts +5 -1
  71. package/dist/src/commanderAdapter.js +12 -55
  72. package/dist/src/commanderAdapter.test.js +15 -3
  73. package/dist/src/community-plugin-sync.d.ts +11 -0
  74. package/dist/src/community-plugin-sync.js +138 -0
  75. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  76. package/dist/src/community-plugin-sync.test.js +123 -0
  77. package/dist/src/completion-fast.d.ts +4 -4
  78. package/dist/src/completion-fast.js +15 -34
  79. package/dist/src/completion-shared.d.ts +4 -0
  80. package/dist/src/completion-shared.js +38 -0
  81. package/dist/src/completion.js +3 -27
  82. package/dist/src/completion.test.js +31 -2
  83. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  84. package/dist/src/docs-sync-review-cli.test.js +322 -0
  85. package/dist/src/docs-sync-review.d.ts +117 -0
  86. package/dist/src/docs-sync-review.js +475 -0
  87. package/dist/src/docs-sync-review.test.d.ts +1 -0
  88. package/dist/src/docs-sync-review.test.js +324 -0
  89. package/dist/src/errors.d.ts +12 -3
  90. package/dist/src/errors.js +11 -7
  91. package/dist/src/errors.test.js +14 -1
  92. package/dist/src/execution.d.ts +1 -2
  93. package/dist/src/execution.js +4 -45
  94. package/dist/src/generate-release-notes-cli.test.js +7 -2
  95. package/dist/src/help.d.ts +4 -0
  96. package/dist/src/help.js +50 -255
  97. package/dist/src/help.test.js +27 -1
  98. package/dist/src/hosted/args.d.ts +2 -7
  99. package/dist/src/hosted/args.js +2 -99
  100. package/dist/src/hosted/args.test.js +15 -1
  101. package/dist/src/hosted/availability.d.ts +13 -0
  102. package/dist/src/hosted/availability.js +16 -0
  103. package/dist/src/hosted/availability.test.d.ts +1 -0
  104. package/dist/src/hosted/availability.test.js +191 -0
  105. package/dist/src/hosted/browser-args.d.ts +18 -0
  106. package/dist/src/hosted/browser-args.js +152 -0
  107. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  108. package/dist/src/hosted/browser-args.test.js +182 -0
  109. package/dist/src/hosted/client.d.ts +31 -2
  110. package/dist/src/hosted/client.js +441 -53
  111. package/dist/src/hosted/client.test.js +651 -32
  112. package/dist/src/hosted/config.d.ts +7 -2
  113. package/dist/src/hosted/config.js +26 -4
  114. package/dist/src/hosted/config.test.js +34 -1
  115. package/dist/src/hosted/contract.d.ts +94 -0
  116. package/dist/src/hosted/contract.js +208 -0
  117. package/dist/src/hosted/contract.test.d.ts +1 -0
  118. package/dist/src/hosted/contract.test.js +361 -0
  119. package/dist/src/hosted/credentials.d.ts +38 -0
  120. package/dist/src/hosted/credentials.js +248 -0
  121. package/dist/src/hosted/credentials.test.d.ts +1 -0
  122. package/dist/src/hosted/credentials.test.js +93 -0
  123. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  124. package/dist/src/hosted/file-contract.test.js +174 -0
  125. package/dist/src/hosted/files.d.ts +29 -0
  126. package/dist/src/hosted/files.js +296 -0
  127. package/dist/src/hosted/files.test.d.ts +1 -0
  128. package/dist/src/hosted/files.test.js +231 -0
  129. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  130. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  131. package/dist/src/hosted/manifest.d.ts +5 -0
  132. package/dist/src/hosted/manifest.js +21 -66
  133. package/dist/src/hosted/manifest.test.js +120 -2
  134. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  135. package/dist/src/hosted/output-parity.test.js +108 -0
  136. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  137. package/dist/src/hosted/root-command-surface.test.js +646 -0
  138. package/dist/src/hosted/runner.d.ts +7 -0
  139. package/dist/src/hosted/runner.js +554 -253
  140. package/dist/src/hosted/runner.test.js +1471 -53
  141. package/dist/src/hosted/setup.d.ts +3 -2
  142. package/dist/src/hosted/setup.js +45 -15
  143. package/dist/src/hosted/setup.test.js +133 -6
  144. package/dist/src/hosted/types.d.ts +101 -23
  145. package/dist/src/main.js +120 -108
  146. package/dist/src/manifest-types.d.ts +2 -0
  147. package/dist/src/output.d.ts +17 -2
  148. package/dist/src/output.js +88 -81
  149. package/dist/src/output.test.js +141 -51
  150. package/dist/src/pipeline/executor.test.js +1 -0
  151. package/dist/src/pipeline/steps/download.test.js +1 -0
  152. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  153. package/dist/src/plugin-create-cli.test.js +37 -0
  154. package/dist/src/plugin-manifest.d.ts +12 -0
  155. package/dist/src/plugin-manifest.js +16 -0
  156. package/dist/src/plugin-manifest.test.js +19 -1
  157. package/dist/src/plugin-scaffold.d.ts +4 -1
  158. package/dist/src/plugin-scaffold.js +4 -1
  159. package/dist/src/plugin-scaffold.test.js +23 -8
  160. package/dist/src/plugin.js +4 -1
  161. package/dist/src/plugin.test.js +13 -0
  162. package/dist/src/registry.d.ts +2 -0
  163. package/dist/src/release-notes.js +12 -4
  164. package/dist/src/release-notes.test.js +27 -15
  165. package/dist/src/root-command-surface.d.ts +31 -0
  166. package/dist/src/root-command-surface.js +106 -0
  167. package/dist/src/serialization.d.ts +1 -16
  168. package/dist/src/serialization.js +5 -55
  169. package/dist/src/stream-write.d.ts +12 -0
  170. package/dist/src/stream-write.js +91 -0
  171. package/dist/src/stream-write.test.d.ts +1 -0
  172. package/dist/src/stream-write.test.js +186 -0
  173. package/dist/src/types.d.ts +5 -0
  174. package/dist/src/utils.d.ts +1 -1
  175. package/dist/src/utils.js +2 -8
  176. package/dist/src/utils.test.js +50 -0
  177. package/hosted-contract.json +36759 -0
  178. package/package.json +6 -1
  179. package/scripts/check-hosted-contract.mjs +108 -0
  180. package/scripts/docs-sync-review.ts +332 -0
  181. package/scripts/sync-community-plugins.ts +14 -0
  182. package/skills/webcmd-autofix/SKILL.md +26 -37
  183. package/skills/webcmd-usage/SKILL.md +7 -0
@@ -1,5 +1,72 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { HostedClient } from './client.js';
3
+ const invalidTraceUrlCases = [
4
+ {
5
+ name: 'raw absolute Kernel URL with token',
6
+ field: 'liveViewUrl',
7
+ value: 'https://kernel.example/session/secret?token=kernel-secret-token',
8
+ executionId: 'exec_trace',
9
+ },
10
+ {
11
+ name: 'protocol-relative provider URL',
12
+ field: 'replayUrl',
13
+ value: '//provider.example/replay/secret',
14
+ executionId: 'exec_trace',
15
+ },
16
+ {
17
+ name: 'public path with query token',
18
+ field: 'artifactsUrl',
19
+ value: '/v1/executions/exec_trace/artifacts?token=secret-query-token',
20
+ executionId: 'exec_trace',
21
+ },
22
+ {
23
+ name: 'public path with hash token',
24
+ field: 'replayUrl',
25
+ value: '/v1/executions/exec_trace/replay#secret-hash-token',
26
+ executionId: 'exec_trace',
27
+ },
28
+ {
29
+ name: 'mismatched execution path',
30
+ field: 'artifactsUrl',
31
+ value: '/v1/executions/exec_other/artifacts',
32
+ executionId: 'exec_trace',
33
+ },
34
+ {
35
+ name: 'wrong resource suffix',
36
+ field: 'artifactsUrl',
37
+ value: '/v1/executions/exec_trace/live',
38
+ executionId: 'exec_trace',
39
+ },
40
+ {
41
+ name: 'unencoded execution ID path',
42
+ field: 'artifactsUrl',
43
+ value: '/v1/executions/exec/trace/artifacts',
44
+ executionId: 'exec/trace',
45
+ },
46
+ {
47
+ name: 'traversal path',
48
+ field: 'artifactsUrl',
49
+ value: '/v1/executions/../exec_trace/artifacts',
50
+ executionId: 'exec_trace',
51
+ },
52
+ {
53
+ name: 'traversal execution ID route',
54
+ field: 'artifactsUrl',
55
+ value: '/v1/executions/../artifacts',
56
+ executionId: '..',
57
+ },
58
+ {
59
+ name: 'near-match trailing slash',
60
+ field: 'liveViewUrl',
61
+ value: '/v1/executions/exec_trace/live/',
62
+ executionId: 'exec_trace',
63
+ },
64
+ ];
65
+ const validTraceUrlCases = [
66
+ { field: 'artifactsUrl', suffix: 'artifacts', executionId: 'exec/trace' },
67
+ { field: 'liveViewUrl', suffix: 'live', executionId: 'exec_trace' },
68
+ { field: 'replayUrl', suffix: 'replay', executionId: 'exec_trace' },
69
+ ];
3
70
  describe('HostedClient', () => {
4
71
  it('sends bearer auth and parses hosted manifest', async () => {
5
72
  const requests = [];
@@ -13,17 +80,55 @@ describe('HostedClient', () => {
13
80
  });
14
81
  return new Response(JSON.stringify({
15
82
  ok: true,
16
- manifest: { userId: 'user_demo', generatedAt: 'now', commands: [] },
83
+ manifest: {
84
+ userId: 'user_demo',
85
+ metadata: {
86
+ contractSchemaVersion: 1,
87
+ webcmdPackageVersion: '0.3.0',
88
+ generatedAt: 'now',
89
+ },
90
+ commands: [],
91
+ },
17
92
  }), { status: 200 });
18
93
  },
19
94
  });
20
95
  await expect(client.getManifest()).resolves.toEqual({
21
96
  userId: 'user_demo',
22
- generatedAt: 'now',
97
+ metadata: {
98
+ contractSchemaVersion: 1,
99
+ webcmdPackageVersion: '0.3.0',
100
+ generatedAt: 'now',
101
+ },
23
102
  commands: [],
24
103
  });
25
104
  expect(requests).toEqual([{ url: 'https://api.example.com/v1/manifest', authorization: 'Bearer wcmd_live_test' }]);
26
105
  });
106
+ it('parses hosted profile rows without provider identifiers', async () => {
107
+ const client = new HostedClient({
108
+ apiBaseUrl: 'https://api.example.com',
109
+ apiKey: 'wcmd_live_test',
110
+ fetchImpl: async () => new Response(JSON.stringify({
111
+ ok: true,
112
+ profiles: [{
113
+ name: 'default',
114
+ default: true,
115
+ status: 'available',
116
+ createdAt: '2026-07-08T00:00:00.000Z',
117
+ lastUsedAt: '2026-07-08T00:00:00.000Z',
118
+ }],
119
+ }), { status: 200 }),
120
+ });
121
+ await expect(client.listProfiles()).resolves.toEqual({
122
+ ok: true,
123
+ profiles: [{
124
+ name: 'default',
125
+ default: true,
126
+ status: 'available',
127
+ createdAt: '2026-07-08T00:00:00.000Z',
128
+ lastUsedAt: '2026-07-08T00:00:00.000Z',
129
+ }],
130
+ });
131
+ });
27
132
  it('maps hosted error envelopes to CliError-compatible errors', async () => {
28
133
  const client = new HostedClient({
29
134
  apiBaseUrl: 'https://api.example.com',
@@ -45,38 +150,566 @@ describe('HostedClient', () => {
45
150
  exitCode: 77,
46
151
  });
47
152
  });
48
- it('runs hosted browser lifecycle calls and finishes the execution', async () => {
153
+ it('prepares, uploads, runs, and downloads execution artifacts with raw byte bodies', async () => {
49
154
  const requests = [];
155
+ const bytes = new Uint8Array(Buffer.from('hello cloud'));
50
156
  const client = new HostedClient({
51
157
  apiBaseUrl: 'https://api.example.com',
52
- apiKey: 'wcmd_live_test',
158
+ apiKey: 'key',
53
159
  fetchImpl: async (url, init) => {
160
+ const requestUrl = String(url);
54
161
  requests.push({
55
- url: String(url),
56
- body: init?.body ? JSON.parse(String(init.body)) : undefined,
162
+ url: requestUrl,
163
+ method: init?.method ?? 'GET',
164
+ body: init?.body,
165
+ filename: new Headers(init?.headers).get('x-webcmd-filename'),
57
166
  });
58
- if (String(url).endsWith('/runs')) {
167
+ if (requestUrl.endsWith('/v1/executions')) {
59
168
  return new Response(JSON.stringify({
60
169
  ok: true,
61
- run: {
62
- executionId: 'exec_1',
63
- session: 'work',
64
- profile: { id: 'profile_default', displayName: 'default' },
170
+ execution: { id: 'exec_files', command: 'twitter/post', status: 'queued' },
171
+ fileArguments: [{
172
+ name: 'images',
173
+ direction: 'input',
174
+ pathKind: 'file',
175
+ multiple: true,
176
+ required: false,
177
+ }],
178
+ }), { status: 201 });
179
+ }
180
+ if (requestUrl.endsWith('/v1/executions/exec_files/artifacts/images') && init?.method === 'POST') {
181
+ return new Response(JSON.stringify({
182
+ ok: true,
183
+ artifact: {
184
+ artifactId: 'artifact_in',
185
+ argument: 'images',
186
+ direction: 'input',
187
+ pathKind: 'file',
188
+ filename: 'one.png',
189
+ contentType: 'image/png',
190
+ byteSize: 3,
191
+ expiresAt: '2026-07-15T00:00:00.000Z',
65
192
  },
193
+ reference: { $webcmdArtifact: { id: 'artifact_in', direction: 'input' } },
66
194
  }), { status: 201 });
67
195
  }
68
- if (String(url).endsWith('/actions')) {
196
+ if (requestUrl.endsWith('/v1/executions/exec_files/run')) {
197
+ return new Response(JSON.stringify({
198
+ ok: true,
199
+ result: null,
200
+ execution: { id: 'exec_files', command: 'twitter/post', status: 'succeeded' },
201
+ artifacts: [{
202
+ artifactId: 'artifact_out',
203
+ argument: 'output',
204
+ direction: 'output',
205
+ pathKind: 'file',
206
+ filename: 'result.txt',
207
+ contentType: 'text/plain',
208
+ byteSize: bytes.byteLength,
209
+ expiresAt: '2026-07-15T00:00:00.000Z',
210
+ }],
211
+ }), { status: 200 });
212
+ }
213
+ if (requestUrl.endsWith('/v1/executions/exec_files/artifacts/artifact_out')) {
214
+ return new Response(bytes, { status: 200 });
215
+ }
216
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNKNOWN', message: requestUrl, exitCode: 1 } }), { status: 500 });
217
+ },
218
+ });
219
+ await expect(client.prepareExecution({ command: 'twitter/post' })).resolves.toMatchObject({
220
+ execution: { id: 'exec_files', status: 'queued' },
221
+ fileArguments: [{ name: 'images' }],
222
+ });
223
+ await expect(client.uploadExecutionArtifact({
224
+ executionId: 'exec_files',
225
+ argument: 'images',
226
+ filename: 'one.png',
227
+ contentType: 'image/png',
228
+ body: new Uint8Array(Buffer.from('png')),
229
+ })).resolves.toMatchObject({ reference: { $webcmdArtifact: { id: 'artifact_in' } } });
230
+ await expect(client.runPreparedExecution({
231
+ executionId: 'exec_files',
232
+ command: 'twitter/post',
233
+ args: {},
234
+ })).resolves.toMatchObject({ artifacts: [{ artifactId: 'artifact_out' }] });
235
+ await expect(client.downloadExecutionArtifact({
236
+ executionId: 'exec_files',
237
+ artifactId: 'artifact_out',
238
+ })).resolves.toEqual(bytes);
239
+ expect(requests.map(request => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
240
+ 'POST /v1/executions',
241
+ 'POST /v1/executions/exec_files/artifacts/images',
242
+ 'POST /v1/executions/exec_files/run',
243
+ 'GET /v1/executions/exec_files/artifacts/artifact_out',
244
+ ]);
245
+ expect(requests[1]).toMatchObject({
246
+ filename: 'one.png',
247
+ body: new Uint8Array(Buffer.from('png')),
248
+ });
249
+ });
250
+ it('preserves execution and trace metadata from hosted failure envelopes', async () => {
251
+ const execution = { id: 'exec_failure', command: 'github/whoami', status: 'failed' };
252
+ const trace = {
253
+ receipt: 'trace_receipt',
254
+ executionId: 'exec_failure',
255
+ artifactsUrl: '/v1/executions/exec_failure/artifacts',
256
+ };
257
+ const client = new HostedClient({
258
+ apiBaseUrl: 'https://api.example.com',
259
+ apiKey: 'key',
260
+ fetchImpl: async () => new Response(JSON.stringify({
261
+ ok: false,
262
+ error: {
263
+ code: 'AUTH_REQUIRED',
264
+ message: 'Sign in first',
265
+ help: 'Run webcmd github login.',
266
+ exitCode: 77,
267
+ },
268
+ execution,
269
+ trace,
270
+ }), { status: 401 }),
271
+ });
272
+ await expect(client.execute({ command: 'github/whoami', args: {}, trace: 'retain-on-failure' })).rejects.toMatchObject({
273
+ code: 'AUTH_REQUIRED',
274
+ execution,
275
+ trace,
276
+ });
277
+ });
278
+ it.each(['success', 'failure'].flatMap(phase => invalidTraceUrlCases.map(testCase => ({
279
+ phase,
280
+ ...testCase,
281
+ }))))('rejects $phase trace $name without copying the raw URL into the error', async ({ phase, field, value, executionId, }) => {
282
+ const success = phase === 'success';
283
+ const body = success
284
+ ? {
285
+ ok: true,
286
+ result: [],
287
+ execution: { id: executionId, command: 'github/whoami', status: 'succeeded' },
288
+ trace: { receipt: 'trace_receipt', executionId, [field]: value },
289
+ }
290
+ : {
291
+ ok: false,
292
+ error: { code: 'AUTH_REQUIRED', message: 'Sign in first', exitCode: 77 },
293
+ execution: { id: executionId, command: 'github/whoami', status: 'failed' },
294
+ trace: { receipt: 'trace_receipt', executionId, [field]: value },
295
+ };
296
+ const client = new HostedClient({
297
+ apiBaseUrl: 'https://api.example.com',
298
+ apiKey: 'key',
299
+ fetchImpl: async () => new Response(JSON.stringify(body), { status: success ? 200 : 401 }),
300
+ });
301
+ const error = await client.execute({
302
+ command: 'github/whoami',
303
+ args: {},
304
+ trace: success ? 'on' : 'retain-on-failure',
305
+ }).then(() => undefined, caught => caught);
306
+ expect(error).toMatchObject({ code: 'HOSTED_PROTOCOL', exitCode: 1 });
307
+ expect(error?.execution).toBeUndefined();
308
+ expect(error?.trace).toBeUndefined();
309
+ expect(`${error?.message ?? ''}\n${error?.hint ?? ''}`).not.toContain(value);
310
+ });
311
+ it.each(['success', 'failure'].flatMap(phase => validTraceUrlCases.map(testCase => ({
312
+ phase,
313
+ ...testCase,
314
+ }))))('accepts the exact execution-bound $field public path on $phase', async ({ phase, field, suffix, executionId }) => {
315
+ const success = phase === 'success';
316
+ const value = `/v1/executions/${encodeURIComponent(executionId)}/${suffix}`;
317
+ const trace = { receipt: 'trace_receipt', executionId, [field]: value };
318
+ const body = success
319
+ ? {
320
+ ok: true,
321
+ result: [],
322
+ execution: { id: executionId, command: 'github/whoami', status: 'succeeded' },
323
+ trace,
324
+ }
325
+ : {
326
+ ok: false,
327
+ error: { code: 'AUTH_REQUIRED', message: 'Sign in first', exitCode: 77 },
328
+ execution: { id: executionId, command: 'github/whoami', status: 'failed' },
329
+ trace,
330
+ };
331
+ const client = new HostedClient({
332
+ apiBaseUrl: 'https://api.example.com',
333
+ apiKey: 'key',
334
+ fetchImpl: async () => new Response(JSON.stringify(body), { status: success ? 200 : 401 }),
335
+ });
336
+ const request = client.execute({
337
+ command: 'github/whoami',
338
+ args: {},
339
+ trace: success ? 'on' : 'retain-on-failure',
340
+ });
341
+ if (success) {
342
+ await expect(request).resolves.toMatchObject({ trace: { [field]: value } });
343
+ }
344
+ else {
345
+ await expect(request).rejects.toMatchObject({ code: 'AUTH_REQUIRED', trace: { [field]: value } });
346
+ }
347
+ });
348
+ it.each([
349
+ {
350
+ name: 'success without execution metadata',
351
+ status: 200,
352
+ body: { ok: true, result: [] },
353
+ },
354
+ {
355
+ name: 'failure without a typed message',
356
+ status: 500,
357
+ body: { ok: false, error: { code: 'UNKNOWN', exitCode: 1 } },
358
+ },
359
+ {
360
+ name: 'failure with malformed trace metadata',
361
+ status: 500,
362
+ body: {
363
+ ok: false,
364
+ error: { code: 'UNKNOWN', message: 'failed', exitCode: 1 },
365
+ trace: { receipt: 42, executionId: 'exec_bad' },
366
+ },
367
+ },
368
+ {
369
+ name: 'success with a failed execution status',
370
+ status: 200,
371
+ body: {
372
+ ok: true,
373
+ result: [],
374
+ execution: { id: 'exec_bad', command: 'github/whoami', status: 'failed' },
375
+ },
376
+ },
377
+ {
378
+ name: 'failure with a succeeded execution status',
379
+ status: 500,
380
+ body: {
381
+ ok: false,
382
+ error: { code: 'UNKNOWN', message: 'failed', exitCode: 1 },
383
+ execution: { id: 'exec_bad', command: 'github/whoami', status: 'succeeded' },
384
+ },
385
+ },
386
+ {
387
+ name: 'trace for a different execution',
388
+ status: 200,
389
+ body: {
390
+ ok: true,
391
+ result: [],
392
+ execution: { id: 'exec_good', command: 'github/whoami', status: 'succeeded' },
393
+ trace: { receipt: 'trace_bad', executionId: 'exec_other' },
394
+ },
395
+ },
396
+ {
397
+ name: 'trace receipt with terminal control characters',
398
+ status: 200,
399
+ body: {
400
+ ok: true,
401
+ result: [],
402
+ execution: { id: 'exec_good', command: 'github/whoami', status: 'succeeded' },
403
+ trace: { receipt: 'trace_good\ninjected-output', executionId: 'exec_good' },
404
+ },
405
+ },
406
+ {
407
+ name: 'success for a different requested command',
408
+ status: 200,
409
+ body: {
410
+ ok: true,
411
+ result: [],
412
+ execution: { id: 'exec_good', command: 'github/other', status: 'succeeded' },
413
+ },
414
+ },
415
+ {
416
+ name: 'execution-bearing failure without exitCode',
417
+ status: 500,
418
+ body: {
419
+ ok: false,
420
+ error: { code: 'UNKNOWN', message: 'failed' },
421
+ execution: { id: 'exec_bad', command: 'github/whoami', status: 'failed' },
422
+ },
423
+ },
424
+ {
425
+ name: 'execution-bearing failure for a different requested command',
426
+ status: 500,
427
+ body: {
428
+ ok: false,
429
+ error: { code: 'UNKNOWN', message: 'failed', exitCode: 1 },
430
+ execution: { id: 'exec_bad', command: 'github/other', status: 'failed' },
431
+ },
432
+ },
433
+ {
434
+ name: 'legacy success fields outside the public envelope',
435
+ status: 200,
436
+ body: {
437
+ ok: true,
438
+ result: [],
439
+ data: ['/srv/private/token.json'],
440
+ execution: { id: 'exec_good', command: 'github/whoami', status: 'succeeded' },
441
+ },
442
+ },
443
+ {
444
+ name: 'non-string footer text',
445
+ status: 200,
446
+ body: {
447
+ ok: true,
448
+ result: [],
449
+ footerExtra: { internalPath: '/srv/private/token.json' },
450
+ execution: { id: 'exec_good', command: 'github/whoami', status: 'succeeded' },
451
+ },
452
+ },
453
+ {
454
+ name: 'private nested execution fields',
455
+ status: 200,
456
+ body: {
457
+ ok: true,
458
+ result: [],
459
+ execution: {
460
+ id: 'exec_good', command: 'github/whoami', status: 'succeeded', internalPath: '/srv/private/token.json',
461
+ },
462
+ },
463
+ },
464
+ ])('rejects malformed $name as HOSTED_PROTOCOL', async ({ status, body }) => {
465
+ const client = new HostedClient({
466
+ apiBaseUrl: 'https://api.example.com',
467
+ apiKey: 'key',
468
+ fetchImpl: async () => new Response(JSON.stringify(body), { status }),
469
+ });
470
+ await expect(client.execute({ command: 'github/whoami', args: {} })).rejects.toMatchObject({
471
+ code: 'HOSTED_PROTOCOL',
472
+ exitCode: 1,
473
+ });
474
+ });
475
+ it('maps a valid pre-execution 401 envelope without an exit code to permission denied', async () => {
476
+ const client = new HostedClient({
477
+ apiBaseUrl: 'https://api.example.com',
478
+ apiKey: 'bad',
479
+ fetchImpl: async () => new Response(JSON.stringify({
480
+ ok: false,
481
+ error: {
482
+ code: 'UNAUTHORIZED',
483
+ message: 'Invalid or revoked Webcmd API key.',
484
+ help: 'Run setup.',
485
+ },
486
+ }), { status: 401 }),
487
+ });
488
+ await expect(client.getManifest()).rejects.toMatchObject({
489
+ code: 'UNAUTHORIZED',
490
+ message: 'Invalid or revoked Webcmd API key.',
491
+ exitCode: 77,
492
+ });
493
+ });
494
+ it('rejects trace=on success without a trace receipt as HOSTED_PROTOCOL', async () => {
495
+ const client = new HostedClient({
496
+ apiBaseUrl: 'https://api.example.com',
497
+ apiKey: 'key',
498
+ fetchImpl: async () => new Response(JSON.stringify({
499
+ ok: true,
500
+ result: [],
501
+ execution: { id: 'exec_missing_trace', command: 'github/whoami', status: 'succeeded' },
502
+ }), { status: 200 }),
503
+ });
504
+ await expect(client.execute({ command: 'github/whoami', args: {}, trace: 'on' })).rejects.toMatchObject({
505
+ code: 'HOSTED_PROTOCOL',
506
+ });
507
+ });
508
+ it.each([
509
+ { mode: 'off', trace: { receipt: 'unexpected', executionId: 'exec_success' } },
510
+ { mode: 'retain-on-failure', trace: { receipt: 'unexpected', executionId: 'exec_success' } },
511
+ ])('rejects a success trace for trace=$mode', async ({ mode, trace }) => {
512
+ const client = new HostedClient({
513
+ apiBaseUrl: 'https://api.example.com',
514
+ apiKey: 'key',
515
+ fetchImpl: async () => new Response(JSON.stringify({
516
+ ok: true,
517
+ result: [],
518
+ execution: { id: 'exec_success', command: 'github/whoami', status: 'succeeded' },
519
+ trace,
520
+ }), { status: 200 }),
521
+ });
522
+ await expect(client.execute({ command: 'github/whoami', args: {}, trace: mode }))
523
+ .rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
524
+ });
525
+ it.each([
526
+ { mode: 'off', includeTrace: true },
527
+ { mode: 'on', includeTrace: false },
528
+ { mode: 'retain-on-failure', includeTrace: false },
529
+ ])('rejects invalid failure trace relationship for trace=$mode', async ({ mode, includeTrace }) => {
530
+ const client = new HostedClient({
531
+ apiBaseUrl: 'https://api.example.com',
532
+ apiKey: 'key',
533
+ fetchImpl: async () => new Response(JSON.stringify({
534
+ ok: false,
535
+ error: { code: 'UNKNOWN', message: 'failed', exitCode: 1 },
536
+ execution: { id: 'exec_failure', command: 'github/whoami', status: 'failed' },
537
+ ...(includeTrace ? { trace: { receipt: 'trace_failure', executionId: 'exec_failure' } } : {}),
538
+ }), { status: 500 }),
539
+ });
540
+ await expect(client.execute({ command: 'github/whoami', args: {}, trace: mode }))
541
+ .rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
542
+ });
543
+ it('accepts the typed public execution success envelope', async () => {
544
+ const client = new HostedClient({
545
+ apiBaseUrl: 'https://api.example.com',
546
+ apiKey: 'key',
547
+ fetchImpl: async () => new Response(JSON.stringify({
548
+ ok: true,
549
+ result: [{ username: 'octocat' }],
550
+ columns: ['username'],
551
+ execution: { id: 'exec_success', command: 'github/whoami', status: 'succeeded' },
552
+ trace: {
553
+ receipt: 'trace_receipt',
554
+ executionId: 'exec_success',
555
+ artifactsUrl: '/v1/executions/exec_success/artifacts',
556
+ },
557
+ }), { status: 200 }),
558
+ });
559
+ await expect(client.execute({ command: 'github/whoami', args: {}, trace: 'on' })).resolves.toMatchObject({
560
+ ok: true,
561
+ result: [{ username: 'octocat' }],
562
+ execution: { id: 'exec_success', status: 'succeeded' },
563
+ trace: { receipt: 'trace_receipt' },
564
+ });
565
+ });
566
+ it.each([
567
+ {
568
+ name: 'metadata with wrong field type',
569
+ manifest: {
570
+ userId: 'user_demo',
571
+ metadata: { contractSchemaVersion: '1', webcmdPackageVersion: '0.3.0', generatedAt: 'now' },
572
+ commands: [],
573
+ },
574
+ },
575
+ {
576
+ name: 'command without an args array',
577
+ manifest: {
578
+ userId: 'user_demo',
579
+ metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' },
580
+ commands: [{ site: 'github', name: 'whoami', command: 'github/whoami' }],
581
+ },
582
+ },
583
+ {
584
+ name: 'command with malformed argument metadata',
585
+ manifest: {
586
+ userId: 'user_demo',
587
+ metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' },
588
+ commands: [{
589
+ site: 'github', name: 'whoami', command: 'github/whoami', description: 'x', access: 'read',
590
+ strategy: 'PUBLIC', browser: false, args: [{ name: 42 }],
591
+ }],
592
+ },
593
+ },
594
+ {
595
+ name: 'command with a private field',
596
+ manifest: {
597
+ userId: 'user_demo',
598
+ metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' },
599
+ commands: [{
600
+ site: 'github', name: 'whoami', command: 'github/whoami', description: 'x', access: 'read',
601
+ strategy: 'PUBLIC', browser: false, args: [], columns: [], internalPath: '/srv/private/token.json',
602
+ }],
603
+ },
604
+ },
605
+ {
606
+ name: 'private wrapper field',
607
+ manifest: {
608
+ userId: 'user_demo',
609
+ metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' },
610
+ commands: [],
611
+ },
612
+ wrapperExtra: { internalPath: '/srv/private/token.json' },
613
+ },
614
+ ])('rejects malformed manifest $name', async ({ manifest: bodyManifest, wrapperExtra }) => {
615
+ const client = new HostedClient({
616
+ apiBaseUrl: 'https://api.example.com',
617
+ apiKey: 'key',
618
+ fetchImpl: async () => new Response(JSON.stringify({
619
+ ok: true,
620
+ manifest: bodyManifest,
621
+ ...(wrapperExtra ?? {}),
622
+ }), { status: 200 }),
623
+ });
624
+ await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
625
+ });
626
+ it.each([
627
+ {
628
+ method: 'startBrowserRun',
629
+ body: { ok: true, internalPath: '/srv/private/token.json' },
630
+ },
631
+ {
632
+ method: 'startBrowserRun',
633
+ body: { ok: true, run: { executionId: 'exec_1', session: 'work', profile: { displayName: 'default' } } },
634
+ },
635
+ {
636
+ method: 'browserAction',
637
+ body: { ok: true, columns: [], trace: null },
638
+ },
639
+ {
640
+ method: 'browserAction',
641
+ body: { ok: true, result: {}, columns: ['url'], trace: null, internalPath: '/srv/private/token.json' },
642
+ },
643
+ {
644
+ method: 'browserAction',
645
+ body: {
646
+ ok: true,
647
+ result: {},
648
+ columns: ['url'],
649
+ trace: {
650
+ id: 'trace_1', receipt: 'receipt_1', kind: 'network', internalPath: '/srv/private/token.json',
651
+ },
652
+ },
653
+ },
654
+ {
655
+ method: 'finishBrowserRun',
656
+ body: { ok: true, execution: { id: 'exec_other', status: 'succeeded' } },
657
+ },
658
+ {
659
+ method: 'runBrowserAction',
660
+ body: { ok: true, result: {}, columns: [], trace: null },
661
+ },
662
+ {
663
+ method: 'runBrowserAction',
664
+ body: {
665
+ ok: true,
666
+ result: {},
667
+ columns: [],
668
+ trace: null,
669
+ run: { executionId: 'exec_1', session: 'other', profile: { id: 'profile_default', displayName: 'default' } },
670
+ execution: { id: 'exec_1', status: 'succeeded' },
671
+ },
672
+ },
673
+ ])('rejects malformed browser success from $method', async ({ method, body }) => {
674
+ const client = new HostedClient({
675
+ apiBaseUrl: 'https://api.example.com',
676
+ apiKey: 'key',
677
+ fetchImpl: async () => new Response(JSON.stringify(body), { status: 200 }),
678
+ });
679
+ const request = method === 'startBrowserRun'
680
+ ? client.startBrowserRun('work', { command: 'browser/open', args: {} })
681
+ : method === 'browserAction'
682
+ ? client.browserAction('work', 'exec_1', { action: 'navigate', args: {} })
683
+ : method === 'finishBrowserRun'
684
+ ? client.finishBrowserRun('work', 'exec_1', { status: 'succeeded' })
685
+ : client.runBrowserAction('work', { command: 'browser/open', action: 'navigate', args: {} });
686
+ await expect(request).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
687
+ });
688
+ it('runs a hosted browser action through the atomic action endpoint', async () => {
689
+ const requests = [];
690
+ const client = new HostedClient({
691
+ apiBaseUrl: 'https://api.example.com',
692
+ apiKey: 'wcmd_live_test',
693
+ fetchImpl: async (url, init) => {
694
+ requests.push({
695
+ url: String(url),
696
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
697
+ });
698
+ if (String(url).endsWith('/commands')) {
69
699
  return new Response(JSON.stringify({
70
700
  ok: true,
71
701
  result: { url: 'https://example.com' },
72
702
  columns: ['url'],
73
703
  trace: null,
704
+ run: {
705
+ executionId: 'exec_1',
706
+ session: 'work',
707
+ profile: { id: 'profile_default', displayName: 'default' },
708
+ },
709
+ execution: { id: 'exec_1', status: 'succeeded' },
74
710
  }), { status: 200 });
75
711
  }
76
- return new Response(JSON.stringify({
77
- ok: true,
78
- execution: { id: 'exec_1', status: 'succeeded' },
79
- }), { status: 200 });
712
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
80
713
  },
81
714
  });
82
715
  await expect(client.runBrowserAction('work', {
@@ -91,27 +724,13 @@ describe('HostedClient', () => {
91
724
  });
92
725
  expect(requests).toEqual([
93
726
  {
94
- url: 'https://api.example.com/v1/browser/work/runs',
727
+ url: 'https://api.example.com/v1/browser/work/commands',
95
728
  body: {
96
729
  command: 'browser/open',
97
- args: { url: 'https://example.com' },
98
- profile: 'default',
99
- windowMode: 'background',
100
- },
101
- },
102
- {
103
- url: 'https://api.example.com/v1/browser/work/runs/exec_1/actions',
104
- body: {
105
730
  action: 'navigate',
106
731
  args: { url: 'https://example.com' },
107
732
  profile: 'default',
108
- },
109
- },
110
- {
111
- url: 'https://api.example.com/v1/browser/work/runs/exec_1/finish',
112
- body: {
113
- status: 'succeeded',
114
- profile: 'default',
733
+ windowMode: 'background',
115
734
  },
116
735
  },
117
736
  ]);