# 回家后调用办公室的一步操作,不必接管整台电脑 | EasyNet Source: https://easynet.run/cases/problems/call-office-function-from-home [easynet.run · 全部问题场景](https://easynet.run/cases#problem-scenarios) # 回家后调用办公室的一步操作,不必接管整台电脑 晚上补来一份销售表,明早就要交报告。你已经回家,办公室却留着同事维护的处理程序。现在你得开远程桌面、传文件、找窗口、等导出,再把结果拿回来。其实要借用的,只是那一次处理。 先用三行销售数据看清两端各做什么:家里读取 input.csv,把文本交给办公室的 build_sales_report;办公室验表、计算金额、排成 Markdown,家里收到后保存为 report.md。下面是完整代码,输入和结果都能逐项核对。 这里用小表格,是为了让你能逐项核对结果。重点看原有的计算函数怎样保持不变,在外层加装饰器接入,再按这项能力查看调用记录。这三行本来没有必要放到两台设备上;实际工作能本地完成,或已有 Flask/MCP 入口可用,就不必迁移。下面演示的是已有程序尚未接入时的具体做法。 01 家里读取小表 发送 CSV 文本,不发送本地路径 02 核对真实发布信息 owner 与 descriptor 来自提供方 03 办公室执行 只调用 build_sales_report 04 回家取回结果 核对报告,再保存新文件 先生成并检查任务;只有收到远端报告才算执行完成。 从真实发布信息准备 Mission 在已检查的示例目录中执行。descriptor.json 必须是实际 ability show 的输出;需要兼容的已安装 Runtime。这里只生成和编译,不运行远端任务。 Runtime 命令 · bash ``` python3 prepare_mission.py --descriptor descriptor.json \ --input input.csv --output Mission.eal easynet mission compile Mission.eal --emit-ir ``` 预期结果:生成新 Mission.eal 并取得编译结果;编译成功不是远端执行证据,跨 Runtime 路径仍在复测。 以这份三行销售表为例。金额以美分填写:3 本笔记本、10 支笔、4 个文件夹,报告应得到 USD 82.50。这里不计算税费或汇率。 家里:提交 input.csv 的内容 ``` product,quantity,unit_price_cents Notebook,3,1250 Pen,10,250 Folder,4,500 ``` 办公室:生成并返回报告文本 ``` # Sales report | Product | Quantity | Amount (USD) | | --- | ---: | ---: | | Notebook | 3 | 37.50 | | Pen | 10 | 25.00 | | Folder | 4 | 20.00 | Total: USD 82.50 ``` 右侧内容由下方 report.py 在本地运行得出,并与下载的 output.md 逐字比对。远程调用还需要分别接通提供方和调用方;这里没有把本地计算当成双设备实录。 办公室电脑只发布这一项处理 report.py 检查列名、行数与金额,用整数美分求和,再排成 Markdown。provider.py 将它注册为 build_sales_report;不接受磁盘路径、命令或任意模板。 原来:在 Python 里调用已有函数 ``` from pathlib import Path from report import sales_report csv_text = Path("input.csv").read_text(encoding="utf-8") print(sales_report(csv_text)) ``` 接入后:业务函数不动,用装饰器注册入口 下方 provider.py 导入原有的 sales_report,没有重写计算。@node.register 标出对外提供的函数,ComputeNode 与 node.serve() 负责注册和运行接入。调用方传 CSV 文本,拿到相同的 Markdown;不是另写一套报表接口和计算规则。 把 Notebook 的数量改成 0,程序会拒绝生成报告:quantity must be an integer between 1 and 10000。本例的商品名只接受英文字母、数字、空格和少量标点;不接受中文或日文名称、退货负数或额外列。 ``` """Run on the provider device after its EasyNet Runtime is configured.""" from easyremote import ComputeNode from report import sales_report node = ComputeNode(namespace="office_report") @node.register(description="Build a USD sales report from bounded CSV text; no file paths.") def build_sales_report(csv_text: str) -> str: return sales_report(csv_text) if __name__ == "__main__": node.serve() ``` 展开完整 report.py ``` """Synthetic sales report. Business logic only; no network or filesystem access.""" import csv import io import re def sales_report(csv_text: str) -> str: """Return Markdown from at most 100 rows and 32 KiB of CSV text.""" if not isinstance(csv_text, str) or len(csv_text) > 32768: raise ValueError("CSV must be text of at most 32 KiB") if len(csv_text.encode("utf-8")) > 32768: raise ValueError("CSV must be text of at most 32 KiB") reader = csv.DictReader(io.StringIO(csv_text), strict=True) try: fieldnames = reader.fieldnames except csv.Error as error: raise ValueError("Malformed CSV header") from error if fieldnames != ["product", "quantity", "unit_price_cents"]: raise ValueError("Expected product,quantity,unit_price_cents") lines = ["# Sales report", "", "| Product | Quantity | Amount (USD) |", "| --- | ---: | ---: |"] total = 0 count = 0 try: for count, row in enumerate(reader, 1): if count > 100 or set(row) != {"product", "quantity", "unit_price_cents"}: raise ValueError("Expected at most 100 rows with exactly three fields") product = row["product"] or "" if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}", product): raise ValueError("Product must contain 1–60 plain label characters") values = [] for field, ceiling in [("quantity", 10000), ("unit_price_cents", 100000000)]: text = row[field] or "" if not re.fullmatch(r"[0-9]{1,9}", text) or not 1 <= int(text) <= ceiling: raise ValueError(f"{field} must be an integer between 1 and {ceiling}") values.append(int(text)) quantity, price = values amount = quantity * price total += amount lines.append(f"| {product} | {quantity} | {amount // 100}.{amount % 100:02d} |") except csv.Error as error: raise ValueError("Malformed CSV") from error if count == 0: raise ValueError("At least one sales row is required") return "\n".join([*lines, "", f"Total: USD {total // 100}.{total % 100:02d}", ""]) ``` 家里的电脑负责读入与保存 caller.py 读取本机小表格,把文本发给指定的提供方,再将返回内容保存到一个新文件。实际 owner 和 descriptor 必须从发布结果取得,不能拿办公室电脑的名字代替,也不能借用它的账号。 展开 caller.py ``` """Caller-side example; requires actual provider identity and descriptor from deployment.""" import argparse from pathlib import Path from easyremote import Client, FreshRoot, ResolvedTargetSubject, remote def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--owner-ura", required=True) parser.add_argument("--descriptor-ref", required=True) parser.add_argument("--input", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() if args.output.exists(): parser.error("Output already exists; choose a new file") with args.input.open("rb") as source: payload = source.read(32769) if len(payload) > 32768: parser.error("CSV exceeds 32 KiB") with Client(namespace="office_report", invocation_policy=FreshRoot(ResolvedTargetSubject())) as client: @remote(client=client, owner_ura=args.owner_ura, descriptor_ref=args.descriptor_ref, timeout=30) def build_sales_report(csv_text: str) -> str: ... result = build_sales_report(payload.decode("utf-8")) if not isinstance(result, str) or len(result.encode("utf-8")) > 65536: raise ValueError("Provider returned an unexpected report") with args.output.open("x", encoding="utf-8") as output: output.write(result) print(f"Saved report to {args.output}") if __name__ == "__main__": main() ``` 这项函数被调用后,到哪里看? 在提供方终端,把发布结果中的实际 ability URA 保存为 REPORT_ABILITY_URA,再运行下面的命令,只查看这项报表能力的调用记录。根据调用方、状态和 invocation URA 找到本次请求;记录失败不等于生成了报告,也不是整台设备的网络流量统计。 ``` easynet invocation list --ability-ura "$REPORT_ABILITY_URA" --format json ``` 把接入任务交给你的编程 Agent 复制这份 AGENTS.md,把本页地址一起交给编程 Agent,并告诉它当前是办公室电脑还是家里的电脑。它会先取得并检查附件,确认安装版本、身份和测试目录,再用这三行示例数据试跑。 代码、示例输入输出与 AGENTS.md 保留英文原文;界面语言切换不会改动附件。 [下载 AGENTS.md](https://easynet.run/examples/office-report/AGENTS.md) 阅读完整交接任务 ``` # Task: use an office sales-report function from a second device The user wants to submit a small sales CSV from their caller device and receive a Markdown report produced by the provider device. Keep the business code on the provider. Do not replace this with remote desktop or arbitrary shell access. The requested handoff is a Mission.eal created on the provider and executed by the caller's own Runtime. A Python smoke test alone does not finish this task. ## Files and the result to reproduce If the user supplied the example files with these instructions, inspect them locally. Otherwise ask for the case-page URL; copied instructions alone do not identify a download origin. Resolve `/examples/office-report/` against that user-provided URL to obtain `report.py`, `provider.py`, `caller.py`, `input.csv`, `output.md`, `test_report.py`, `prepare_mission.py` and `test_prepare_mission.py`. Do not guess a production domain. A localhost preview URL on one device is not reachable from another device as localhost; ask for an approved reachable source or transfer the inspected example files. Use a new user-approved working directory; do not overwrite an existing AGENTS.md or existing report. Inspect files before running. The synthetic input has Notebook (3 × 1250 cents), Pen (10 × 250 cents), and Folder (4 × 500 cents). The expected total is USD 82.50. Prices are already in USD cents; this example does not calculate tax, refunds or currency conversion. Product labels use ASCII letters, digits, spaces and `. _ -`, start with a letter or digit, and have at most 60 characters. Quantity must be 1–10000 and unit_price_cents 1–100000000. Reject extra columns, zero or negative values; do not silently sanitize the user's data. Replacing Notebook's quantity with 0 must produce `quantity must be an integer between 1 and 10000` locally. ## Establish which device you are on Ask whether this is the provider or caller if it is not known. Confirm the other device, its operator and allowed test network. Never copy private keys, bearer tokens or personal credentials between devices. Do not deploy to an unapproved host, change firewalls, or expose real sales records without explicit permission. Read local repository instructions and the installed CLI help. Use compatible EasyRemote, EasyNet-Cli and EasyNet-Axon versions. Do not assume the installed package exposes the same interfaces as an arbitrary source checkout. ## Provider device 1. In an isolated directory run `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest test_report.py test_prepare_mission.py`. This verifies local calculation and file generation, not a remote connection. 2. Configure and pair the local Runtime using the version-matched official guide. Ask for missing user decisions; do not invent device IDs or authorities. 3. In the compatible EasyRemote environment run `python3 provider.py`. It registers `build_sales_report` under namespace `office_report`. 4. Keep the terminal open. Its `Local active` line contains the actual Ability URA; this does not yet prove the caller can discover it. In another terminal, read the provider's public identity with `easynet runtime status --json` (`pairing.device_ura`). Use these actual values, not a friendly device name: ```sh easynet ability show "$ABILITY_URA" --node "$PROVIDER_DEVICE_URA" --format json > descriptor.json ``` Run this in the new working directory only. Inspect `name`, `owner_ura`, `ability_ura`, `descriptor_ref` and `input_schema` in the result. The name must be `office_report.build_sales_report`. Establish the permitted caller through the Runtime's authorization workflow; a descriptor is not permission. Share only public metadata, never credentials. 5. Keep the provider and its Runtime running for the test. State clearly who maintains them afterwards. This sample does not wake a sleeping computer. ## Optional Python smoke test on the caller 1. Configure the caller's own Runtime and identity; obtain permission for the specific published function. Verify the source/owner of the descriptor. 2. Inspect `caller.py`. It reads at most 32 KiB locally, sends CSV text, and saves the returned text into a new local file. It does not ask the provider to open a path on the caller's filesystem. 3. Run `python3 caller.py --help`, then invoke it with the actual owner URA and descriptor reference, `--input input.csv`, and a new `--output report.md`. Do not literally substitute guessed identifiers or reuse provider credentials. 4. Compare the returned report with `output.md`. Record provider and caller device identities, revisions, request/receipt identifiers and output, without secrets. Test invalid input, denied caller and provider offline separately. 5. A timeout is not proof that execution was cancelled. Do not blindly retry or report success without receiving and checking the output. ## Generate on the provider, execute on the caller The intended deliverable is a Mission.eal file generated on one device, copied to the caller device, compiled there, and executed against this provider. As inspected on 2026-09-07, the current CLI supports: ```sh easynet mission compile Mission.eal --emit-ir easynet mission run Mission.eal --format json easynet mission show --trace ``` There is no verified `mission add` or `mission install` command. Do not pass an EAL file to `ability deploy`; that expects a different package structure. The development source now supports an explicit remote Agent member call with a separate `descriptor_ref` constraint. Do not assume an installed or published Runtime contains this change. The caller's compiler and the Runtime executing the Mission must both support it. The owner and method remain separate fields; do not use a full Ability URA as the method name or guess the descriptor hash. With the actual descriptor.json and inspected input.csv on the provider, run: ```sh python3 prepare_mission.py --descriptor descriptor.json --input input.csv --output Mission.eal ``` The generator refuses to overwrite a file. It records the actual owner, method and descriptor reference, embeds the CSV text, and sets a 30-second timeout with no retries. Generation does not contact a Runtime or grant permission. Transfer Mission.eal using the user's approved file-sharing method. The file contains the CSV data: inspect it before sharing. Do not copy the provider's environment, private keys or credentials. On the caller, inspect the file and compile it with the command above. Check that the IR targets the expected remote Agent and descriptor, uses exactly the sample CSV, and has zero retries. If the compiler rejects this syntax, stop and report the version and error. Do not turn it into a shell-command wrapper and label that native EAL. After approval, run the file once, take the actual run_id from the response, and read its trace. Compare the emitted report byte-for-byte with output.md, not just the displayed total. Match the child invocation and its terminal receipt with the provider's invocation record. A failed step, missing output or mismatched receipt is not completion. Never treat a successful compile as evidence of remote execution. The website example's cross-Runtime Mission path is still being tested; do not promise it works on the user's installed release. If execution fails, retain the sanitized error and do not retry blindly. Test denied-caller and provider offline behavior separately before using real data. ## Completion report Distinguish: local report test; provider publication; real caller invocation; EAL compilation; real two-device EAL execution. Mark only what was observed. Do not present two processes on one computer as two physical devices. List any remaining dependency, and leave user files and shared services intact. ``` 办公室取得实际发布信息后,用下方命令生成任务文件,再交给家里的 Agent。Mission.eal 里包含本次表格和目标地址,不包含办公室的登录凭据。 ``` python3 prepare_mission.py --descriptor descriptor.json --input input.csv --output Mission.eal ``` 展开 prepare_mission.py ``` """Prepare an office-report Mission from actual published metadata; does not execute it.""" import argparse import json from pathlib import Path METHOD = "office_report.build_sales_report" MAX_CSV_BYTES = 32768 def published_selection(metadata): if not isinstance(metadata, dict): raise ValueError("Expected ability show JSON object") fields = ("name", "owner_ura", "ability_ura", "descriptor_ref") if any(not isinstance(metadata.get(key), str) or not metadata[key].strip() for key in fields): raise ValueError("Need actual name, owner_ura, ability_ura and descriptor_ref from ability show") if metadata["name"] != METHOD: raise ValueError("Metadata is not the office_report.build_sales_report function") if metadata.get("admission_action") not in (None, "invoke"): raise ValueError("The report function requires RPC invocation") # This is consistency checking, not a replacement URA/descriptor parser. # The installed compiler must validate canonical owner/reference binding. if not metadata["descriptor_ref"].startswith(metadata["ability_ura"] + "@") or not metadata["descriptor_ref"].endswith("!invoke"): raise ValueError("Descriptor reference does not match the reported RPC ability") return metadata["owner_ura"], METHOD, metadata["descriptor_ref"] def mission_source(metadata, csv_text): if not isinstance(csv_text, str) or len(csv_text.encode("utf-8")) > MAX_CSV_BYTES: raise ValueError("CSV must be UTF-8 text of at most 32 KiB") owner, method, pin = published_selection(metadata) q = json.dumps return ('mission "office-report-fixture" {\n' f' let report = {q(owner)}.{q(method)}(csv_text: {q(csv_text)}) descriptor_ref {q(pin)} timeout 30 on_failure abort\n' ' emit "report" kind "answer" value report.output\n}\n') def read_bounded(path, limit): with Path(path).open("rb") as source: payload = source.read(limit + 1) if len(payload) > limit: raise ValueError("Input exceeds allowed byte size") return payload.decode("utf-8") def prepare(metadata_path, input_path, output_path): output_path = Path(output_path) if output_path.exists() or output_path.is_symlink(): raise ValueError("Output already exists; choose a new Mission file") metadata = json.loads(read_bounded(metadata_path, 65536)) source = mission_source(metadata, read_bounded(input_path, MAX_CSV_BYTES)) # Exclusive creation also refuses a file created after the initial check. with output_path.open("x", encoding="utf-8") as output: output.write(source) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--descriptor", type=Path, required=True, help="Actual ability show --format json output") parser.add_argument("--input", type=Path, required=True, help="Local UTF-8 input.csv, at most 32 KiB") parser.add_argument("--output", type=Path, required=True, help="New Mission.eal; never overwrites") args = parser.parse_args() try: prepare(args.descriptor, args.input, args.output) except (ValueError, OSError) as error: # Do not echo raw metadata, inputs, identifiers or parser exception payloads. parser.exit(1, f"Mission preparation failed ({type(error).__name__}); check metadata, sizes and new output path.\n") print("Created Mission source. Run easynet mission compile --emit-ir to validate it before execution.") if __name__ == "__main__": main() ``` 本例已生成并编译 Mission.eal;当前跨 Runtime 执行停在授权校验,尚未收到远端报告。附件使用实际发布信息生成任务,再通过 mission compile 和 mission run 检查与执行。 [input.csv](https://easynet.run/examples/office-report/input.csv) [output.md](https://easynet.run/examples/office-report/output.md) [report.py](https://easynet.run/examples/office-report/report.py) [provider.py](https://easynet.run/examples/office-report/provider.py) [caller.py](https://easynet.run/examples/office-report/caller.py) [prepare_mission.py](https://easynet.run/examples/office-report/prepare_mission.py) [test_report.py](https://easynet.run/examples/office-report/test_report.py) [test_prepare_mission.py](https://easynet.run/examples/office-report/test_prepare_mission.py) 接入时的检查项 提供方确实执行了指定转换,调用方没有重新安装处理环境。 无权限的文件或参数是否被拒绝。 设备断线时是否显示失败或未知状态而不是假成功。 源码与接入资料 [函数接入指南](https://easynet.run/docs/connect/function) [设备接入指南](https://easynet.run/docs/connect/runtime)