deepflow-openagent-cli 0.1.6__tar.gz → 0.1.8__tar.gz

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 (15) hide show
  1. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/PKG-INFO +118 -3
  2. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/README.md +117 -2
  3. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/pyproject.toml +1 -1
  4. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli/__init__.py +1 -1
  5. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli/cli.py +160 -0
  6. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli.egg-info/PKG-INFO +118 -3
  7. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/tests/test_cloudpc_cli.py +180 -0
  8. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/setup.cfg +0 -0
  9. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli/__main__.py +0 -0
  10. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli.egg-info/SOURCES.txt +0 -0
  11. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli.egg-info/dependency_links.txt +0 -0
  12. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli.egg-info/entry_points.txt +0 -0
  13. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli.egg-info/requires.txt +0 -0
  14. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/src/deepflow_openagent_cli.egg-info/top_level.txt +0 -0
  15. {deepflow_openagent_cli-0.1.6 → deepflow_openagent_cli-0.1.8}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepflow-openagent-cli
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Command-line client for OpenAgent OpenAPI (agents, conversations, SSE chat, tools, channels, knowledge bases).
5
5
  Author: DeepFlow AI
6
6
  Project-URL: Repository, https://github.com/DeepFlowAI/OpenAgent
@@ -158,6 +158,30 @@ selected version does not exist; use `--from base` for a fresh environment.
158
158
  Version ID is different from the `x.y.z` version label. First save automatically
159
159
  publishes; later saves require a separate `versions publish` command.
160
160
 
161
+ Binary uploads (including PDF) use the same sandbox upload writer as the workbench:
162
+
163
+ ```bash
164
+ deepflow_openagent_cli cloudpc files upload 123 --file ./report.pdf
165
+ # After retry exhaustion, reuse BOTH printed IDs and the same local file:
166
+ deepflow_openagent_cli cloudpc files upload 123 --file ./report.pdf \
167
+ --upload-id UPLOAD_ID --sandbox-id SANDBOX_ID
168
+ ```
169
+
170
+ Uploads accept 1 byte–50 MiB and create `/workspace/files/uploads` automatically.
171
+ The returned path is `/workspace/files/uploads/<upload_id>-<original_filename>`;
172
+ use that path in subsequent OCR or chat commands. The ID prefix prevents duplicate
173
+ files after a lost response. Reusing the same ID/name with different bytes is
174
+ rejected. The sandbox ID pins retries to the original development environment.
175
+ This admin/config API accepts binary content; channel visitor format restrictions
176
+ still apply only to visitor uploads. Uploading does not automatically start OCR.
177
+
178
+ Uploads and `dev exec` submission/status/stop retry connection failures and HTTP
179
+ 408/429/502/503/504 up to four times (1/2/4/8 second delays). Execution retries keep
180
+ the same execution ID and output cursor. Upload retries resend the whole binary
181
+ body; chunk resume is not implemented. Other mutating commands are not retried.
182
+ Requires updated CLI, API server and Cloud PC control plane; no VM image rebuild
183
+ is needed for the upload writer.
184
+
161
185
  Additional operations:
162
186
 
163
187
  ```bash
@@ -176,8 +200,9 @@ Config update replaces configuration: provide `model_name`, `thinking_enabled`
176
200
  and the complete `env_vars` list; omitted thinking/env fields reset to defaults.
177
201
  Config queries include environment variable values. File mutation and snapshot
178
202
  operations share the per-agent management lock; direct writes by the sandbox's
179
- Agent or terminal are outside that lock. Cloud PC HTTP requests have a 900-second
180
- timeout and are not automatically retried. After a timeout, inspect sandbox or
203
+ Agent or terminal are outside that lock. Upload and execution requests have a
204
+ 120-second per-attempt timeout and the retries described above. Other Cloud PC
205
+ HTTP requests have a 900-second timeout and are not automatically retried. After a timeout, inspect sandbox or
181
206
  version state before retrying a mutation. Conversation logs and chat-control
182
207
  routes still require an administrator session.
183
208
 
@@ -255,3 +280,93 @@ After installation or Agent edits, use `cloudpc versions save` and optionally
255
280
  `versions publish` to preserve and publish the environment. Deploy the updated
256
281
  OpenAgent backend, cloudpc VM service, and bridge bundle together; update the CLI
257
282
  package as well. No database migration is required.
283
+
284
+ ### Cloud PC channels
285
+
286
+ Cloud PC channel settings use `cloudpc channels`, separately from Agent settings
287
+ (`cloudpc config`) and Web SDK channels (`channels`). These commands require an
288
+ API key with `config` scope and Cloud PC enabled for the tenant. The backend must
289
+ also include support for API key authentication on `/api/v1/cloudpc/channels`;
290
+ older backends accept administrator sessions only.
291
+
292
+ ```bash
293
+ deepflow_openagent_cli cloudpc channels list --page 1 --per-page 20
294
+ deepflow_openagent_cli cloudpc channels create --name "Cloud workspace"
295
+ deepflow_openagent_cli cloudpc channels agent-options
296
+ deepflow_openagent_cli cloudpc channels get 7
297
+ deepflow_openagent_cli docs show cloudpc.channels.update --json
298
+ ```
299
+
300
+ Update uses the existing PUT replacement contract. Read the current channel
301
+ first and prepare `channel.json` with the complete name, description, ordered
302
+ `agent_ids` and `appearance`. Convert the returned `bindings` to their `id`
303
+ values; the GET response is not itself an update payload.
304
+
305
+ ```json
306
+ {
307
+ "name": "Cloud workspace",
308
+ "description": "Team assistant",
309
+ "agent_ids": [123, 456],
310
+ "appearance": {
311
+ "pageTitle": "Workspace",
312
+ "title": "Assistant",
313
+ "embedButtonBgColor": "#18181B",
314
+ "embedButtonIconColor": "#FFFFFF",
315
+ "userMessageStyle": {
316
+ "backgroundColor": "#1A1A1A",
317
+ "borderColor": "#CCCCCC",
318
+ "textColor": "#FFFFFF",
319
+ "borderRadius": {
320
+ "topLeft": 14,
321
+ "topRight": 14,
322
+ "bottomLeft": 14,
323
+ "bottomRight": 4
324
+ }
325
+ }
326
+ },
327
+ "require_auth": false,
328
+ "file_upload": {
329
+ "enabled": true,
330
+ "allowed_extensions": [".pdf", ".txt"]
331
+ }
332
+ }
333
+ ```
334
+
335
+ ```bash
336
+ deepflow_openagent_cli cloudpc channels update 7 --json-file ./channel.json
337
+ deepflow_openagent_cli cloudpc channels get 7
338
+ ```
339
+
340
+ `--json` also accepts an inline JSON object. `name` is required; omitted
341
+ `description`, `agent_ids` and `appearance` reset to null, an empty list and
342
+ empty appearance respectively. Omitted/null `require_auth` and `file_upload`
343
+ preserve the current values. Enabling uploads requires at least one extension.
344
+ Only Cloud PCs belonging to the current tenant can be bound (up to 20).
345
+
346
+ User message styles are configured under `appearance.userMessageStyle` in the
347
+ same update payload. They apply to both main and follow-up messages, including
348
+ history and SDK embeds, after opening or refreshing the channel. They do not
349
+ change Agent replies or the admin workbench.
350
+
351
+ | Field | Value | Reset behavior |
352
+ | --- | --- | --- |
353
+ | `backgroundColor` | `#RRGGBB` | Empty string, null or omission restores each bubble's default |
354
+ | `borderColor` | `#RRGGBB`, rendered as a 1px solid border | Empty string, null or omission removes the border |
355
+ | `textColor` | `#RRGGBB`, also applied to the follow-up label | Empty string, null or omission restores the default |
356
+ | `borderRadius` | Object containing all four integer corners, 0–100 px | Null or omission restores each bubble's default |
357
+
358
+ The server trims and uppercases colors. Radius keys are `topLeft`, `topRight`,
359
+ `bottomLeft`, and `bottomRight`; partial groups, negative/fractional/out-of-range
360
+ values, strings and booleans are rejected with HTTP 422 (CLI exits nonzero).
361
+ To clear only one override, set that field to null while retaining the other
362
+ settings. To reset all user message styles, set `"userMessageStyle": null` in
363
+ the complete `channel.json`, then run the same update command. The outer PUT
364
+ still replaces `appearance` and bindings: keep all unrelated settings from the
365
+ GET response in the payload. Reading the channel returns the saved style under
366
+ `appearance.userMessageStyle`.
367
+
368
+ `deepflow_openagent_cli cloudpc channels delete 7` deletes the channel and
369
+ destroys its instances. The server entry point
370
+ `python -m app.openapi_cli cloudpc channels ...` supports the same commands.
371
+ This feature requires updating the backend and CLI; no database migration or
372
+ Cloud PC sandbox image update is needed.
@@ -137,6 +137,30 @@ selected version does not exist; use `--from base` for a fresh environment.
137
137
  Version ID is different from the `x.y.z` version label. First save automatically
138
138
  publishes; later saves require a separate `versions publish` command.
139
139
 
140
+ Binary uploads (including PDF) use the same sandbox upload writer as the workbench:
141
+
142
+ ```bash
143
+ deepflow_openagent_cli cloudpc files upload 123 --file ./report.pdf
144
+ # After retry exhaustion, reuse BOTH printed IDs and the same local file:
145
+ deepflow_openagent_cli cloudpc files upload 123 --file ./report.pdf \
146
+ --upload-id UPLOAD_ID --sandbox-id SANDBOX_ID
147
+ ```
148
+
149
+ Uploads accept 1 byte–50 MiB and create `/workspace/files/uploads` automatically.
150
+ The returned path is `/workspace/files/uploads/<upload_id>-<original_filename>`;
151
+ use that path in subsequent OCR or chat commands. The ID prefix prevents duplicate
152
+ files after a lost response. Reusing the same ID/name with different bytes is
153
+ rejected. The sandbox ID pins retries to the original development environment.
154
+ This admin/config API accepts binary content; channel visitor format restrictions
155
+ still apply only to visitor uploads. Uploading does not automatically start OCR.
156
+
157
+ Uploads and `dev exec` submission/status/stop retry connection failures and HTTP
158
+ 408/429/502/503/504 up to four times (1/2/4/8 second delays). Execution retries keep
159
+ the same execution ID and output cursor. Upload retries resend the whole binary
160
+ body; chunk resume is not implemented. Other mutating commands are not retried.
161
+ Requires updated CLI, API server and Cloud PC control plane; no VM image rebuild
162
+ is needed for the upload writer.
163
+
140
164
  Additional operations:
141
165
 
142
166
  ```bash
@@ -155,8 +179,9 @@ Config update replaces configuration: provide `model_name`, `thinking_enabled`
155
179
  and the complete `env_vars` list; omitted thinking/env fields reset to defaults.
156
180
  Config queries include environment variable values. File mutation and snapshot
157
181
  operations share the per-agent management lock; direct writes by the sandbox's
158
- Agent or terminal are outside that lock. Cloud PC HTTP requests have a 900-second
159
- timeout and are not automatically retried. After a timeout, inspect sandbox or
182
+ Agent or terminal are outside that lock. Upload and execution requests have a
183
+ 120-second per-attempt timeout and the retries described above. Other Cloud PC
184
+ HTTP requests have a 900-second timeout and are not automatically retried. After a timeout, inspect sandbox or
160
185
  version state before retrying a mutation. Conversation logs and chat-control
161
186
  routes still require an administrator session.
162
187
 
@@ -234,3 +259,93 @@ After installation or Agent edits, use `cloudpc versions save` and optionally
234
259
  `versions publish` to preserve and publish the environment. Deploy the updated
235
260
  OpenAgent backend, cloudpc VM service, and bridge bundle together; update the CLI
236
261
  package as well. No database migration is required.
262
+
263
+ ### Cloud PC channels
264
+
265
+ Cloud PC channel settings use `cloudpc channels`, separately from Agent settings
266
+ (`cloudpc config`) and Web SDK channels (`channels`). These commands require an
267
+ API key with `config` scope and Cloud PC enabled for the tenant. The backend must
268
+ also include support for API key authentication on `/api/v1/cloudpc/channels`;
269
+ older backends accept administrator sessions only.
270
+
271
+ ```bash
272
+ deepflow_openagent_cli cloudpc channels list --page 1 --per-page 20
273
+ deepflow_openagent_cli cloudpc channels create --name "Cloud workspace"
274
+ deepflow_openagent_cli cloudpc channels agent-options
275
+ deepflow_openagent_cli cloudpc channels get 7
276
+ deepflow_openagent_cli docs show cloudpc.channels.update --json
277
+ ```
278
+
279
+ Update uses the existing PUT replacement contract. Read the current channel
280
+ first and prepare `channel.json` with the complete name, description, ordered
281
+ `agent_ids` and `appearance`. Convert the returned `bindings` to their `id`
282
+ values; the GET response is not itself an update payload.
283
+
284
+ ```json
285
+ {
286
+ "name": "Cloud workspace",
287
+ "description": "Team assistant",
288
+ "agent_ids": [123, 456],
289
+ "appearance": {
290
+ "pageTitle": "Workspace",
291
+ "title": "Assistant",
292
+ "embedButtonBgColor": "#18181B",
293
+ "embedButtonIconColor": "#FFFFFF",
294
+ "userMessageStyle": {
295
+ "backgroundColor": "#1A1A1A",
296
+ "borderColor": "#CCCCCC",
297
+ "textColor": "#FFFFFF",
298
+ "borderRadius": {
299
+ "topLeft": 14,
300
+ "topRight": 14,
301
+ "bottomLeft": 14,
302
+ "bottomRight": 4
303
+ }
304
+ }
305
+ },
306
+ "require_auth": false,
307
+ "file_upload": {
308
+ "enabled": true,
309
+ "allowed_extensions": [".pdf", ".txt"]
310
+ }
311
+ }
312
+ ```
313
+
314
+ ```bash
315
+ deepflow_openagent_cli cloudpc channels update 7 --json-file ./channel.json
316
+ deepflow_openagent_cli cloudpc channels get 7
317
+ ```
318
+
319
+ `--json` also accepts an inline JSON object. `name` is required; omitted
320
+ `description`, `agent_ids` and `appearance` reset to null, an empty list and
321
+ empty appearance respectively. Omitted/null `require_auth` and `file_upload`
322
+ preserve the current values. Enabling uploads requires at least one extension.
323
+ Only Cloud PCs belonging to the current tenant can be bound (up to 20).
324
+
325
+ User message styles are configured under `appearance.userMessageStyle` in the
326
+ same update payload. They apply to both main and follow-up messages, including
327
+ history and SDK embeds, after opening or refreshing the channel. They do not
328
+ change Agent replies or the admin workbench.
329
+
330
+ | Field | Value | Reset behavior |
331
+ | --- | --- | --- |
332
+ | `backgroundColor` | `#RRGGBB` | Empty string, null or omission restores each bubble's default |
333
+ | `borderColor` | `#RRGGBB`, rendered as a 1px solid border | Empty string, null or omission removes the border |
334
+ | `textColor` | `#RRGGBB`, also applied to the follow-up label | Empty string, null or omission restores the default |
335
+ | `borderRadius` | Object containing all four integer corners, 0–100 px | Null or omission restores each bubble's default |
336
+
337
+ The server trims and uppercases colors. Radius keys are `topLeft`, `topRight`,
338
+ `bottomLeft`, and `bottomRight`; partial groups, negative/fractional/out-of-range
339
+ values, strings and booleans are rejected with HTTP 422 (CLI exits nonzero).
340
+ To clear only one override, set that field to null while retaining the other
341
+ settings. To reset all user message styles, set `"userMessageStyle": null` in
342
+ the complete `channel.json`, then run the same update command. The outer PUT
343
+ still replaces `appearance` and bindings: keep all unrelated settings from the
344
+ GET response in the payload. Reading the channel returns the saved style under
345
+ `appearance.userMessageStyle`.
346
+
347
+ `deepflow_openagent_cli cloudpc channels delete 7` deletes the channel and
348
+ destroys its instances. The server entry point
349
+ `python -m app.openapi_cli cloudpc channels ...` supports the same commands.
350
+ This feature requires updating the backend and CLI; no database migration or
351
+ Cloud PC sandbox image update is needed.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepflow-openagent-cli"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "Command-line client for OpenAgent OpenAPI (agents, conversations, SSE chat, tools, channels, knowledge bases)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """DeepFlow OpenAgent CLI — published PyPI package."""
2
2
 
3
- __version__ = "0.1.6"
3
+ __version__ = "0.1.8"
@@ -8,6 +8,7 @@ Run with:
8
8
  from __future__ import annotations
9
9
 
10
10
  import argparse
11
+ from http.client import HTTPException
11
12
  import json
12
13
  import mimetypes
13
14
  import os
@@ -129,6 +130,73 @@ def _param(name: str, type_: str, required: bool, description: str) -> ParamDoc:
129
130
 
130
131
 
131
132
  ENDPOINTS: tuple[EndpointDoc, ...] = (
133
+ EndpointDoc(
134
+ key="cloudpc.channels.list", method="GET", path="/api/v1/cloudpc/channels", scope="config",
135
+ summary="List Cloud PC channels for the current tenant.",
136
+ query_params=[_param("page", "integer", False, "Page number (default 1)."), _param("per_page", "integer", False, "Page size, 1-100 (default 20).")],
137
+ response="CloudPcChannelListResponse.",
138
+ example="deepflow_openagent_cli cloudpc channels list --page 1 --per-page 20",
139
+ ),
140
+ EndpointDoc(
141
+ key="cloudpc.channels.create", method="POST", path="/api/v1/cloudpc/channels", scope="config",
142
+ summary="Create a Cloud PC channel; use update to configure bindings and appearance.",
143
+ body_fields=[_param("name", "string", True, "Channel name, 1-64 characters."), _param("description", "string", False, "Description, max 500 characters.")],
144
+ response="CloudPcChannelDetail, HTTP 201.",
145
+ example='deepflow_openagent_cli cloudpc channels create --name "Cloud workspace"',
146
+ ),
147
+ EndpointDoc(
148
+ key="cloudpc.channels.agent_options", method="GET", path="/api/v1/cloudpc/channels/agent-options", scope="config",
149
+ summary="List enabled Cloud PCs available for binding, including unpublished ones.",
150
+ response="CloudPcAgentOptionList: items with id, name, description, version and status.",
151
+ example="deepflow_openagent_cli cloudpc channels agent-options",
152
+ ),
153
+ EndpointDoc(
154
+ key="cloudpc.channels.get", method="GET", path="/api/v1/cloudpc/channels/{channel_id}", scope="config",
155
+ summary="Get Cloud PC channel settings and ordered bindings.",
156
+ path_params=[_param("channel_id", "integer", True, "Cloud PC channel ID.")],
157
+ response="CloudPcChannelDetail: id, name, description, token, appearance, bindings, require_auth, has_secret_key, file_upload and timestamps.",
158
+ example="deepflow_openagent_cli cloudpc channels get 1",
159
+ ),
160
+ EndpointDoc(
161
+ key="cloudpc.channels.update", method="PUT", path="/api/v1/cloudpc/channels/{channel_id}", scope="config",
162
+ summary="Replace channel name, description, bindings and appearance; omitted require_auth/file_upload are preserved.",
163
+ path_params=[_param("channel_id", "integer", True, "Cloud PC channel ID.")],
164
+ body_fields=[
165
+ _param("name", "string", True, "Channel name, 1-64 characters."),
166
+ _param("description", "string", False, "Description, max 500 characters; omitted resets to null."),
167
+ _param("agent_ids", "array", False, "Complete ordered list of Cloud PC Agent IDs, max 20 unique IDs; omitted clears bindings. Convert GET bindings to their id values."),
168
+ _param("appearance", "object", False, "Complete appearance: favicon, pageTitle, logo, title, embedButtonBgColor, embedButtonIconColor, userMessageStyle; omitted clears appearance. Titles max 32 characters; colors #RRGGBB."),
169
+ _param("appearance.userMessageStyle", "object|null", False, "Overrides main and follow-up user bubbles in this channel, including history and embeds. Omitted/null restores defaults; other appearance fields must still be supplied."),
170
+ _param("appearance.userMessageStyle.backgroundColor", "string|null", False, "Background #RRGGBB. Server trims and uppercases; empty string/null restores each bubble's default."),
171
+ _param("appearance.userMessageStyle.borderColor", "string|null", False, "Border #RRGGBB; 1px solid when set. Empty string/null removes the border."),
172
+ _param("appearance.userMessageStyle.textColor", "string|null", False, "Text #RRGGBB, including the follow-up label. Empty string/null restores the default."),
173
+ _param("appearance.userMessageStyle.borderRadius", "object|null", False, "All four corners are required when set: topLeft, topRight, bottomLeft, bottomRight, each an integer 0-100 px. Null restores each bubble's default; partial groups, strings, floats and booleans are rejected."),
174
+ _param("require_auth", "boolean", False, "Require signed visitor authentication; omitted/null preserves current value."),
175
+ _param("file_upload", "object", False, "enabled and allowed_extensions; enabled uploads require at least one extension. Omitted/null preserves current value."),
176
+ ],
177
+ response="CloudPcChannelDetail.",
178
+ errors=["400 invalid bindings or duplicate name", "401 invalid API key", "403 config scope missing, non-admin session or Cloud PC disabled", "404 channel not found in current tenant", "422 validation error"],
179
+ example="deepflow_openagent_cli cloudpc channels update 1 --json-file channel.json",
180
+ ),
181
+ EndpointDoc(
182
+ key="cloudpc.channels.delete", method="DELETE", path="/api/v1/cloudpc/channels/{channel_id}", scope="config",
183
+ summary="Delete a Cloud PC channel and destroy its instances.",
184
+ path_params=[_param("channel_id", "integer", True, "Cloud PC channel ID.")],
185
+ response="Deletion message.",
186
+ example="deepflow_openagent_cli cloudpc channels delete 1",
187
+ ),
188
+ EndpointDoc(
189
+ key="cloudpc.files.upload", method="POST",
190
+ path="/api/v1/cloudpc/agents/{agent_id}/dev-sandbox/files/upload", scope="config",
191
+ summary="Upload binary content (1 byte–50 MiB) into /workspace/files/uploads; safe to retry with the same upload ID and sandbox ID.",
192
+ path_params=[_param("agent_id", "integer", True, "Cloud PC Agent ID.")],
193
+ query_params=[_param("filename", "string", True, "Original basename."),
194
+ _param("upload_id", "string", True, "32 lowercase hex characters; prefixes the saved filename."),
195
+ _param("sandbox_id", "integer", True, "Pinned development sandbox ID from dev get.")],
196
+ response="Saved filename, absolute path and size.",
197
+ example="deepflow_openagent_cli cloudpc files upload 1 --file ./report.pdf",
198
+ ),
199
+
132
200
  EndpointDoc(
133
201
  key="cloudpc.dev.exec", method="POST", path="/api/v1/cloudpc/agents/{agent_id}/dev-sandbox/executions", scope="config",
134
202
  summary='Start an idempotent shell execution. Output remains in the sandbox runtime until restart or eviction (32 retained).',
@@ -1254,6 +1322,38 @@ class OpenApiClient:
1254
1322
  except (URLError, OSError, UnicodeError, ValueError) as exc:
1255
1323
  raise CliError(f"Chat stream interrupted: {exc}") from exc
1256
1324
 
1325
+ def _read_retryable(self, request: Request) -> bytes:
1326
+ """Only for operations whose server contract makes replay safe."""
1327
+ for attempt in range(5):
1328
+ try:
1329
+ with urlopen(request, timeout=120) as response:
1330
+ return response.read()
1331
+ except HTTPError as exc:
1332
+ if exc.code not in {408, 429, 502, 503, 504} or attempt == 4:
1333
+ raise CliError(f"HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')}") from exc
1334
+ except (URLError, OSError, HTTPException) as exc:
1335
+ if attempt == 4:
1336
+ raise CliError(f"Connection failed after 5 attempts: {exc}") from exc
1337
+ print(f"Connection interrupted; retry {attempt + 1}/4…", file=sys.stderr)
1338
+ time.sleep(2 ** attempt)
1339
+
1340
+ def upload(self, agent_id: int, file: Path, upload_id: str, sandbox_id: int) -> bytes:
1341
+ if not self.api_key:
1342
+ raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
1343
+ try:
1344
+ with file.open("rb") as source:
1345
+ content = source.read(50 * 1024 * 1024 + 1)
1346
+ except OSError as exc:
1347
+ raise CliError(f"Cannot read upload: {exc}") from exc
1348
+ if not 0 < len(content) <= 50 * 1024 * 1024:
1349
+ raise CliError("Upload must be between 1 byte and 50 MiB.")
1350
+ url = self._build_url(get_endpoint("cloudpc.files.upload"), {"agent_id": agent_id},
1351
+ {"filename": file.name, "upload_id": upload_id, "sandbox_id": sandbox_id})
1352
+ request = Request(url, data=content, method="POST", headers={
1353
+ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/octet-stream",
1354
+ })
1355
+ return self._read_retryable(request)
1356
+
1257
1357
  def request(
1258
1358
  self,
1259
1359
  endpoint_key: str,
@@ -1262,6 +1362,7 @@ class OpenApiClient:
1262
1362
  body: dict[str, Any] | None = None,
1263
1363
  *,
1264
1364
  stream: bool = False,
1365
+ retry: bool = False,
1265
1366
  ) -> bytes | None:
1266
1367
  endpoint = get_endpoint(endpoint_key)
1267
1368
  if endpoint.scope and not self.api_key:
@@ -1277,6 +1378,8 @@ class OpenApiClient:
1277
1378
  headers["Content-Type"] = "application/json"
1278
1379
 
1279
1380
  request = Request(url, data=data, headers=headers, method=endpoint.method)
1381
+ if not stream and (retry or endpoint_key in {"cloudpc.dev.exec", "cloudpc.dev.exec_status", "cloudpc.dev.exec_cancel"}):
1382
+ return self._read_retryable(request)
1280
1383
  try:
1281
1384
  options = {"timeout": 900} if endpoint_key.startswith("cloudpc.") else {}
1282
1385
  with urlopen(request, **options) as response: # noqa: S310 - CLI calls user-provided API host.
@@ -1877,9 +1980,32 @@ def handle_cloudpc_chat(args: argparse.Namespace) -> int:
1877
1980
  raise
1878
1981
 
1879
1982
 
1983
+ def handle_cloudpc_channels(args: argparse.Namespace) -> int:
1984
+ """Manage Cloud PC channels through their dedicated API."""
1985
+ action = args.cloudpc_action
1986
+ endpoint = f"cloudpc.channels.{action.replace('-', '_')}"
1987
+ if action == "list":
1988
+ return run_request(args, endpoint, query={"page": args.page, "per_page": args.per_page})
1989
+ if action == "agent-options":
1990
+ return run_request(args, endpoint)
1991
+ if action == "create":
1992
+ return run_request(args, endpoint, body={"name": args.name, "description": args.description})
1993
+ body = None
1994
+ if action == "update":
1995
+ try:
1996
+ body = merge_payload(args)
1997
+ except (OSError, UnicodeError) as exc:
1998
+ raise CliError(f"Cannot read channel configuration file: {exc}") from exc
1999
+ if not body.get("name"):
2000
+ raise CliError("Channel update requires name in --json or --json-file.")
2001
+ return run_request(args, endpoint, {"channel_id": args.channel_id}, body=body)
2002
+
2003
+
1880
2004
  def handle_cloudpc(args: argparse.Namespace) -> int:
1881
2005
  """Manage Cloud PC configuration, development files and saved versions."""
1882
2006
  group = args.cloudpc_group
2007
+ if group == "channels":
2008
+ return handle_cloudpc_channels(args)
1883
2009
  if group == "capability":
1884
2010
  return run_request(args, "cloudpc.capability")
1885
2011
  if group == "create":
@@ -1919,6 +2045,21 @@ def handle_cloudpc(args: argparse.Namespace) -> int:
1919
2045
  raise CliError("Version ID must be positive.")
1920
2046
  body = {"from_version_id": version_id}
1921
2047
  elif group == "files":
2048
+ if action == "upload":
2049
+ if bool(args.upload_id) != (args.sandbox_id is not None):
2050
+ raise CliError("Use --upload-id and --sandbox-id together when resuming an upload.")
2051
+ client = client_from_args(args)
2052
+ upload_id = args.upload_id or uuid4().hex
2053
+ sandbox_id = args.sandbox_id
2054
+ if sandbox_id is None:
2055
+ dev = json.loads(client.request("cloudpc.dev.get", path_params, retry=True))
2056
+ sandbox_id = dev.get("sandbox_id")
2057
+ if sandbox_id is None:
2058
+ raise CliError("No upload-capable development sandbox; create/resume it and update the server.")
2059
+ print(f"upload_id={upload_id} sandbox_id={sandbox_id}; reuse both IDs to retry this upload.", file=sys.stderr)
2060
+ data = client.upload(args.agent_id, Path(args.file).expanduser(), upload_id, sandbox_id)
2061
+ print_output(data, pretty=args.pretty, raw=args.raw)
2062
+ return 0
1922
2063
  if action in {"read", "delete"}:
1923
2064
  query = {"path": args.path}
1924
2065
  elif action == "write":
@@ -2076,6 +2217,20 @@ def build_parser() -> argparse.ArgumentParser:
2076
2217
 
2077
2218
  cloudpc = subparsers.add_parser("cloudpc", help="Cloud PC management (requires config scope).")
2078
2219
  cloudpc_sub = cloudpc.add_subparsers(dest="cloudpc_group", required=True)
2220
+ cloudpc_channels = cloudpc_sub.add_parser("channels", help="Cloud PC channel configuration.").add_subparsers(dest="cloudpc_action", required=True)
2221
+ channel_list = cloudpc_channels.add_parser("list")
2222
+ channel_list.add_argument("--page", type=int)
2223
+ channel_list.add_argument("--per-page", type=int)
2224
+ cloudpc_channels.add_parser("agent-options")
2225
+ channel_create = cloudpc_channels.add_parser("create")
2226
+ channel_create.add_argument("--name", required=True)
2227
+ channel_create.add_argument("--description")
2228
+ for action in ("get", "delete"):
2229
+ cloudpc_channels.add_parser(action).add_argument("channel_id", type=int)
2230
+ channel_update_help = "Replace name/description/bindings/appearance; omitted bindings/appearance are cleared. Omitted require_auth/file_upload are preserved. Set appearance.userMessageStyle in --json or --json-file for user bubble colors and four-corner radius; null restores defaults. See docs show cloudpc.channels.update."
2231
+ channel_update = cloudpc_channels.add_parser("update", help=channel_update_help, description=channel_update_help)
2232
+ channel_update.add_argument("channel_id", type=int)
2233
+ add_json_args(channel_update)
2079
2234
  cloudpc_sub.add_parser("capability")
2080
2235
  cloudpc_create = cloudpc_sub.add_parser("create")
2081
2236
  cloudpc_create.add_argument("--name", required=True)
@@ -2129,6 +2284,11 @@ def build_parser() -> argparse.ArgumentParser:
2129
2284
  elif action == "rename":
2130
2285
  command.add_argument("--src", required=True)
2131
2286
  command.add_argument("--dst", required=True)
2287
+ upload = cloudpc_files.add_parser("upload", help="Upload binary files to /workspace/files/uploads (max 50 MiB).")
2288
+ upload.add_argument("agent_id", type=int)
2289
+ upload.add_argument("--file", required=True)
2290
+ upload.add_argument("--upload-id", help="Reuse the printed ID when retrying an interrupted upload.")
2291
+ upload.add_argument("--sandbox-id", type=int, help="Reuse the printed sandbox ID on manual retry.")
2132
2292
  cloudpc_versions = cloudpc_sub.add_parser("versions").add_subparsers(dest="cloudpc_action", required=True)
2133
2293
  for action in ("list", "save", "publish"):
2134
2294
  command = cloudpc_versions.add_parser(action)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepflow-openagent-cli
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Command-line client for OpenAgent OpenAPI (agents, conversations, SSE chat, tools, channels, knowledge bases).
5
5
  Author: DeepFlow AI
6
6
  Project-URL: Repository, https://github.com/DeepFlowAI/OpenAgent
@@ -158,6 +158,30 @@ selected version does not exist; use `--from base` for a fresh environment.
158
158
  Version ID is different from the `x.y.z` version label. First save automatically
159
159
  publishes; later saves require a separate `versions publish` command.
160
160
 
161
+ Binary uploads (including PDF) use the same sandbox upload writer as the workbench:
162
+
163
+ ```bash
164
+ deepflow_openagent_cli cloudpc files upload 123 --file ./report.pdf
165
+ # After retry exhaustion, reuse BOTH printed IDs and the same local file:
166
+ deepflow_openagent_cli cloudpc files upload 123 --file ./report.pdf \
167
+ --upload-id UPLOAD_ID --sandbox-id SANDBOX_ID
168
+ ```
169
+
170
+ Uploads accept 1 byte–50 MiB and create `/workspace/files/uploads` automatically.
171
+ The returned path is `/workspace/files/uploads/<upload_id>-<original_filename>`;
172
+ use that path in subsequent OCR or chat commands. The ID prefix prevents duplicate
173
+ files after a lost response. Reusing the same ID/name with different bytes is
174
+ rejected. The sandbox ID pins retries to the original development environment.
175
+ This admin/config API accepts binary content; channel visitor format restrictions
176
+ still apply only to visitor uploads. Uploading does not automatically start OCR.
177
+
178
+ Uploads and `dev exec` submission/status/stop retry connection failures and HTTP
179
+ 408/429/502/503/504 up to four times (1/2/4/8 second delays). Execution retries keep
180
+ the same execution ID and output cursor. Upload retries resend the whole binary
181
+ body; chunk resume is not implemented. Other mutating commands are not retried.
182
+ Requires updated CLI, API server and Cloud PC control plane; no VM image rebuild
183
+ is needed for the upload writer.
184
+
161
185
  Additional operations:
162
186
 
163
187
  ```bash
@@ -176,8 +200,9 @@ Config update replaces configuration: provide `model_name`, `thinking_enabled`
176
200
  and the complete `env_vars` list; omitted thinking/env fields reset to defaults.
177
201
  Config queries include environment variable values. File mutation and snapshot
178
202
  operations share the per-agent management lock; direct writes by the sandbox's
179
- Agent or terminal are outside that lock. Cloud PC HTTP requests have a 900-second
180
- timeout and are not automatically retried. After a timeout, inspect sandbox or
203
+ Agent or terminal are outside that lock. Upload and execution requests have a
204
+ 120-second per-attempt timeout and the retries described above. Other Cloud PC
205
+ HTTP requests have a 900-second timeout and are not automatically retried. After a timeout, inspect sandbox or
181
206
  version state before retrying a mutation. Conversation logs and chat-control
182
207
  routes still require an administrator session.
183
208
 
@@ -255,3 +280,93 @@ After installation or Agent edits, use `cloudpc versions save` and optionally
255
280
  `versions publish` to preserve and publish the environment. Deploy the updated
256
281
  OpenAgent backend, cloudpc VM service, and bridge bundle together; update the CLI
257
282
  package as well. No database migration is required.
283
+
284
+ ### Cloud PC channels
285
+
286
+ Cloud PC channel settings use `cloudpc channels`, separately from Agent settings
287
+ (`cloudpc config`) and Web SDK channels (`channels`). These commands require an
288
+ API key with `config` scope and Cloud PC enabled for the tenant. The backend must
289
+ also include support for API key authentication on `/api/v1/cloudpc/channels`;
290
+ older backends accept administrator sessions only.
291
+
292
+ ```bash
293
+ deepflow_openagent_cli cloudpc channels list --page 1 --per-page 20
294
+ deepflow_openagent_cli cloudpc channels create --name "Cloud workspace"
295
+ deepflow_openagent_cli cloudpc channels agent-options
296
+ deepflow_openagent_cli cloudpc channels get 7
297
+ deepflow_openagent_cli docs show cloudpc.channels.update --json
298
+ ```
299
+
300
+ Update uses the existing PUT replacement contract. Read the current channel
301
+ first and prepare `channel.json` with the complete name, description, ordered
302
+ `agent_ids` and `appearance`. Convert the returned `bindings` to their `id`
303
+ values; the GET response is not itself an update payload.
304
+
305
+ ```json
306
+ {
307
+ "name": "Cloud workspace",
308
+ "description": "Team assistant",
309
+ "agent_ids": [123, 456],
310
+ "appearance": {
311
+ "pageTitle": "Workspace",
312
+ "title": "Assistant",
313
+ "embedButtonBgColor": "#18181B",
314
+ "embedButtonIconColor": "#FFFFFF",
315
+ "userMessageStyle": {
316
+ "backgroundColor": "#1A1A1A",
317
+ "borderColor": "#CCCCCC",
318
+ "textColor": "#FFFFFF",
319
+ "borderRadius": {
320
+ "topLeft": 14,
321
+ "topRight": 14,
322
+ "bottomLeft": 14,
323
+ "bottomRight": 4
324
+ }
325
+ }
326
+ },
327
+ "require_auth": false,
328
+ "file_upload": {
329
+ "enabled": true,
330
+ "allowed_extensions": [".pdf", ".txt"]
331
+ }
332
+ }
333
+ ```
334
+
335
+ ```bash
336
+ deepflow_openagent_cli cloudpc channels update 7 --json-file ./channel.json
337
+ deepflow_openagent_cli cloudpc channels get 7
338
+ ```
339
+
340
+ `--json` also accepts an inline JSON object. `name` is required; omitted
341
+ `description`, `agent_ids` and `appearance` reset to null, an empty list and
342
+ empty appearance respectively. Omitted/null `require_auth` and `file_upload`
343
+ preserve the current values. Enabling uploads requires at least one extension.
344
+ Only Cloud PCs belonging to the current tenant can be bound (up to 20).
345
+
346
+ User message styles are configured under `appearance.userMessageStyle` in the
347
+ same update payload. They apply to both main and follow-up messages, including
348
+ history and SDK embeds, after opening or refreshing the channel. They do not
349
+ change Agent replies or the admin workbench.
350
+
351
+ | Field | Value | Reset behavior |
352
+ | --- | --- | --- |
353
+ | `backgroundColor` | `#RRGGBB` | Empty string, null or omission restores each bubble's default |
354
+ | `borderColor` | `#RRGGBB`, rendered as a 1px solid border | Empty string, null or omission removes the border |
355
+ | `textColor` | `#RRGGBB`, also applied to the follow-up label | Empty string, null or omission restores the default |
356
+ | `borderRadius` | Object containing all four integer corners, 0–100 px | Null or omission restores each bubble's default |
357
+
358
+ The server trims and uppercases colors. Radius keys are `topLeft`, `topRight`,
359
+ `bottomLeft`, and `bottomRight`; partial groups, negative/fractional/out-of-range
360
+ values, strings and booleans are rejected with HTTP 422 (CLI exits nonzero).
361
+ To clear only one override, set that field to null while retaining the other
362
+ settings. To reset all user message styles, set `"userMessageStyle": null` in
363
+ the complete `channel.json`, then run the same update command. The outer PUT
364
+ still replaces `appearance` and bindings: keep all unrelated settings from the
365
+ GET response in the payload. Reading the channel returns the saved style under
366
+ `appearance.userMessageStyle`.
367
+
368
+ `deepflow_openagent_cli cloudpc channels delete 7` deletes the channel and
369
+ destroys its instances. The server entry point
370
+ `python -m app.openapi_cli cloudpc channels ...` supports the same commands.
371
+ This feature requires updating the backend and CLI; no database migration or
372
+ Cloud PC sandbox image update is needed.
@@ -44,6 +44,12 @@ def run(cli, *args):
44
44
 
45
45
 
46
46
  @pytest.mark.parametrize(("arguments", "method", "suffix", "body"), [
47
+ (["channels", "list", "--page", "2", "--per-page", "5"], "GET", "/channels?page=2&per_page=5", None),
48
+ (["channels", "create", "--name", "渠道", "--description", "Demo"], "POST", "/channels", {"name": "渠道", "description": "Demo"}),
49
+ (["channels", "agent-options"], "GET", "/channels/agent-options", None),
50
+ (["channels", "get", "7"], "GET", "/channels/7", None),
51
+ (["channels", "update", "7", "--json", '{"name":"渠道","agent_ids":[3,2],"appearance":{"title":"助手"},"require_auth":false,"file_upload":{"enabled":true,"allowed_extensions":[".pdf"]}}'], "PUT", "/channels/7", {"name": "渠道", "agent_ids": [3, 2], "appearance": {"title": "助手"}, "require_auth": False, "file_upload": {"enabled": True, "allowed_extensions": [".pdf"]}}),
52
+ (["channels", "delete", "7"], "DELETE", "/channels/7", None),
47
53
  (["capability"], "GET", "/capability", None),
48
54
  (["create", "--name", "Test"], "POST", "/agents", {"name": "Test", "description": None}),
49
55
  (["config", "get", "7"], "GET", "/agents/7/config", None),
@@ -79,6 +85,100 @@ def test_agents_kind_filter(cli, transport):
79
85
  assert "kind=cloudpc" in calls[0][0].full_url
80
86
 
81
87
 
88
+ def test_channel_update_from_file(cli, transport, tmp_path):
89
+ payload = {"name": "渠道", "description": None, "agent_ids": [], "appearance": {}}
90
+ source = tmp_path / "channel.json"
91
+ source.write_text(json.dumps(payload), encoding="utf-8")
92
+ assert run(cli, "cloudpc", "channels", "update", "7", "--json-file", str(source)) == 0
93
+ calls, _ = transport
94
+ assert len(calls) == 1
95
+ assert json.loads(calls[0][0].data) == payload
96
+
97
+
98
+ @pytest.mark.parametrize("payload", ["{}", "[]", "{invalid"])
99
+ def test_invalid_channel_update_does_not_send_request(cli, transport, payload):
100
+ assert run(cli, "cloudpc", "channels", "update", "7", "--json", payload) == 1
101
+ assert transport[0] == []
102
+
103
+
104
+ def test_missing_channel_file_has_clean_error(cli, transport, tmp_path, capsys):
105
+ assert run(cli, "cloudpc", "channels", "update", "7", "--json-file", str(tmp_path / "missing.json")) == 1
106
+ assert transport[0] == []
107
+ assert "Cannot read channel configuration file" in capsys.readouterr().err
108
+
109
+
110
+ def test_channel_docs_describe_replacement(cli, capsys):
111
+ assert cli.main(["docs", "show", "cloudpc.channels.update", "--json"]) == 0
112
+ doc = json.loads(capsys.readouterr().out)
113
+ assert doc["scope"] == "config"
114
+ assert doc["path"] == "/api/v1/cloudpc/channels/{channel_id}"
115
+ fields = {field["name"]: field for field in doc["body_fields"]}
116
+ assert fields["name"]["required"]
117
+ assert "omitted clears" in fields["agent_ids"]["description"]
118
+ assert "preserves" in fields["file_upload"]["description"]
119
+
120
+
121
+ @pytest.mark.parametrize("source", ["--json", "--json-file"])
122
+ @pytest.mark.parametrize("style", [
123
+ {"backgroundColor": "#123456", "borderColor": "#ABCDEF", "textColor": "#FFFFFF",
124
+ "borderRadius": {"topLeft": 0, "topRight": 100, "bottomLeft": 12, "bottomRight": 4}},
125
+ {"textColor": " #aabbcc "},
126
+ {"backgroundColor": "#123456", "borderColor": None, "textColor": "", "borderRadius": None},
127
+ None,
128
+ ])
129
+ def test_channel_message_style_update_payload(cli, transport, tmp_path, source, style):
130
+ payload = {
131
+ "name": "Workspace", "description": "Keep", "agent_ids": [3, 2],
132
+ "appearance": {"title": "Assistant", "embedButtonBgColor": "#1A1A1A", "userMessageStyle": style},
133
+ "require_auth": True, "file_upload": {"enabled": True, "allowed_extensions": [".pdf"]},
134
+ }
135
+ value = json.dumps(payload)
136
+ if source == "--json-file":
137
+ path = tmp_path / "channel.json"
138
+ path.write_text(value, encoding="utf-8")
139
+ value = str(path)
140
+ assert run(cli, "cloudpc", "channels", "update", "7", source, value) == 0
141
+ calls, _ = transport
142
+ assert len(calls) == 1
143
+ assert calls[0][0].method == "PUT"
144
+ assert json.loads(calls[0][0].data) == payload
145
+
146
+
147
+ def test_channel_message_style_read(cli, transport, capsys):
148
+ detail = {"id": 7, "appearance": {"userMessageStyle": {"textColor": "#AABBCC", "borderRadius": None}}}
149
+ transport[1]["http://test/api/v1/cloudpc/channels/7"] = json.dumps(detail).encode()
150
+ assert run(cli, "cloudpc", "channels", "get", "7") == 0
151
+ assert json.loads(capsys.readouterr().out) == detail
152
+
153
+
154
+ def test_channel_message_style_validation_failure_is_reported_without_retry(cli, transport, capsys):
155
+ url = "http://test/api/v1/cloudpc/channels/7"
156
+ body = {"detail": [{"loc": ["body", "appearance", "userMessageStyle", "borderRadius", "topRight"], "msg": "Field required"}]}
157
+ transport[1][url] = HTTPError(url, 422, "Unprocessable Entity", {}, io.BytesIO(json.dumps(body).encode()))
158
+ payload = {"name": "Workspace", "appearance": {"userMessageStyle": {"borderRadius": {"topLeft": 1}}}}
159
+ assert run(cli, "cloudpc", "channels", "update", "7", "--json", json.dumps(payload)) == 1
160
+ assert len(transport[0]) == 1
161
+ error = capsys.readouterr().err
162
+ assert "HTTP 422" in error and "userMessageStyle" in error and "topRight" in error
163
+
164
+
165
+ def test_channel_message_style_docs_and_help(cli, capsys):
166
+ assert cli.main(["docs", "show", "cloudpc.channels.update", "--json"]) == 0
167
+ fields = {field["name"]: field for field in json.loads(capsys.readouterr().out)["body_fields"]}
168
+ assert "userMessageStyle" in fields["appearance"]["description"]
169
+ prefix = "appearance.userMessageStyle"
170
+ for key in ["backgroundColor", "borderColor", "textColor", "borderRadius"]:
171
+ assert not fields[f"{prefix}.{key}"]["required"]
172
+ assert "null" in fields[f"{prefix}.{key}"]["description"].lower()
173
+ radius = fields[f"{prefix}.borderRadius"]["description"]
174
+ for key in ["topLeft", "topRight", "bottomLeft", "bottomRight", "0-100"]:
175
+ assert key in radius
176
+ with pytest.raises(SystemExit) as exc:
177
+ cli.main(["cloudpc", "channels", "update", "--help"])
178
+ assert exc.value.code == 0
179
+ assert "appearance.userMessageStyle" in capsys.readouterr().out
180
+
181
+
82
182
  @pytest.mark.parametrize(("source", "expected"), [("latest", 23), ("published", 19)])
83
183
  def test_resolve_version_and_report_actual_source(cli, transport, capsys, source, expected):
84
184
  calls, responses = transport
@@ -254,3 +354,83 @@ def test_dev_chat_requires_message_or_existing_round(cli, transport):
254
354
  assert run(cli, 'cloudpc', 'dev', 'chat', '7') == 1
255
355
  assert run(cli, 'cloudpc', 'dev', 'chat', '7', '--attach') == 1
256
356
  assert transport[0] == []
357
+
358
+
359
+ def test_binary_upload_replays_exact_body_after_ssl_eof(cli, monkeypatch, tmp_path, capsys):
360
+ import ssl
361
+ source = tmp_path / '报告.pdf'
362
+ content = b'%PDF-1.7\n\x00\xff' + b'x' * (2 * 1024 * 1024)
363
+ source.write_bytes(content)
364
+ calls = []
365
+ def send(request, **kwargs):
366
+ calls.append(request)
367
+ if request.method == 'GET':
368
+ return io.BytesIO(b'{"sandbox_id":42,"exists":true}')
369
+ if len(calls) == 2:
370
+ raise ssl.SSLEOFError('UNEXPECTED_EOF_WHILE_READING')
371
+ return io.BytesIO(b'{"path":"/workspace/files/uploads/saved.pdf"}')
372
+ monkeypatch.setattr(cli, 'urlopen', send)
373
+ monkeypatch.setattr(cli.time, 'sleep', lambda _: None)
374
+ assert run(cli, 'cloudpc', 'files', 'upload', '7', '--file', str(source)) == 0
375
+ assert calls[1].data == calls[2].data == content
376
+ assert calls[1].full_url == calls[2].full_url
377
+ assert 'sandbox_id=42' in calls[1].full_url
378
+ assert 'upload_id=' in capsys.readouterr().err
379
+
380
+
381
+ def test_exec_retries_same_submission_and_cursor(cli, monkeypatch, capsys):
382
+ from http.client import IncompleteRead
383
+ calls = []
384
+ def send(request, **kwargs):
385
+ calls.append(request)
386
+ if len(calls) in {1, 3}:
387
+ raise IncompleteRead(b'partial')
388
+ if request.method == 'POST':
389
+ return io.BytesIO(b'{}')
390
+ return io.BytesIO(b'{"events":[{"event":{"type":"stdout","text":"once"}}],"cursor":1,"state":"exited","exit_code":0}')
391
+ monkeypatch.setattr(cli, 'urlopen', send)
392
+ monkeypatch.setattr(cli.time, 'sleep', lambda _: None)
393
+ assert run(cli, 'cloudpc', 'dev', 'exec', '7', '--command', 'echo once', '--execution-id', 'job') == 0
394
+ assert calls[0].data == calls[1].data
395
+ assert calls[2].full_url == calls[3].full_url
396
+ assert capsys.readouterr().out == 'once'
397
+
398
+
399
+ @pytest.mark.parametrize('code', [400, 401, 403, 409, 413])
400
+ def test_upload_permanent_errors_not_retried(cli, monkeypatch, tmp_path, code):
401
+ file = tmp_path / 'x.pdf'
402
+ file.write_bytes(b'\xff\x00')
403
+ calls = []
404
+ def send(request, **kwargs):
405
+ calls.append(request)
406
+ raise HTTPError(request.full_url, code, 'rejected', {}, io.BytesIO(b'rejected'))
407
+ monkeypatch.setattr(cli, 'urlopen', send)
408
+ assert run(cli, 'cloudpc', 'files', 'upload', '7', '--file', str(file), '--sandbox-id', '42', '--upload-id', 'a' * 32) == 1
409
+ assert len(calls) == 1
410
+
411
+
412
+ def test_exec_retry_exhaustion_preserves_attach_hint(cli, monkeypatch, capsys):
413
+ calls = []
414
+ def send(request, **kwargs):
415
+ calls.append(request)
416
+ raise ConnectionResetError('reset')
417
+ monkeypatch.setattr(cli, 'urlopen', send)
418
+ monkeypatch.setattr(cli.time, 'sleep', lambda _: None)
419
+ assert run(cli, 'cloudpc', 'dev', 'exec-attach', '7', 'job', '--after', '8') == 1
420
+ assert len(calls) == 5
421
+ assert all('after=8' in request.full_url for request in calls)
422
+ assert 'exec-attach 7 job --after 8' in capsys.readouterr().err
423
+
424
+
425
+ @pytest.mark.parametrize('size', [0, 50 * 1024 * 1024 + 1])
426
+ def test_upload_size_rejected_before_send(cli, transport, tmp_path, size):
427
+ source = tmp_path / 'x.pdf'
428
+ with source.open('wb') as file:
429
+ file.truncate(size)
430
+ assert run(cli, 'cloudpc', 'files', 'upload', '7', '--file', str(source), '--upload-id', 'a' * 32, '--sandbox-id', '42') == 1
431
+ assert transport[0] == []
432
+
433
+
434
+ def test_upload_manual_retry_requires_both_ids(cli, transport):
435
+ assert run(cli, 'cloudpc', 'files', 'upload', '7', '--file', 'x.pdf', '--upload-id', 'a' * 32) == 1
436
+ assert transport[0] == []