Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion docs/architecture/detached-task-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,16 @@ not change protocol version 6 or the required submission capabilities. The targe
path inside the job workspace, checks canonical containment (including symlinks), and returns
UTF-8 text up to 4 MiB. The controller displays a read-only memory editor, never watches or
reads the target path on its own filesystem, and drops replies after a device-surface switch.
Binary or larger files can be inspected after result synchronization. Detached projections do
Images and downloads use the separate optional `query_file_chunks_v1` capability,
`kind: "readFileChunk"`, and `fileChunk: { offset, limit, expectedRevision? }`.
The target reads at most 256 KiB per request from the job's workspace or that
session's runtime output root. It returns base64 bytes, offset, size, MIME type,
and a revision derived from file metadata. Continuations require the first
revision; changed files fail instead of mixing versions. Controllers retry a
failed chunk at its existing offset, show supported images inline, and download
other binary outputs. Preview and download budgets are 12 MiB and 128 MiB.
This optional capability is not a new submission requirement and does not change
the protocol version. Older targets can still execute jobs and sync results.
Detached projections do
not expose controller-local snapshot rollback or message editing until a target-side history
mutation capability exists.
39 changes: 39 additions & 0 deletions docs/features/remote-workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,45 @@ grants trust implicitly as a fallback of a failed read. The same rule applies
in Peer Device Mode: the read-only probe answers for a controller, granting is
refused on the peer host.

## Output files through remote control

Output links and images belong to the session that produced them. Remote Connect
resolves relative paths, `computer://`, and `file:` references against that
session's execution root, including its worktree. SSH workspace files are read
through the workspace filesystem provider. Runtime artifact references remain
scoped to the executing host and session, even when the SSH workspace is offline.
An unavailable session or provider returns an error; it never borrows the
controller's current workspace or a same-named local file.

Mobile web displays supported output images in the message and offers retry or
download when a preview cannot load. Native mobile surfaces expose output file
cards with image preview and download. Multi-chunk reads reject incomplete or
inconsistent transfers, and switching devices invalidates pending reads. Desktop
image caches are isolated by the active device surface.

Feishu, Telegram, and WeChat bots deliver files from the originating session
after its reply. Images use the provider's supported native image representation;
other outputs are sent as files. Duplicate image/link references are delivered
once, and Markdown code examples are not treated as delivery requests. Missing
files and upload failures produce an explicit reply.

Account-device bot submissions retain the original device, session, and account
identity for result polling, interactions, and attachment reads. Attachments use
bounded chunks so encrypted relay payloads stay within the transport limit.
New hosts include an optional file revision; supported readers reject a changed
revision, while legacy replies without this field remain readable. A temporary
connection failure replays the same turn query instead of resubmitting the prompt.
Questions and tool approvals return to that captured target even after menu
selection changes. Account replacement retires the observer's authority.

Detached Dispatch negotiates the optional `query_file_chunks_v1` capability for
binary images and downloads, alongside `query_file_content` for text previews.
Chunk reads carry an offset and file revision; the controller rejects inconsistent
transfers and drops responses after a device-surface switch. Older targets retain
working execution and text-query paths and receive an explicit upgrade instruction
for a missing binary capability. Existing Remote Connect file commands and
persisted session records remain compatible; the file-chunk revision is additive.

## Upgrade compatibility

Existing SSH profiles remain plain SSH targets because the new `proxyJump` and
Expand Down
15 changes: 15 additions & 0 deletions docs/remote-connect/feishu-bot-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@

Use this guide to pair OpenBitFun through a Feishu bot.

## Receiving generated images and files

When a reply links to a generated file, the bot sends the attachment from that
reply's session workspace. Supported images appear as image messages; HTML,
documents, and other files arrive as file attachments. A preview image and a
link to the same file produce one attachment. If a file cannot be read or
uploaded, the bot reports the failure in the chat. Changing the selected
workspace while the agent runs does not change the source of its output files.

This also works with SSH workspaces and account-linked remote devices. After
a remote submission, the bot follows that turn and delivers its final reply and
attachments here. Questions and tool approvals can be answered in the bot chat.
A temporary disconnect resumes polling the same turn without submitting it again.
Attachment size limits imposed by the target and chat provider still apply.

## Setup Steps

### Step 1
Expand Down
11 changes: 11 additions & 0 deletions docs/remote-connect/feishu-bot-setup.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@

适用于通过飞书机器人完成 OpenBitFun 远程连接配对。

## 接收生成的图片和文件

回复中包含生成文件的链接时,机器人会从这条回复所属的会话工作区读取并发送附件。
支持的图片以图片消息显示,HTML、文档等其他输出以文件附件发送。同一张图片的预览
和下载链接只发送一次附件。文件无法读取或上传失败时,聊天中会显示失败提示。
Agent 执行期间切换选中的工作区,不会改变输出文件的来源。

上述行为也适用于 SSH 工作区和账号关联的远端设备。跨设备提交后,机器人会跟进
同一轮任务,将最终回复和附件发回当前聊天;提问和工具授权也能直接在聊天中处理。
短暂断线后会继续查询原任务,不会再次提交。附件仍受目标主机和聊天平台的大小限制。

## 配置步骤

### 第一步
Expand Down
2 changes: 2 additions & 0 deletions scripts/core-boundaries/rules/feature-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,8 @@ export const capabilityContractDependencyRules = [
capabilityForwarder('git', 'git-port'),
capabilityForwarder('remote-connect', 'agent-api'),
capabilityForwarder('remote-connect', 'remote-workspace-ports'),
// Session output delivery reads through the injected filesystem, including SSH.
capabilityForwarder('remote-connect', 'workspace-ports'),
capabilityForwarder('remote-ssh', 'remote-exec-port'),
capabilityForwarder('remote-ssh', 'remote-workspace-ports'),
capabilityForwarder('remote-ssh', 'workspace-ports'),
Expand Down
73 changes: 72 additions & 1 deletion src/apps/cli/src/dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ async fn probe(request: DispatchProbeRequest) -> Result<DispatchProbeResponse> {
capabilities.push(
openbitfun_services_core::dispatch_contract::DISPATCH_READ_FILE_CAPABILITY.to_string(),
);
capabilities.push(
openbitfun_services_core::dispatch_contract::DISPATCH_FILE_CHUNKS_CAPABILITY.to_string(),
);
if runner::is_supported() {
capabilities.push(
openbitfun_services_core::dispatch_contract::DISPATCH_DETACHED_WORKER_CAPABILITY
Expand Down Expand Up @@ -323,9 +326,47 @@ async fn continue_job(request: DispatchContinueRequest) -> Result<DispatchContin
/// the live session without contending for anything.
async fn query(request: DispatchQueryRequest) -> Result<serde_json::Value> {
let store = DispatchStore::open_default()?;
query_in_store(&store, request).await
}

async fn query_in_store(
store: &DispatchStore,
request: DispatchQueryRequest,
) -> Result<serde_json::Value> {
let job = store.load_job(&request.job_id)?;
match request.kind {
DispatchQueryKind::ReadFileChunk => {
let reference = request
.file_path
.as_deref()
.filter(|path| !path.trim().is_empty())
.context("Dispatch file query requires a filePath")?;
let chunk_request = request
.file_chunk
.as_ref()
.context("Dispatch chunk query requires fileChunk")?;
let chunk = openbitfun_core::service::output_files::read_dispatch_output_chunk(
Path::new(&job.request.workspace_path),
&job.request.session_id,
reference,
chunk_request,
)
.await
.map_err(anyhow::Error::msg)?;
let mut response = serde_json::to_value(chunk)?;
let fields = response.as_object_mut().expect("chunk is an object");
fields.insert("kind".into(), serde_json::json!("readFileChunk"));
fields.insert("jobId".into(), serde_json::json!(request.job_id));
fields.insert(
"sessionId".into(),
serde_json::json!(job.request.session_id),
);
Ok(response)
}
DispatchQueryKind::ReadFile => {
if request.file_chunk.is_some() {
bail!("Text queries do not accept fileChunk");
}
let file_path = request
.file_path
.as_deref()
Expand All @@ -342,7 +383,7 @@ async fn query(request: DispatchQueryRequest) -> Result<serde_json::Value> {
}))
}
DispatchQueryKind::UsageReport => {
if request.file_path.is_some() {
if request.file_path.is_some() || request.file_chunk.is_some() {
bail!("usageReport does not accept a filePath");
}
let path_manager = openbitfun_core::infrastructure::PathManager::new()
Expand Down Expand Up @@ -1084,6 +1125,36 @@ mod tests {
}
}

#[tokio::test]
async fn binary_query_uses_durable_job_origin_and_validates_continuations() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
std::fs::write(workspace.join("image.png"), [0u8, 255, 1, 2]).unwrap();
let store = DispatchStore::open(dir.path().join("dispatch")).unwrap();
let mut job = test_request("binary-query");
job.workspace_path = workspace.to_string_lossy().into_owned();
store.create_job(job, "Binary output".into()).unwrap();
let request: DispatchQueryRequest=parse(serde_json::json!({"jobId":"binary-query","kind":"readFileChunk","filePath":"image.png","fileChunk":{"offset":0,"limit":2}})).unwrap();
let first = query_in_store(&store, request).await.unwrap();
assert_eq!(first["kind"], "readFileChunk");
assert_eq!(first["sessionId"], "session-binary-query");
assert_eq!(first["contentBase64"], "AP8=");
let resumed = serde_json::json!({"jobId":"binary-query","kind":"readFileChunk","filePath":"image.png","fileChunk":{"offset":2,"limit":2,"expectedRevision":first["revision"]}});
let second = query_in_store(&store, parse(resumed.clone()).unwrap())
.await
.unwrap();
assert_eq!(second["contentBase64"], "AQI=");
std::fs::write(workspace.join("image.png"), [3u8, 4, 5, 6, 7]).unwrap();
assert!(query_in_store(&store, parse(resumed).unwrap())
.await
.is_err());
let escape = serde_json::json!({"jobId":"binary-query","kind":"readFileChunk","filePath":"../outside.png","fileChunk":{"offset":0,"limit":2}});
assert!(query_in_store(&store, parse(escape).unwrap())
.await
.is_err());
}

#[test]
fn submit_protocol_requires_version_and_explicit_unattended_policy() {
let missing = serde_json::json!({
Expand Down
4 changes: 4 additions & 0 deletions src/apps/cli/src/dispatch/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,13 +454,17 @@ pub(crate) struct DispatchQueryRequest {
pub(crate) kind: DispatchQueryKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) file_chunk:
Option<openbitfun_services_core::dispatch_contract::DispatchFileChunkRequest>,
}

#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum DispatchQueryKind {
UsageReport,
ReadFile,
ReadFileChunk,
}

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,8 @@ internal const val FILE_DOWNLOAD_ACTION_TEST_TAG: String = "file-download-action
* small targets, and because the projection dedupes: a turn that mentions one
* file four times gets one card, not four links to hunt through.
*
* The source pairs each card with a download button. There is no download here,
* and the button is left out rather than drawn dead: `RemoteWorkspaceIntent` has
* no such intent and the desktop has no command behind it, so the whole path is
* missing rather than merely unwired on this client.
* Preview and download both use the displayed session's file channel. Source
* references are preserved for the owning remote host to resolve.
*/
@Composable
internal fun FileReferenceCards(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ export interface FileInfo {
}

export interface ReadFileChunkResponse extends CommandStatusResponse {
revision?: string;
name?: string;
chunk_base64?: string;
offset?: number;
Expand All @@ -506,6 +507,7 @@ export interface ReadFileResult {
}

export interface ReadFileChunkResult {
revision?: string;
name: string;
contentBase64: string;
offset: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export class FileTargetResolver {
return false;
}
const lower = scheme[1].toLowerCase();
if (reference.startsWith('openbitfun://runtime/') || reference.startsWith('openbitfun://current-session/')) return false;
return lower !== 'computer' && lower !== 'file';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class MessageFileReferenceProjector {
if (inline.type === 'code') {
return;
}
const found: string[] = inline.text.match(/computer:\/\/[^\s)\]}>"']+/g) || [];
const found: string[] = inline.text.match(/(?:computer|file|openbitfun):\/\/[^\s)\]}>"']+/g) || [];
found.forEach((reference: string) => {
if (references.length < limit) {
MessageFileReferenceProjector.add(reference, references, seenRemotePaths);
Expand All @@ -91,9 +91,11 @@ export class MessageFileReferenceProjector {
references: MessageFileReference[],
seenRemotePaths: Set<string>
): void {
if (reference.trim().toLowerCase().indexOf('computer://') !== 0) {
return;
}
const lower = reference.trim().toLowerCase();
const explicit = lower.startsWith('computer://') || lower.startsWith('file:') ||
lower.startsWith('openbitfun://runtime/') || lower.startsWith('openbitfun://current-session/');
const output = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico|html?|pdf|docx?|xlsx?|pptx?|csv|tsv|zip|mp4|webm|mp3|wav)(?:#.*)?$/i.test(lower);
if (!explicit && !output) return;
const resolution = FileTargetResolver.resolve(reference, '', MessageFileReferenceProjector.CONTEXT);
if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target ||
seenRemotePaths.has(resolution.target.remotePath)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class RemoteFilePreviewController {
return;
}
if (renderer === FilePreviewRendererKind.Image) {
const image = await this.client.readFile(target.remotePath, target.sessionId);
const image = await this.client.readFile(target.remotePath, target.sessionId, undefined, FilePreviewPolicy.IMAGE_MAX_BYTES);
if (!this.isCurrentTarget(version, target) || this.failIfUnavailable()) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,27 +409,48 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile
return {
name: response.name || RemoteResponseMapper.basename(path),
contentBase64: response.chunk_base64 || '',
offset: response.offset || offset,
revision: response.revision,
offset: response.offset ?? offset,
chunkSize: response.chunk_size || 0,
totalSize: response.total_size || 0,
mimeType: response.mime_type || 'application/octet-stream'
};
}

async readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void): Promise<ReadFileResult> {
async readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void, maxBytes?: number): Promise<ReadFileResult> {
const chunkSize = 3 * 1024 * 1024;
let offset = 0;
let fileName = RemoteResponseMapper.basename(path);
let mimeType = 'application/octet-stream';
let totalSize = 0;
const chunks: string[] = [];
let revision: string | undefined;
const generation = this.transportGeneration;
const transport = this.transport;
const chunks: Uint8Array[] = [];
while (true) {
const response = await this.readFileChunk(path, offset, chunkSize, sessionId);
if (generation !== this.transportGeneration || transport !== this.transport) {
throw new Error('Remote target changed during file transfer.');
}
const response = await this.readFileChunk(path, offset, Math.min(chunkSize, maxBytes ?? chunkSize), sessionId);
if (generation !== this.transportGeneration || transport !== this.transport) {
throw new Error('Remote target changed during file transfer.');
}
const readSize = response.chunkSize;
chunks.push(response.contentBase64);
fileName = response.name || fileName;
mimeType = response.mimeType || mimeType;
totalSize = response.totalSize || totalSize;
const bytes = Encoding.base64ToBytes(response.contentBase64);
if (!Number.isSafeInteger(response.totalSize) || response.totalSize < 0 ||
response.offset !== offset || readSize !== bytes.length || readSize > chunkSize ||
readSize > response.totalSize - offset || (readSize === 0 && offset < response.totalSize)) {
throw new Error('Remote file transfer is incomplete or inconsistent.');
}
if (maxBytes !== undefined && response.totalSize > maxBytes) throw new Error('File too large for preview.');
if (chunks.length > 0 && (response.totalSize !== totalSize || response.name !== fileName || response.mimeType !== mimeType || response.revision !== revision)) {
throw new Error('Remote file changed during transfer.');
}
chunks.push(bytes);
fileName = response.name;
mimeType = response.mimeType;
totalSize = response.totalSize;
revision = response.revision;
offset += readSize;
if (onProgress) {
onProgress(offset, totalSize);
Expand All @@ -438,9 +459,15 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile
break;
}
}
if (generation !== this.transportGeneration || transport !== this.transport) {
throw new Error('Remote target changed during file transfer.');
}
const bytes = new Uint8Array(offset);
let copied = 0;
chunks.forEach((chunk: Uint8Array) => { bytes.set(chunk, copied); copied += chunk.length; });
return {
name: fileName,
contentBase64: chunks.join(''),
contentBase64: Encoding.bytesToBase64(bytes),
mimeType,
size: totalSize
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface RemoteWorkspaceFileClient {
readFile(
path: string,
sessionId?: string,
onProgress?: (downloaded: number, total: number) => void
onProgress?: (downloaded: number, total: number) => void,
maxBytes?: number
): Promise<ReadFileResult>;
}
Loading
Loading