@agentrhq/webcmd 0.2.5 → 0.3.1

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 (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -0,0 +1,738 @@
1
+ import { describe, expect, it } from 'vitest';
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
+ ];
70
+ describe('HostedClient', () => {
71
+ it('sends bearer auth and parses hosted manifest', async () => {
72
+ const requests = [];
73
+ const client = new HostedClient({
74
+ apiBaseUrl: 'https://api.example.com/',
75
+ apiKey: 'wcmd_live_test',
76
+ fetchImpl: async (url, init) => {
77
+ requests.push({
78
+ url: String(url),
79
+ authorization: new Headers(init?.headers).get('authorization'),
80
+ });
81
+ return new Response(JSON.stringify({
82
+ ok: true,
83
+ manifest: {
84
+ userId: 'user_demo',
85
+ metadata: {
86
+ contractSchemaVersion: 1,
87
+ webcmdPackageVersion: '0.3.0',
88
+ generatedAt: 'now',
89
+ },
90
+ commands: [],
91
+ },
92
+ }), { status: 200 });
93
+ },
94
+ });
95
+ await expect(client.getManifest()).resolves.toEqual({
96
+ userId: 'user_demo',
97
+ metadata: {
98
+ contractSchemaVersion: 1,
99
+ webcmdPackageVersion: '0.3.0',
100
+ generatedAt: 'now',
101
+ },
102
+ commands: [],
103
+ });
104
+ expect(requests).toEqual([{ url: 'https://api.example.com/v1/manifest', authorization: 'Bearer wcmd_live_test' }]);
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
+ });
132
+ it('maps hosted error envelopes to CliError-compatible errors', async () => {
133
+ const client = new HostedClient({
134
+ apiBaseUrl: 'https://api.example.com',
135
+ apiKey: 'bad',
136
+ fetchImpl: async () => new Response(JSON.stringify({
137
+ ok: false,
138
+ error: {
139
+ code: 'UNAUTHORIZED',
140
+ message: 'Invalid key',
141
+ help: 'Run setup',
142
+ exitCode: 77,
143
+ },
144
+ }), { status: 401 }),
145
+ });
146
+ await expect(client.getManifest()).rejects.toMatchObject({
147
+ code: 'UNAUTHORIZED',
148
+ message: 'Invalid key',
149
+ hint: 'Run setup',
150
+ exitCode: 77,
151
+ });
152
+ });
153
+ it('prepares, uploads, runs, and downloads execution artifacts with raw byte bodies', async () => {
154
+ const requests = [];
155
+ const bytes = new Uint8Array(Buffer.from('hello cloud'));
156
+ const client = new HostedClient({
157
+ apiBaseUrl: 'https://api.example.com',
158
+ apiKey: 'key',
159
+ fetchImpl: async (url, init) => {
160
+ const requestUrl = String(url);
161
+ requests.push({
162
+ url: requestUrl,
163
+ method: init?.method ?? 'GET',
164
+ body: init?.body,
165
+ filename: new Headers(init?.headers).get('x-webcmd-filename'),
166
+ });
167
+ if (requestUrl.endsWith('/v1/executions')) {
168
+ return new Response(JSON.stringify({
169
+ ok: true,
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',
192
+ },
193
+ reference: { $webcmdArtifact: { id: 'artifact_in', direction: 'input' } },
194
+ }), { status: 201 });
195
+ }
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')) {
699
+ return new Response(JSON.stringify({
700
+ ok: true,
701
+ result: { url: 'https://example.com' },
702
+ columns: ['url'],
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' },
710
+ }), { status: 200 });
711
+ }
712
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
713
+ },
714
+ });
715
+ await expect(client.runBrowserAction('work', {
716
+ command: 'browser/open',
717
+ action: 'navigate',
718
+ args: { url: 'https://example.com' },
719
+ profile: 'default',
720
+ windowMode: 'background',
721
+ })).resolves.toMatchObject({
722
+ result: { url: 'https://example.com' },
723
+ execution: { id: 'exec_1', status: 'succeeded' },
724
+ });
725
+ expect(requests).toEqual([
726
+ {
727
+ url: 'https://api.example.com/v1/browser/work/commands',
728
+ body: {
729
+ command: 'browser/open',
730
+ action: 'navigate',
731
+ args: { url: 'https://example.com' },
732
+ profile: 'default',
733
+ windowMode: 'background',
734
+ },
735
+ },
736
+ ]);
737
+ });
738
+ });