From be5d537db29f6acc135f2e60fc860449657e0cd2 Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Mon, 28 Sep 2026 18:41:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(opencode):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=97=A7=E7=89=88=E5=8D=8F=E8=AE=AEprofile=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1.16.0/evidence/session.schema.json | 1840 +++++++++++++++++ .../2.0.0/evidence/deletion.contract.json | 734 +++++++ .../2.0.0/evidence/transfer.schema.json | 1689 +++++++++++++++ package.json | 3 + scripts/verify-legacy-managed-server.ts | 55 + scripts/verify-opencode-v1.ts | 12 +- scripts/verify-package.ts | 7 +- scripts/verify-version-contract.ts | 54 +- src/cli/commands.ts | 1 + src/cli/interactive-migrate.ts | 86 +- src/migration/__tests__/executor.test.ts | 14 + src/migration/__tests__/manifest.test.ts | 16 + src/migration/__tests__/plan.test.ts | 12 + src/migration/__tests__/target.test.ts | 27 +- src/migration/executor.ts | 19 +- src/migration/manifest.ts | 14 + src/migration/plan.ts | 4 +- src/migration/rollback.ts | 6 +- src/migration/target.ts | 26 +- src/target/__tests__/opencode-binary.test.ts | 20 + .../__tests__/opencode-capability.test.ts | 82 +- .../__tests__/opencode-contract.test.ts | 50 +- .../__tests__/opencode-deletion.test.ts | 6 + .../__tests__/opencode-v1-capability.test.ts | 64 +- src/target/opencode/binary.ts | 28 +- src/target/opencode/capability-probe.ts | 46 +- src/target/opencode/compatibility.ts | 3 +- src/target/opencode/contract.ts | 27 +- src/target/opencode/deletion.ts | 16 +- src/target/opencode/dialect.ts | 15 +- src/target/opencode/native-adapter.ts | 111 +- src/target/opencode/protocol-rules.ts | 96 +- src/target/opencode/v1/adapter.ts | 18 +- src/target/opencode/v1/contract.ts | 19 +- tsconfig.integration.json | 1 + 35 files changed, 5125 insertions(+), 96 deletions(-) create mode 100644 fixtures/opencode/1.16.0/evidence/session.schema.json create mode 100644 fixtures/opencode/2.0.0/evidence/deletion.contract.json create mode 100644 fixtures/opencode/2.0.0/evidence/transfer.schema.json create mode 100644 scripts/verify-legacy-managed-server.ts diff --git a/fixtures/opencode/1.16.0/evidence/session.schema.json b/fixtures/opencode/1.16.0/evidence/session.schema.json new file mode 100644 index 0000000..2a2a01b --- /dev/null +++ b/fixtures/opencode/1.16.0/evidence/session.schema.json @@ -0,0 +1,1840 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": [ + "info", + "messages" + ], + "properties": { + "info": { + "$ref": "#/components/schemas/Session" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "info", + "parts" + ], + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + } + }, + "components": { + "schemas": { + "Part": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPart" + }, + { + "$ref": "#/components/schemas/SubtaskPart" + }, + { + "$ref": "#/components/schemas/ReasoningPart" + }, + { + "$ref": "#/components/schemas/FilePart" + }, + { + "$ref": "#/components/schemas/ToolPart" + }, + { + "$ref": "#/components/schemas/StepStartPart" + }, + { + "$ref": "#/components/schemas/StepFinishPart" + }, + { + "$ref": "#/components/schemas/SnapshotPart" + }, + { + "$ref": "#/components/schemas/PatchPart" + }, + { + "$ref": "#/components/schemas/AgentPart" + }, + { + "$ref": "#/components/schemas/RetryPart" + }, + { + "$ref": "#/components/schemas/CompactionPart" + } + ] + }, + "CompactionPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "auto": { + "type": "boolean" + }, + "overflow": { + "type": "boolean" + }, + "tail_start_id": { + "type": "string", + "pattern": "^msg" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "auto" + ], + "additionalProperties": false + }, + "RetryPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "retry" + ] + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "error": { + "$ref": "#/components/schemas/APIError" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "attempt", + "error", + "time" + ], + "additionalProperties": false + }, + "APIError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "APIError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "minimum": 0 + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "message", + "isRetryable" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "AgentPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "agent" + ] + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "value", + "start", + "end" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "name" + ], + "additionalProperties": false + }, + "PatchPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "patch" + ] + }, + "hash": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "hash", + "files" + ], + "additionalProperties": false + }, + "SnapshotPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "snapshot" + ], + "additionalProperties": false + }, + "StepFinishPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "step-finish" + ] + }, + "reason": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "reason", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "StepStartPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "step-start" + ] + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type" + ], + "additionalProperties": false + }, + "ToolPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ToolState" + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "callID", + "tool", + "state" + ], + "additionalProperties": false + }, + "ToolState": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolStatePending" + }, + { + "$ref": "#/components/schemas/ToolStateRunning" + }, + { + "$ref": "#/components/schemas/ToolStateCompleted" + }, + { + "$ref": "#/components/schemas/ToolStateError" + } + ] + }, + "ToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "input": { + "type": "object" + }, + "error": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "input", + "error", + "time" + ], + "additionalProperties": false + }, + "ToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "input": { + "type": "object" + }, + "output": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + }, + "compacted": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePart" + } + } + }, + "required": [ + "status", + "input", + "output", + "title", + "metadata", + "time" + ], + "additionalProperties": false + }, + "FilePart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "mime", + "url" + ], + "additionalProperties": false + }, + "FilePartSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSource" + }, + { + "$ref": "#/components/schemas/SymbolSource" + }, + { + "$ref": "#/components/schemas/ResourceSource" + } + ] + }, + "ResourceSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "resource" + ] + }, + "clientName": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "clientName", + "uri" + ], + "additionalProperties": false + }, + "FilePartSourceText": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "value", + "start", + "end" + ], + "additionalProperties": false + }, + "SymbolSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "symbol" + ] + }, + "path": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "text", + "type", + "path", + "range", + "name", + "kind" + ], + "additionalProperties": false + }, + "Range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "character": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0 + }, + "character": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "FileSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "path" + ], + "additionalProperties": false + }, + "ToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "input": { + "type": "object" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "start" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "input", + "time" + ], + "additionalProperties": false + }, + "ToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "input": { + "type": "object" + }, + "raw": { + "type": "string" + } + }, + "required": [ + "status", + "input", + "raw" + ], + "additionalProperties": false + }, + "ReasoningPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ] + }, + "text": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "start" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "time" + ], + "additionalProperties": false + }, + "SubtaskPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "subtask" + ] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "prompt", + "description", + "agent" + ], + "additionalProperties": false + }, + "TextPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "start" + ], + "additionalProperties": false + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text" + ], + "additionalProperties": false + }, + "Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + } + ] + }, + "AssistantMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "role": { + "type": "string", + "enum": [ + "assistant" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + "parentID": { + "type": "string", + "pattern": "^msg" + }, + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "cwd", + "root" + ], + "additionalProperties": false + }, + "summary": { + "type": "boolean" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "structured": {}, + "variant": { + "type": "string" + }, + "finish": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "ContextOverflowError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ContextOverflowError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "responseBody": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "StructuredOutputError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "StructuredOutputError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "retries": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "message", + "retries" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "MessageAbortedError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "MessageAbortedError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "MessageOutputLengthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "MessageOutputLengthError" + ] + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "UnknownError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "UnknownError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "ProviderAuthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ProviderAuthError" + ] + }, + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "providerID", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "role": { + "type": "string", + "enum": [ + "user" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "summary": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "diffs" + ], + "additionalProperties": false + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ], + "additionalProperties": false + }, + "system": { + "type": "string" + }, + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "agent", + "model" + ], + "additionalProperties": false + }, + "SnapshotFileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] + } + }, + "required": [ + "additions", + "deletions" + ], + "additionalProperties": false + }, + "OutputFormat": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputFormatText" + }, + { + "$ref": "#/components/schemas/OutputFormatJsonSchema" + } + ] + }, + "OutputFormatJsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "json_schema" + ] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + }, + "JSONSchema": { + "type": "object" + }, + "OutputFormatText": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "additions", + "deletions", + "files" + ], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "id", + "providerID" + ], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "slug", + "projectID", + "directory", + "title", + "version", + "time" + ], + "additionalProperties": false + }, + "PermissionRuleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRule" + } + }, + "PermissionRule": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/PermissionAction" + } + }, + "required": [ + "permission", + "pattern", + "action" + ], + "additionalProperties": false + }, + "PermissionAction": { + "type": "string", + "enum": [ + "allow", + "deny", + "ask" + ] + } + } + } +} diff --git a/fixtures/opencode/2.0.0/evidence/deletion.contract.json b/fixtures/opencode/2.0.0/evidence/deletion.contract.json new file mode 100644 index 0000000..f221c46 --- /dev/null +++ b/fixtures/opencode/2.0.0/evidence/deletion.contract.json @@ -0,0 +1,734 @@ +{ + "paths": { + "/api/session": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.list", + "parameters": [ + { + "name": "workspace", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "pattern": "^wrk" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Maximum number of sessions to return. Defaults to the newest 50 sessions." + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + { + "type": "null" + } + ], + "description": "Session order for the first page. Use desc for newest first or asc for oldest first." + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "parentID", + "in": "query", + "schema": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "pattern": "^ses" + }, + { + "type": "string", + "enum": [ + "null" + ] + } + ], + "description": "Filter by parent session. Use null to return only root sessions." + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "directory", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "project", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "subpath", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response." + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionsResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsResponse" + } + } + } + }, + "400": { + "description": "InvalidCursorError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidCursorErrorEncoded" + }, + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + }, + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List sessions" + }, + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Session.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + } + }, + "description": "Create a session at the requested location.", + "summary": "Create session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "pattern": "^ses" + }, + { + "type": "null" + } + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Metadata" + }, + { + "type": "null" + } + ] + }, + "permissions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Permission.Ruleset" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Session.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" + } + } + } + } + }, + "description": "Retrieve a session by ID.", + "summary": "Get session" + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" + } + } + } + } + }, + "description": "Delete a session and its child sessions.", + "summary": "Delete session" + } + }, + "/api/session/import": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.import", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Session.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictErrorEncoded" + } + } + } + } + }, + "description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.", + "summary": "Import session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session.Info" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message.Info" + } + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "info", + "messages" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/export": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.export", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "sanitize", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionTransfer.Data" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorEncoded" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownErrorEncoded" + } + } + } + } + }, + "description": "Export a complete projected session transcript.", + "summary": "Export session" + } + } + }, + "components": { + "schemas": { + "SessionsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Info" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "data", + "cursor" + ], + "additionalProperties": false + } + } + } +} diff --git a/fixtures/opencode/2.0.0/evidence/transfer.schema.json b/fixtures/opencode/2.0.0/evidence/transfer.schema.json new file mode 100644 index 0000000..82505f1 --- /dev/null +++ b/fixtures/opencode/2.0.0/evidence/transfer.schema.json @@ -0,0 +1,1689 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/components/schemas/SessionTransfer.Data", + "components": { + "schemas": { + "SessionTransfer.Data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session.Info" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message.Info" + } + } + }, + "required": [ + "info", + "messages" + ], + "additionalProperties": false + }, + "Session.Message.Info": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.AgentSelected" + }, + { + "$ref": "#/components/schemas/Session.Message.ModelSelected" + }, + { + "$ref": "#/components/schemas/Session.Message.LocationSwitched" + }, + { + "$ref": "#/components/schemas/Session.Message.User" + }, + { + "$ref": "#/components/schemas/Session.Message.Synthetic" + }, + { + "$ref": "#/components/schemas/Session.Message.System" + }, + { + "$ref": "#/components/schemas/Session.Message.Skill" + }, + { + "$ref": "#/components/schemas/Session.Message.Shell" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction" + } + ] + }, + "Session.Message.Compaction": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Compaction.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Failed" + } + ] + }, + "Session.Message.Compaction.Failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "error" + ], + "additionalProperties": false + }, + "TokenUsage.Info": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "Money.USD": { + "type": "number" + }, + "Session.StructuredError": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Completed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_5" + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + }, + "providerContext": { + "$ref": "#/components/schemas/Session.ProviderContext" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.ProviderContext": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "provenance": { + "$ref": "#/components/schemas/Session.ProviderContext.Provenance" + }, + "messages": {} + }, + "required": [ + "version", + "provenance", + "messages" + ], + "additionalProperties": false + }, + "Session.ProviderContext.Provenance": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "route": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "providerID", + "provider", + "modelID", + "route", + "protocol", + "endpoint" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState_5": { + "type": "object" + }, + "Model.Ref": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "id", + "providerID" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Running": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.Message.Assistant": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "streamed": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "assistant" + ] + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Assistant.Text" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Reasoning" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Tool" + } + ] + } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] + }, + "rawFinish": { + "type": "string" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_4" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + }, + "retry": { + "$ref": "#/components/schemas/Session.Message.Assistant.Retry" + } + }, + "required": [ + "id", + "time", + "type", + "agent", + "model", + "content" + ], + "additionalProperties": false + }, + "Session.Message.Assistant.Retry": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "at": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "attempt", + "at", + "error" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState_4": { + "type": "object" + }, + "Session.Message.Assistant.Tool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "executed": { + "type": "boolean" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_2" + }, + "providerResultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_3" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.ToolState.Streaming" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Error" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "id", + "name", + "state", + "time" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Error": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "input": { + "type": "object" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + }, + "content": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/components/schemas/Tool.Content" + } + ], + "minItems": 1, + "items": { + "$ref": "#/components/schemas/Tool.Content" + } + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "status", + "input", + "error" + ], + "additionalProperties": false + }, + "Tool.Content": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + }, + "Tool.FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.TextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Completed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "input": { + "type": "object" + }, + "content": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/components/schemas/Tool.Content" + } + ], + "minItems": 1, + "items": { + "$ref": "#/components/schemas/Tool.Content" + } + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "status", + "input", + "content" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Running": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "input": { + "type": "object" + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "status", + "input", + "metadata" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Streaming": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "streaming" + ] + }, + "input": { + "type": "string" + } + }, + "required": [ + "status", + "input" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState_3": { + "type": "object" + }, + "Session.Message.ProviderState_2": { + "type": "object" + }, + "Session.Message.Assistant.Reasoning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ] + }, + "text": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState_1" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState_1": { + "type": "object" + }, + "Session.Message.Assistant.Text": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState": { + "type": "object" + }, + "Session.Message.Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "shell" + ] + }, + "shellID": { + "type": "string", + "pattern": "^sh_" + }, + "command": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "output": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "time", + "type", + "shellID", + "command", + "status" + ], + "additionalProperties": false + }, + "Session.Message.Skill": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "skill" + ] + }, + "skill": { + "type": "string" + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "skill", + "name", + "text" + ], + "additionalProperties": false + }, + "Session.Message.System": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "system" + ] + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "text" + ], + "additionalProperties": false + }, + "Session.Message.Synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "synthetic" + ] + } + }, + "required": [ + "id", + "time", + "text", + "type" + ], + "additionalProperties": false + }, + "Session.Message.User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.SkillAttachment" + } + }, + "type": { + "type": "string", + "enum": [ + "user" + ] + } + }, + "required": [ + "id", + "time", + "text", + "type" + ], + "additionalProperties": false + }, + "Prompt.SkillAttachment": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + }, + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "Prompt.Mention": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": [ + "start", + "end", + "text" + ], + "additionalProperties": false + }, + "Prompt.AgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "Prompt.FileAttachment": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Prompt.Base64" + }, + "mime": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.FileSource" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" + } + }, + "required": [ + "data", + "mime", + "source" + ], + "additionalProperties": false + }, + "Prompt.FileSource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "uri" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + } + ] + }, + "Prompt.Base64": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + }, + "Session.Message.LocationSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "location-switched" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "projectID": { + "type": "string" + }, + "subpath": { + "type": "string" + }, + "previous": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "projectID": { + "type": "string" + }, + "subpath": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "time", + "type", + "location" + ], + "additionalProperties": false + }, + "Location.Ref": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "Session.Message.ModelSelected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "model-switched" + ] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": [ + "id", + "time", + "type", + "model" + ], + "additionalProperties": false + }, + "Session.Message.AgentSelected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^msg_" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "agent-switched" + ] + }, + "agent": { + "type": "string" + }, + "previous": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "agent" + ], + "additionalProperties": false + }, + "Session.Info": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "fork": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" + } + }, + "required": [ + "sessionID", + "boundary" + ], + "additionalProperties": false + }, + "projectID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" + }, + "outcome": { + "type": "string", + "enum": [ + "succeeded", + "failed", + "interrupted" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "idle": { + "type": "number" + }, + "viewed": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Session.Metadata" + }, + "permissions": { + "$ref": "#/components/schemas/Permission.Ruleset" + }, + "revert": { + "$ref": "#/components/schemas/Session.Revert" + } + }, + "required": [ + "id", + "projectID", + "cost", + "tokens", + "time", + "location" + ], + "additionalProperties": false + }, + "Session.Revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff.Info" + } + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + }, + "FileDiff.Info": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] + } + }, + "required": [ + "file", + "patch", + "additions", + "deletions", + "status" + ], + "additionalProperties": false + }, + "Permission.Ruleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission.Rule" + } + }, + "Permission.Rule": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "effect": { + "$ref": "#/components/schemas/Permission.Effect" + } + }, + "required": [ + "action", + "resource", + "effect" + ], + "additionalProperties": false + }, + "Permission.Effect": { + "type": "string", + "enum": [ + "allow", + "deny", + "ask" + ] + }, + "Session.Metadata": { + "type": "object" + }, + "Session.ForkBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + } + ] + } + } + } +} diff --git a/package.json b/package.json index fcf159b..1621ad4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "CHANGELOG.md", "docs/**/*.md", "fixtures/opencode/2.0.12/evidence/transfer.schema.json", + "fixtures/opencode/2.0.0/evidence/transfer.schema.json", "fixtures/opencode/1.18.32/evidence/session.schema.json", + "fixtures/opencode/1.16.0/evidence/session.schema.json", "fixtures/ir/v1/valid-trae-assembled.json" ], "engines": { @@ -34,6 +36,7 @@ "verify:platform": "tsx scripts/verify-platform.ts", "verify:versions": "tsx scripts/verify-version-contract.ts", "verify:integration:v1": "tsx scripts/verify-opencode-v1.ts", + "verify:legacy-startup": "tsx scripts/verify-legacy-managed-server.ts", "verify:resilience": "node --max-old-space-size=512 --import tsx scripts/verify-resilience.ts", "verify:version": "tsx scripts/verify-product-version.ts", "verify:package": "tsx scripts/verify-package.ts", diff --git a/scripts/verify-legacy-managed-server.ts b/scripts/verify-legacy-managed-server.ts new file mode 100644 index 0000000..a737ba3 --- /dev/null +++ b/scripts/verify-legacy-managed-server.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { + startManagedOpenCodeServer, +} from "../src/cli/interactive-migrate.js"; +import { resolveOpenCodeBinary } from "../src/target/opencode/binary.js"; + +const configured = process.env.T2O_TEST_V2_LEGACY_BINARY; +assert.ok(configured, "Set T2O_TEST_V2_LEGACY_BINARY to OpenCode 2.0.0"); +const binary = resolveOpenCodeBinary(path.resolve(configured)); +await fs.mkdir("tmp", { recursive: true }); +const root = await fs.mkdtemp(path.resolve("tmp", "legacy-managed-server-")); +Object.assign(process.env, { + HOME: root, + USERPROFILE: root, + APPDATA: path.join(root, "roaming"), + LOCALAPPDATA: path.join(root, "local"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + OPENCODE_CONFIG_CONTENT: "{}", + OPENCODE_DISABLE_PROJECT_CONFIG: "1", +}); + +let server: Awaited> | undefined; +try { + server = await startManagedOpenCodeServer(binary, "v2"); + const authorization = `Basic ${Buffer.from(`opencode:${server.password}`).toString("base64")}`; + const response = await fetch(new URL("/api/health", server.url), { + headers: { authorization }, + signal: AbortSignal.timeout(5_000), + }); + assert.equal(response.status, 200); + const body = await response.json() as { healthy?: unknown; version?: unknown }; + assert.equal(body.healthy, true); + assert.equal(body.version, "2.0.0"); + const report = { + platform: process.platform, + node: process.version, + version: body.version, + serviceDescriptorFallback: true, + loopbackOnly: new URL(server.url).hostname === "127.0.0.1", + status: "verified", + }; + await fs.writeFile( + "tmp/m7-2-legacy-startup-report.json", + JSON.stringify(report, null, 2) + "\n", + ); + console.log(JSON.stringify(report)); +} finally { + await server?.close(); + await fs.rm(root, { recursive: true, force: true }); +} diff --git a/scripts/verify-opencode-v1.ts b/scripts/verify-opencode-v1.ts index 617f3d9..2327ccd 100644 --- a/scripts/verify-opencode-v1.ts +++ b/scripts/verify-opencode-v1.ts @@ -13,7 +13,7 @@ import { resolveOpenCodeBinary } from "../src/target/opencode/binary.js"; import { withIsolatedOpenCodeServer } from "../src/target/opencode/isolated-server.js"; /** - * Baseline and unreviewed compatible OpenCode 1.x releases use their real native binary: + * Current, legacy and unreviewed OpenCode 1.x releases use their real native binary: * version, health, `/doc` routes, the reviewed schema hash, a real import/export round * trip with readback reconciliation, conflict detection and rollback. */ @@ -21,6 +21,7 @@ import { withIsolatedOpenCodeServer } from "../src/target/opencode/isolated-serv // T2O_TEST_OPENCODE_BINARY overrides it; adjacent native release from // tmp/opencode-v1-adjacent unless T2O_TEST_V1_ADJACENT_BINARY overrides it. const adjacentDefault = path.resolve("tmp/opencode-v1-adjacent/node_modules/opencode-ai/bin/opencode.exe"); +const legacyDefault = path.resolve("tmp/opencode-v1-legacy/node_modules/opencode-ai/bin/opencode.exe"); const configured = [ { label: "current", value: process.env.T2O_TEST_OPENCODE_BINARY ?? "opencode" }, { @@ -28,6 +29,11 @@ const configured = [ value: process.env.T2O_TEST_V1_ADJACENT_BINARY ?? (existsSync(adjacentDefault) ? adjacentDefault : undefined), }, + { + label: "legacy", + value: process.env.T2O_TEST_V1_LEGACY_BINARY ?? + (existsSync(legacyDefault) ? legacyDefault : undefined), + }, ].filter((entry): entry is { label: string; value: string } => typeof entry.value === "string"); assert.ok(configured.length > 0, "Set T2O_TEST_OPENCODE_BINARY and/or T2O_TEST_V1_ADJACENT_BINARY"); @@ -42,6 +48,8 @@ async function verifyVersion(label: string, binary: string) { assert.equal(capabilities.writable, true, `${label}: expected a writable target (${capabilities.reasons.join(", ") || "no reason reported"})`); assert.equal(capabilities.dialect, "v1", `${label}: expected the v1 dialect`); + assert.equal(capabilities.protocolRule, label === "legacy" + ? "v1-cli-library-legacy" : "v1-cli-library"); assert.equal(capabilities.binaryVersion, capabilities.serverVersion); assert.ok(capabilities.schemaHash, `${label}: missing schema hash`); assert.equal(capabilities.compatibility, isVerifiedOpenCodeVersion(capabilities.binaryVersion) @@ -49,6 +57,7 @@ async function verifyVersion(label: string, binary: string) { const plan = await buildMigrationPlan(structuredClone(bundle) as MigrationBundle, { dialect: capabilities.dialect, + protocolRule: capabilities.protocolRule!, targetVersion: capabilities.binaryVersion!, fallbackDirectory: server.directory, }); @@ -83,6 +92,7 @@ async function verifyVersion(label: string, binary: string) { results.push({ label, binary: resolved, version: capabilities.binaryVersion, + protocolRule: capabilities.protocolRule, compatibility: capabilities.compatibility, resume: true, schemaHash: capabilities.schemaHash, messages: readback.messages.length, parts: parts.length, status: "verified", diff --git a/scripts/verify-package.ts b/scripts/verify-package.ts index b49f0d9..b10ee2b 100644 --- a/scripts/verify-package.ts +++ b/scripts/verify-package.ts @@ -13,7 +13,9 @@ const npmCli = process.env.npm_execpath; assert.ok(npmCli, "Run through npm run verify:package"); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "t2o package 中文 ")); const schema = "fixtures/opencode/2.0.12/evidence/transfer.schema.json"; +const legacySchema = "fixtures/opencode/2.0.0/evidence/transfer.schema.json"; const v1Schema = "fixtures/opencode/1.18.32/evidence/session.schema.json"; +const legacyV1Schema = "fixtures/opencode/1.16.0/evidence/session.schema.json"; const sample = "fixtures/ir/v1/valid-trae-assembled.json"; const npm = async (args: string[], cwd: string) => { const result = await exec(process.execPath, [npmCli, ...args], { @@ -34,7 +36,7 @@ try { const files = artifact.files.map((file) => file.path); for (const required of [ "package.json", "README.md", "CHANGELOG.md", "LICENSE", "dist/cli/index.js", - schema, v1Schema, sample, + schema, legacySchema, v1Schema, legacyV1Schema, sample, ]) { assert.ok(files.includes(required), `Missing package file: ${required}`); } @@ -43,7 +45,8 @@ try { file === "CHANGELOG.md" || file === "LICENSE" || (file.startsWith("dist/") && file.endsWith(".js") && !file.includes("/__tests__/")) || (file.startsWith("docs/") && file.endsWith(".md")) || - file === schema || file === v1Schema || file === sample; + file === schema || file === legacySchema || + file === v1Schema || file === legacyV1Schema || file === sample; assert.ok(allowed, `Unexpected package file: ${file}`); } const installation = path.join(temporary, "fresh installation"); diff --git a/scripts/verify-version-contract.ts b/scripts/verify-version-contract.ts index 19d6b2b..65985ed 100644 --- a/scripts/verify-version-contract.ts +++ b/scripts/verify-version-contract.ts @@ -1,4 +1,4 @@ -/** Reviewed releases and an unreviewed native binary must satisfy the actual protocol. */ +/** Current, legacy and unreviewed native binaries must satisfy their actual protocols. */ import assert from "node:assert/strict"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -9,7 +9,9 @@ import { migrate, verifyMigration } from "../src/migration/executor.js"; import { rollbackMigration } from "../src/migration/rollback.js"; import { createMigrationTarget } from "../src/migration/target.js"; import { requireOpenCodeCapabilities, probeOpenCodeCapabilities } from "../src/target/opencode/capability-probe.js"; -import { assertOpenCodeTransfer, TRANSFER_SCHEMA_HASH } from "../src/target/opencode/contract.js"; +import { + assertOpenCodeTransfer, LEGACY_TRANSFER_SCHEMA_HASH, TRANSFER_SCHEMA_HASH, +} from "../src/target/opencode/contract.js"; import { withIsolatedOpenCodeServer } from "../src/target/opencode/isolated-server.js"; import { mapOpenCodeSession, @@ -39,6 +41,7 @@ const adjacent = path.resolve(process.env.T2O_TEST_ADJACENT_BINARY ?? "tmp/opencode-adjacent/node_modules/@opencode/cli/bin/opencode.exe"); const current = process.env.T2O_TEST_OPENCODE_BINARY; const compatible = process.env.T2O_TEST_COMPATIBLE_BINARY; +const legacy = process.env.T2O_TEST_V2_LEGACY_BINARY; let baselineReport: Record | undefined; await withIsolatedOpenCodeServer({ temporaryRoot: "tmp", ...(current ? { binary: path.resolve(current) } : {}), @@ -93,7 +96,8 @@ await withIsolatedOpenCodeServer({ binary: adjacent, temporaryRoot: "tmp" }, asy assert.equal(capabilities.serverVersion, "2.0.11"); assert.equal(capabilities.compatibility, "isolated-roundtrip"); const plan = await buildMigrationPlan(await readBundleFile("fixtures/ir/v1/valid-trae-assembled.json"), { - dialect: capabilities.dialect, targetVersion: capabilities.binaryVersion!, + dialect: capabilities.dialect, protocolRule: capabilities.protocolRule!, + targetVersion: capabilities.binaryVersion!, fallbackDirectory: server.directory, }); const target = createMigrationTarget({ @@ -178,10 +182,54 @@ if (compatible) { }); } +let legacyReport: Record | undefined; +if (legacy) { + await withIsolatedOpenCodeServer({ + temporaryRoot: "tmp", + binary: path.resolve(legacy), + }, async (server) => { + const capabilities = await requireOpenCodeCapabilities(server.transport); + assert.equal(capabilities.binaryVersion, "2.0.0"); + assert.equal(capabilities.serverVersion, "2.0.0"); + assert.equal(capabilities.protocolRule, "v2-session-transfer-legacy"); + assert.equal(capabilities.schemaHash, LEGACY_TRANSFER_SCHEMA_HASH); + + const plan = await buildMigrationPlan( + await readBundleFile("fixtures/ir/v1/valid-trae-assembled.json"), + { + dialect: capabilities.dialect, + protocolRule: capabilities.protocolRule, + targetVersion: capabilities.binaryVersion!, + fallbackDirectory: server.directory, + }, + ); + const target = createMigrationTarget({ + serverUrl: server.serverUrl, transport: server.transport, temporaryRoot: server.directory, + }); + const outputDirectory = path.join(server.directory, "legacy-migration"); + const first = await migrate(plan, target, { outputDirectory }); + assert.equal(first.verified, 1); + assert.equal(first.hasFailures, false); + const manifest = path.join(outputDirectory, first.manifest); + assert.equal((await verifyMigration(manifest, target)).hasFailures, false); + assert.equal((await migrate(plan, target, { resumeManifest: manifest })).hasFailures, false); + assert.equal((await rollbackMigration(manifest, target, { + confirm: first.runId, exclusiveTarget: true, + })).hasFailures, false); + assert.equal(await target.readSession(plan.sessions[0].targetId), null); + legacyReport = { + version: capabilities.binaryVersion, + protocolRule: capabilities.protocolRule, + migration: true, verify: true, resume: true, rollback: true, + }; + }); +} + const report = { ...baselineReport, unreviewed: unreviewedReport, ...(compatibleReport ? { compatible: compatibleReport } : {}), + ...(legacyReport ? { legacy: legacyReport } : {}), }; await fs.writeFile("tmp/m7-2-version-report.json", JSON.stringify(report, null, 2) + "\n"); console.log(JSON.stringify(report)); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index df5d250..0192208 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -108,6 +108,7 @@ export async function executeReadCommand(command: string, options: CommandOption recovery, pathMaps, namespace: options.namespace, fallbackDirectory: options.fallbackDirectory, ...(capabilities === undefined ? {} : { dialect: capabilities.dialect, + protocolRule: capabilities.protocolRule ?? undefined, targetVersion: capabilities.binaryVersion ?? OPENCODE_VERSION, }), }); diff --git a/src/cli/interactive-migrate.ts b/src/cli/interactive-migrate.ts index eb94378..3114efc 100644 --- a/src/cli/interactive-migrate.ts +++ b/src/cli/interactive-migrate.ts @@ -565,6 +565,26 @@ async function canAccessOpenCodeServerV1(server: string, password?: string): Pro } } +async function canAccessLegacyV2Server(server: string, password?: string): Promise { + try { + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"; + const authorization = password === undefined + ? undefined + : `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + const response = await fetch(new URL("/api/health", server), { + headers: authorization ? { authorization } : {}, + redirect: "error", + signal: AbortSignal.timeout(3_000), + }); + if (response.status !== 200) return false; + const body: unknown = await response.json(); + return body !== null && typeof body === "object" && + (body as { healthy?: unknown }).healthy === true; + } catch { + return false; + } +} + /** The executable decides the target dialect; a `serve --service` flag cannot be guessed. */ async function detectOpenCodeBinaryDialect(binary: string): Promise { const output = await new Promise((resolve) => { @@ -684,11 +704,51 @@ async function startManagedV1Server(binary: string): Promise { - if (dialect === "v1") return startManagedV1Server(binary); +async function startManagedLegacyV2Server(binary: string): Promise { + const temporaryParent = path.join(process.cwd(), "tmp"); + await fs.mkdir(temporaryParent, { recursive: true, mode: 0o700 }); + const stateDirectory = await fs.mkdtemp(path.join(temporaryParent, "opencode-state-")); + const password = randomBytes(32).toString("hex"); + const env: NodeJS.ProcessEnv = { + ...createManagedOpenCodeEnvironment(stateDirectory), + OPENCODE_SERVER_PASSWORD: password, + NO_COLOR: "1", + }; + const child = spawn(binary, [ + "serve", "--hostname", "127.0.0.1", "--port", String(MANAGED_OPENCODE_PORT), + ], { + cwd: process.cwd(), env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + }); + let printed = ""; + let failed = false; + child.once("error", () => { failed = true; }); + child.stdout?.on("data", (chunk: Buffer) => { printed += chunk.toString(); }); + child.stderr?.on("data", (chunk: Buffer) => { printed += chunk.toString(); }); + try { + for (let attempt = 0; attempt < 100; attempt++) { + if (failed || child.exitCode !== null || child.signalCode !== null) break; + const url = localServerUrl(printed); + if (url && await canAccessLegacyV2Server(url, password)) { + return { + url, + password, + async close() { + await stopManagedServer(child); + await fs.rm(stateDirectory, { recursive: true, force: true }); + }, + }; + } + await delay(100); + } + } catch { + // Fall through to the contained startup error. + } + await stopManagedServer(child); + await fs.rm(stateDirectory, { recursive: true, force: true }); + throw new Error("OPENCODE_SERVER_START_FAILED"); +} + +async function startManagedV2Service(binary: string): Promise { const temporaryParent = path.join(process.cwd(), "tmp"); await fs.mkdir(temporaryParent, { recursive: true, mode: 0o700 }); const stateDirectory = await fs.mkdtemp(path.join(temporaryParent, "opencode-state-")); @@ -734,6 +794,19 @@ async function startManagedOpenCodeServer( throw new Error("OPENCODE_SERVER_START_FAILED"); } +export async function startManagedOpenCodeServer( + binary: string, + dialect: OpenCodeDialect, +): Promise { + if (dialect === "v1") return startManagedV1Server(binary); + try { + return await startManagedV2Service(binary); + } catch { + // v2 2.0.0 predates service descriptors but prints its loopback URL like v1. + return startManagedLegacyV2Server(binary); + } +} + async function runCli( args: string[], cliPath: string, @@ -1145,7 +1218,8 @@ async function main(): Promise { serverPassword = discovered.password; console.log(`已连接当前 OpenCode ${discovered.version} 本机服务。`); } else if (!await canAccessOpenCodeServer(server, serverPassword) && - !await canAccessOpenCodeServerV1(server, serverPassword)) { + !await canAccessOpenCodeServerV1(server, serverPassword) && + !await canAccessLegacyV2Server(server, serverPassword)) { console.log("默认 OpenCode 服务不可访问,正在临时启动本机服务..."); let dialect: OpenCodeDialect; try { diff --git a/src/migration/__tests__/executor.test.ts b/src/migration/__tests__/executor.test.ts index b0d63bf..a021151 100644 --- a/src/migration/__tests__/executor.test.ts +++ b/src/migration/__tests__/executor.test.ts @@ -61,6 +61,20 @@ describe("migration executor", () => { assert.equal(fake.imports.length, 0); })); + it("rejects a plan built for another protocol profile before creating output", () => setup(async ({ + plan, fake, outputDirectory, + }) => { + plan.options.protocolRule = "v2-session-transfer"; + fake.api.describe = async () => ({ + ...descriptor, + protocolRule: "v2-session-transfer-legacy", + protocolHash: hash, + }); + await assert.rejects(migrate(plan, fake.api, { outputDirectory }), + { code: "T2O_MIGRATION_TARGET_CHANGED" }); + await assert.rejects(fs.stat(outputDirectory), { code: "ENOENT" }); + })); + it("persists intent before import, verifies content, and resumes without a second import", () => setup(async (ctx) => { const { plan, fake, filename, outputDirectory } = ctx; const nativeImport = fake.api.importSession; diff --git a/src/migration/__tests__/manifest.test.ts b/src/migration/__tests__/manifest.test.ts index 03e576a..444018e 100644 --- a/src/migration/__tests__/manifest.test.ts +++ b/src/migration/__tests__/manifest.test.ts @@ -49,6 +49,22 @@ describe("migration manifest persistence", () => { assert.deepEqual((await readManifest(filename)).target, manifest.target); })); + it("persists the selected protocol profile and rejects a cross-dialect profile", () => temporary(async (filename) => { + const manifest = emptyManifest(); + manifest.target.binaryVersion = "1.16.0"; + manifest.target.serverVersion = "1.16.0"; + manifest.target.protocolRule = "v1-cli-library-legacy"; + manifest.target.protocolHash = hash; + await withManifestStore(filename, async (store) => store.save(manifest)); + assert.equal((await readManifest(filename)).target.protocolRule, "v1-cli-library-legacy"); + manifest.target.protocolRule = "v2-session-transfer-legacy"; + await assert.rejects(withManifestStore(filename, async (store) => store.save(manifest)), + { code: "T2O_MIGRATION_CHECKPOINT_FAILED" }); + delete manifest.target.protocolRule; + await assert.rejects(withManifestStore(filename, async (store) => store.save(manifest)), + { code: "T2O_MIGRATION_CHECKPOINT_FAILED" }); + })); + it("persists unreviewed stable versions without discarding the exact target identity", () => temporary(async (filename) => { for (const version of ["2.0.11", "2.1.0", "1.18.31"]) { const manifest = emptyManifest(); diff --git a/src/migration/__tests__/plan.test.ts b/src/migration/__tests__/plan.test.ts index 84b3b0a..236cd39 100644 --- a/src/migration/__tests__/plan.test.ts +++ b/src/migration/__tests__/plan.test.ts @@ -105,6 +105,18 @@ describe("migration plan", () => { assert.deepEqual(assistant.info.path, { cwd: process.cwd(), root: process.cwd() }); }); + it("builds the legacy v1 payload through the selected protocol profile", async () => { + const plan = await buildMigrationPlan(await fixture(), { + dialect: "v1", + protocolRule: "v1-cli-library-legacy", + targetVersion: "1.16.0", + fallbackDirectory: process.cwd(), + }); + assert.equal(plan.options.protocolRule, "v1-cli-library-legacy"); + assert.equal(plan.sessions[0].status, "ready"); + assert.equal(plan.sessions[0].transfer?.info.version, "1.16.0"); + }); + it("snapshots caller inputs before asynchronous directory checks", async () => { const bundle = await fixture(); const mutableOptions = { ...options }; diff --git a/src/migration/__tests__/target.test.ts b/src/migration/__tests__/target.test.ts index 8c5e226..0aab3fe 100644 --- a/src/migration/__tests__/target.test.ts +++ b/src/migration/__tests__/target.test.ts @@ -1,8 +1,33 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { createMigrationTarget } from "../target.js"; +import { + createMigrationTarget, migrationTargetDescriptorsMatch, type MigrationTargetDescriptor, +} from "../target.js"; describe("migration target", () => { + it("matches pre-profile descriptors only when their core target evidence is unchanged", () => { + const core = { + endpointHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + binaryVersion: "2.0.12", + serverVersion: "2.0.12", + schemaHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + fingerprint: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + } satisfies MigrationTargetDescriptor; + const current = { + ...core, + protocolRule: "v2-session-transfer" as const, + protocolHash: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + fingerprint: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }; + assert.equal(migrationTargetDescriptorsMatch(core, current), true); + assert.equal(migrationTargetDescriptorsMatch( + core, { ...current, schemaHash: current.protocolHash }, + ), false); + assert.equal(migrationTargetDescriptorsMatch( + current, { ...current, protocolRule: "v2-session-transfer-legacy" }, + ), false); + }); + it("rejects remote or credential-bearing URLs even with an injected transport", () => { const transport = { async run() { assert.fail("No process before endpoint validation"); }, diff --git a/src/migration/executor.ts b/src/migration/executor.ts index ae7a4a6..a5ac5a3 100644 --- a/src/migration/executor.ts +++ b/src/migration/executor.ts @@ -15,7 +15,8 @@ import { summarizeMigrationPlan, type MigrationPlan } from "./plan.js"; import { isOwnedByRun, jsonHash } from "./ownership.js"; import { authorizeReplacements, removeReplacements } from "./replacement.js"; import { - descriptorDialect, type MigrationTarget, type MigrationTargetDescriptor, + descriptorDialect, migrationTargetDescriptorsMatch, + type MigrationTarget, type MigrationTargetDescriptor, } from "./target.js"; export { isOwnedByRun } from "./ownership.js"; @@ -51,7 +52,7 @@ function createManifest(plan: MigrationPlan, target: MigrationTargetDescriptor): } function requireTarget(manifest: MigrationManifest, target: MigrationTargetDescriptor): void { - if (jsonHash(manifest.target) !== jsonHash(target)) { + if (!migrationTargetDescriptorsMatch(manifest.target, target)) { throw new Trae2OpenCodeError("T2O_MIGRATION_TARGET_CHANGED"); } } @@ -65,12 +66,15 @@ function isCompatibleTargetChange( previous.serverVersion !== current.serverVersion; const previousDialect = openCodeDialectForVersion(previous.binaryVersion); const currentDialect = openCodeDialectForVersion(current.binaryVersion); + const sameProtocol = previous.protocolRule === undefined || + (previous.protocolRule === current.protocolRule && + previous.protocolHash === current.protocolHash); // A dialect change reaches a different data model, so it can never be rebound. return endpointOrVersionChanged && previousDialect !== undefined && previousDialect === currentDialect && openCodeDialectForVersion(previous.serverVersion) === previousDialect && openCodeDialectForVersion(current.serverVersion) === currentDialect && - previous.schemaHash === current.schemaHash; + previous.schemaHash === current.schemaHash && sameProtocol; } /** @@ -85,6 +89,11 @@ async function requireOrRebindTarget( dialect: OpenCodeDialect, ): Promise { if (jsonHash(manifest.target) === jsonHash(descriptor)) return; + if (migrationTargetDescriptorsMatch(manifest.target, descriptor)) { + manifest.target = structuredClone(descriptor); + await store.save(manifest); + return; + } if (!isCompatibleTargetChange(manifest.target, descriptor)) { throw new Trae2OpenCodeError("T2O_MIGRATION_TARGET_CHANGED"); } @@ -226,6 +235,10 @@ export async function migrate( const dialect = descriptorDialect(descriptor); const plannedDialect = inputPlan.options.dialect ?? "v2"; if (dialect !== plannedDialect) throw new Trae2OpenCodeError("T2O_MIGRATION_TARGET_CHANGED"); + if (inputPlan.options.protocolRule !== undefined && + inputPlan.options.protocolRule !== descriptor.protocolRule) { + throw new Trae2OpenCodeError("T2O_MIGRATION_TARGET_CHANGED"); + } let filename: string; if (options.resumeManifest) filename = path.resolve(options.resumeManifest); else { diff --git a/src/migration/manifest.ts b/src/migration/manifest.ts index b0f6dd0..22cdaa6 100644 --- a/src/migration/manifest.ts +++ b/src/migration/manifest.ts @@ -9,6 +9,9 @@ import type { JsonValue } from "../ir/types.js"; import { Trae2OpenCodeError } from "../shared/errors.js"; import { assertNoCredentials } from "../shared/sensitive.js"; import { OPENCODE_CANDIDATE_VERSION_PATTERN, openCodeDialectForVersion } from "../target/opencode/contract.js"; +import { + OPENCODE_PROTOCOL_RULES, protocolRuleById, +} from "../target/opencode/protocol-rules.js"; import type { OpenCodeReconciliation } from "../target/opencode/reconciliation.js"; import type { MigrationTargetDescriptor } from "./target.js"; @@ -75,6 +78,8 @@ const validate = new Ajv().compile({ required: ["endpointHash", "binaryVersion", "serverVersion", "schemaHash", "fingerprint"], properties: { endpointHash: hash, schemaHash: hash, fingerprint: hash, + protocolHash: hash, + protocolRule: { enum: OPENCODE_PROTOCOL_RULES.map((rule) => rule.id) }, binaryVersion: { type: "string", pattern: OPENCODE_CANDIDATE_VERSION_PATTERN }, serverVersion: { type: "string", pattern: OPENCODE_CANDIDATE_VERSION_PATTERN }, }, @@ -118,6 +123,15 @@ export function assertManifest(value: unknown): asserts value is MigrationManife openCodeDialectForVersion(manifest.target.serverVersion)) { throw new Trae2OpenCodeError("T2O_MIGRATION_MANIFEST_INVALID"); } + if ((manifest.target.protocolRule === undefined) !== + (manifest.target.protocolHash === undefined)) { + throw new Trae2OpenCodeError("T2O_MIGRATION_MANIFEST_INVALID"); + } + if (manifest.target.protocolRule !== undefined && + protocolRuleById(manifest.target.protocolRule).dialect !== + openCodeDialectForVersion(manifest.target.binaryVersion)) { + throw new Trae2OpenCodeError("T2O_MIGRATION_MANIFEST_INVALID"); + } const seen = new Set(); const sourceIds = new Set(); for (const session of manifest.sessions) { diff --git a/src/migration/plan.ts b/src/migration/plan.ts index 3d183c6..f6c74f9 100644 --- a/src/migration/plan.ts +++ b/src/migration/plan.ts @@ -20,6 +20,7 @@ import { resolveOpenCodeDirectory, type DirectoryResolution, type ProjectPathMap, } from "../target/opencode/project-directory.js"; import type { OpenCodeReconciliation } from "../target/opencode/reconciliation.js"; +import type { OpenCodeProtocolRuleId } from "../target/opencode/protocol-rules.js"; export interface MigrationPlanOptions { namespace?: string; @@ -28,6 +29,7 @@ export interface MigrationPlanOptions { fallbackDirectory?: string; /** Set from the verified target before planning; defaults to the v2 contract. */ dialect?: OpenCodeDialect; + protocolRule?: OpenCodeProtocolRuleId; targetVersion?: string; } @@ -108,7 +110,7 @@ export async function buildMigrationPlan( mapping = mapTargetSession(bundle, session.sourceId, { ...identity, directory: process.cwd(), - dialect, + dialect, protocolRule: options.protocolRule, targetVersion, }); } catch (error) { diff --git a/src/migration/rollback.ts b/src/migration/rollback.ts index 59d5d0d..ef0c6e5 100644 --- a/src/migration/rollback.ts +++ b/src/migration/rollback.ts @@ -2,7 +2,9 @@ import { normalizeError, Trae2OpenCodeError } from "../shared/errors.js"; import { targetReconciliation } from "../target/opencode/dialect.js"; import { withManifestStore, type ManifestSession, type MigrationManifest } from "./manifest.js"; import { isOwnedByRun, jsonHash } from "./ownership.js"; -import { descriptorDialect, type MigrationTarget } from "./target.js"; +import { + descriptorDialect, migrationTargetDescriptorsMatch, type MigrationTarget, +} from "./target.js"; type RollbackAction = "delete" | "absent" | "already-rolled-back" | "skip" | "protected"; interface RollbackEntry { @@ -67,7 +69,7 @@ export async function rollbackMigration( return withManifestStore(filename, async (store) => { const manifest = await store.read(); if (confirmed && options.confirm !== manifest.runId) throw new Trae2OpenCodeError("T2O_MIGRATION_CONFIRMATION_REQUIRED"); - if (jsonHash(manifest.target) !== jsonHash(await target.describe())) { + if (!migrationTargetDescriptorsMatch(manifest.target, await target.describe())) { throw new Trae2OpenCodeError("T2O_MIGRATION_TARGET_CHANGED"); } const entries: RollbackEntry[] = []; diff --git a/src/migration/target.ts b/src/migration/target.ts index 1dd3532..69410bb 100644 --- a/src/migration/target.ts +++ b/src/migration/target.ts @@ -4,6 +4,9 @@ import { Trae2OpenCodeError } from "../shared/errors.js"; import { requireOpenCodeCapabilities } from "../target/opencode/capability-probe.js"; import { openCodeDialectForVersion, type OpenCodeDialect } from "../target/opencode/contract.js"; import type { OpenCodeSession } from "../target/opencode/mapping.js"; +import { + protocolRuleById, type OpenCodeProtocolRuleId, +} from "../target/opencode/protocol-rules.js"; import { createNativeOpenCodeAdapter, type NativeOpenCodeAdapterOptions, } from "../target/opencode/native-adapter.js"; @@ -14,6 +17,8 @@ export interface MigrationTargetDescriptor { binaryVersion: string; serverVersion: string; schemaHash: string; + protocolRule?: OpenCodeProtocolRuleId; + protocolHash?: string; fingerprint: string; } @@ -25,9 +30,26 @@ export interface MigrationTarget { deleteSession?(id: string, expectedHash: string, exclusiveTarget: boolean): Promise; } +/** Old manifests predate protocol fields; unchanged core evidence remains valid. */ +export function migrationTargetDescriptorsMatch( + expected: MigrationTargetDescriptor, actual: MigrationTargetDescriptor, +): boolean { + if (hashCanonicalJson(expected as unknown as JsonValue) === + hashCanonicalJson(actual as unknown as JsonValue)) return true; + const legacyDescriptor = expected.protocolRule === undefined && expected.protocolHash === undefined; + return legacyDescriptor && + expected.endpointHash === actual.endpointHash && + expected.binaryVersion === actual.binaryVersion && + expected.serverVersion === actual.serverVersion && + expected.schemaHash === actual.schemaHash; +} + /** The descriptor is produced only after protocol and compatibility validation. */ export function descriptorDialect(descriptor: MigrationTargetDescriptor): OpenCodeDialect { - const dialect = openCodeDialectForVersion(descriptor.binaryVersion); + const versionDialect = openCodeDialectForVersion(descriptor.binaryVersion); + const dialect = descriptor.protocolRule === undefined + ? versionDialect : protocolRuleById(descriptor.protocolRule).dialect; + if (versionDialect !== dialect) throw new Trae2OpenCodeError("T2O_OPENCODE_VERSION_UNSUPPORTED"); if (!dialect) throw new Trae2OpenCodeError("T2O_OPENCODE_VERSION_UNSUPPORTED"); return dialect; } @@ -45,6 +67,8 @@ export function createMigrationTarget(options: NativeOpenCodeAdapterOptions): Mi const descriptor = { endpointHash, binaryVersion: capabilities.binaryVersion!, serverVersion: capabilities.serverVersion!, schemaHash: capabilities.schemaHash!, + protocolRule: capabilities.protocolRule!, + protocolHash: capabilities.protocolHash!, }; return { ...descriptor, fingerprint: hashCanonicalJson(descriptor as JsonValue) }; }, diff --git a/src/target/__tests__/opencode-binary.test.ts b/src/target/__tests__/opencode-binary.test.ts index 71b2b95..a2d9cd0 100644 --- a/src/target/__tests__/opencode-binary.test.ts +++ b/src/target/__tests__/opencode-binary.test.ts @@ -55,6 +55,26 @@ describe("resolveOpenCodeBinary", () => { ); }); + it("resolves the embedded native binary used by older POSIX Node launchers", () => { + const directory = path.join("/", "synthetic", "opencode-ai", "bin"); + const launcher = path.join(directory, "opencode"); + const linked = path.join("/", "synthetic", "bin", "opencode"); + const embedded = path.join(directory, ".opencode"); + const existing = new Set([linked, launcher, embedded]); + assert.equal(resolveOpenCodeBinary("opencode", { + platform: "darwin", + env: { PATH: path.dirname(linked) }, + isFile: (candidate) => existing.has(candidate), + realpath: (candidate) => candidate === linked ? launcher : candidate, + }), embedded); + assert.equal(resolveOpenCodeBinary(launcher, { + platform: "linux", + env: {}, + isFile: (candidate) => existing.has(candidate), + realpath: (candidate) => candidate, + }), embedded); + }); + it("resolves the native executable inside an npm cmd shim", () => { const files = { [npmShim]: [ diff --git a/src/target/__tests__/opencode-capability.test.ts b/src/target/__tests__/opencode-capability.test.ts index 918ef89..6af3019 100644 --- a/src/target/__tests__/opencode-capability.test.ts +++ b/src/target/__tests__/opencode-capability.test.ts @@ -6,7 +6,13 @@ import type { JsonValue } from "../../ir/types.js"; import { probeOpenCodeCapabilities, requireOpenCodeCapabilities, type OpenCodeCompatibilityEvidence, } from "../opencode/capability-probe.js"; -import { EXPORT_ROUTE, IMPORT_ROUTE, TRANSFER_REF, TRANSFER_SCHEMA_HASH } from "../opencode/contract.js"; +import { + EXPORT_ROUTE, IMPORT_ROUTE, LEGACY_TRANSFER_SCHEMA_HASH, TRANSFER_REF, + TRANSFER_SCHEMA_HASH, +} from "../opencode/contract.js"; +import { + LEGACY_EXPORT_ROUTE, LEGACY_IMPORT_ROUTE, +} from "../opencode/protocol-rules.js"; import type { OpenCodeTransport } from "../opencode/transport.js"; function makeApi() { @@ -38,6 +44,35 @@ function makeApi() { }; } +function makeLegacyApi() { + const schema = JSON.parse(readFileSync(new URL( + "../../../fixtures/opencode/2.0.0/evidence/transfer.schema.json", import.meta.url, + ), "utf8")); + return { + info: { version: "0.0.1" }, + components: schema.components, + paths: { + [LEGACY_IMPORT_ROUTE]: { post: { + requestBody: { content: { "application/json": { schema: { + type: "object", + properties: { + info: { $ref: "#/components/schemas/Session.Info" }, + messages: { type: "array", items: { $ref: "#/components/schemas/Session.Message.Info" } }, + location: { anyOf: [{ $ref: "#/components/schemas/Location.Ref" }, { type: "null" }] }, + }, + required: ["info", "messages"], additionalProperties: false, + } } } }, + } }, + [LEGACY_EXPORT_ROUTE]: { get: { + responses: { "200": { content: { "application/json": { schema: { + type: "object", properties: { data: { $ref: TRANSFER_REF } }, + required: ["data"], additionalProperties: false, + } } } } }, + } }, + } as Record, + }; +} + function importRequestSchema(api: ReturnType) { const route = api.paths[IMPORT_ROUTE] as { post: { @@ -51,6 +86,7 @@ function importRequestSchema(api: ReturnType) { function connection(options: { binaryVersion?: string; serverVersion?: string; status?: number; api?: unknown; + infoStatus?: number; healthStatus?: number; verify?: (evidence: OpenCodeCompatibilityEvidence) => Promise; } = {}) { const calls: string[] = []; @@ -64,7 +100,12 @@ function connection(options: { async request(route) { calls.push(route); if (route === "/api/info") return { - status: 200, body: { version: options.serverVersion ?? "2.0.12", paths: { private: "secret-path" } }, + status: options.infoStatus ?? 200, + body: { version: options.serverVersion ?? "2.0.12", paths: { private: "secret-path" } }, + }; + if (route === "/api/health") return { + status: options.healthStatus ?? 200, + body: { version: options.serverVersion ?? "2.0.0" }, }; assert.equal(route, "/openapi.json"); return { status: options.status ?? 200, body: options.api ?? makeApi() }; @@ -104,6 +145,43 @@ describe("probeOpenCodeCapabilities", () => { } }); + it("selects and verifies the legacy v2 profile from its routes and schema", async () => { + const { calls, transport } = connection({ + binaryVersion: "2.0.0", serverVersion: "2.0.0", + infoStatus: 404, api: makeLegacyApi(), + }); + const report = await requireOpenCodeCapabilities(transport); + assert.equal(report.protocolRule, "v2-session-transfer-legacy"); + assert.equal(report.schemaHash, LEGACY_TRANSFER_SCHEMA_HASH); + assert.equal(report.schemaCompatibility, "exact"); + assert.equal(report.compatibility, "verified-release"); + assert.equal(report.writable, true); + assert.deepEqual(calls, [ + "version", "/api/info", + "/api/health", "/openapi.json", + ]); + }); + + it("requires isolated evidence for unreviewed releases using the legacy v2 profile", async () => { + const verified: OpenCodeCompatibilityEvidence[] = []; + const report = await requireOpenCodeCapabilities(connection({ + binaryVersion: "2.0.2", serverVersion: "2.0.2", + infoStatus: 404, api: makeLegacyApi(), + verify: async (evidence) => { verified.push(evidence); }, + }).transport); + assert.equal(report.protocolRule, "v2-session-transfer-legacy"); + assert.equal(report.compatibility, "isolated-roundtrip"); + assert.equal(report.writable, true); + assert.deepEqual(verified, [{ + dialect: "v2", + protocolRule: "v2-session-transfer-legacy", + protocolHash: report.protocolHash!, + binaryVersion: "2.0.2", + serverVersion: "2.0.2", + schemaHash: LEGACY_TRANSFER_SCHEMA_HASH, + }]); + }); + it("refuses unknown majors, prereleases and malformed versions before network access", async () => { for (const binaryVersion of ["3.0.0", "2.0.12-dev", "secret output", "v2.0.12", "2.0.12\nextra"]) { const { calls, transport } = connection({ binaryVersion }); diff --git a/src/target/__tests__/opencode-contract.test.ts b/src/target/__tests__/opencode-contract.test.ts index f299640..97372f4 100644 --- a/src/target/__tests__/opencode-contract.test.ts +++ b/src/target/__tests__/opencode-contract.test.ts @@ -7,7 +7,7 @@ import { openCodeDialectForVersion, isVerifiedOpenCodeVersion, } from "../opencode/contract.js"; import { - OPENCODE_PROTOCOL_RULES, protocolRuleForVersion, + OPENCODE_PROTOCOL_RULES, protocolRuleForVersion, protocolRulesForVersion, } from "../opencode/protocol-rules.js"; const schema = JSON.parse(readFileSync(new URL( @@ -27,7 +27,10 @@ describe("OpenCode transfer contract", () => { assert.equal(openCodeDialectForVersion(version), "v1"); assert.equal(isVerifiedOpenCodeVersion(version), false); } - for (const version of ["1.17.9", "1.18.32", "2.0.12", "2.0.16"]) { + for (const version of [ + "1.16.0", "1.17.0", "1.17.9", "1.18.32", + "2.0.0", "2.0.12", "2.0.16", + ]) { assert.equal(isVerifiedOpenCodeVersion(version), true); } for (const version of [null, "", "3.0.0", "2.1.0-beta", "v2.0.12", "2.00.12", "2.0.12\n"]) { @@ -44,6 +47,9 @@ describe("OpenCode transfer contract", () => { openapiRoute: rule.openapiRoute, operations: rule.operations.map(({ route, method }) => `${method.toUpperCase()} ${route}`), schemaProfile: rule.schemaProfile, + mappingProfile: rule.mappingProfile, + adapterProfile: rule.adapterProfile, + deletionProfile: rule.deletionProfile, transfer: rule.transfer, })), [ { @@ -54,6 +60,22 @@ describe("OpenCode transfer contract", () => { "GET /api/experimental/session/{sessionID}/export", ], schemaProfile: "v2-transfer", + mappingProfile: "v2-session-transfer", + adapterProfile: "v2-session-transfer", + deletionProfile: "v2-current", + transfer: "http", + }, + { + id: "v2-session-transfer-legacy", dialect: "v2", + versionRoute: "/api/health", openapiRoute: "/openapi.json", + operations: [ + "POST /api/session/import", + "GET /api/session/{sessionID}/export", + ], + schemaProfile: "v2-transfer-legacy", + mappingProfile: "v2-session-transfer", + adapterProfile: "v2-session-transfer-legacy", + deletionProfile: "v2-legacy", transfer: "http", }, { @@ -64,11 +86,35 @@ describe("OpenCode transfer contract", () => { "GET /session/{sessionID}/children", ], schemaProfile: "v1-session", + mappingProfile: "v1-session", + adapterProfile: "v1-cli-library", + deletionProfile: "v1", + transfer: "cli", + }, + { + id: "v1-cli-library-legacy", dialect: "v1", + versionRoute: "/global/health", openapiRoute: "/doc", + operations: [ + "DELETE /session/{sessionID}", + "GET /session/{sessionID}/children", + ], + schemaProfile: "v1-session-legacy", + mappingProfile: "v1-session", + adapterProfile: "v1-cli-library", + deletionProfile: "v1", transfer: "cli", }, ]); assert.equal(protocolRuleForVersion("2.0.18")?.id, "v2-session-transfer"); assert.equal(protocolRuleForVersion("1.18.31")?.id, "v1-cli-library"); + assert.deepEqual(protocolRulesForVersion("2.0.0").map((rule) => rule.id), [ + "v2-session-transfer", + "v2-session-transfer-legacy", + ]); + assert.deepEqual(protocolRulesForVersion("1.16.0").map((rule) => rule.id), [ + "v1-cli-library", + "v1-cli-library-legacy", + ]); assert.equal(protocolRuleForVersion("3.0.0"), undefined); }); diff --git a/src/target/__tests__/opencode-deletion.test.ts b/src/target/__tests__/opencode-deletion.test.ts index 831508f..00b58da 100644 --- a/src/target/__tests__/opencode-deletion.test.ts +++ b/src/target/__tests__/opencode-deletion.test.ts @@ -10,6 +10,9 @@ import type { OpenCodeTransport } from "../opencode/transport.js"; const contract = JSON.parse(readFileSync(new URL( "../../../fixtures/opencode/2.0.12/evidence/deletion.contract.json", import.meta.url, ), "utf8")); +const legacyContract = JSON.parse(readFileSync(new URL( + "../../../fixtures/opencode/2.0.0/evidence/deletion.contract.json", import.meta.url, +), "utf8")); const schema = JSON.parse(readFileSync(new URL( "../../../fixtures/opencode/2.0.12/evidence/transfer.schema.json", import.meta.url, ), "utf8")); @@ -51,6 +54,9 @@ function setup() { describe("OpenCode guarded native deletion", () => { it("accepts the captured contract and refuses list/delete/schema drift", () => { assert.doesNotThrow(() => assertDeletionContract(contract)); + assert.doesNotThrow(() => assertDeletionContract(legacyContract, "v2-legacy")); + assert.throws(() => assertDeletionContract(legacyContract), + { code: "T2O_OPENCODE_DELETE_UNSUPPORTED" }); for (const operation of ["delete", "list", "schema"]) { const changed = structuredClone(contract); if (operation === "delete") delete changed.paths["/api/session/{sessionID}"].delete; diff --git a/src/target/__tests__/opencode-v1-capability.test.ts b/src/target/__tests__/opencode-v1-capability.test.ts index 027666f..d436fbe 100644 --- a/src/target/__tests__/opencode-v1-capability.test.ts +++ b/src/target/__tests__/opencode-v1-capability.test.ts @@ -5,7 +5,8 @@ import { hashCanonicalJson } from "../../ir/canonical.js"; import type { JsonValue } from "../../ir/types.js"; import { probeOpenCodeCapabilities, requireOpenCodeCapabilities } from "../opencode/capability-probe.js"; import { - extractV1SessionSchema, V1_HEALTH_ROUTE, V1_OPENAPI_ROUTE, V1_SESSION_SCHEMA_HASH, + extractV1SessionSchema, V1_HEALTH_ROUTE, V1_LEGACY_SESSION_SCHEMA_HASH, + V1_OPENAPI_ROUTE, V1_SESSION_SCHEMA_HASH, } from "../opencode/v1/contract.js"; import type { OpenCodeTransport } from "../opencode/transport.js"; @@ -25,6 +26,22 @@ function makeV1Api() { }; } +function makeLegacyV1Api() { + const fixture = JSON.parse(readFileSync(new URL( + "../../../fixtures/opencode/1.16.0/evidence/session.schema.json", import.meta.url, + ), "utf8")); + return { + openapi: "3.1.0", + info: { title: "opencode", version: "1.0.0" }, + components: fixture.components, + paths: { + "/session": { get: {}, post: {} }, + "/session/{sessionID}": { get: {}, delete: {}, patch: {} }, + "/session/{sessionID}/children": { get: {} }, + } as Record, + }; +} + function connection(options: { binaryVersion?: string; serverVersion?: string; api?: unknown; healthStatus?: number; openapiStatus?: number; dropDelete?: boolean; dropChildren?: boolean; @@ -78,6 +95,51 @@ describe("probeOpenCodeCapabilities for OpenCode v1", () => { assert.equal(report.writable, true); }); + it("selects the legacy profile from its actual schema and admits reviewed legacy releases", async () => { + for (const version of ["1.16.0", "1.17.0"]) { + const { calls, transport } = connection({ + binaryVersion: version, serverVersion: version, api: makeLegacyV1Api(), + }); + const report = await requireOpenCodeCapabilities(transport); + assert.equal(report.protocolRule, "v1-cli-library-legacy"); + assert.equal(report.schemaHash, V1_LEGACY_SESSION_SCHEMA_HASH); + assert.equal(report.schemaCompatibility, "exact"); + assert.equal(report.compatibility, "verified-release"); + assert.equal(report.writable, true); + assert.deepEqual(calls, [ + "version", + V1_HEALTH_ROUTE, V1_OPENAPI_ROUTE, + V1_HEALTH_ROUTE, V1_OPENAPI_ROUTE, + ]); + } + }); + + it("requires isolated evidence for unreviewed releases using the legacy profile", async () => { + let canaries = 0; + const { transport } = connection({ + binaryVersion: "1.16.2", serverVersion: "1.16.2", api: makeLegacyV1Api(), + }); + transport.verifyCompatibility = async (evidence) => { + canaries++; + assert.equal(evidence.protocolRule, "v1-cli-library-legacy"); + assert.equal(evidence.schemaHash, V1_LEGACY_SESSION_SCHEMA_HASH); + }; + const report = await requireOpenCodeCapabilities(transport); + assert.equal(report.compatibility, "isolated-roundtrip"); + assert.equal(report.writable, true); + assert.equal(canaries, 1); + }); + + it("does not classify a pre-metadata session schema as a safe legacy target", async () => { + const api = makeLegacyV1Api(); + delete api.components.schemas.Session.properties.metadata; + const report = await probeOpenCodeCapabilities(connection({ + binaryVersion: "1.15.0", serverVersion: "1.15.0", api, + }).transport); + assert.equal(report.writable, false); + assert.deepEqual(report.reasons, ["T2O_OPENCODE_SCHEMA_UNSUPPORTED"]); + }); + it("fails closed on an unmatched v1 binary, a v2 server and a mismatched contract", async () => { await assert.rejects( requireOpenCodeCapabilities(connection({ binaryVersion: "1.18.31" }).transport), diff --git a/src/target/opencode/binary.ts b/src/target/opencode/binary.ts index 3d0c428..5e530f6 100644 --- a/src/target/opencode/binary.ts +++ b/src/target/opencode/binary.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import * as path from "node:path"; export interface ResolveBinaryOptions { @@ -7,6 +7,7 @@ export interface ResolveBinaryOptions { env?: NodeJS.ProcessEnv; isFile?: (candidate: string) => boolean; readText?: (candidate: string) => string | undefined; + realpath?: (candidate: string) => string; } /** npm's Windows shims for a native CLI; each one points at the real executable. */ @@ -93,8 +94,29 @@ export function resolveOpenCodeBinary( options: ResolveBinaryOptions = {}, ): string { const platform = options.platform ?? process.platform; - if (platform !== "win32" || binary.length === 0) return binary; + if (binary.length === 0) return binary; const isFile = options.isFile ?? defaultIsFile; + if (platform !== "win32") { + const env = options.env ?? process.env; + const directories = (env.PATH ?? "").split(path.delimiter).filter(Boolean); + const candidates = /[/\\]/.test(binary) + ? [path.resolve(binary)] + : directories.map((directory) => path.resolve(directory, binary)); + const resolveRealpath = options.realpath ?? realpathSync; + for (const candidate of candidates) { + if (!isFile(candidate)) continue; + let launcher: string; + try { + launcher = resolveRealpath(candidate); + } catch { + launcher = candidate; + } + const embedded = path.join(path.dirname(launcher), ".opencode"); + if (isFile(embedded)) return embedded; + return binary; + } + return binary; + } if (isFile(binary)) return binary; // An explicit `.exe` path is reported exactly as configured when it does not exist. if (path.win32.extname(binary).toLowerCase() === ".exe") return binary; @@ -126,4 +148,4 @@ export function resolveOpenCodeBinary( } } return binary; -} \ No newline at end of file +} diff --git a/src/target/opencode/capability-probe.ts b/src/target/opencode/capability-probe.ts index 0721072..a6364cc 100644 --- a/src/target/opencode/capability-probe.ts +++ b/src/target/opencode/capability-probe.ts @@ -7,7 +7,8 @@ import { supportedVersionsForDialect, type OpenCodeDialect, } from "./contract.js"; import { - protocolRuleForVersion, type OpenCodeOperationRule, type OpenCodeProtocolRule, + protocolRuleById, protocolRulesForVersion, + type OpenCodeOperationRule, type OpenCodeProtocolRule, type OpenCodeProtocolRuleId, } from "./protocol-rules.js"; import { @@ -71,7 +72,7 @@ function operationAt( export function extractSchemaForProtocolRule( rule: OpenCodeProtocolRule, openapi: unknown, ): Record { - return rule.schemaProfile === "v1-session" + return rule.schemaProfile === "v1-session" || rule.schemaProfile === "v1-session-legacy" ? extractV1SessionSchema(openapi) : extractTransferSchema(openapi); } @@ -79,7 +80,10 @@ function analyzeSchemaForProtocolRule( rule: OpenCodeProtocolRule, openapi: unknown, ): SchemaCompatibilityAnalysis { return rule.schemaProfile === "v1-session" - ? analyzeOpenCodeV1Schema(openapi) : analyzeOpenCodeSchema(openapi); + ? analyzeOpenCodeV1Schema(openapi) + : rule.schemaProfile === "v1-session-legacy" + ? analyzeOpenCodeV1Schema(openapi, rule.schemaProfile) + : analyzeOpenCodeSchema(openapi, rule.schemaProfile); } function combineSchemaAnalysis( @@ -101,7 +105,7 @@ async function probeRule( const versionValue = versionResponse.status === 200 && isRecord(versionResponse.body) ? versionResponse.body[rule.versionProbe.responseField] : undefined; report.serverVersion = parseVersion(versionValue); - if (protocolRuleForVersion(report.serverVersion)?.id !== rule.id) { + if (!protocolRulesForVersion(report.serverVersion).some((candidate) => candidate.id === rule.id)) { throw new Trae2OpenCodeError("T2O_OPENCODE_VERSION_UNSUPPORTED"); } const response = await transport.request(rule.openapiRoute); @@ -155,20 +159,36 @@ async function probeRule( /** Only reads version/HTTP evidence; never accesses user sessions. */ async function probeProtocol(transport: OpenCodeTransport): Promise { - const report: OpenCodeCapabilities = { + const emptyReport = (): OpenCodeCapabilities => ({ dialect: "v2", protocolRule: null, protocolHash: null, binaryVersion: null, serverVersion: null, nativeImport: false, nativeExport: false, schemaHash: null, schemaCompatibility: null, schemaChanges: 0, writable: false, compatibility: "unsupported", reasons: [], - }; + }); + const report = emptyReport(); try { report.binaryVersion = parseVersion(await transport.run(["--version"])); - const rule = protocolRuleForVersion(report.binaryVersion); - if (!rule) throw new Trae2OpenCodeError("T2O_OPENCODE_VERSION_UNSUPPORTED"); - report.dialect = rule.dialect; - report.protocolRule = rule.id; - await probeRule(transport, report, rule); - report.compatibility = "protocol-only"; + const rules = protocolRulesForVersion(report.binaryVersion); + if (rules.length === 0) throw new Trae2OpenCodeError("T2O_OPENCODE_VERSION_UNSUPPORTED"); + let firstFailure: OpenCodeCapabilities | undefined; + for (const rule of rules) { + const candidate = { + ...emptyReport(), + dialect: rule.dialect, + protocolRule: rule.id, + binaryVersion: report.binaryVersion, + }; + try { + await probeRule(transport, candidate, rule); + candidate.compatibility = "protocol-only"; + return candidate; + } catch (error) { + candidate.reasons.push(error instanceof Trae2OpenCodeError + ? error.code : "T2O_OPENCODE_CAPABILITY_UNAVAILABLE"); + firstFailure ??= candidate; + } + } + return firstFailure!; } catch (error) { report.reasons.push(error instanceof Trae2OpenCodeError ? error.code : "T2O_OPENCODE_CAPABILITY_UNAVAILABLE"); @@ -180,7 +200,7 @@ async function probeProtocol(transport: OpenCodeTransport): Promise { const report = await probeProtocol(transport); if (report.reasons.length) return report; - const rule = protocolRuleForVersion(report.binaryVersion)!; + const rule = protocolRuleById(report.protocolRule!); const reviewed = rule.reviewedVersions.includes(report.binaryVersion!) && rule.reviewedVersions.includes(report.serverVersion!) && report.schemaCompatibility === "exact"; diff --git a/src/target/opencode/compatibility.ts b/src/target/opencode/compatibility.ts index 0c75fe2..3eb4647 100644 --- a/src/target/opencode/compatibility.ts +++ b/src/target/opencode/compatibility.ts @@ -84,7 +84,8 @@ export async function exerciseOpenCodeRoundtrip( const reconciliation = targetReconciliation(capabilities.dialect); const map = (suffix: string, parentId?: string) => mapTargetSession( bundle, parentId ? "session-synthetic-child" : "session-synthetic", { - dialect: capabilities.dialect, targetVersion: capabilities.binaryVersion!, + dialect: capabilities.dialect, protocolRule: capabilities.protocolRule!, + targetVersion: capabilities.binaryVersion!, sessionId: `ses_compat_${nonce}_${suffix}`, parentId, directory: server.directory, messageIds: new Map([ ["user-synthetic", `msg_compat_${nonce}_${suffix}_0001`], diff --git a/src/target/opencode/contract.ts b/src/target/opencode/contract.ts index bdee20c..845605a 100644 --- a/src/target/opencode/contract.ts +++ b/src/target/opencode/contract.ts @@ -5,8 +5,8 @@ import type { JsonValue } from "../../ir/types.js"; import { Trae2OpenCodeError } from "../../shared/errors.js"; import { EXPORT_ROUTE, IMPORT_ROUTE, OPENCODE_CANDIDATE_VERSION_PATTERN, - protocolRuleForVersion, reviewedVersionsForDialect, TRANSFER_REF, - type OpenCodeDialect, + protocolRuleForVersion, protocolRulesForVersion, reviewedVersionsForDialect, TRANSFER_REF, + type OpenCodeDialect, type OpenCodeSchemaProfile, } from "./protocol-rules.js"; import { analyzeSchemaCompatibility, type SchemaCompatibilityAnalysis, @@ -36,8 +36,8 @@ export function supportedVersionsForDialect(dialect: OpenCodeDialect): readonly } export function isVerifiedOpenCodeVersion(value: string | null): boolean { - const rule = protocolRuleForVersion(value); - return rule?.reviewedVersions.includes(value ?? "") ?? false; + return protocolRulesForVersion(value) + .some((rule) => rule.reviewedVersions.includes(value ?? "")); } /** Every supported release reports the same `--version` / health version shape. */ @@ -55,13 +55,26 @@ export function isRecord(value: unknown): value is Record { const baseline = JSON.parse(readFileSync(new URL( "../../../fixtures/opencode/2.0.12/evidence/transfer.schema.json", import.meta.url, ), "utf8")) as Record; +const legacyBaseline = JSON.parse(readFileSync(new URL( + "../../../fixtures/opencode/2.0.0/evidence/transfer.schema.json", import.meta.url, +), "utf8")) as Record; export const TRANSFER_SCHEMA_HASH = hashCanonicalJson(baseline as JsonValue); +export const LEGACY_TRANSFER_SCHEMA_HASH = hashCanonicalJson(legacyBaseline as JsonValue); const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(baseline); +const validateLegacy = new Ajv2020({ strict: false, validateFormats: false }).compile(legacyBaseline); export function assertOpenCodeTransfer(value: unknown): void { if (!validate(value)) throw new Trae2OpenCodeError("T2O_OPENCODE_TRANSFER_INVALID"); } +export function assertOpenCodeTransferForProfile( + value: unknown, + profile: Extract, +): void { + const valid = profile === "v2-transfer-legacy" ? validateLegacy(value) : validate(value); + if (!valid) throw new Trae2OpenCodeError("T2O_OPENCODE_TRANSFER_INVALID"); +} + /** Extract only local schemas reachable from the transfer root, including cycles. */ export function extractTransferSchema(openapi: unknown): Record { const components = isRecord(openapi) ? openapi.components : undefined; @@ -107,10 +120,12 @@ export function assertOpenCodeSchema(openapi: unknown): string { return analysis.actualHash; } -export function analyzeOpenCodeSchema(openapi: unknown): SchemaCompatibilityAnalysis { +export function analyzeOpenCodeSchema( + openapi: unknown, profile: OpenCodeSchemaProfile = "v2-transfer", +): SchemaCompatibilityAnalysis { const schema = extractTransferSchema(openapi); return analyzeSchemaCompatibility( - baseline as JsonValue, + (profile === "v2-transfer-legacy" ? legacyBaseline : baseline) as JsonValue, schema as JsonValue, ); } diff --git a/src/target/opencode/deletion.ts b/src/target/opencode/deletion.ts index 03fe7bd..d46464d 100644 --- a/src/target/opencode/deletion.ts +++ b/src/target/opencode/deletion.ts @@ -4,36 +4,44 @@ import { Trae2OpenCodeError } from "../../shared/errors.js"; import { requireOpenCodeCapabilities } from "./capability-probe.js"; import { isRecord } from "./contract.js"; import type { OpenCodeSession } from "./mapping.js"; +import type { OpenCodeDeletionProfile } from "./protocol-rules.js"; import type { OpenCodeTransport } from "./transport.js"; // Captured from an isolated OpenCode 2.0.12 server on 2026-09-24. const REMOVE_HASH = "sha256:bf2c3d9d2dcb643e2d31f1e4aeca0db9591d51c9444a7d0eee36263193802f14"; const LIST_HASH = "sha256:28b438923094c8b480126933ddb5e5a83d1aa5e5d474d722b4e2629b0c8de333"; +const LEGACY_REMOVE_HASH = "sha256:8f5432962c4564206ca1b8e559cba4437049026d9e7d78e78c82f75fc3365107"; +const LEGACY_LIST_HASH = "sha256:fca8b8b345ff2a8122aadab39c2ff1d46f6fbd9ea3222ada185fc1d9dadcd889"; const RESPONSE_HASH = "sha256:2db1f7104094ac63c9b1903c639c7754e1632228b78eb37d42aae73f1c22d1e3"; const validId = (id: unknown): id is string => typeof id === "string" && /^ses_[a-zA-Z0-9_-]+$/.test(id); const matches = (value: unknown, hash: string) => isRecord(value) && hashCanonicalJson(value as JsonValue) === hash; -export function assertDeletionContract(api: unknown): void { +export function assertDeletionContract( + api: unknown, profile: Extract = "v2-current", +): void { const paths = isRecord(api) && isRecord(api.paths) ? api.paths : {}; const session = paths["/api/session/{sessionID}"]; const list = paths["/api/session"]; const schemas = isRecord(api) && isRecord(api.components) && isRecord(api.components.schemas) ? api.components.schemas : {}; - const supported = isRecord(session) && matches(session.delete, REMOVE_HASH) && - isRecord(list) && matches(list.get, LIST_HASH) && matches(schemas.SessionsResponse, RESPONSE_HASH); + const removeHash = profile === "v2-legacy" ? LEGACY_REMOVE_HASH : REMOVE_HASH; + const listHash = profile === "v2-legacy" ? LEGACY_LIST_HASH : LIST_HASH; + const supported = isRecord(session) && matches(session.delete, removeHash) && + isRecord(list) && matches(list.get, listHash) && matches(schemas.SessionsResponse, RESPONSE_HASH); if (!supported) throw new Trae2OpenCodeError("T2O_OPENCODE_DELETE_UNSUPPORTED"); } export function createOpenCodeDeletionAdapter( transport: OpenCodeTransport, serverUrl: string, read: (id: string) => Promise, + profile: Extract = "v2-current", ) { const requireContract = async () => { await requireOpenCodeCapabilities(transport); const response = await transport.request("/openapi.json"); if (response.status !== 200) throw new Trae2OpenCodeError("T2O_OPENCODE_DELETE_UNSUPPORTED"); - assertDeletionContract(response.body); + assertDeletionContract(response.body, profile); }; const listChildren = async (id: string): Promise => { if (!validId(id)) throw new Trae2OpenCodeError("T2O_OPENCODE_TRANSFER_INVALID"); diff --git a/src/target/opencode/dialect.ts b/src/target/opencode/dialect.ts index 7ce961f..dd488e4 100644 --- a/src/target/opencode/dialect.ts +++ b/src/target/opencode/dialect.ts @@ -1,7 +1,11 @@ import type { Diagnostic, MigrationBundle } from "../../ir/types.js"; +import { Trae2OpenCodeError } from "../../shared/errors.js"; import type { OpenCodeDialect } from "./contract.js"; import type { OpenCodeMappingOptions, OpenCodeSession } from "./mapping.js"; import { mapOpenCodeSession } from "./mapping.js"; +import { + protocolRuleById, protocolRuleForVersion, type OpenCodeProtocolRuleId, +} from "./protocol-rules.js"; import { reconcileOpenCodeTransfer, type OpenCodeReconciliation, } from "./reconciliation.js"; @@ -10,6 +14,7 @@ import { reconcileOpenCodeV1Session } from "./v1/reconciliation.js"; export interface TargetMappingOptions extends OpenCodeMappingOptions { dialect: OpenCodeDialect; + protocolRule?: OpenCodeProtocolRuleId; /** The verified executable version; only the v1 payload persists it. */ targetVersion: string; } @@ -22,7 +27,13 @@ export interface TargetMapping { export function mapTargetSession( value: MigrationBundle, sourceSessionId: string, options: TargetMappingOptions, ): TargetMapping { - return options.dialect === "v1" + const rule = options.protocolRule === undefined + ? protocolRuleForVersion(options.targetVersion) + : protocolRuleById(options.protocolRule); + if (!rule || rule.dialect !== options.dialect) { + throw new Trae2OpenCodeError("T2O_OPENCODE_VERSION_UNSUPPORTED"); + } + return rule.mappingProfile === "v1-session" ? mapOpenCodeV1Session(value, sourceSessionId, options) : mapOpenCodeSession(value, sourceSessionId, options); } @@ -42,4 +53,4 @@ export function targetReconciliation(dialect: OpenCodeDialect): TargetReconcilia snapshot: (value) => reconcile(value, value).expected, assertTransfer: (value) => { reconcile(value, value); }, }; -} \ No newline at end of file +} diff --git a/src/target/opencode/native-adapter.ts b/src/target/opencode/native-adapter.ts index a6c2c59..c66baac 100644 --- a/src/target/opencode/native-adapter.ts +++ b/src/target/opencode/native-adapter.ts @@ -11,8 +11,14 @@ import { import { MAX_OPENCODE_TRANSFER_BYTES } from "../../shared/limits.js"; import { assertNoCredentials } from "../../shared/sensitive.js"; import { requireOpenCodeCapabilities } from "./capability-probe.js"; -import { assertOpenCodeTransfer, EXPORT_ROUTE, isRecord } from "./contract.js"; +import { + assertOpenCodeTransferForProfile, EXPORT_ROUTE, isRecord, +} from "./contract.js"; import type { OpenCodeSession, OpenCodeTransfer } from "./mapping.js"; +import { + LEGACY_EXPORT_ROUTE, protocolRuleById, + type OpenCodeProtocolRuleId, type OpenCodeSchemaProfile, +} from "./protocol-rules.js"; import { requireOpenCodeReconciliation } from "./reconciliation.js"; import { createOpenCodeDeletionAdapter } from "./deletion.js"; import { withTemporaryInput } from "./temporary-input.js"; @@ -36,8 +42,11 @@ function assertId(id: string): void { } } -function transferFrom(value: unknown, expectedId: string): OpenCodeTransfer { - assertOpenCodeTransfer(value); +function transferFrom( + value: unknown, expectedId: string, + schemaProfile: Extract, +): OpenCodeTransfer { + assertOpenCodeTransferForProfile(value, schemaProfile); const transfer = value as OpenCodeTransfer; if (transfer.info.id !== expectedId) throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); return transfer; @@ -58,24 +67,66 @@ export function createNativeOpenCodeAdapter(options: NativeOpenCodeAdapterOption const serverUrl = new URL(options.serverUrl).origin; const temporaryRoot = path.resolve(options.temporaryRoot ?? os.tmpdir()); - const readV2 = async (id: string): Promise => { - assertId(id); - const result = await transport.request(EXPORT_ROUTE.replace("{sessionID}", id)); - if (result.status === 404) return null; - if (result.status !== 200 || !isRecord(result.body)) { - throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); + const v2ByRule = new Map; + listChildren(id: string): Promise; + deleteSession(id: string, expectedHash: string, exclusiveTarget: boolean): Promise; + schemaProfile: Extract; + }>(); + const v2For = (protocolRule: OpenCodeProtocolRuleId | null) => { + if (protocolRule === null) throw new Trae2OpenCodeError("T2O_OPENCODE_CAPABILITY_UNAVAILABLE"); + const cached = v2ByRule.get(protocolRule); + if (cached) return cached; + const rule = protocolRuleById(protocolRule); + if (rule.adapterProfile !== "v2-session-transfer" && + rule.adapterProfile !== "v2-session-transfer-legacy") { + throw new Trae2OpenCodeError("T2O_OPENCODE_CAPABILITY_UNAVAILABLE"); } - return transferFrom(result.body.data, id); + const exportRoute = rule.adapterProfile === "v2-session-transfer-legacy" + ? LEGACY_EXPORT_ROUTE : EXPORT_ROUTE; + const schemaProfile = rule.schemaProfile as Extract< + OpenCodeSchemaProfile, "v2-transfer" | "v2-transfer-legacy" + >; + const read = async (id: string): Promise => { + assertId(id); + const result = await transport.request(exportRoute.replace("{sessionID}", id)); + if (result.status === 404) return null; + if (result.status !== 200 || !isRecord(result.body)) { + throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); + } + return transferFrom(result.body.data, id, schemaProfile); + }; + const adapter = { + read, schemaProfile, + ...createOpenCodeDeletionAdapter( + transport, options.serverUrl, read, + rule.deletionProfile === "v2-legacy" ? "v2-legacy" : "v2-current", + ), + }; + v2ByRule.set(protocolRule, adapter); + return adapter; }; - const v2 = { - read: readV2, - ...createOpenCodeDeletionAdapter(transport, options.serverUrl, readV2), + const v1 = { + current: createOpenCodeV1Adapter({ transport, temporaryRoot }), + legacy: createOpenCodeV1Adapter({ + transport, temporaryRoot, schemaProfile: "v1-session-legacy", + }), + }; + const v1For = (protocolRule: OpenCodeProtocolRuleId | null) => { + if (protocolRule === null) throw new Trae2OpenCodeError("T2O_OPENCODE_CAPABILITY_UNAVAILABLE"); + const rule = protocolRuleById(protocolRule); + if (rule.adapterProfile !== "v1-cli-library") { + throw new Trae2OpenCodeError("T2O_OPENCODE_CAPABILITY_UNAVAILABLE"); + } + return rule.schemaProfile === "v1-session-legacy" ? v1.legacy : v1.current; }; - const v1 = createOpenCodeV1Adapter({ transport, temporaryRoot }); - const importV2 = async (value: OpenCodeSession): Promise => { + const importV2 = async ( + value: OpenCodeSession, protocolRule: OpenCodeProtocolRuleId, + ): Promise => { + const v2 = v2For(protocolRule); assertNoCredentials(value); - assertOpenCodeTransfer(value); + assertOpenCodeTransferForProfile(value, v2.schemaProfile); const transfer = value as OpenCodeTransfer; const id = transfer.info.id; assertId(id); @@ -99,9 +150,9 @@ export function createNativeOpenCodeAdapter(options: NativeOpenCodeAdapterOption } catch { throw new Trae2OpenCodeError("T2O_OPENCODE_DIRECTORY_INVALID"); } - if (await readV2(id)) throw new Trae2OpenCodeError("T2O_OPENCODE_SESSION_CONFLICT"); + if (await v2.read(id)) throw new Trae2OpenCodeError("T2O_OPENCODE_SESSION_CONFLICT"); const parentId = transfer.info.parentID; - if (typeof parentId === "string" && !await readV2(parentId)) { + if (typeof parentId === "string" && !await v2.read(parentId)) { throw new Trae2OpenCodeError("T2O_OPENCODE_PARENT_MISSING"); } await withTemporaryInput( @@ -117,7 +168,7 @@ export function createNativeOpenCodeAdapter(options: NativeOpenCodeAdapterOption (filename) => transport.run(["session", "import", filename, "--server", serverUrl, "--directory", directory]), ); - const actual = await readV2(id); + const actual = await v2.read(id); if (!actual) throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); requireOpenCodeReconciliation(transfer, actual); return actual; @@ -127,13 +178,15 @@ export function createNativeOpenCodeAdapter(options: NativeOpenCodeAdapterOption async readSession(id: string): Promise { assertId(id); const capabilities = await requireOpenCodeCapabilities(transport); - return capabilities.dialect === "v1" ? v1.read(id) : readV2(id); + return capabilities.dialect === "v1" + ? v1For(capabilities.protocolRule).read(id) + : v2For(capabilities.protocolRule).read(id); }, async exportSession(id: string): Promise { assertId(id); const capabilities = await requireOpenCodeCapabilities(transport); if (capabilities.dialect === "v1") { - const session = await v1.read(id); + const session = await v1For(capabilities.protocolRule).read(id); if (!session) throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); return session; } @@ -141,26 +194,32 @@ export function createNativeOpenCodeAdapter(options: NativeOpenCodeAdapterOption let value: unknown; try { value = JSON.parse(output); } catch { throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); } - return transferFrom(value, id); + return transferFrom( + value, id, v2For(capabilities.protocolRule).schemaProfile, + ); }, async importSession(value: OpenCodeSession): Promise { assertNoCredentials(value); // Snapshot before the first await so caller mutation cannot alter checked data. const transfer = structuredClone(value); const capabilities = await requireOpenCodeCapabilities(transport); - return capabilities.dialect === "v1" ? v1.importSession(transfer) : importV2(transfer); + return capabilities.dialect === "v1" + ? v1For(capabilities.protocolRule).importSession(transfer) + : importV2(transfer, capabilities.protocolRule!); }, async listChildren(id: string): Promise { assertId(id); const capabilities = await requireOpenCodeCapabilities(transport); - return capabilities.dialect === "v1" ? v1.listChildren(id) : v2.listChildren(id); + return capabilities.dialect === "v1" + ? v1For(capabilities.protocolRule).listChildren(id) + : v2For(capabilities.protocolRule).listChildren(id); }, async deleteSession(id: string, expectedHash: string, exclusiveTarget: boolean): Promise { assertId(id); const capabilities = await requireOpenCodeCapabilities(transport); return capabilities.dialect === "v1" - ? v1.deleteSession(id, expectedHash, exclusiveTarget) - : v2.deleteSession(id, expectedHash, exclusiveTarget); + ? v1For(capabilities.protocolRule).deleteSession(id, expectedHash, exclusiveTarget) + : v2For(capabilities.protocolRule).deleteSession(id, expectedHash, exclusiveTarget); }, }; } diff --git a/src/target/opencode/protocol-rules.ts b/src/target/opencode/protocol-rules.ts index 0b4a8f6..c9515b0 100644 --- a/src/target/opencode/protocol-rules.ts +++ b/src/target/opencode/protocol-rules.ts @@ -1,8 +1,15 @@ import type { JsonValue } from "../../ir/types.js"; export type OpenCodeDialect = "v2" | "v1"; -export type OpenCodeProtocolRuleId = "v2-session-transfer" | "v1-cli-library"; -export type OpenCodeSchemaProfile = "v2-transfer" | "v1-session"; +export type OpenCodeProtocolRuleId = + "v2-session-transfer" | "v2-session-transfer-legacy" | + "v1-cli-library" | "v1-cli-library-legacy"; +export type OpenCodeSchemaProfile = + "v2-transfer" | "v2-transfer-legacy" | "v1-session" | "v1-session-legacy"; +export type OpenCodeMappingProfile = "v2-session-transfer" | "v1-session"; +export type OpenCodeAdapterProfile = + "v2-session-transfer" | "v2-session-transfer-legacy" | "v1-cli-library"; +export type OpenCodeDeletionProfile = "v2-current" | "v2-legacy" | "v1"; export type OpenCodeCapabilityFlag = "nativeImport" | "nativeExport"; export interface OpenCodeOperationSchemaRule { @@ -27,6 +34,9 @@ export interface OpenCodeProtocolRule { openapiRoute: string; operations: readonly OpenCodeOperationRule[]; schemaProfile: OpenCodeSchemaProfile; + mappingProfile: OpenCodeMappingProfile; + adapterProfile: OpenCodeAdapterProfile; + deletionProfile: OpenCodeDeletionProfile; transfer: "http" | "cli"; } @@ -34,6 +44,8 @@ export const OPENCODE_CANDIDATE_VERSION_PATTERN = "^[12]\\.(0|[1-9][0-9]{0,4})\\.(0|[1-9][0-9]{0,4})$"; export const IMPORT_ROUTE = "/api/experimental/session/import"; export const EXPORT_ROUTE = "/api/experimental/session/{sessionID}/export"; +export const LEGACY_IMPORT_ROUTE = "/api/session/import"; +export const LEGACY_EXPORT_ROUTE = "/api/session/{sessionID}/export"; export const TRANSFER_REF = "#/components/schemas/SessionTransfer.Data"; export const V1_OPENAPI_ROUTE = "/doc"; export const V1_HEALTH_ROUTE = "/global/health"; @@ -58,6 +70,19 @@ const exportEnvelope: JsonValue = { additionalProperties: false, }; +const legacyImportEnvelope: JsonValue = { + ...importEnvelope, + properties: { + ...(importEnvelope.properties as Record), + location: { anyOf: [{ $ref: "#/components/schemas/Location.Ref" }, { type: "null" }] }, + }, +}; + +const v1Operations: readonly OpenCodeOperationRule[] = [ + { route: V1_SESSION_ROUTE, method: "delete" }, + { route: V1_SESSION_CHILDREN_ROUTE, method: "get" }, +]; + export const OPENCODE_PROTOCOL_RULES: readonly OpenCodeProtocolRule[] = [ { id: "v2-session-transfer", @@ -81,6 +106,36 @@ export const OPENCODE_PROTOCOL_RULES: readonly OpenCodeProtocolRule[] = [ }, ], schemaProfile: "v2-transfer", + mappingProfile: "v2-session-transfer", + adapterProfile: "v2-session-transfer", + deletionProfile: "v2-current", + transfer: "http", + }, + { + id: "v2-session-transfer-legacy", + dialect: "v2", + versionPattern: "^2\\.(0|[1-9][0-9]{0,4})\\.(0|[1-9][0-9]{0,4})$", + reviewedVersions: ["2.0.0"], + versionProbe: { route: "/api/health", responseField: "version" }, + openapiRoute: "/openapi.json", + operations: [ + { + route: LEGACY_IMPORT_ROUTE, + method: "post", + capability: "nativeImport", + schema: { source: "request", expected: legacyImportEnvelope }, + }, + { + route: LEGACY_EXPORT_ROUTE, + method: "get", + capability: "nativeExport", + schema: { source: "response", status: "200", expected: exportEnvelope }, + }, + ], + schemaProfile: "v2-transfer-legacy", + mappingProfile: "v2-session-transfer", + adapterProfile: "v2-session-transfer-legacy", + deletionProfile: "v2-legacy", transfer: "http", }, { @@ -90,18 +145,36 @@ export const OPENCODE_PROTOCOL_RULES: readonly OpenCodeProtocolRule[] = [ reviewedVersions: ["1.17.9", "1.18.32"], versionProbe: { route: V1_HEALTH_ROUTE, responseField: "version" }, openapiRoute: V1_OPENAPI_ROUTE, - operations: [ - { route: V1_SESSION_ROUTE, method: "delete" }, - { route: V1_SESSION_CHILDREN_ROUTE, method: "get" }, - ], + operations: v1Operations, schemaProfile: "v1-session", + mappingProfile: "v1-session", + adapterProfile: "v1-cli-library", + deletionProfile: "v1", + transfer: "cli", + }, + { + id: "v1-cli-library-legacy", + dialect: "v1", + versionPattern: "^1\\.(0|[1-9][0-9]{0,4})\\.(0|[1-9][0-9]{0,4})$", + reviewedVersions: ["1.16.0", "1.17.0"], + versionProbe: { route: V1_HEALTH_ROUTE, responseField: "version" }, + openapiRoute: V1_OPENAPI_ROUTE, + operations: v1Operations, + schemaProfile: "v1-session-legacy", + mappingProfile: "v1-session", + adapterProfile: "v1-cli-library", + deletionProfile: "v1", transfer: "cli", }, ]; +export function protocolRulesForVersion(value: string | null): readonly OpenCodeProtocolRule[] { + if (value === null) return []; + return OPENCODE_PROTOCOL_RULES.filter((rule) => new RegExp(rule.versionPattern).test(value)); +} + export function protocolRuleForVersion(value: string | null): OpenCodeProtocolRule | undefined { - if (value === null) return undefined; - return OPENCODE_PROTOCOL_RULES.find((rule) => new RegExp(rule.versionPattern).test(value)); + return protocolRulesForVersion(value)[0]; } export function protocolRuleById(id: OpenCodeProtocolRuleId): OpenCodeProtocolRule { @@ -113,5 +186,10 @@ export function protocolRuleById(id: OpenCodeProtocolRuleId): OpenCodeProtocolRu export function reviewedVersionsForDialect(dialect: OpenCodeDialect): readonly string[] { return OPENCODE_PROTOCOL_RULES .filter((candidate) => candidate.dialect === dialect) - .flatMap((candidate) => candidate.reviewedVersions); + .flatMap((candidate) => candidate.reviewedVersions) + .sort((left, right) => { + const a = left.split(".").map(Number); + const b = right.split(".").map(Number); + return a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!; + }); } diff --git a/src/target/opencode/v1/adapter.ts b/src/target/opencode/v1/adapter.ts index c921f9b..f962cc3 100644 --- a/src/target/opencode/v1/adapter.ts +++ b/src/target/opencode/v1/adapter.ts @@ -11,10 +11,13 @@ import { import { MAX_OPENCODE_TRANSFER_BYTES } from "../../../shared/limits.js"; import { assertNoCredentials } from "../../../shared/sensitive.js"; import { isRecord } from "../contract.js"; +import type { OpenCodeSchemaProfile } from "../protocol-rules.js"; import type { OpenCodeSession } from "../mapping.js"; import { withTemporaryInput } from "../temporary-input.js"; import type { OpenCodeTransport } from "../transport.js"; -import { assertOpenCodeV1Session, V1_SESSION_ROUTE, V1_SESSION_CHILDREN_ROUTE } from "./contract.js"; +import { + assertOpenCodeV1SessionForProfile, V1_SESSION_ROUTE, V1_SESSION_CHILDREN_ROUTE, +} from "./contract.js"; import type { OpenCodeV1Session } from "./mapping.js"; import { requireOpenCodeV1Reconciliation } from "./reconciliation.js"; @@ -24,10 +27,14 @@ const validId = (value: unknown): value is string => export interface OpenCodeV1AdapterOptions { transport: OpenCodeTransport; temporaryRoot: string; + schemaProfile?: Extract; } -function sessionFrom(value: unknown, expectedId: string): OpenCodeV1Session { - assertOpenCodeV1Session(value); +function sessionFrom( + value: unknown, expectedId: string, + schemaProfile: Extract, +): OpenCodeV1Session { + assertOpenCodeV1SessionForProfile(value, schemaProfile); const session = value as OpenCodeV1Session; if (session.info.id !== expectedId) throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); return session; @@ -39,6 +46,7 @@ function sessionFrom(value: unknown, expectedId: string): OpenCodeV1Session { */ export function createOpenCodeV1Adapter(options: OpenCodeV1AdapterOptions) { const { transport, temporaryRoot } = options; + const schemaProfile = options.schemaProfile ?? "v1-session"; const route = (template: string, id: string) => template.replace("{sessionID}", id); const exists = async (id: string): Promise => { @@ -61,7 +69,7 @@ export function createOpenCodeV1Adapter(options: OpenCodeV1AdapterOptions) { } catch { throw new Trae2OpenCodeError("T2O_OPENCODE_READBACK_INVALID"); } - return sessionFrom(value, id); + return sessionFrom(value, id, schemaProfile); }; const listChildren = async (id: string): Promise => { @@ -89,7 +97,7 @@ export function createOpenCodeV1Adapter(options: OpenCodeV1AdapterOptions) { assertNoCredentials(value); // Snapshot before the first await so caller mutation cannot alter checked data. const session = structuredClone(value) as OpenCodeV1Session; - assertOpenCodeV1Session(session); + assertOpenCodeV1SessionForProfile(session, schemaProfile); const id = session.info.id; if (!validId(id)) throw new Trae2OpenCodeError("T2O_OPENCODE_TRANSFER_INVALID"); // v1 adopts the importing process working directory as the session directory. diff --git a/src/target/opencode/v1/contract.ts b/src/target/opencode/v1/contract.ts index 227bff4..14b33a1 100644 --- a/src/target/opencode/v1/contract.ts +++ b/src/target/opencode/v1/contract.ts @@ -10,6 +10,7 @@ import { import { analyzeSchemaCompatibility, type SchemaCompatibilityAnalysis, } from "../schema-compatibility.js"; +import type { OpenCodeSchemaProfile } from "../protocol-rules.js"; /** * OpenCode v1 exposes its OpenAPI document at `/doc`, keeps no session import/export @@ -26,13 +27,25 @@ const V1_SESSION_ROOTS = ["Session", "Message", "Part"] as const; const baseline = JSON.parse(readFileSync(new URL( "../../../../fixtures/opencode/1.18.32/evidence/session.schema.json", import.meta.url, ), "utf8")) as Record; +const legacyBaseline = JSON.parse(readFileSync(new URL( + "../../../../fixtures/opencode/1.16.0/evidence/session.schema.json", import.meta.url, +), "utf8")) as Record; export const V1_SESSION_SCHEMA_HASH = hashCanonicalJson(baseline as JsonValue); +export const V1_LEGACY_SESSION_SCHEMA_HASH = hashCanonicalJson(legacyBaseline as JsonValue); const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(baseline); +const validateLegacy = new Ajv2020({ strict: false, validateFormats: false }).compile(legacyBaseline); export function assertOpenCodeV1Session(value: unknown): void { if (!validate(value)) throw new Trae2OpenCodeError("T2O_OPENCODE_TRANSFER_INVALID"); } +export function assertOpenCodeV1SessionForProfile( + value: unknown, profile: OpenCodeSchemaProfile, +): void { + const valid = profile === "v1-session-legacy" ? validateLegacy(value) : validate(value); + if (!valid) throw new Trae2OpenCodeError("T2O_OPENCODE_TRANSFER_INVALID"); +} + /** Extract only local schemas reachable from the v1 session roots, including cycles. */ export function extractV1SessionSchema(openapi: unknown): Record { const components = isRecord(openapi) ? openapi.components : undefined; @@ -94,10 +107,12 @@ export function assertOpenCodeV1Schema(openapi: unknown): string { return analysis.actualHash; } -export function analyzeOpenCodeV1Schema(openapi: unknown): SchemaCompatibilityAnalysis { +export function analyzeOpenCodeV1Schema( + openapi: unknown, profile: OpenCodeSchemaProfile = "v1-session", +): SchemaCompatibilityAnalysis { const schema = extractV1SessionSchema(openapi); return analyzeSchemaCompatibility( - baseline as JsonValue, + (profile === "v1-session-legacy" ? legacyBaseline : baseline) as JsonValue, schema as JsonValue, ); } diff --git a/tsconfig.integration.json b/tsconfig.integration.json index 141b5af..e9b61ba 100644 --- a/tsconfig.integration.json +++ b/tsconfig.integration.json @@ -6,6 +6,7 @@ }, "include": [ "scripts/verify-opencode-roundtrip.ts", + "scripts/verify-legacy-managed-server.ts", "scripts/verify-product-version.ts", "scripts/verify-platform.ts", "scripts/verify-version-contract.ts", From 8bd13e2f4ef21ea83729c342be20b4e5855ebc67 Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Mon, 28 Sep 2026 18:42:28 +0800 Subject: [PATCH 2/4] =?UTF-8?q?ci(opencode):=20=E5=A2=9E=E5=8A=A0=E6=97=A7?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E8=B7=A8=E5=B9=B3=E5=8F=B0=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5c6b017..060b353 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -12,7 +12,7 @@ jobs: check: name: ${{ matrix.os }} / Node ${{ matrix.node }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -65,6 +65,10 @@ jobs: if: matrix.node == '22' run: npm install --prefix tmp/opencode-unreviewed --no-package-lock --no-save @opencode/cli@2.0.18 + - name: Install legacy OpenCode v2 + if: matrix.node == '22' + run: npm install --prefix tmp/opencode-v2-legacy --no-package-lock --no-save @opencode/cli@2.0.0 + - name: Run public roundtrip command with baseline v2 if: matrix.node == '22' run: npm run verify:opencode -- --binary tmp/opencode/node_modules/@opencode/cli/bin/opencode.exe --output tmp/roundtrip-v2-baseline @@ -73,17 +77,29 @@ jobs: if: matrix.node == '22' run: npm run verify:opencode -- --binary tmp/opencode-unreviewed/node_modules/@opencode/cli/bin/opencode.exe --output tmp/roundtrip-v2-unreviewed + - name: Run public roundtrip command with legacy v2 + if: matrix.node == '22' + run: npm run verify:opencode -- --binary tmp/opencode-v2-legacy/node_modules/@opencode/cli/bin/opencode.exe --output tmp/roundtrip-v2-legacy + + - name: Verify legacy v2 managed-server fallback + if: matrix.node == '22' + run: npm run verify:legacy-startup + env: + T2O_TEST_V2_LEGACY_BINARY: tmp/opencode-v2-legacy/node_modules/@opencode/cli/bin/opencode.exe + - name: Verify current and adjacent version contracts if: matrix.node == '22' run: npm run verify:versions env: T2O_TEST_OPENCODE_BINARY: tmp/opencode/node_modules/@opencode/cli/bin/opencode.exe + T2O_TEST_V2_LEGACY_BINARY: tmp/opencode-v2-legacy/node_modules/@opencode/cli/bin/opencode.exe - - name: Install baseline and unreviewed OpenCode v1 + - name: Install baseline, unreviewed and legacy OpenCode v1 if: matrix.node == '22' run: | npm install --prefix tmp/opencode-v1-current --no-package-lock --no-save opencode-ai@1.18.32 npm install --prefix tmp/opencode-v1-unreviewed --no-package-lock --no-save opencode-ai@1.18.31 + npm install --prefix tmp/opencode-v1-legacy --no-package-lock --no-save opencode-ai@1.16.0 - name: Verify v1 protocol compatibility and recovery if: matrix.node == '22' @@ -91,6 +107,7 @@ jobs: env: T2O_TEST_OPENCODE_BINARY: tmp/opencode-v1-current/node_modules/opencode-ai/bin/opencode.exe T2O_TEST_V1_ADJACENT_BINARY: tmp/opencode-v1-unreviewed/node_modules/opencode-ai/bin/opencode.exe + T2O_TEST_V1_LEGACY_BINARY: tmp/opencode-v1-legacy/node_modules/opencode-ai/bin/opencode.exe - name: Run public roundtrip command with baseline v1 if: matrix.node == '22' @@ -100,6 +117,10 @@ jobs: if: matrix.node == '22' run: npm run verify:opencode -- --binary tmp/opencode-v1-unreviewed/node_modules/opencode-ai/bin/opencode.exe --output tmp/roundtrip-v1-unreviewed + - name: Run public roundtrip command with legacy v1 + if: matrix.node == '22' + run: npm run verify:opencode -- --binary tmp/opencode-v1-legacy/node_modules/opencode-ai/bin/opencode.exe --output tmp/roundtrip-v1-legacy + - name: Verify bounded heap and process interruption recovery if: matrix.node == '22' run: npm run verify:resilience @@ -114,6 +135,7 @@ jobs: path: | tmp/m7-1-platform-report.json tmp/m7-2-version-report.json + tmp/m7-2-legacy-startup-report.json tmp/m7-3-resilience-report.json tmp/roundtrip-v*/report.json tmp/roundtrip-v*/*.schema.json From ff8c8ca116ada4ca6dff6ad84009b202aada27ae Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Mon, 28 Sep 2026 18:43:03 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(opencode):=20=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=97=A7=E5=8D=8F=E8=AE=AE=E6=94=AF=E6=8C=81=E4=B8=8E=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++++++++----- docs/implementation-plan.md | 2 +- docs/m4-1-opencode-capability.md | 12 +++++---- docs/m7-2-version-contract.md | 37 +++++++++++++++++++++------- docs/opencode-compatibility.md | 42 ++++++++++++++++++++++++++++++-- docs/operation-manual.md | 8 ++++-- docs/troubleshooting.md | 4 ++- website/app.js | 4 +-- website/index.html | 8 +++--- 9 files changed, 102 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 8de7e7c..3e451da 100644 --- a/README.md +++ b/README.md @@ -22,19 +22,23 @@ OpenCode **v1 或 v2 协议**。 | 组件 | 要求 | | --- | --- | | TRAE 来源 | TRAE CN **3.3.104**,已登录,并以本机 CDP 端口 `9222` 启动 | -| OpenCode v2 目标 | 已验证基线:`@opencode/cli` **2.0.12**、**2.0.16**;其他稳定 2.x 自动检测兼容性 | -| OpenCode v1 目标 | 已验证基线:`opencode-ai` **1.17.9**、**1.18.32**;其他稳定 1.x 自动检测兼容性 | +| OpenCode v2 目标 | 已验证基线:**2.0.0**(legacy)、**2.0.12**、**2.0.16**;其他稳定 2.x 自动检测兼容性 | +| OpenCode v1 目标 | 已验证基线:**1.16.0 / 1.17.0**(legacy)、**1.17.9 / 1.18.32**;其他稳定 1.x 自动检测兼容性 | | 系统 | macOS、Windows 可直接读取本机 TRAE;Linux 只支持导入已导出的 bundle | | Node.js | `>=18.18`,推荐 Node.js 22 | 附件、Skill 与 MCP 资源不在当前迁移范围内。未知的 TRAE 来源版本仍会被拒绝。 -OpenCode 根据实际协议和隔离往返结果决定能否迁移。目标方言由可执行文件版本决定:v2 走 HTTP -`SessionTransfer`,v1 走 `opencode import` / `opencode export` 子命令。 +OpenCode 根据实际协议 profile 和隔离往返结果决定能否迁移。v2 自动区分旧版与当前 +HTTP `SessionTransfer` 路径;v1 自动区分 legacy 与当前会话 schema,均通过 +`opencode import` / `opencode export` 子命令。 > **未收录的 OpenCode 稳定版本不再仅因版本号被拒绝。** > CLI 与目标服务必须为同一版本,必要路由和 schema 必须完全匹配或仅有安全增量,然后在独立临时库中 > 验证导入、导出、回读、冲突与删除保护。通过后才允许迁移;预发布版本、未知主版本、 > 破坏性 schema 变化或行为验证失败仍会停止。无需增加跳过校验参数。 +> +> **旧版本也不是无条件放行。** `opencode-ai@1.15.x` 无法保留安全所有权 metadata, +> `1.14.x` 的 OpenAPI 不暴露完整会话 schema,因此仍拒绝写入;工具不会直写旧版数据库。 ## 快速开始 @@ -71,7 +75,8 @@ npm install -g opencode-ai@1.18.32 opencode --version ``` -建议使用上述基线版本;其他稳定 1.x/2.x 会在迁移前自动检测兼容性。 +建议新安装使用上述当前基线;已有 2.0.0 或 1.16/早期 1.17 环境无需为了迁移强制升级, +工具会选择 legacy profile。其他稳定 1.x/2.x 会在迁移前自动检测兼容性。 也可运行 `npm run verify:opencode` 单独检查本机 OpenCode,命令复用相同协议规则和 隔离往返验证,支持 `--binary`、`--output` 与 `--json`;详见[独立验证说明](docs/opencode-compatibility.md#独立验证本机-opencode)。 `migrate:local` 会读取 @@ -229,7 +234,7 @@ TRAE 本身的历史被删除,源数据始终保持只读。 | `无法发现 TRAE workbench` | 完全退出后,用上面的终端命令重新启动 TRAE;确认目标项目窗口已打开。 | | `所选 workbench 没有可迁移的本地会话` | 选择正确的项目窗口,并在 TRAE 中打开该项目后再运行。 | | `无法自动启动 OpenCode` | 确认 `opencode --version` 可运行,再用 `npm run verify:opencode` 检查兼容性;需要重装时可选择上述已验证基线。 | -| `OpenCode 版本或协议不受支持` | 确认是稳定 1.x/2.x;必要路由和 schema 必须完全匹配或只有可验证的安全增量。 | +| `OpenCode 版本或协议不受支持` | 确认是稳定 1.x/2.x;目标必须匹配当前或 legacy profile,并保留安全所有权和回读能力。 | | `未收录的 OpenCode 版本需要同版本 CLI` | 安装与目标服务相同版本的 CLI,或用 `T2O_OPENCODE_BINARY` 指定。 | | `隔离导入、回读或删除验证失败` | 尚未向目标写入会话;使用已验证基线版本,保留错误码用于排查。 | | `所选会话包含当前无法无损映射的内容` | 工具尚未写入 OpenCode。保留产物并查看[故障排查](docs/troubleshooting.md)。 | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index ea752e4..a8ec2c9 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -614,7 +614,7 @@ OpenCode 2.0.12,expected/actual 计数和 hash 一致。resume 与重复跳过 | ID | 任务 | 依赖 | 验收 | | --- | --- | --- | --- | | M7-1 | macOS/Windows 集成矩阵 | M5 | 已通过三系统 Node 18.20.8/22;macOS/Windows 默认源目录与三端目标链路通过 | -| M7-2 | OpenCode 版本契约测试 | M4 | 已通过:基线原生写入/回读;2.0.11 与 2.0.12 混用拒写,同版本 2.0.11 通过隔离检测后准入 | +| M7-2 | OpenCode 版本契约测试 | M4 | 已通过:current 与 legacy profile 原生写入/回读;跨 profile 混用拒写,未收录版本需隔离准入 | | M7-3 | 大会话与异常中断测试 | M5 | 已通过三端:512 MiB 堆固定负载、两组真实 kill/resume,无重复 import | | M7-4 | 安装包、README、故障排查 | M7-1..3 | 已通过:白名单 tarball、隔离安装/SQLite/bin/dry-run;三端六任务安装 CI 成功 | | M7-5 | 可选 SQLite fallback 评估 | M7-2 | 当前原生 import 满足 P0,不立项 | diff --git a/docs/m4-1-opencode-capability.md b/docs/m4-1-opencode-capability.md index 22cbfee..63102fb 100644 --- a/docs/m4-1-opencode-capability.md +++ b/docs/m4-1-opencode-capability.md @@ -1,13 +1,15 @@ # M4-1:OpenCode 版本与原生导入契约探测 初始契约日期:2026-09-24;2026-09-26 增加清单外稳定版本的自动兼容性检测; -2026-09-27 增加 schema 兼容分析和声明式协议规则。 +2026-09-27 增加 schema 兼容分析和声明式协议规则;2026-09-28 增加旧协议 profile。 ## 探测契约 `probeOpenCodeCapabilities` 对用户目标只读取版本及 OpenAPI,不创建目标会话。 -v2 读取 `/api/info` 和 `/openapi.json`,v1 读取 `/global/health` 和 `/doc`。 -基线 v2 `2.0.12` / `2.0.16`、v1 `1.17.9` / `1.18.32` 使用已有行为证据。 +v2 current 读取 `/api/info`,legacy 读取 `/api/health`,两者 OpenAPI 均位于 +`/openapi.json`;v1 读取 `/global/health` 和 `/doc`。 +基线 v2 `2.0.0` / `2.0.12` / `2.0.16`、v1 `1.16.0` / `1.17.0` / +`1.17.9` / `1.18.32` 使用已有行为证据。 其他稳定 1.x/2.x 要求同版本 CLI,在独立临时库完成导入、导出、回读和删除保护验证, 并在测试结束后重新核对实际目标的版本与 schema。 OpenAPI 的 `info.version` 为 HTTP 接口版本 `0.0.1`,不能用作产品版本门禁。 @@ -29,8 +31,8 @@ schema 对象 key 顺序不影响比较,不相关 API/schema 变化不阻断 实际数据 schema 使用 `schemaHash` 标识;规则、必要操作、envelope 和数据 schema 的组合 使用 `protocolHash` 标识。任一证据在隔离验证期间变化都会让本次结果失效。 -固定 schema hash: -`sha256:8379854ab529739a39826bbabcb0103847c66368280803e4b5559f09415a3d32`。 +current 与 legacy profile 分别固定自己的 schema hash;完整列表见 +[版本契约](m7-2-version-contract.md)。 `writable` 表示目标契约门禁通过;源 profile、完成时间、映射和逐会话对账 仍由后续写入流程校验,不能据此宣称真实迁移完成。 diff --git a/docs/m7-2-version-contract.md b/docs/m7-2-version-contract.md index 314b3e4..0473437 100644 --- a/docs/m7-2-version-contract.md +++ b/docs/m7-2-version-contract.md @@ -1,6 +1,7 @@ # M7-2 OpenCode 版本契约 -已验证基线为 v2 **2.0.12**、**2.0.16**,以及 v1 **1.17.9**、**1.18.32**。 +已验证基线为 v2 **2.0.0**(legacy)、**2.0.12**、**2.0.16**,以及 +v1 **1.16.0** / **1.17.0**(legacy)、**1.17.9**、**1.18.32**。 其他稳定 1.x/2.x 不再仅因版本号拒写,详见[协议兼容性检测](opencode-compatibility.md)。 `2.0.11` 保持不在基线清单内,用 npm 官方原生二进制验证自动准入路径,不伪造版本字符串。 @@ -19,19 +20,29 @@ `compatibility=isolated-roundtrip`。检查实际目标仍为空,再执行 migrate、verify、 resume 与 rollback,全流程通过。 7. 切回 2.0.12 后原生 import/export 完整往返成功。 +8. 2.0.0 自动选择 `v2-session-transfer-legacy`,使用旧 `/api/health`、 + `/api/session/import` 与 `/api/session/{sessionID}/export`,完成 migrate、verify、 + resume、冲突保护及 rollback;2.0.2 另通过未收录版本隔离准入。 -CI 在三系统的 Node 22 任务执行 2.0.12 与 2.0.11 的验收;传入 -`T2O_TEST_COMPATIBLE_BINARY` 时额外验证 2.0.16。报告为 `m7-2-version-report.json`。 +CI 在三系统的 Node 22 任务执行 2.0.0、2.0.12 与 2.0.11 的验收,并通过 +`verify:legacy-startup` 验证 2.0.0 无 service descriptor 时的一键启动回退;传入 +`T2O_TEST_COMPATIBLE_BINARY` 时额外验证 2.0.16。报告为 +`m7-2-version-report.json` 与 `m7-2-legacy-startup-report.json`。 安装相邻二进制: ```bash npm install --prefix tmp/opencode-adjacent --no-package-lock --no-save @opencode/cli@2.0.11 +npm install --prefix tmp/opencode-v2-legacy --no-package-lock --no-save @opencode/cli@2.0.0 +T2O_TEST_V2_LEGACY_BINARY="$PWD/tmp/opencode-v2-legacy/node_modules/@opencode/cli/bin/opencode.exe" \ npm run verify:versions +T2O_TEST_V2_LEGACY_BINARY="$PWD/tmp/opencode-v2-legacy/node_modules/@opencode/cli/bin/opencode.exe" \ +npm run verify:legacy-startup ``` 可通过 `T2O_TEST_OPENCODE_BINARY` 指定当前二进制, `T2O_TEST_ADJACENT_BINARY` 指定未收录的 2.0.11 二进制, +`T2O_TEST_V2_LEGACY_BINARY` 指定 legacy 2.0.0 二进制, `T2O_TEST_COMPATIBLE_BINARY` 指定 2.0.16 二进制。路径作为独立参数传给进程。 不因版本号更高而推定兼容。已验证基线仍检查实际协议;其他候选必须满足同版本 CLI、 @@ -50,7 +61,8 @@ CLI 的 `export` / `import` 是顶层子命令且不接受 `--server` / `--direc `serve` 没有 `--service`,会话 JSON 为 `{info, messages:[{info, parts:[]}]}`。 因此 v1 不能复用 v2 的 adapter,而是独立的 contract / mapping / reconciliation / adapter。 -v1 基线为 **1.18.32** 与 **1.17.9**,两个都使用 npm 官方发布的原生二进制实测。 +v1 当前基线为 **1.18.32** 与 **1.17.9**;legacy 基线为 **1.16.0** 与 **1.17.0**, +都使用 npm 官方发布的原生二进制实测。 清单外稳定 1.x 需要额外通过隔离 CLI 验证。契约摘要: 1. 版本:`opencode --version` 与 `GET /global/health` 的 `version` 必须同属 v1。 @@ -58,13 +70,18 @@ v1 基线为 **1.18.32** 与 **1.17.9**,两个都使用 npm 官方发布的原 和删除保护测试,不能仅由 `/doc` schema 推断 CLI 兼容。 2. 路由:`GET /doc` 的 OpenAPI 必须包含 `DELETE /session/{sessionID}` 与 `GET /session/{sessionID}/children`,否则拒绝写入。 -3. schema:`fixtures/opencode/1.18.32/evidence/session.schema.json` 是从 v1 `/doc` 的 - `Session` / `Message` / `Part` 三个根定义按 `$ref` 递归抽取出的 envelope,其规范化 - hash 为 `sha256:e110d00a7c579aac2ff85453d7661bbbdd7b5720efa12caa3da9ceb2c722a132`; - 新版本必须让 `extractV1SessionSchema` 的 hash 与之一致。 -4. 真实往返:两个版本都在隔离数据目录中完成 probe、plan、`opencode import`(以目标 +3. schema:current 与 legacy fixture 都从 v1 `/doc` 的 `Session` / `Message` / + `Part` 三个根定义按 `$ref` 递归抽取;目标必须匹配其中一个 profile,或只存在 + 经过兼容分析并通过隔离验证的增量。 +4. 真实往返:各基线都在隔离数据目录中完成 probe、plan、`opencode import`(以目标 目录为 cwd)、`opencode export` 回读、逐项对账、`verify`、重复迁移冲突检测与 `rollback`。合成 fixture 的 2 条消息、2 个 text、2 个 reasoning、1 个 tool 全部无损。 +5. legacy:1.16.0 / 1.17.0 的 schema hash 为 + `sha256:45fa528fc128f0884b3abd8d02b77ab8a06b92bca6f05f873176f006e9f20f53`, + 自动选择 `v1-cli-library-legacy`。当前 v1 映射的合成数据属于该旧 schema 的安全子集, + 原生迁移、续跑、冲突和回滚全部通过。 +6. 旧版下界:1.15.x 没有 Session metadata,1.14.x 不暴露完整会话 schema; + 两者无法满足所有权和契约验证,继续拒绝写入。 实测确认的 v1 语义(写 adapter 时必须遵守): @@ -85,8 +102,10 @@ v1 基线为 **1.18.32** 与 **1.17.9**,两个都使用 npm 官方发布的原 ```bash npm install --prefix tmp/opencode-v1-adjacent --no-package-lock --no-save opencode-ai@1.17.9 +npm install --prefix tmp/opencode-v1-legacy --no-package-lock --no-save opencode-ai@1.16.0 T2O_TEST_OPENCODE_BINARY="$(command -v opencode)" \ T2O_TEST_V1_ADJACENT_BINARY="$PWD/tmp/opencode-v1-adjacent/node_modules/opencode-ai/bin/opencode.exe" \ +T2O_TEST_V1_LEGACY_BINARY="$PWD/tmp/opencode-v1-legacy/node_modules/opencode-ai/bin/opencode.exe" \ npm run verify:integration:v1 ``` diff --git a/docs/opencode-compatibility.md b/docs/opencode-compatibility.md index 5de67f8..2475be7 100644 --- a/docs/opencode-compatibility.md +++ b/docs/opencode-compatibility.md @@ -7,7 +7,7 @@ | 情况 | 处理 | | --- | --- | -| v2 `2.0.12` / `2.0.16` 或 v1 `1.17.9` / `1.18.32`,协议完全匹配 | 沿用已验证的原生行为 | +| v2 `2.0.0` / `2.0.12` / `2.0.16` 或 v1 `1.16.0` / `1.17.0` / `1.17.9` / `1.18.32`,对应 profile 完全匹配 | 沿用已验证的原生行为 | | 其他稳定 1.x / 2.x,或 schema 存在兼容增量,CLI 与服务版本一致 | 协议分析通过后执行隔离往返验证,通过才获得迁移资格 | | 清单外版本且 CLI / 服务版本不同 | 拒绝,要求提供与目标服务同版本的 CLI | | 预发布、无法解析或未实现的主版本 | 拒绝,不猜测协议 | @@ -17,6 +17,31 @@ 位置、必要操作、请求/响应 envelope、schema profile 和 CLI transfer 方式集中声明在 `protocol-rules.ts`;能力探测不再分别硬编码两套流程。 +## 当前与 Legacy Profile + +能力探测会尝试同一主版本的候选 profile,并以实际端点和 schema 选择唯一匹配项。 +版本号只限制候选范围,不直接决定最终适配器。 + +| Profile | 实测基线 | 原生接口 | 映射与回读 | +| --- | --- | --- | --- | +| `v2-session-transfer` | 2.0.12 / 2.0.16 | `/api/info`、`/api/experimental/session/*` | 当前 v2 mapping / adapter | +| `v2-session-transfer-legacy` | 2.0.0 | `/api/health`、`/api/session/import`、`.../export` | v2 映射;legacy 路由、schema 与删除契约 | +| `v1-cli-library` | 1.17.9 / 1.18.32 | `/global/health`、`/doc`、CLI import/export | 当前 v1 schema | +| `v1-cli-library-legacy` | 1.16.0 / 1.17.0 | 同一组 v1 CLI/HTTP 能力 | legacy v1 schema;复用经验证的 v1 映射语义 | + +legacy profile 不是关闭校验。每个 profile 都有独立的实际 schema hash、`protocolHash` +和删除契约;迁移计划及 manifest 记录所选规则,执行时不允许换成同方言的另一个 profile。 +未收录版本若匹配 legacy profile,仍要求同版本 CLI 完成隔离往返。例如 v2 2.0.2 已通过 +这条自动准入路径。 + +### 旧版拒绝边界 + +- `opencode-ai@1.15.x` 的 Session schema 没有 `metadata`,无法保存 + `trae2opencode` 所有权、来源 hash 和覆盖/回滚证据,因此拒绝。 +- `opencode-ai@1.14.x` 的 `/doc` 不暴露完整 Session / Message / Part schema, + 无法验证 CLI import 的数据契约,因此拒绝。 +- 更早的 v1 和 0.x 不具备本项目已验证的安全导入链路,不会通过数据库直写绕过。 + ## Schema 兼容性分析 工具先抽取迁移可达的 schema,再相对基线做有方向的兼容分析: @@ -76,7 +101,7 @@ npm run migrate:local | 值 | 含义 | | --- | --- | | `verified-release` | 基线版本且实际协议与 schema 完全匹配 | -| `isolated-roundtrip` | 未收录版本或兼容 schema 增量的隔离行为验证通过 | +| `isolated-roundtrip` | 未收录版本、legacy profile 或兼容 schema 增量的隔离行为验证通过 | | `protocol-only` | 协议匹配,但同版本 CLI 或隔离验证条件未满足;`writable=false` | | `unsupported` | 版本格式、协议或 schema 未通过 | @@ -181,3 +206,16 @@ CI 新增三系统直接执行上述四个版本的公开命令,并上传报 - 470 项测试、lint、类型检查、版本一致性、构建和 smoke 通过。实际 `verify:opencode` 验证 v2 `2.0.18` / `2.0.12` 与 v1 `1.18.31` / `1.18.32`; `verify:versions`、`verify:integration:v1` 和安装包验收通过。 + +### 旧协议向后兼容(2026-09-28) + +- 新增 `v2-session-transfer-legacy` 与 `v1-cli-library-legacy`,按实际路由和 schema + 选择,不把整个 1.x/2.x 粗略归到同一个 profile。 +- v2 2.0.0 / 2.0.2 和 v1 1.16.0 / 1.17.0 使用 npm 官方二进制实测; + import、export、逐项回读、重复冲突、父子关系、删除保护均通过。 +- 2.0.0、1.16.0、1.17.0 进一步完成 migrate、verify、resume 与 rollback。 +- 1.15.0 因无法保留 Session metadata 拒绝;1.14.17 因不暴露完整会话 schema 拒绝。 +- POSIX 上旧版 Node launcher 现在会解析到内嵌原生 `.opencode`,确保隔离服务可被直接 + 终止和清理;Windows 继续解析 npm shim 到对应原生 executable。 +- 480 项测试、lint、类型检查、版本一致性、构建、smoke 与安装包验收通过。 + CI 在三系统 Node 22 增加 v2 2.0.0 与 v1 1.16.0 的真实回归。 diff --git a/docs/operation-manual.md b/docs/operation-manual.md index ce579fc..746abd2 100644 --- a/docs/operation-manual.md +++ b/docs/operation-manual.md @@ -69,8 +69,12 @@ opencode --version 上述清单是已验证基线。其他稳定 1.x/2.x 会自动校验必要接口并分析完整 schema,再使用 同版本 CLI 在独立临时库完成导入、导出、回读及删除保护验证。通过后允许迁移; 清单外版本的 CLI 与服务版本不同、预发布版本、未知主版本或验证失败仍拒绝。 -目标方言由可执行文件版本决定,不接受手动指定:v2 使用 HTTP `SessionTransfer`,v1 使用 `opencode import` / -`opencode export` 子命令。 +已有旧环境还支持实测 legacy 基线:v2 `2.0.0`,v1 `1.16.0` / `1.17.0`。 +目标 profile 由版本候选、实际路由和 schema 共同决定,不接受手动指定:v2 使用对应版本的 +HTTP `SessionTransfer` 路径,v1 使用 `opencode import` / `opencode export` 子命令。 + +v1 `1.15.x` 缺少会话 metadata,无法保留安全所有权;`1.14.x` 不暴露完整会话 schema。 +这两类及更早版本不会写入,请升级 OpenCode 或只保留离线 bundle。 不必先启动 OpenCode server。迁移程序会读取当前 OpenCode service descriptor 发现动态端口,并检查 `127.0.0.1:4096`。都不可用时会临时启动仅本机可访问的 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b830e97..0018710 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -59,7 +59,7 @@ macOS 的 `open -a "Trae CN" --args ...` 经 3.3.104 实机确认可能丢弃调 | `T2O_OPENCODE_VERSION_UNSUPPORTED` | 版本格式、预发布或未知主版本不受支持;只探测稳定的 1.x/2.x | | `T2O_OPENCODE_COMPATIBILITY_BINARY_REQUIRED` | 未收录版本需要与目标服务完全同版本的 CLI;设置 `T2O_OPENCODE_BINARY` 或 `--binary` | | `T2O_OPENCODE_COMPATIBILITY_UNVERIFIED` | 协议一致但隔离导入、回读或删除验证未通过;尚未写入用户目标,使用已验证基线或报告版本与错误码 | -| `T2O_OPENCODE_SCHEMA_UNSUPPORTED` | 实际 schema 与固定契约不同;保留 IR,等待经验证的 adapter | +| `T2O_OPENCODE_SCHEMA_UNSUPPORTED` | 实际 schema 不匹配 current/legacy profile;保留 IR,升级目标或等待经验证的 adapter | | `T2O_OPENCODE_REQUEST_FAILED` | 检查 server 进程、端口和认证环境变量;server URL 不含凭据 | | `T2O_OPENCODE_MAPPING_REJECTED` | 源会话缺少完成时间/映射证据或包含不支持内容;不会补造字段 | | `T2O_OPENCODE_V1_UNSUPPORTED_STATE` | 源会话含 v1 无法在不编造数据的前提下保存的状态(如无法解析的回复对象);改用 v2 目标或放弃该会话 | @@ -72,6 +72,8 @@ macOS 的 `open -a "Trae CN" --args ...` 经 3.3.104 实机确认可能丢弃调 未收录的稳定版本不再因版本号直接拒绝,但仍需通过 schema 兼容分析和隔离往返验证。 类型、必填字段或现有枚举收窄等破坏性变化仍会拒绝。 +当前明确拒绝 v1 1.15.x(缺少所有权 metadata)及 1.14.x/更早版本(缺少完整可验证 +导入契约);不要通过数据库直写绕过。 `doctor` / `dry-run` 不会修改用户目标,会按需在独立临时库测试合成会话。 详见[OpenCode 协议兼容性检测](opencode-compatibility.md)。 diff --git a/website/app.js b/website/app.js index e4a5269..c7d6b51 100644 --- a/website/app.js +++ b/website/app.js @@ -69,7 +69,7 @@ const translations = { "compatibility.upgrade": "提示升级", "compatibility.platform": "本机来源读取", "compatibility.offline": "Linux 仅导入 bundle", - "compatibility.note": "表中 OpenCode 版本为已验证基线;其他稳定 1.x/2.x 需使用同版本 CLI,通过 schema 兼容分析及隔离往返验证后才能迁移。", + "compatibility.note": "已验证 legacy profile 覆盖 v2 2.0.0 与 v1 1.16.0 / 1.17.0;其他稳定版本按实际路由、schema 和隔离往返结果准入。", "cta.eyebrow": "上下文不该被困在一个工具里", "cta.copy": "保留过程,验证结果,然后从原来的地方继续工作。", "cta.github": "在 GitHub 获取", @@ -149,7 +149,7 @@ const translations = { "compatibility.upgrade": "Upgrade required", "compatibility.platform": "Local source access", "compatibility.offline": "Linux imports bundles only", - "compatibility.note": "Listed OpenCode versions are verified baselines. Other stable 1.x/2.x releases need a CLI matching the server version and must pass schema compatibility analysis and isolated round-trip checks before migration.", + "compatibility.note": "Verified legacy profiles cover v2 2.0.0 and v1 1.16.0 / 1.17.0. Other stable releases are admitted only after route, schema, and isolated round-trip checks.", "cta.eyebrow": "Context should not be trapped in one tool", "cta.copy": "Keep the process, verify the result, and continue where you left off.", "cta.github": "Get it on GitHub", diff --git a/website/index.html b/website/index.html index fc96766..69e71fe 100644 --- a/website/index.html +++ b/website/index.html @@ -489,12 +489,12 @@

兼容性,由实际验证决定

OpenCode v2 - 2.0.12 / 2.0.16 + 2.0.0 / 2.0.12 / 2.0.16 其他稳定 2.x 自动检测
OpenCode v1 - 1.17.9 / 1.18.32 + 1.16.0 / 1.17.0 / 1.17.9 / 1.18.32 其他稳定 1.x 自动检测
@@ -511,7 +511,7 @@

兼容性,由实际验证决定

- 表中 OpenCode 版本为已验证基线;其他稳定 1.x/2.x 需使用同版本 CLI,通过 schema 兼容分析及隔离往返验证后才能迁移。 + 已验证 legacy profile 覆盖 v2 2.0.0 与 v1 1.16.0 / 1.17.0;其他稳定版本按实际路由、schema 和隔离往返结果准入。

@@ -565,6 +565,6 @@

Trae2OpenCode

- + From 357582ad22e4c596d1fa0fffd5b70f4fc31dc46d Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Tue, 29 Sep 2026 11:36:40 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(opencode):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=B9=B3=E5=8F=B0=E8=A7=84=E5=88=99=E8=A7=A3?= =?UTF-8?q?=E6=9E=90POSIX=E5=90=AF=E5=8A=A8=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/target/__tests__/opencode-binary.test.ts | 10 +++++----- src/target/opencode/binary.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/target/__tests__/opencode-binary.test.ts b/src/target/__tests__/opencode-binary.test.ts index a2d9cd0..110798e 100644 --- a/src/target/__tests__/opencode-binary.test.ts +++ b/src/target/__tests__/opencode-binary.test.ts @@ -56,14 +56,14 @@ describe("resolveOpenCodeBinary", () => { }); it("resolves the embedded native binary used by older POSIX Node launchers", () => { - const directory = path.join("/", "synthetic", "opencode-ai", "bin"); - const launcher = path.join(directory, "opencode"); - const linked = path.join("/", "synthetic", "bin", "opencode"); - const embedded = path.join(directory, ".opencode"); + const directory = path.posix.join("/", "synthetic", "opencode-ai", "bin"); + const launcher = path.posix.join(directory, "opencode"); + const linked = path.posix.join("/", "synthetic", "bin", "opencode"); + const embedded = path.posix.join(directory, ".opencode"); const existing = new Set([linked, launcher, embedded]); assert.equal(resolveOpenCodeBinary("opencode", { platform: "darwin", - env: { PATH: path.dirname(linked) }, + env: { PATH: path.posix.dirname(linked) }, isFile: (candidate) => existing.has(candidate), realpath: (candidate) => candidate === linked ? launcher : candidate, }), embedded); diff --git a/src/target/opencode/binary.ts b/src/target/opencode/binary.ts index 5e530f6..e1de90a 100644 --- a/src/target/opencode/binary.ts +++ b/src/target/opencode/binary.ts @@ -97,11 +97,12 @@ export function resolveOpenCodeBinary( if (binary.length === 0) return binary; const isFile = options.isFile ?? defaultIsFile; if (platform !== "win32") { + const platformPath = path.posix; const env = options.env ?? process.env; - const directories = (env.PATH ?? "").split(path.delimiter).filter(Boolean); - const candidates = /[/\\]/.test(binary) - ? [path.resolve(binary)] - : directories.map((directory) => path.resolve(directory, binary)); + const directories = (env.PATH ?? "").split(platformPath.delimiter).filter(Boolean); + const candidates = binary.includes("/") + ? [platformPath.resolve(binary)] + : directories.map((directory) => platformPath.resolve(directory, binary)); const resolveRealpath = options.realpath ?? realpathSync; for (const candidate of candidates) { if (!isFile(candidate)) continue; @@ -111,7 +112,7 @@ export function resolveOpenCodeBinary( } catch { launcher = candidate; } - const embedded = path.join(path.dirname(launcher), ".opencode"); + const embedded = platformPath.join(platformPath.dirname(launcher), ".opencode"); if (isFile(embedded)) return embedded; return binary; }