diff --git a/README.md b/README.md index 9cb8a67..37c00a2 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ Windows以外では定期実行の登録に未対応です。Codexが終了し ### 通常Chatの一時Chat(GPT-6 Pro)へ渡す(任意) +0.7.0では、確認済み接続の操作・範囲・有効期限をローカル台帳に保持し、`chat_plan.py` が必要な項目だけ機械照合します。分類専用のAI呼出しや全接続の再調査を避け、`chat_transfer.py` の一回限りのローカルフォームで、準備済み本文と最終回答をBrowser内の変数のまま受け渡せます。親のモデルへ全文を出力して再転記する必要を減らします。[接続と分類](plugins/codex-task-routing/skills/task-routing/references/chat-capabilities.md)、[転送補助](plugins/codex-task-routing/skills/task-routing/references/chat-transfer.md)。これらは明示実行の補助で、フックから起動・送信しません。 + +通常ChatのGPT-6 ProはWork/Codexと別枠ですが、親の準備・操作・検収はCodex側に残ります。6 Proには契約別の上限があり、無制限ではありません。モデルや残量は実際のUIで確認し、第三者ツール固有の料金まで無料と扱いません。[公式の利用枠](https://help.openai.com/en/articles/20001354)、[Work/Codexの共有枠](https://learn.chatgpt.com/docs/pricing)。 + 0.6.0では、この経路を有効にしたrootが、まとまった独立工程の担当選定時にまずChatへの適合を検討します。材料と確認済みツールで完結し、準備・待機・検収・手戻りが見合う比較・分析・設計・文案・レビューは6 Proを優先します。開発も証拠取得→Chatで分析→Codexで実装・検証と分けられます。短い仕事や頻繁な現物照会はCodexに残し、親にも独立作業がある条件を維持します。経路自体は既定で無効、子のmodel・effort表は従来どおりです。有効化後は毎回プラグインを指定する必要はありません。 新規依頼には、目的・材料・合格条件に加え、`handoff` で必要情報、出典要件、鮮度、許可ツール、返却形式、停止条件を渡します。MCPはChat側で必要な操作が使えるかを確認し、回答に実取得の出典・版や日時・取得範囲を求めます。設定済み・選択済みだけを取得成功と扱いません。本文だけの仕事はツールなしで完結させ、承認済み範囲の確認を繰り返しません。 diff --git a/docs/validation.md b/docs/validation.md index 50e08ee..4c68ba6 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,5 +1,14 @@ # 検証記録 +## 0.7.1: PR #9の境界値レビュー + +2026-09-15、PR #9のローカル転送、接続台帳、キャッシュ公開の差分を確認しました。 + +- JSONで表現できてもUTF-8へ保存できない単独サロゲートを、共通の文字列検証で拒否します。依頼のhash生成前・回答の保存前に安全なプロトコルエラーを返し、修正した回答は同じ転送で受理できます。通常の補助平面Unicodeは保持します。 +- 接続期限の上限判定を日時への加算から差分比較へ変更し、UTC変換の範囲外も安全な入力エラーとして扱います。 +- CIから取得したPRソースをLinux/Python 3.13.5で検証し、104件の単体テストが成功(環境依存2件はskip)、package検査とdiffの空白検査も通過しました。初回の旧101件では既存の子プロセス起動テストが1件失敗しましたが、対象再実行と修正後の全件で成功しました。タイミング依存の可能性は残します。 +- Browser実画面、Windowsの実プラグイン導入、実行モデル・利用量の測定は今回追加していません。3 OSの最終コミットの結果はPRのCIを参照してください。 + ## 0.6.0: Chat優先判定と引継ぎ契約 有効なrootの工程選定にChat適合の優先判定を加え、分類表・スキル・起動時の追加指示をそろえました。既定オフ、子からのChat再委譲禁止、親の独立作業、nativeモデルとeffortの範囲は維持しています。 diff --git a/plugins/codex-task-routing/.codex-plugin/plugin.json b/plugins/codex-task-routing/.codex-plugin/plugin.json index 77c2c20..52ce040 100644 --- a/plugins/codex-task-routing/.codex-plugin/plugin.json +++ b/plugins/codex-task-routing/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-task-routing", - "version": "0.6.0", + "version": "0.7.1", "description": "Task delegation policies for research, documents, images, operations, and development in Codex.", "author": { "name": "Acecore", diff --git a/plugins/codex-task-routing/scripts/chat_plan.py b/plugins/codex-task-routing/scripts/chat_plan.py new file mode 100644 index 0000000..9e62520 --- /dev/null +++ b/plugins/codex-task-routing/scripts/chat_plan.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Local, deterministic suitability checks; never discover tools or send work.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone +import json +from pathlib import Path +import sys + +from chatgpt_route import ChatRouteError, _load_json_object, _require_keys + +FLAGS = {"substantial", "parent_has_independent_work", "materials_approved", + "handoff_proportionate", "requires_repeated_local_access"} +STATES = {"advertised", "verified", "blocked"} + + +def _keys(value, required, purpose): + if not isinstance(value, dict): + raise ChatRouteError(f"{purpose} must be an object") + _require_keys(value, required=required, allowed=required, purpose=purpose) + + +def _text(value): + if not isinstance(value, str) or not value.strip() or len(value) > 512: + raise ChatRouteError("metadata must be a nonempty string of at most 512 characters") + return value + + +def _time(value): + try: + parsed = datetime.fromisoformat(_text(value).replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError() + return parsed.astimezone(timezone.utc) + except (ValueError, OverflowError) as exc: + raise ChatRouteError("observation time requires an ISO timestamp with timezone") from exc + + +def validate_inventory(value): + _keys(value, {"schema_version", "surface", "model", "installed_plugins", "capabilities"}, "inventory") + if type(value["schema_version"]) is not int or value["schema_version"] != 1: + raise ChatRouteError("unsupported inventory schema") + if value["surface"] != "chat-temporary" or value["model"] != "6 Pro": + raise ChatRouteError("inventory must describe Temporary Chat with 6 Pro") + installed, caps = value["installed_plugins"], value["capabilities"] + if not isinstance(installed, list) or len(installed) > 128: + raise ChatRouteError("installed_plugins must be a bounded list") + for name in installed: + _text(name) + if len(set(installed)) != len(installed): + raise ChatRouteError("duplicate installed plugin") + if not isinstance(caps, list) or len(caps) > 256: + raise ChatRouteError("capabilities must be a bounded list") + seen = set() + for cap in caps: + _keys(cap, {"tool", "operation", "scope", "state", "observed_at", "expires_at"}, "capability") + key = tuple(_text(cap[k]) for k in ("tool", "operation", "scope")) + if key in seen: + raise ChatRouteError("duplicate capability scope") + seen.add(key) + if cap["tool"] not in installed or not isinstance(cap["state"], str) or cap["state"] not in STATES: + raise ChatRouteError("unknown tool or capability state") + observed, expires = _time(cap["observed_at"]), _time(cap["expires_at"]) + if not timedelta(0) < expires - observed <= timedelta(days=7): + raise ChatRouteError("capability lifetime must be positive and at most seven days") + return value + + +def plan(facts, inventory=None, *, now=None): + _keys(facts, FLAGS | {"needs"}, "facts") + if any(type(facts[k]) is not bool for k in FLAGS): + raise ChatRouteError("suitability flags must be JSON booleans") + needs = facts["needs"] + if not isinstance(needs, list) or len(needs) > 16: + raise ChatRouteError("needs must be a list of at most sixteen capabilities") + for need in needs: + _keys(need, {"tool", "operation", "scope"}, "need") + for val in need.values(): + _text(val) + if inventory is not None: + validate_inventory(inventory) + # The caller supplies semantic judgments once. No classifier model is run. + if not facts["materials_approved"]: + return {"route": "not_ready", "reason": "materials_approval_not_established"} + for flag in ("substantial", "parent_has_independent_work", "handoff_proportionate"): + if not facts[flag]: + return {"route": "codex", "reason": flag + "_false"} + if facts["requires_repeated_local_access"]: + return {"route": "codex", "reason": "repeated_local_access"} + instant = now or datetime.now(timezone.utc) + checks = [] + caps = inventory["capabilities"] if inventory else [] + for need in needs: + cap = next((c for c in caps if all(c[k] == v for k, v in need.items())), None) + state = "unknown" + if cap: + state = cap["state"] if _time(cap["observed_at"]) <= instant < _time(cap["expires_at"]) else "stale" + checks.append({**need, "state": state, "installed": bool(inventory and need["tool"] in inventory["installed_plugins"])}) + if any(c["state"] == "blocked" for c in checks): + return {"route": "not_ready", "reason": "required_capability_blocked", "checks": checks} + if any(c["state"] != "verified" for c in checks): + return {"route": "preflight_needed", "reason": "verify_only_required_capabilities", "checks": checks} + return {"route": "chat_candidate", "reason": "verify_live_surface_model_and_permissions", "checks": checks} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--facts", type=Path, required=True) + parser.add_argument("--inventory", type=Path) + args = parser.parse_args(argv) + try: + facts = _load_json_object(args.facts, purpose="facts") + inventory = _load_json_object(args.inventory, purpose="inventory") if args.inventory else None + print(json.dumps(plan(facts, inventory), ensure_ascii=False, separators=(",", ":"))) + return 0 + except (ChatRouteError, TypeError) as exc: + print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex-task-routing/scripts/chat_transfer.py b/plugins/codex-task-routing/scripts/chat_transfer.py new file mode 100644 index 0000000..753cafc --- /dev/null +++ b/plugins/codex-task-routing/scripts/chat_transfer.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python3 +"""Serve one local-only Browser handoff for an approved Chat route bundle. + +The server deliberately exposes a prepared prompt only through a loopback +HTML document. It never opens ChatGPT, calls a model or network API, reads +authentication or conversation data, or evaluates browser-side script. A +supported Browser surface can read the readonly prompt field and paste it +into an already-approved temporary Chat. It can later put that Chat's final +JSON response into this form for local schema/correlation validation. +""" + +from __future__ import annotations + +import argparse +import html +import json +import os +import secrets +import socket +import stat +import sys +import tempfile +import time +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any, Mapping +from urllib.parse import parse_qs, urlsplit + + +# The helper is intentionally loaded from the same installed plugin directory. +# It is the protocol authority for prepared-bundle and response validation. +SCRIPT_DIRECTORY = Path(__file__).resolve().parent +if str(SCRIPT_DIRECTORY) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIRECTORY)) +import chatgpt_route # noqa: E402 + + +DEFAULT_TIMEOUT_SECONDS = 10 * 60 +MAX_TIMEOUT_SECONDS = 60 * 60 +# A URL-encoded form can expand a valid 256 KiB reply substantially. The +# decoded canonical reply is separately kept within chatgpt_route.MAX_JSON_BYTES. +MAX_POST_BYTES = chatgpt_route.MAX_JSON_BYTES * 3 +CONTENT_SECURITY_POLICY = ( + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; " + "base-uri 'none'; frame-ancestors 'none'" +) + + +class ChatTransferError(ValueError): + """A local transfer error that does not include submitted material.""" + + +def _origin_for(port: int) -> str: + return f"http://127.0.0.1:{port}" + + +def _host_is_valid(host: str | None, port: int) -> bool: + return host == f"127.0.0.1:{port}" + + +def _origin_is_valid(origin: str | None, port: int) -> bool: + return origin == _origin_for(port) + + +def _path_is_valid(path: str, token: str) -> bool: + parsed = urlsplit(path) + return not parsed.query and parsed.path == f"/transfer/{token}" + + +def _minimal_error_page() -> bytes: + """Return an unauthenticated error document with no transfer material.""" + + return b"
Local transfer unavailable.
" + + +def _parse_post_length(value: str | None) -> int: + try: + length = int(value or "") + except ValueError as exc: + raise ChatTransferError("invalid response length") from exc + if length < 0 or length > MAX_POST_BYTES: + raise ChatTransferError("response exceeds the size limit") + return length + + +def _response_text_from_form(body: bytes) -> str: + try: + form = parse_qs( + body.decode("utf-8"), strict_parsing=True, keep_blank_values=True, max_num_fields=2, errors="strict" + ) + except (UnicodeDecodeError, ValueError) as exc: + raise ChatTransferError("invalid form data") from exc + values = form.get("response_json") + if set(form) != {"response_json"} or values is None or len(values) != 1: + raise ChatTransferError("exactly one response JSON field is required") + response_text = values[0] + if len(response_text.encode("utf-8")) > chatgpt_route.MAX_JSON_BYTES: + raise ChatTransferError("response exceeds the size limit") + return response_text + + +def _response_parse_status(response_text: str, error: chatgpt_route.ChatRouteError) -> str: + """Describe a JSON rejection without exposing any response characters.""" + + cause = error.__cause__ + if isinstance(cause, json.JSONDecodeError): + if cause.pos < len(response_text): + character = f"U+{ord(response_text[cause.pos]):04X}" + else: + character = "EOF" + return ( + "Response JSON parse rejected " + f"(syntax line={cause.lineno}, column={cause.colno}, character={character}, " + f"decoded_chars={len(response_text)})." + ) + if isinstance(cause, chatgpt_route.DuplicateKeyError): + return f"Response JSON parse rejected (duplicate object key, decoded_chars={len(response_text)})." + if isinstance(cause, ValueError): + return f"Response JSON parse rejected (unsupported JSON value, decoded_chars={len(response_text)})." + if "must be a JSON object" in str(error): + return f"Response JSON parse rejected (top-level object required, decoded_chars={len(response_text)})." + return f"Response JSON parse rejected (unclassified parser failure, decoded_chars={len(response_text)})." + + +def _safe_reply_path(path: Path | str) -> Path: + """Return one explicit reply target after link/reparse and parent checks.""" + + safe_path = chatgpt_route._assert_safe_ancestors(path) + parent = safe_path.parent + try: + metadata = os.lstat(parent) + except OSError as exc: + raise ChatTransferError("reply directory is unavailable") from exc + if not stat.S_ISDIR(metadata.st_mode) or chatgpt_route._is_link_or_reparse(parent): + raise ChatTransferError("reply directory is unavailable") + if os.path.lexists(safe_path) and chatgpt_route._is_link_or_reparse(safe_path): + raise ChatTransferError("refusing a symlink or reparse-point reply path") + if os.path.lexists(safe_path): + try: + target_metadata = os.lstat(safe_path) + except OSError as exc: + raise ChatTransferError("reply path is unavailable") from exc + if not stat.S_ISREG(target_metadata.st_mode): + raise ChatTransferError("reply path is not a regular file") + return safe_path + + +def _canonical_response_bytes(response: Mapping[str, Any]) -> bytes: + encoded = ( + json.dumps(response, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + "\n" + ).encode("utf-8") + if len(encoded) > chatgpt_route.MAX_JSON_BYTES: + raise ChatTransferError("response exceeds the supported reply size limit") + return encoded + + +def _load_existing_reply(path: Path) -> dict[str, Any] | None: + if not os.path.lexists(path): + return None + try: + return chatgpt_route._load_json_object(path, purpose="existing response") + except chatgpt_route.ChatRouteError as exc: + raise ChatTransferError("existing reply is unavailable") from exc + + +def _atomic_create_reply(path: Path, content: bytes) -> bool: + """Create *path* atomically without replacing an independently made file. + + ``os.replace`` would make the write atomic but could overwrite a different + reply created after the initial check. Linking a fully-fsynced temporary + file creates the final name only when it did not already exist. + """ + + temporary_name: str | None = None + try: + descriptor, temporary_name = tempfile.mkstemp( + prefix=".chat-transfer-", dir=path.parent + ) + with os.fdopen(descriptor, "wb") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temporary = Path(temporary_name) + if chatgpt_route._is_link_or_reparse(temporary): + raise ChatTransferError("temporary reply path is unsafe") + try: + os.link(temporary, path) + except FileExistsError: + return False + return True + except ChatTransferError: + raise + except OSError as exc: + raise ChatTransferError("cannot save the reply") from exc + finally: + if temporary_name and os.path.lexists(temporary_name): + try: + os.unlink(temporary_name) + except OSError: + pass + + +class TransferState: + """The bounded, in-memory state for exactly one bundle and reply target.""" + + def __init__(self, *, bundle: Mapping[str, Any], reply_path: Path) -> None: + request_id, input_sha256 = chatgpt_route._expected_request(bundle) + prompt = bundle.get("prompt") + if not isinstance(prompt, str): + raise ChatTransferError("prepared bundle prompt is unavailable") + self.bundle = dict(bundle) + self.prompt = prompt + self.request_id = request_id + self.input_sha256 = input_sha256 + self.reply_path = _safe_reply_path(reply_path) + self.response_status: str | None = None + # Exposed only on the authorized local page. These fixed strings + # identify transport progress without retaining response material. + self.transfer_status = "Waiting for one response JSON submission." + + @classmethod + def from_paths(cls, bundle_path: Path | str, reply_path: Path | str) -> "TransferState": + bundle = chatgpt_route._load_json_object(Path(bundle_path), purpose="prepared bundle") + return cls(bundle=bundle, reply_path=Path(reply_path)) + + def save_response_text(self, response_text: str) -> tuple[dict[str, Any], bool]: + """Validate and save one response, accepting only an identical retry.""" + + try: + response = chatgpt_route._parse_json_object(response_text, purpose="response") + except chatgpt_route.ChatRouteError as exc: + self.transfer_status = _response_parse_status(response_text, exc) + raise ChatTransferError(str(exc)) from exc + return self.save_response(response) + + def save_response(self, response: Mapping[str, Any]) -> tuple[dict[str, Any], bool]: + """Return validation metadata and whether this call created the reply.""" + + try: + _safe_reply_path(self.reply_path) + except chatgpt_route.ChatRouteError as exc: + raise ChatTransferError("reply path is unsafe") from exc + try: + validated = chatgpt_route.validate_exchange(self.bundle, response) + except chatgpt_route.ChatRouteError as exc: + raise ChatTransferError(str(exc)) from exc + content = _canonical_response_bytes(response) + existing = _load_existing_reply(self.reply_path) + if existing is not None: + try: + chatgpt_route.validate_exchange(self.bundle, existing) + except chatgpt_route.ChatRouteError as exc: + raise ChatTransferError("existing reply is not a valid response for this bundle") from exc + if existing != dict(response): + raise ChatTransferError("reply already exists with a different response") + self.response_status = validated["status"] + return validated, False + + created = _atomic_create_reply(self.reply_path, content) + if not created: + # Another process won the race. It is idempotent only when its + # parsed protocol object is exactly the response just submitted. + existing = _load_existing_reply(self.reply_path) + if existing is None: + raise ChatTransferError("reply could not be saved") + try: + chatgpt_route.validate_exchange(self.bundle, existing) + except chatgpt_route.ChatRouteError as exc: + raise ChatTransferError("existing reply is not a valid response for this bundle") from exc + if existing != dict(response): + raise ChatTransferError("reply already exists with a different response") + self.response_status = validated["status"] + return validated, created + + +def _response_status_message(status: str | None, *, created: bool | None = None) -> str: + if status is None: + return "Waiting for one response JSON submission." + prefix = "Response saved" if created else "Matching response already saved" + if status == "completed": + return ( + f"{prefix} after schema and request-correlation validation. " + "Response status is completed. Parent semantic acceptance is still required." + ) + return ( + f"{prefix} after schema and request-correlation validation. " + f"Response status is {status}; this is not a completed outcome and requires parent attention." + ) + + +def _page(state: TransferState, *, status: str, error: bool = False) -> bytes: + status_class = "error" if error else "status" + escaped_prompt = html.escape(state.prompt, quote=False) + escaped_status = html.escape(status, quote=False) + action = html.escape(f"/transfer/{state.token}", quote=True) + document = f""" + + + + +Use supported Browser DOM controls to move this approved prompt and the final response. This page does not contact Chat or any external service.
+ +{escaped_status}
+ +""" + return document.encode("utf-8") + + +class TransferServer(HTTPServer): + """A loopback-only HTTP server carrying one TransferState.""" + + def __init__(self, state: TransferState) -> None: + super().__init__(("127.0.0.1", 0), TransferHandler) + self.state = state + self.completed = False + self.client_timeout_seconds = 1.0 + self.deadline: float | None = None + + @property + def origin(self) -> str: + return _origin_for(self.server_port) + + def get_request(self) -> tuple[Any, Any]: + request, address = super().get_request() + request.settimeout(self.client_timeout_seconds) + return request, address + + def finish_request(self, request: Any, client_address: Any) -> None: + # Socket timeouts bound inactivity, not a client continuously sending + # bytes. Interrupt the active connection at the absolute CLI deadline. + def expire() -> None: + try: + request.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + timer = None + if self.deadline is not None: + timer = threading.Timer(max(0.0, self.deadline - time.monotonic()), expire) + timer.daemon = True + timer.start() + try: + super().finish_request(request, client_address) + except OSError: + # A disconnected Browser or expired socket carries no useful + # traceback; preserve the one-shot server until its deadline. + self.state.transfer_status = "Browser connection ended before transfer completion." + finally: + if timer is not None: + timer.cancel() + + +class TransferHandler(BaseHTTPRequestHandler): + server: TransferServer + # Explicit close framing keeps Browser form responses unambiguous across + # the loopback environments exercised by this helper. + protocol_version = "HTTP/1.0" + + def log_message(self, _format: str, *args: object) -> None: + # Do not emit the capability URL, submitted response, or request paths. + return + + def _host_is_valid(self) -> bool: + return _host_is_valid(self.headers.get("Host"), self.server.server_port) + + def _path_is_valid(self) -> bool: + return _path_is_valid(self.path, self.server.state.token) + + def _set_transfer_status(self, status: str) -> None: + self.server.state.transfer_status = status + + def _send(self, code: HTTPStatus, content: bytes, *, content_type: str = "text/html; charset=utf-8") -> None: + # The response is complete before this connection is closed. Each + # local capability URL has one request per connection, avoiding a + # keep-alive parser retaining form data after submission. + self.close_connection = True + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.send_header("Connection", "close") + self.send_header("Cache-Control", "no-store, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header( + "Content-Security-Policy", + CONTENT_SECURITY_POLICY, + ) + # Match the document policy so same-origin Browser form submissions + # retain their Origin header while cross-origin navigation leaks none. + self.send_header("Referrer-Policy", "same-origin") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(content) + + def _send_error_page(self, code: HTTPStatus, status: str) -> None: + self._send(code, _page(self.server.state, status=status, error=True)) + + def _send_unavailable(self, code: HTTPStatus) -> None: + self._send(code, _minimal_error_page()) + + def do_GET(self) -> None: + if not self._host_is_valid(): + self._send_unavailable(HTTPStatus.BAD_REQUEST) + return + if not self._path_is_valid(): + self._send_unavailable(HTTPStatus.NOT_FOUND) + return + self._send( + HTTPStatus.OK, + _page( + self.server.state, + status=( + _response_status_message(self.server.state.response_status) + if self.server.state.response_status is not None + else self.server.state.transfer_status + ), + ), + ) + + def do_POST(self) -> None: + if not self._host_is_valid(): + self._send_unavailable(HTTPStatus.BAD_REQUEST) + return + if not self._path_is_valid(): + self._send_unavailable(HTTPStatus.NOT_FOUND) + return + origin_valid = _origin_is_valid(self.headers.get("Origin"), self.server.server_port) + self._set_transfer_status( + f"POST received (host_valid=true, path_valid=true, origin_valid={'true' if origin_valid else 'false'})." + ) + if not origin_valid: + self._send_unavailable(HTTPStatus.FORBIDDEN) + return + self._set_transfer_status("POST origin accepted; checking form encoding.") + content_type = self.headers.get("Content-Type", "") + if content_type.split(";", 1)[0].strip().lower() != "application/x-www-form-urlencoded": + self._set_transfer_status("Response form rejected before body processing.") + self._send_error_page(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Transfer status: response must use the local form.") + return + try: + length = _parse_post_length(self.headers.get("Content-Length")) + except ChatTransferError as exc: + code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE if "size limit" in str(exc) else HTTPStatus.BAD_REQUEST + self._set_transfer_status("Response form rejected before body processing.") + self._send_error_page(code, f"Transfer status: {exc}.") + return + self._set_transfer_status(f"POST body reading (content_length={length} bytes).") + try: + body = self.rfile.read(length) + except (OSError, TimeoutError): + # The client socket is bounded by serve_transfer's remaining + # deadline. A partial upload must not keep the CLI alive. + self._set_transfer_status("Response body did not arrive before the transfer deadline.") + self.close_connection = True + return + if len(body) != length: + self._set_transfer_status("Response body was incomplete.") + self._send_error_page(HTTPStatus.BAD_REQUEST, "Transfer status: incomplete response body.") + return + self._set_transfer_status("Checking response JSON format.") + try: + response_text = _response_text_from_form(body) + except ChatTransferError as exc: + code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE if "size limit" in str(exc) else HTTPStatus.BAD_REQUEST + self._set_transfer_status("Response JSON was rejected before correlation validation.") + self._send_error_page(code, f"Transfer status: {exc}.") + return + self._set_transfer_status("Validating response correlation.") + try: + validated, created = self.server.state.save_response_text(response_text) + except ChatTransferError as exc: + if not self.server.state.transfer_status.startswith("Response JSON parse rejected"): + self._set_transfer_status("Response JSON was rejected by correlation or save checks.") + self._send_error_page(HTTPStatus.UNPROCESSABLE_ENTITY, f"Transfer status: response rejected ({exc}).") + return + self.server.completed = True + self._send( + HTTPStatus.OK, + _page( + self.server.state, + status=_response_status_message(validated["status"], created=created), + ), + ) + + +def create_server(state: TransferState) -> TransferServer: + """Create a random-port, random-capability loopback server for tests or CLI.""" + + state.token = secrets.token_urlsafe(32) + return TransferServer(state) + + +def serve_transfer(state: TransferState, *, timeout_seconds: int) -> int: + server = create_server(state) + deadline = time.monotonic() + timeout_seconds + server.deadline = deadline + metadata = { + "event": "listening", + "input_sha256": state.input_sha256, + "request_id": state.request_id, + "timeout_seconds": timeout_seconds, + "url": f"{server.origin}/transfer/{state.token}", + } + print(json.dumps(metadata, ensure_ascii=True, sort_keys=True), flush=True) + try: + while not server.completed: + remaining = deadline - time.monotonic() + if remaining <= 0: + print( + json.dumps( + {"event": "timeout", "request_id": state.request_id}, + ensure_ascii=True, + sort_keys=True, + ), + flush=True, + ) + return 3 + server.timeout = min(remaining, 1.0) + # A submitted form may take more than a second to arrive from the + # Browser. Bound the read by the transfer deadline itself, rather + # than closing a valid in-flight response after one second. + server.client_timeout_seconds = max(0.01, remaining) + server.handle_request() + except KeyboardInterrupt: + print( + json.dumps( + {"event": "cancelled", "request_id": state.request_id}, + ensure_ascii=True, + sort_keys=True, + ), + flush=True, + ) + return 130 + finally: + server.server_close() + final_status = state.response_status + print( + json.dumps( + {"event": "saved", "request_id": state.request_id, "status": final_status}, + ensure_ascii=True, + sort_keys=True, + ), + flush=True, + ) + return 0 if final_status == "completed" else 3 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run one local Browser-to-Chat response transfer") + parser.add_argument("--bundle", type=Path, required=True, help="approved bundle.json path") + parser.add_argument("--reply", type=Path, required=True, help="the single reply.json path to create") + parser.add_argument( + "--timeout", + type=int, + default=DEFAULT_TIMEOUT_SECONDS, + help=f"seconds to wait before stopping (1-{MAX_TIMEOUT_SECONDS}; default {DEFAULT_TIMEOUT_SECONDS})", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + if not 1 <= args.timeout <= MAX_TIMEOUT_SECONDS: + print("error: timeout must be within the supported range", file=sys.stderr) + return 2 + try: + state = TransferState.from_paths(args.bundle, args.reply) + except (ChatTransferError, chatgpt_route.ChatRouteError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + return serve_transfer(state, timeout_seconds=args.timeout) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex-task-routing/scripts/chatgpt_route.py b/plugins/codex-task-routing/scripts/chatgpt_route.py index c4093a4..7b88910 100644 --- a/plugins/codex-task-routing/scripts/chatgpt_route.py +++ b/plugins/codex-task-routing/scripts/chatgpt_route.py @@ -177,6 +177,12 @@ def _require_keys(value: Mapping[str, Any], *, required: set[str], allowed: set[ def _require_nonempty_string(value: Any, *, purpose: str, maximum: int) -> str: if not isinstance(value, str) or not value.strip() or len(value) > maximum: raise ChatRouteError(f"{purpose} must be a non-empty string within the size limit") + # JSON permits escaped lone surrogates that cannot be saved as UTF-8. + # Reject them at the shared protocol boundary, before hashing or saving. + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + raise ChatRouteError(f"{purpose} must contain valid Unicode text") from exc return value diff --git a/plugins/codex-task-routing/scripts/routing.py b/plugins/codex-task-routing/scripts/routing.py index eb3f541..08f86c6 100644 --- a/plugins/codex-task-routing/scripts/routing.py +++ b/plugins/codex-task-routing/scripts/routing.py @@ -19,6 +19,7 @@ import secrets import stat import sys +import time from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Iterator, Mapping @@ -58,6 +59,14 @@ # an effective policy mid-document. MAX_ADDITIONAL_CONTEXT_CHARS = 8000 +# Windows can briefly retain a directory handle immediately after another +# process creates a fresh cache tree. Only retry the Windows errors that can +# describe that short sharing/access window, and leave all other filesystem +# errors visible to the caller. +CACHE_RENAME_MAX_ATTEMPTS = 3 +CACHE_RENAME_RETRY_SECONDS = 0.05 +WINDOWS_TRANSIENT_RENAME_WINERRORS = frozenset({5, 32, 33}) + EFFORT_ORDER = {"medium": 0, "high": 1, "xhigh": 2, "max": 3} MODEL_FIELDS = ("id", "min_effort", "default_effort", "max_effort") REQUIRED_MODEL_ROLES = ("luna", "terra", "sol", "astra") @@ -532,16 +541,30 @@ def _cache_matches_policy(directory: Path, policy: Policy) -> bool: return True +def _verified_existing_cache(destination: Path, policy: Policy) -> bool: + """Return whether a published cache is exact, rejecting unsafe entries.""" + + if not os.path.lexists(destination): + return False + if _is_link_or_reparse(destination) or not destination.is_dir(): + raise RoutingError("policy cache entry is unsafe") + if not _cache_matches_policy(destination, policy): + raise RoutingError("policy cache entry does not match the effective policy") + return True + + +def _is_transient_windows_rename_error(exc: OSError) -> bool: + """Recognize only bounded-retry candidates from Windows directory sharing.""" + + return os.name == "nt" and getattr(exc, "winerror", None) in WINDOWS_TRANSIENT_RENAME_WINERRORS + + def _cache_directory(codex_home: Path, policy: Policy) -> Path: """Create a fresh content-addressed cache directory without touching an old one.""" cache_root = _safe_mkdir(_absolute(codex_home) / CACHE_RELATIVE) destination = cache_root / policy.content_hash - if os.path.lexists(destination): - if _is_link_or_reparse(destination) or not destination.is_dir(): - raise RoutingError("policy cache entry is unsafe") - if not _cache_matches_policy(destination, policy): - raise RoutingError("policy cache entry does not match the effective policy") + if _verified_existing_cache(destination, policy): return destination staging = cache_root / f".routing-staging-{policy.content_hash}-{secrets.token_hex(8)}" try: @@ -549,21 +572,34 @@ def _cache_directory(codex_home: Path, policy: Policy) -> Path: staging.mkdir() render_policy(policy, staging) # os.rename does not overwrite an existing destination on Windows. - os.rename(staging, destination) - except OSError as exc: - # Windows uses EEXIST and Linux may use ENOTEMPTY when a concurrent - # hook has already atomically published this non-empty directory. - if exc.errno in {errno.EEXIST, errno.ENOTEMPTY}: - if ( - os.path.lexists(destination) - and not _is_link_or_reparse(destination) - and destination.is_dir() - and _cache_matches_policy(destination, policy) - ): + for attempt in range(CACHE_RENAME_MAX_ATTEMPTS): + try: + os.rename(staging, destination) return destination - raise RoutingError("policy cache entry appeared during rendering") from exc + except OSError as exc: + # Windows uses EEXIST and Linux may use ENOTEMPTY when a + # concurrent hook has already atomically published this + # non-empty directory. Never accept a link or mismatched + # directory as a concurrent winner. + if exc.errno in {errno.EEXIST, errno.ENOTEMPTY}: + if _verified_existing_cache(destination, policy): + return destination + raise RoutingError("policy cache entry appeared during rendering") from exc + # A fresh directory can be temporarily held by Windows file + # indexing or another local process. WinError 5 is also used + # for permanent denial, so this remains short and bounded; a + # persistent failure still escapes as a RoutingError. + if ( + _is_transient_windows_rename_error(exc) + and attempt + 1 < CACHE_RENAME_MAX_ATTEMPTS + ): + if _verified_existing_cache(destination, policy): + return destination + time.sleep(CACHE_RENAME_RETRY_SECONDS * (attempt + 1)) + continue + raise RoutingError("cannot create the policy cache entry") from exc + except OSError as exc: raise RoutingError("cannot create the policy cache entry") from exc - return destination def _guidance_directories(cwd: Path, codex_home: Path | None) -> list[Path]: diff --git a/plugins/codex-task-routing/skills/task-routing/SKILL.md b/plugins/codex-task-routing/skills/task-routing/SKILL.md index b559f41..377128b 100644 --- a/plugins/codex-task-routing/skills/task-routing/SKILL.md +++ b/plugins/codex-task-routing/skills/task-routing/SKILL.md @@ -5,6 +5,8 @@ description: Apply Codex Task Routing for substantial independent tasks, includi # Task routing +Avoid a classifier-only model call or repeated full tool inventories. For a substantial Chat candidate, reuse the scoped, expiring capability inventory and deterministic `chat_plan.py` checks in [capability reuse](references/chat-capabilities.md); short work needs no extra planning files. Use [local transfer](references/chat-transfer.md) when supported Browser DOM operations can move the prepared prompt and final response without echoing their full text into the parent's context. These helpers do not authorize dispatch, attest a model, or replace semantic acceptance. + For an enabled normal Chat route, evaluate Chat suitability before choosing a native child for each substantial independent work unit; users need not mention this plugin again. Prefer 6 Pro for work that can finish with approved materials and verified tools when preparation, waiting, acceptance and rework are proportionate. Split analysis or design from local execution when useful. Preserve the parent's useful independent work requirement. A small remainder or repeated local inspection stays in Codex. Use the route reference's selection and handoff checks; enabling Chat does not grant data sharing or tool permissions. Use the effective policy already supplied by this plugin's start hook. Keep the user's chosen parent model and effort. It authorizes choosing suitable standard subagents only within the user's task, available tools, and higher-priority instructions. A child requires a bounded independent task and useful independent work for its direct parent. Short work stays with the current capable agent. diff --git a/plugins/codex-task-routing/skills/task-routing/references/chat-capabilities.md b/plugins/codex-task-routing/skills/task-routing/references/chat-capabilities.md new file mode 100644 index 0000000..f5e74b8 --- /dev/null +++ b/plugins/codex-task-routing/skills/task-routing/references/chat-capabilities.md @@ -0,0 +1,72 @@ +# 接続の再利用と機械的な適合確認 + +自然文の意味判断、機密の範囲、引継ぎが見合うかはrootが一度判断します。分類専用の子やChatを起動しません。短い仕事ではCLI入力を作る負担も避け、その場で判断します。まとまった工程では下の事実JSONと確認済み接続を `chat_plan.py` で照合できます。これは送信許可や実行器ではなく、モデルを使わない補助です。 + +## 接続一覧を毎回読まない + +同じ工程は同じfactsファイルと一行の選定理由を再利用し、材料・範囲・必要操作・親の独立作業・準備や検収の負担が変わった時だけ意味判断をやり直します。期限の照合はCLIで再実行でき、分類のためにモデルへ材料を再送しません。新しいChatのUI・6 Pro・必要プラグインの提供状態は現在の画面で確認します。 + +初回は依頼に必要な接続だけ、利用者が一覧調査を依頼した時は既存接続のメタデータだけを確認します。サポートされたChatの設定・一時Chatのプラスメニューで確認し、Codex側のツール一覧からChatの能力を推測しません。接続の確認だけを目的にメール本文や顧客データを取得しません。 + +結果は利用者が指定したローカル領域、通常は `