From 55a1beaf9d30866c90798035b54a403c19da4186 Mon Sep 17 00:00:00 2001 From: yilsem Date: Fri, 9 Oct 2026 11:55:16 +0300 Subject: [PATCH] feat(settings): add Codex link controls and authenticated phone bridge --- apps/desktop/codex-link-control.schema.json | 1096 +++++++++++++++++ apps/desktop/codex-link-control.ts | 123 ++ apps/desktop/codex-link-control.types.ts | 22 + apps/desktop/codex-link-json.ts | 259 ++++ .../e2e/fixtures/codex-link-settings.html | 2 + .../e2e/fixtures/codex-link-settings.tsx | 19 + .../electron/codex-link-client.test.ts | 173 +++ apps/desktop/electron/codex-link-client.ts | 132 ++ apps/desktop/electron/main.ts | 12 + .../desktop/electron/mobile-lan-proxy.test.ts | 57 + apps/desktop/electron/mobile-lan-proxy.ts | 35 + apps/desktop/electron/preload.ts | 1 + .../scripts/check-codex-link-layout.mjs | 55 + .../app/settings/codex-link-settings.test.tsx | 196 +++ .../src/app/settings/codex-link-settings.tsx | 465 +++++++ apps/desktop/src/app/settings/index.tsx | 15 +- apps/desktop/src/app/settings/types.ts | 1 + .../src/app/starmap/share-controls.test.tsx | 2 +- apps/desktop/src/global.d.ts | 2 + apps/desktop/src/i18n/codex-link.ts | 121 ++ apps/desktop/tsconfig.electron.json | 2 +- scripts/generate-codex-link-control.py | 70 ++ 22 files changed, 2857 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/codex-link-control.schema.json create mode 100644 apps/desktop/codex-link-control.ts create mode 100644 apps/desktop/codex-link-control.types.ts create mode 100644 apps/desktop/codex-link-json.ts create mode 100644 apps/desktop/e2e/fixtures/codex-link-settings.html create mode 100644 apps/desktop/e2e/fixtures/codex-link-settings.tsx create mode 100644 apps/desktop/electron/codex-link-client.test.ts create mode 100644 apps/desktop/electron/codex-link-client.ts create mode 100644 apps/desktop/scripts/check-codex-link-layout.mjs create mode 100644 apps/desktop/src/app/settings/codex-link-settings.test.tsx create mode 100644 apps/desktop/src/app/settings/codex-link-settings.tsx create mode 100644 apps/desktop/src/i18n/codex-link.ts create mode 100644 scripts/generate-codex-link-control.py diff --git a/apps/desktop/codex-link-control.schema.json b/apps/desktop/codex-link-control.schema.json new file mode 100644 index 0000000..342d4d6 --- /dev/null +++ b/apps/desktop/codex-link-control.schema.json @@ -0,0 +1,1096 @@ +{ + "$defs": { + "catalog_sync": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "catalog_sync" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + } + } + }, + "context_compact_view": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id", + "view_handle", + "expected_checkpoint_revision" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "context_compact_view" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "view_handle": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "expected_checkpoint_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, + "context_view": { + "type": "object", + "additionalProperties": false, + "required": [ + "view_handle", + "can_compact", + "label", + "checkpoint_revision", + "current_model_label", + "limiting_target_id", + "target_budgets", + "active_summary_count", + "summary_tokens", + "summary_token_measurement", + "last_operation_id", + "last_operation_state", + "last_operation_error", + "compact_attempts" + ], + "properties": { + "view_handle": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "can_compact": { + "type": "boolean" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "checkpoint_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "current_model_label": { + "type": "string", + "minLength": 1 + }, + "limiting_target_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + { + "type": "null" + } + ] + }, + "target_budgets": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/target_budget" + }, + "minItems": 1 + }, + { + "type": "null" + } + ] + }, + "active_summary_count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "summary_tokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "summary_token_measurement": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "target_id", + "tokenizer_id", + "tokenizer_revision", + "count_method" + ], + "properties": { + "target_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "tokenizer_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "tokenizer_revision": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "count_method": { + "enum": [ + "exact_tokenizer", + "verified_upper_bound" + ] + } + } + }, + { + "type": "null" + } + ] + }, + "last_operation_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + { + "type": "null" + } + ] + }, + "last_operation_state": { + "anyOf": [ + { + "enum": [ + "accepted", + "running", + "completed", + "failed", + "indeterminate" + ] + }, + { + "type": "null" + } + ] + }, + "last_operation_error": { + "anyOf": [ + { + "$ref": "#/$defs/error" + }, + { + "type": "null" + } + ] + }, + "compact_attempts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "attempt_id", + "member_label", + "state", + "usage" + ], + "properties": { + "attempt_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "member_label": { + "type": "string", + "minLength": 1 + }, + "state": { + "enum": [ + "prepared", + "running", + "completed", + "failed", + "indeterminate" + ] + }, + "usage": { + "$ref": "#/$defs/usage" + } + } + }, + "minItems": 0 + } + } + }, + "context_views_get": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "context_views_get" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + } + } + }, + "context_views_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id", + "settings_revision", + "views" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "context_views_result" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "settings_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "views": { + "type": "array", + "items": { + "$ref": "#/$defs/context_view" + }, + "minItems": 0 + } + } + }, + "control_accepted": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id", + "settings_revision", + "operation_state", + "error" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "control_accepted" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "settings_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "operation_state": { + "enum": [ + "accepted", + "running", + "completed", + "failed", + "indeterminate" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/error" + }, + { + "type": "null" + } + ] + } + } + }, + "control_error": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id", + "settings_revision", + "error" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "control_error" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "settings_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/$defs/error" + } + } + }, + "control_request": { + "oneOf": [ + { + "$ref": "#/$defs/settings_get" + }, + { + "$ref": "#/$defs/settings_update" + }, + { + "$ref": "#/$defs/status_get" + }, + { + "$ref": "#/$defs/catalog_sync" + }, + { + "$ref": "#/$defs/context_views_get" + }, + { + "$ref": "#/$defs/context_compact_view" + } + ] + }, + "control_response": { + "oneOf": [ + { + "$ref": "#/$defs/settings_result" + }, + { + "$ref": "#/$defs/status_result" + }, + { + "$ref": "#/$defs/context_views_result" + }, + { + "$ref": "#/$defs/control_accepted" + }, + { + "$ref": "#/$defs/control_error" + } + ] + }, + "error": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "recovery_action" + ], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "message": { + "type": "string", + "minLength": 1 + }, + "recovery_action": { + "enum": [ + "retry_control_read", + "reconcile_only", + "repair_required", + "select_route", + "reconfigure", + "none" + ] + } + } + }, + "output_cap_enforcement": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "field", + "value", + "proof_revision", + "verified" + ], + "properties": { + "method": { + "const": "request_field" + }, + "field": { + "enum": [ + "max_output_tokens", + "max_tokens", + "max_completion_tokens", + "num_predict" + ] + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "proof_revision": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "verified": { + "const": true + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "value", + "proof_revision", + "verified" + ], + "properties": { + "method": { + "const": "provider_fixed_maximum" + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "proof_revision": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "verified": { + "const": true + } + } + } + ] + }, + "settings": { + "type": "object", + "additionalProperties": false, + "required": [ + "settings_revision", + "enabled", + "compression_enabled", + "threshold_tokens", + "source_binding_handle", + "compact_route_id", + "legacy_intent_imported", + "ownership_manifest_revision", + "activation_state" + ], + "properties": { + "settings_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "enabled": { + "type": "boolean" + }, + "compression_enabled": { + "type": "boolean" + }, + "threshold_tokens": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "source_binding_handle": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + { + "type": "null" + } + ] + }, + "compact_route_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + { + "type": "null" + } + ] + }, + "legacy_intent_imported": { + "type": "boolean" + }, + "ownership_manifest_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "activation_state": { + "enum": [ + "disabled", + "pending_runtime_acceptance", + "ready", + "restart_required", + "conflict", + "recovery_pending" + ] + } + } + }, + "settings_get": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "settings_get" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + } + } + }, + "settings_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id", + "settings", + "available_sources" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "settings_result" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "settings": { + "$ref": "#/$defs/settings" + }, + "available_sources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_binding_handle", + "label", + "authorized" + ], + "properties": { + "source_binding_handle": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "authorized": { + "type": "boolean" + } + } + }, + "minItems": 0 + } + } + }, + "settings_update": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id", + "expected_settings_revision", + "patch" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "settings_update" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "expected_settings_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "patch": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "enabled": { + "type": "boolean" + }, + "compression_enabled": { + "type": "boolean" + }, + "threshold_tokens": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "source_binding_handle": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + } + }, + "minProperties": 1 + } + } + }, + "status_get": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "status_get" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + } + } + }, + "status_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "kind", + "request_id", + "operation_id", + "status" + ], + "properties": { + "contract_version": { + "const": "codex-link/1" + }, + "kind": { + "const": "status_result" + }, + "request_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "status": { + "$ref": "#/$defs/status_snapshot" + } + } + }, + "status_snapshot": { + "type": "object", + "additionalProperties": false, + "required": [ + "settings_revision", + "runtime_mode", + "native_available", + "external_admission", + "needs_restart", + "catalog_state", + "active_turns", + "uncertain_operations", + "last_operation_id", + "error" + ], + "properties": { + "settings_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "runtime_mode": { + "enum": [ + "native", + "managed", + "unknown" + ] + }, + "native_available": { + "type": "boolean" + }, + "external_admission": { + "enum": [ + "open", + "closed", + "draining", + "recovery_required" + ] + }, + "needs_restart": { + "type": "boolean" + }, + "catalog_state": { + "enum": [ + "unknown", + "fresh", + "stale", + "unauthorized", + "empty" + ] + }, + "active_turns": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "uncertain_operations": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_operation_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/error" + }, + { + "type": "null" + } + ] + } + } + }, + "target_budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "target_id", + "canonical_leaf_id", + "limits_source", + "limits_revision", + "tokenizer_id", + "tokenizer_revision", + "count_method", + "serialization_revision", + "input_tokens", + "context_window_tokens", + "output_cap_tokens", + "safety_margin_tokens", + "max_input_tokens", + "max_output_tokens", + "request_bytes", + "max_request_bytes", + "output_cap_enforcement" + ], + "properties": { + "target_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "canonical_leaf_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "limits_source": { + "enum": [ + "provider_contract", + "provider_api", + "verified_adapter", + "verified_measurement" + ] + }, + "limits_revision": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "tokenizer_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "tokenizer_revision": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "count_method": { + "enum": [ + "exact_tokenizer", + "verified_upper_bound" + ] + }, + "serialization_revision": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "input_tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "context_window_tokens": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "output_cap_tokens": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "safety_margin_tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "max_input_tokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "max_output_tokens": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "request_bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "max_request_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "output_cap_enforcement": { + "$ref": "#/$defs/output_cap_enforcement" + } + } + }, + "usage": { + "type": "object", + "additionalProperties": false, + "required": [ + "input_tokens", + "output_tokens", + "measurement", + "cost_microunits", + "currency", + "cost_source" + ], + "properties": { + "input_tokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "output_tokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "measurement": { + "enum": [ + "measured", + "estimated", + "unknown" + ] + }, + "cost_microunits": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "currency": { + "anyOf": [ + { + "const": "USD" + }, + { + "type": "null" + } + ] + }, + "cost_source": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + { + "type": "null" + } + ] + } + } + } + } +} diff --git a/apps/desktop/codex-link-control.ts b/apps/desktop/codex-link-control.ts new file mode 100644 index 0000000..a4c8a77 --- /dev/null +++ b/apps/desktop/codex-link-control.ts @@ -0,0 +1,123 @@ +import schema from './codex-link-control.schema.json' +import type { CodexLinkControlRequest, CodexLinkControlResponse } from './codex-link-control.types' +import { canonicalJson, CodexLinkValidationError, parseStrictJson } from './codex-link-json' + +export const CODEX_LINK_REQUEST_BYTES = 64 * 1024 +export const CODEX_LINK_RESPONSE_BYTES = 1024 * 1024 +type Rule = Record +const definitions: Record = schema.$defs + +function matches(value: any, rule: Rule): boolean { + if (rule.$ref) { + const target = definitions[rule.$ref.replace('#/$defs/', '')] + + return !!target && matches(value, target) + } + + if (rule.oneOf) { + return rule.oneOf.filter((r: Rule) => matches(value, r)).length === 1 + } + + if (rule.anyOf) { + return rule.anyOf.some((r: Rule) => matches(value, r)) + } + + if ('const' in rule && value !== rule.const) { + return false + } + + if (rule.enum && !rule.enum.includes(value)) { + return false + } + + switch (rule.type) { + case 'object': { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false + } + + const keys = Object.keys(value) + + return ( + keys.length >= (rule.minProperties ?? 0) && + rule.required.every((key: string) => Object.hasOwn(value, key)) && + keys.every(key => Object.hasOwn(rule.properties, key) && matches(value[key], rule.properties[key])) + ) + } + + case 'array': + return ( + Array.isArray(value) && + value.length >= (rule.minItems ?? 0) && + (!rule.uniqueItems || new Set(value.map(canonicalJson)).size === value.length) && + value.every(item => matches(item, rule.items)) + ) + + case 'string': + return ( + typeof value === 'string' && + [...value].length >= (rule.minLength ?? 0) && + (!rule.pattern || new RegExp(rule.pattern).test(value)) + ) + + case 'integer': + return ( + Number.isSafeInteger(value) && + value >= (rule.minimum ?? -Number.MAX_SAFE_INTEGER) && + value <= (rule.maximum ?? Number.MAX_SAFE_INTEGER) + ) + + case 'boolean': + return typeof value === 'boolean' + + case 'null': + return value === null + + case undefined: + return 'const' in rule || 'enum' in rule + + default: + return false + } +} + +export function validateControlRequest(value: unknown): asserts value is CodexLinkControlRequest { + const text = canonicalJson(value) + + if ( + new TextEncoder().encode(text).byteLength > CODEX_LINK_REQUEST_BYTES || + !matches(value, definitions.control_request) + ) { + throw new CodexLinkValidationError('invalid_control_request') + } +} + +export function parseControlResponse( + raw: Uint8Array | string, + request: CodexLinkControlRequest +): CodexLinkControlResponse { + const value = parseStrictJson(raw, CODEX_LINK_RESPONSE_BYTES) as CodexLinkControlResponse + + if ( + !matches(value, definitions.control_response) || + value.request_id !== request.request_id || + value.operation_id !== request.operation_id + ) { + throw new CodexLinkValidationError('invalid_control_response') + } + + const expected = { + settings_get: 'settings_result', + settings_update: 'settings_result', + status_get: 'status_result', + context_views_get: 'context_views_result', + catalog_sync: 'control_accepted', + context_compact_view: 'control_accepted' + } + + if (value.kind !== 'control_error' && value.kind !== expected[request.kind]) { + throw new CodexLinkValidationError('unexpected_control_response') + } + + return value +} diff --git a/apps/desktop/codex-link-control.types.ts b/apps/desktop/codex-link-control.types.ts new file mode 100644 index 0000000..4640651 --- /dev/null +++ b/apps/desktop/codex-link-control.types.ts @@ -0,0 +1,22 @@ +// Generated by scripts/generate-codex-link-control.py. Do not edit. +export const CODEX_LINK_CONTROL_SCHEMA_HASH = "sha256:f969be5410af06a168f2934ed1025061b06482901a7421f0034e5e4e86aca238" +export type CodexLinkCatalogSync = { "contract_version": "codex-link/1"; "kind": "catalog_sync"; "request_id": string; "operation_id": string } +export type CodexLinkContextCompactView = { "contract_version": "codex-link/1"; "kind": "context_compact_view"; "request_id": string; "operation_id": string; "view_handle": string; "expected_checkpoint_revision": number } +export type CodexLinkContextView = { "view_handle": string; "can_compact": boolean; "label": string; "checkpoint_revision": number; "current_model_label": string; "limiting_target_id": (string | null); "target_budgets": (Array | null); "active_summary_count": number; "summary_tokens": (number | null); "summary_token_measurement": ({ "target_id": string; "tokenizer_id": string; "tokenizer_revision": string; "count_method": "exact_tokenizer" | "verified_upper_bound" } | null); "last_operation_id": (string | null); "last_operation_state": ("accepted" | "running" | "completed" | "failed" | "indeterminate" | null); "last_operation_error": (CodexLinkError | null); "compact_attempts": Array<{ "attempt_id": string; "member_label": string; "state": "prepared" | "running" | "completed" | "failed" | "indeterminate"; "usage": CodexLinkUsage }> } +export type CodexLinkContextViewsGet = { "contract_version": "codex-link/1"; "kind": "context_views_get"; "request_id": string; "operation_id": string } +export type CodexLinkContextViewsResult = { "contract_version": "codex-link/1"; "kind": "context_views_result"; "request_id": string; "operation_id": string; "settings_revision": number; "views": Array } +export type CodexLinkControlAccepted = { "contract_version": "codex-link/1"; "kind": "control_accepted"; "request_id": string; "operation_id": string; "settings_revision": number; "operation_state": "accepted" | "running" | "completed" | "failed" | "indeterminate"; "error": (CodexLinkError | null) } +export type CodexLinkControlError = { "contract_version": "codex-link/1"; "kind": "control_error"; "request_id": string; "operation_id": string; "settings_revision": number; "error": CodexLinkError } +export type CodexLinkControlRequest = (CodexLinkSettingsGet | CodexLinkSettingsUpdate | CodexLinkStatusGet | CodexLinkCatalogSync | CodexLinkContextViewsGet | CodexLinkContextCompactView) +export type CodexLinkControlResponse = (CodexLinkSettingsResult | CodexLinkStatusResult | CodexLinkContextViewsResult | CodexLinkControlAccepted | CodexLinkControlError) +export type CodexLinkError = { "code": string; "message": string; "recovery_action": "retry_control_read" | "reconcile_only" | "repair_required" | "select_route" | "reconfigure" | "none" } +export type CodexLinkOutputCapEnforcement = ({ "method": "request_field"; "field": "max_output_tokens" | "max_tokens" | "max_completion_tokens" | "num_predict"; "value": number; "proof_revision": string; "verified": true } | { "method": "provider_fixed_maximum"; "value": number; "proof_revision": string; "verified": true }) +export type CodexLinkSettings = { "settings_revision": number; "enabled": boolean; "compression_enabled": boolean; "threshold_tokens": number; "source_binding_handle": (string | null); "compact_route_id": (string | null); "legacy_intent_imported": boolean; "ownership_manifest_revision": number; "activation_state": "disabled" | "pending_runtime_acceptance" | "ready" | "restart_required" | "conflict" | "recovery_pending" } +export type CodexLinkSettingsGet = { "contract_version": "codex-link/1"; "kind": "settings_get"; "request_id": string; "operation_id": string } +export type CodexLinkSettingsResult = { "contract_version": "codex-link/1"; "kind": "settings_result"; "request_id": string; "operation_id": string; "settings": CodexLinkSettings; "available_sources": Array<{ "source_binding_handle": string; "label": string; "authorized": boolean }> } +export type CodexLinkSettingsUpdate = { "contract_version": "codex-link/1"; "kind": "settings_update"; "request_id": string; "operation_id": string; "expected_settings_revision": number; "patch": { "enabled"?: boolean; "compression_enabled"?: boolean; "threshold_tokens"?: number; "source_binding_handle"?: string } } +export type CodexLinkStatusGet = { "contract_version": "codex-link/1"; "kind": "status_get"; "request_id": string; "operation_id": string } +export type CodexLinkStatusResult = { "contract_version": "codex-link/1"; "kind": "status_result"; "request_id": string; "operation_id": string; "status": CodexLinkStatusSnapshot } +export type CodexLinkStatusSnapshot = { "settings_revision": number; "runtime_mode": "native" | "managed" | "unknown"; "native_available": boolean; "external_admission": "open" | "closed" | "draining" | "recovery_required"; "needs_restart": boolean; "catalog_state": "unknown" | "fresh" | "stale" | "unauthorized" | "empty"; "active_turns": number; "uncertain_operations": number; "last_operation_id": (string | null); "error": (CodexLinkError | null) } +export type CodexLinkTargetBudget = { "target_id": string; "canonical_leaf_id": string; "limits_source": "provider_contract" | "provider_api" | "verified_adapter" | "verified_measurement"; "limits_revision": string; "tokenizer_id": string; "tokenizer_revision": string; "count_method": "exact_tokenizer" | "verified_upper_bound"; "serialization_revision": string; "input_tokens": number; "context_window_tokens": number; "output_cap_tokens": number; "safety_margin_tokens": number; "max_input_tokens": (number | null); "max_output_tokens": number; "request_bytes": number; "max_request_bytes": number; "output_cap_enforcement": CodexLinkOutputCapEnforcement } +export type CodexLinkUsage = { "input_tokens": (number | null); "output_tokens": (number | null); "measurement": "measured" | "estimated" | "unknown"; "cost_microunits": (number | null); "currency": ("USD" | null); "cost_source": (string | null) } diff --git a/apps/desktop/codex-link-json.ts b/apps/desktop/codex-link-json.ts new file mode 100644 index 0000000..a0e502d --- /dev/null +++ b/apps/desktop/codex-link-json.ts @@ -0,0 +1,259 @@ +// Strict P1 JSON domain/parser, shared algorithm with router codexLinkValidation. +// Browser-safe; never apply to the opaque native/provider JSON body. +type Data = Record + +export class CodexLinkValidationError extends Error { + readonly code: string + constructor(code: string) { + super(code) + this.code = code + this.name = 'CodexLinkValidationError' + } +} + +function requireValue(condition: unknown, code: string): asserts condition { + if (!condition) { + throw new CodexLinkValidationError(code) + } +} + +function object(value: unknown): Data { + requireValue(value !== null && typeof value === 'object' && !Array.isArray(value), 'schema_rejected') + const prototype = Object.getPrototypeOf(value) + requireValue(prototype === Object.prototype || prototype === null, 'schema_rejected') + + return value as Data +} + +function integer(value: unknown, positive = false): asserts value is number { + requireValue(Number.isSafeInteger(value) && (value as number) >= (positive ? 1 : 0), 'schema_rejected') +} + +function id(value: unknown): asserts value is string { + requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value), 'schema_rejected') +} + +function exactKeys(value: Data, keys: string[]) { + requireValue( + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)), + 'schema_rejected' + ) +} + +function unicode(value: string) { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i) + + if (code >= 0xd800 && code <= 0xdbff) { + const low = value.charCodeAt(++i) + requireValue(low >= 0xdc00 && low <= 0xdfff, 'invalid_unicode') + } else { + requireValue(code < 0xdc00 || code > 0xdfff, 'invalid_unicode') + } + } +} + +/** P1 canonical domain: ASCII object keys, safe integers, exact Unicode text. */ +export function canonicalJson(value: unknown): string { + const ancestors = new Set() + + function encode(item: unknown, depth: number): string { + requireValue(depth <= 256, 'invalid_json') + + if (item === null || typeof item === 'boolean') { + return JSON.stringify(item) + } + + if (typeof item === 'string') { + unicode(item) + + return JSON.stringify(item) + } + + if (typeof item === 'number') { + requireValue(Number.isInteger(item), 'floats_or_non_json_type') + requireValue(Number.isSafeInteger(item), 'unsafe_integer') + + return JSON.stringify(item) + } + + requireValue(typeof item === 'object' && item !== null, 'floats_or_non_json_type') + requireValue(!ancestors.has(item), 'invalid_json') + ancestors.add(item) + let result: string + + if (Array.isArray(item)) { + result = '[' + item.map(child => encode(child, depth + 1)).join(',') + ']' + } else { + const data = object(item) + const keys = Object.keys(data).sort() + requireValue( + keys.every(key => [...key].every(char => char.charCodeAt(0) <= 127)), + 'non_ascii_key' + ) + result = '{' + keys.map(key => JSON.stringify(key) + ':' + encode(data[key], depth + 1)).join(',') + '}' + } + + ancestors.delete(item) + + return result + } + + return encode(value, 0) +} + +/** A bounded parser that detects duplicates before JSON.parse can erase them. */ +export function parseStrictJson(raw: string | Uint8Array, maxBytes: number): unknown { + integer(maxBytes, true) + const rawBytes = typeof raw === 'string' ? new TextEncoder().encode(raw).byteLength : raw.byteLength + requireValue(rawBytes <= maxBytes, 'transport_bytes_exceeded') + let text: string + + try { + text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw) + } catch { + throw new CodexLinkValidationError('invalid_json') + } + + unicode(text) + let position = 0 + + function whitespace() { + while (/[ \t\r\n]/.test(text[position] ?? '') && position < text.length) { + position++ + } + } + + function quoted(): string { + const start = position++ + + while (position < text.length) { + const next = text[position++] + + if (next === '\\') { + position++ + + continue + } + + if (next === '"') { + try { + const value = JSON.parse(text.slice(start, position)) + unicode(value) + + return value + } catch (error) { + if (error instanceof CodexLinkValidationError) { + throw error + } + + throw new CodexLinkValidationError('invalid_json') + } + } + } + + throw new CodexLinkValidationError('invalid_json') + } + + function read(depth: number): unknown { + requireValue(depth <= 256, 'invalid_json') + whitespace() + const token = text[position] + + if (token === '"') { + return quoted() + } + + if (token === '{') { + position++ + whitespace() + const value: Data = Object.create(null) + + if (text[position] === '}') { + position++ + + return value + } + + while (position < text.length) { + requireValue(text[position] === '"', 'invalid_json') + const key = quoted() + requireValue( + [...key].every(char => char.charCodeAt(0) <= 127), + 'non_ascii_key' + ) + requireValue(!Object.hasOwn(value, key), 'duplicate_json_key') + whitespace() + requireValue(text[position++] === ':', 'invalid_json') + value[key] = read(depth + 1) + whitespace() + const separator = text[position++] + + if (separator === '}') { + return value + } + + requireValue(separator === ',', 'invalid_json') + whitespace() + } + } else if (token === '[') { + position++ + whitespace() + const value: unknown[] = [] + + if (text[position] === ']') { + position++ + + return value + } + + while (position < text.length) { + value.push(read(depth + 1)) + whitespace() + const separator = text[position++] + + if (separator === ']') { + return value + } + + requireValue(separator === ',', 'invalid_json') + } + } else { + for (const [word, value] of [ + ['true', true], + ['false', false], + ['null', null] + ] as const) { + if (text.startsWith(word, position)) { + position += word.length + + return value + } + } + + if (/^(?:NaN|[-+]?Infinity)/.test(text.slice(position))) { + throw new CodexLinkValidationError('floats_or_non_json_type') + } + + const match = /^-?(?:0|[1-9]\d*)/.exec(text.slice(position)) + + if (match) { + position += match[0].length + requireValue(!/[.eE]/.test(text[position] ?? ''), 'floats_or_non_json_type') + const number = Number(match[0]) + requireValue(Number.isSafeInteger(number), 'unsafe_integer') + + return number + } + } + + throw new CodexLinkValidationError('invalid_json') + } + + const value = read(0) + whitespace() + requireValue(position === text.length, 'invalid_json') + canonicalJson(value) + + return value +} diff --git a/apps/desktop/e2e/fixtures/codex-link-settings.html b/apps/desktop/e2e/fixtures/codex-link-settings.html new file mode 100644 index 0000000..f26a382 --- /dev/null +++ b/apps/desktop/e2e/fixtures/codex-link-settings.html @@ -0,0 +1,2 @@ + +Codex Bağlama kabul görünümü
diff --git a/apps/desktop/e2e/fixtures/codex-link-settings.tsx b/apps/desktop/e2e/fixtures/codex-link-settings.tsx new file mode 100644 index 0000000..b54166a --- /dev/null +++ b/apps/desktop/e2e/fixtures/codex-link-settings.tsx @@ -0,0 +1,19 @@ +// Synthetic, isolated visual acceptance fixture. Never contacts the installed manager. +import '../../src/styles.css' +import { createRoot } from 'react-dom/client' +import { CodexLinkSettings } from '../../src/app/settings/codex-link-settings' +import { I18nProvider } from '../../src/i18n/context' +import { ThemeProvider } from '../../src/themes/context' +import type { CodexLinkControlRequest, CodexLinkControlResponse, CodexLinkSettings as Settings } from '../../codex-link-control.types' + +let saved: Settings = { settings_revision: 1, enabled: true, compression_enabled: true, threshold_tokens: 250000, source_binding_handle: 'source-demo', compact_route_id: 'Compact', legacy_intent_imported: false, ownership_manifest_revision: 1, activation_state: 'ready' } +const budget = { target_id: 'target-demo', canonical_leaf_id: 'leaf-demo', limits_source: 'verified_adapter' as const, limits_revision: 'limits-1', tokenizer_id: 'tokenizer-demo', tokenizer_revision: '1', count_method: 'exact_tokenizer' as const, serialization_revision: '1', input_tokens: 85000, context_window_tokens: 100000, output_cap_tokens: 8000, safety_margin_tokens: 1000, max_input_tokens: null, max_output_tokens: 8000, request_bytes: 400000, max_request_bytes: 4000000, output_cap_enforcement: { method: 'request_field' as const, field: 'max_output_tokens' as const, value: 8000, proof_revision: '1', verified: true as const } } +window.hermesDesktop = { codexLink: { control: async (request: CodexLinkControlRequest): Promise => { + const base = { contract_version: 'codex-link/1' as const, request_id: request.request_id, operation_id: request.operation_id } + if (request.kind === 'status_get') return { ...base, kind: 'status_result', status: { settings_revision: saved.settings_revision, runtime_mode: 'managed', native_available: true, external_admission: 'open', needs_restart: false, catalog_state: 'fresh', active_turns: 0, uncertain_operations: 0, last_operation_id: null, error: null } } + if (request.kind === 'context_views_get') return { ...base, kind: 'context_views_result', settings_revision: saved.settings_revision, views: [{ view_handle: 'view-demo', can_compact: true, label: 'Proje sohbeti · örnek kayıt', checkpoint_revision: 4, current_model_label: '100 bin bağlamlı model', limiting_target_id: budget.target_id, target_budgets: [budget], active_summary_count: 3, summary_tokens: 20000, summary_token_measurement: { target_id: budget.target_id, tokenizer_id: budget.tokenizer_id, tokenizer_revision: budget.tokenizer_revision, count_method: budget.count_method }, last_operation_id: null, last_operation_state: null, last_operation_error: null, compact_attempts: [] }] } + if (request.kind === 'settings_update') saved = { ...saved, ...request.patch, settings_revision: saved.settings_revision + 1 } + if (request.kind === 'catalog_sync' || request.kind === 'context_compact_view') return { ...base, kind: 'control_accepted', settings_revision: saved.settings_revision, operation_state: 'accepted', error: null } + return { ...base, kind: 'settings_result', settings: saved, available_sources: [{ source_binding_handle: 'source-demo', label: 'AITURK hesabı', authorized: true }] } +} } } as typeof window.hermesDesktop +createRoot(document.getElementById('root')!).render(
) diff --git a/apps/desktop/electron/codex-link-client.test.ts b/apps/desktop/electron/codex-link-client.test.ts new file mode 100644 index 0000000..19f00d7 --- /dev/null +++ b/apps/desktop/electron/codex-link-client.test.ts @@ -0,0 +1,173 @@ +import { createHash } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' + +import { describe, expect, it, vi } from 'vitest' + +import { parseControlResponse, validateControlRequest } from '../codex-link-control' +import controlSchema from '../codex-link-control.schema.json' +import { CODEX_LINK_CONTROL_SCHEMA_HASH } from '../codex-link-control.types' +import type { CodexLinkControlRequest } from '../codex-link-control.types' +import { canonicalJson } from '../codex-link-json' + +import { createCodexLinkClient } from './codex-link-client' + +const request: CodexLinkControlRequest = { + contract_version: 'codex-link/1', + kind: 'settings_get', + request_id: 'req-1', + operation_id: 'op-1' +} + +const response = { + ...request, + kind: 'settings_result', + settings: { + settings_revision: 1, + enabled: true, + compression_enabled: true, + threshold_tokens: 250000, + source_binding_handle: null, + compact_route_id: null, + legacy_intent_imported: false, + ownership_manifest_revision: 0, + activation_state: 'pending_runtime_acceptance' + }, + available_sources: [] +} + +function harness(reply: string = JSON.stringify(response), env: Record = {}) { + let stdin = '' + + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: new PassThrough(), + kill: vi.fn() + }) + + child.stdin.on('data', chunk => { + stdin += chunk.toString() + }) + child.stdin.on('finish', () => + queueMicrotask(() => { + const bytes = Buffer.from(reply) + + // UTF-8 chunks may end inside a multi-byte character. + for (const byte of bytes) { + child.stdout.write(Buffer.from([byte])) + } + + child.stderr.write('diagnostic that must not become a response') + child.emit('close', 0) + }) + ) + const spawn = vi.fn(() => child) + + const control = createCodexLinkClient({ + spawn, + existsSync: () => true, + platform: 'win32', + env: { LOCALAPPDATA: 'C:\\Users\\Example User\\AppData\\Local', SystemRoot: 'C:\\Windows', ...env } + }) + + return { control, spawn, child, stdin: () => stdin } +} + +describe('Codex connection control boundary', () => { + it('pins the generated control-only schema', () => { + expect('sha256:' + createHash('sha256').update(canonicalJson(controlSchema)).digest('hex')).toBe(CODEX_LINK_CONTROL_SCHEMA_HASH) + expect(controlSchema.$defs).not.toHaveProperty('runtime_attach') + expect(controlSchema.$defs).not.toHaveProperty('checkpoint_commit') + }) + it('uses only fixed command arguments and sends the exact request on stdin with EOF', async () => { + const h = harness() + expect(await h.control(request)).toEqual(response) + expect(h.stdin()).toBe(JSON.stringify(request) + '\n') + const [file, args, options] = h.spawn.mock.calls[0] as unknown as [string, string[], any] + expect(file).toBe('C:\\Windows\\System32\\cmd.exe') + expect(args).toEqual([ + '/d', + '/s', + '/c', + '""C:\\Users\\Example User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd" codex-link control --stdin"' + ]) + expect(options).toMatchObject({ shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + }) + it('rejects runtime and arbitrary path/credential commands before spawning', async () => { + const h = harness() + await expect(h.control({ ...request, kind: 'runtime_attach' })).rejects.toThrow() + await expect(h.control({ ...request, path: 'C:\\private' })).rejects.toThrow() + expect(h.spawn).not.toHaveBeenCalled() + }) + it('rejects shell expansion in the entrypoint path', async () => { + const h = harness(undefined, { LOCALAPPDATA: 'C:\\Users\\%PATH%\\Local' }) + await expect(h.control(request)).rejects.toThrow('invalid_entrypoint') + expect(h.spawn).not.toHaveBeenCalled() + }) + it('binds the response to both request and operation identity', () => { + expect(() => + parseControlResponse(JSON.stringify({ ...response, operation_id: 'other-chat-op' }), request) + ).toThrow() + expect(() => parseControlResponse(JSON.stringify({ ...response, request_id: 'other-request' }), request)).toThrow() + }) + it('rejects duplicate keys, floats, trailing JSON and invalid UTF8', () => { + expect(() => + parseControlResponse( + JSON.stringify(response).replace('"settings_revision":1', '"settings_revision":1,"settings_revision":2'), + request + ) + ).toThrow('duplicate_json_key') + expect(() => parseControlResponse(JSON.stringify(response).replace('250000', '250000.0'), request)).toThrow( + 'floats_or_non_json_type' + ) + expect(() => parseControlResponse(JSON.stringify(response) + '{}', request)).toThrow() + expect(() => parseControlResponse(new Uint8Array([0xc3, 0x28]), request)).toThrow() + }) + it('rejects oversized requests and unsafe integer settings', () => { + expect(() => + validateControlRequest({ + ...request, + kind: 'settings_update', + expected_settings_revision: 1, + patch: { threshold_tokens: Number.MAX_SAFE_INTEGER + 1 } + }) + ).toThrow() + expect(() => validateControlRequest({ ...request, operation_id: 'x'.repeat(70000) })).toThrow() + }) + it('does not retry a write after a broken reply', async () => { + const h = harness('broken') + await expect(h.control({ ...request, kind: 'catalog_sync' })).rejects.toThrow('outcome_unknown') + expect(h.spawn).toHaveBeenCalledTimes(1) + }) + it('preserves a structured CLI rejection returned with exit code one', async () => { + const rejection = { ...request, kind: 'control_error', settings_revision: 2, error: { code: 'stale_settings_cas', message: 'Ayarlar değişti.', recovery_action: 'retry_control_read' } } + const h = harness() + h.child.stdin.removeAllListeners('finish') + h.child.stdin.on('finish', () => queueMicrotask(() => { + h.child.stdout.write(JSON.stringify(rejection)) + h.child.emit('close', 1) + })) + expect(await h.control(request)).toEqual(rejection) + expect(h.spawn).toHaveBeenCalledTimes(1) + }) + it('preserves exact Unicode when response bytes arrive separately', async () => { + const h = harness( + JSON.stringify({ + ...response, + available_sources: [{ source_binding_handle: 'source-a', label: 'Türkçe é/é 🧪', authorized: true }] + }) + ) + + const result = await h.control(request) + expect(result.kind === 'settings_result' && result.available_sources[0].label).toBe('Türkçe é/é 🧪') + }) + it('bounds stdout and terminates only the short proxy', async () => { + const h = harness('x'.repeat(1024 * 1024 + 1)) + // Avoid a million small chunks: replace the synthetic finish listener. + h.child.stdin.removeAllListeners('finish') + h.child.stdin.on('finish', () => queueMicrotask(() => h.child.stdout.emit('data', Buffer.alloc(1024 * 1024 + 1)))) + await expect(h.control(request)).rejects.toThrow('outcome_unknown') + expect(h.child.kill).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/electron/codex-link-client.ts b/apps/desktop/electron/codex-link-client.ts new file mode 100644 index 0000000..d1e1fdc --- /dev/null +++ b/apps/desktop/electron/codex-link-client.ts @@ -0,0 +1,132 @@ +import { CODEX_LINK_RESPONSE_BYTES, parseControlResponse, validateControlRequest } from '../codex-link-control' +import type { CodexLinkControlResponse } from '../codex-link-control.types' + +import { type AiturkCliRunnerDeps, resolveAiturkCliPath } from './aiturk-login' + +type Dependencies = AiturkCliRunnerDeps +// Only the fixed entrypoint and fixed arguments go through cmd.exe. All values travel on stdin. +// eslint-disable-next-line no-control-regex -- Reject control bytes in executable paths before cmd parsing. +const UNSAFE_PATH = /["%^!\u0000-\u001f]/ +const ABSOLUTE_PATH = /^[A-Za-z]:\\/ + +export function createCodexLinkClient(deps: Dependencies) { + const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)) + const clearTimer = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType)) + + return async function control(raw: unknown): Promise { + validateControlRequest(raw) + const cli = resolveAiturkCliPath(deps) + + if (!cli) { + throw new Error('codex-link:cli_unavailable') + } + + const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '') + + if (UNSAFE_PATH.test(cli) || UNSAFE_PATH.test(systemRoot) || !ABSOLUTE_PATH.test(systemRoot)) { + throw new Error('codex-link:invalid_entrypoint') + } + + return new Promise((resolve, reject) => { + let settled = false + let timer: unknown + let bytes = 0 + const chunks: Buffer[] = [] + let child: any + + const finish = (error?: Error, response?: CodexLinkControlResponse) => { + if (settled) { + return + } + + settled = true + + if (timer !== undefined) { + clearTimer(timer) + } + + error ? reject(error) : resolve(response!) + } + + try { + child = deps.spawn( + `${systemRoot}\\System32\\cmd.exe`, + ['/d', '/s', '/c', `""${cli}" codex-link control --stdin"`], + { + shell: false, + windowsHide: true, + windowsVerbatimArguments: true, + stdio: ['pipe', 'pipe', 'pipe'] + } + ) + } catch { + finish(new Error('codex-link:cli_unavailable')) + + return + } + + timer = setTimer(() => { + // The durable manager may have accepted a write. Never repeat it under a new ID. + finish(new Error('codex-link:outcome_unknown')) + + try { + child.kill() + } catch { + /* proxy already exited; manager is independent */ + } + }, deps.timeoutMs ?? 30_000) + child.stdout.on('data', (chunk: Buffer) => { + if (settled) { + return + } + + bytes += chunk.length + + if (bytes > CODEX_LINK_RESPONSE_BYTES) { + finish(new Error('codex-link:outcome_unknown')) + + try { + child.kill() + } catch { + /* no process tree termination */ + } + } else { + chunks.push(Buffer.from(chunk)) + } + }) + // Drain concurrently, with no history, paths, credentials or uncontrolled diagnostics logged. + child.stderr.on('data', () => {}) + child.stdin.on('error', () => {}) + child.once('error', () => finish(new Error('codex-link:outcome_unknown'))) + child.once('close', (code: number | null) => { + if (settled) { + return + } + + if (code !== 0 && code !== 1) { + finish(new Error('codex-link:outcome_unknown')) + + return + } + + try { + const response = parseControlResponse(Buffer.concat(chunks), raw) + + if (code === 1 && response.kind !== 'control_error') { + finish(new Error('codex-link:outcome_unknown')) + } else { + finish(undefined, response) + } + } catch { + finish(new Error('codex-link:outcome_unknown')) + } + }) + + try { + child.stdin.end(JSON.stringify(raw) + '\n') + } catch { + finish(new Error('codex-link:outcome_unknown')) + } + }) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a9817fe..3975059 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -36,6 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' +import { createCodexLinkClient } from './codex-link-client' import { createMobileBackendResolver } from './mobile-backend' import { createMobileLink, @@ -17266,6 +17267,16 @@ const aiturkLogin = createAiturkLogin({ now: () => Date.now() }) +const codexLinkControl = createCodexLinkClient({ spawn, existsSync: fs.existsSync, env: process.env }) +ipcMain.handle('aiturk:codex-link:control', async (event, request) => { + const window = BrowserWindow.fromWebContents(event.sender) + if (!window || (window !== mainWindow && !instanceWindows.has(window)) || + event.senderFrame !== event.sender.mainFrame) { + throw new Error('codex-link:unavailable') + } + return codexLinkControl(request) +}) + // Only the primary window's top-level frame may drive a sign-in. function assertAiturkLoginSender(event) { const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null @@ -17863,6 +17874,7 @@ function getMobileLink(): MobileLink { const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile') mobileLink = createMobileLink({ + codexLinkControl, // Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept. vault: new MobileVault(directory, safeStorage), store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises), diff --git a/apps/desktop/electron/mobile-lan-proxy.test.ts b/apps/desktop/electron/mobile-lan-proxy.test.ts index db78da4..4323088 100644 --- a/apps/desktop/electron/mobile-lan-proxy.test.ts +++ b/apps/desktop/electron/mobile-lan-proxy.test.ts @@ -271,6 +271,63 @@ async function startProxy(over: Record = {}) { return { proxy, port, identity, clock, logs, saved, ended, pair, ca: identity.cert } } +describe('LAN proxy: narrow Codex control', () => { + const route = '/api/aiturk/codex-link/control' + const body = { contract_version: 'codex-link/1', kind: 'settings_get', request_id: 'phone-read-1', operation_id: 'phone-op-1' } + it('requires pairing before invoking local control and never starts a backend', async () => { + const calls: unknown[] = [] + const t = await startProxy({ codexLinkControl: async request => { calls.push(request); return { ...request, kind: 'control_error', settings_revision: 0, error: { code: 'pending', message: 'Not ready', recovery_action: 'none' } } } }) + const before = resolveCalls + try { + expect((await call(t.port, 'POST', route, { ca: t.ca, body })).status).toBe(401) + expect(calls).toHaveLength(0) + const phone = await t.pair() + const result = await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body }) + expect(result.status).toBe(200) + expect(calls).toEqual([body]) + expect(resolveCalls).toBe(before) + expect(JSON.stringify(calls)).not.toContain(phone.token) + } finally { await t.proxy.stop() } + }) + it('rejects runtime commands, extra paths, duplicate keys and large bodies', async () => { + let calls = 0 + const t = await startProxy({ codexLinkControl: async () => { calls++; throw new Error('must not execute') } }) + try { + const phone = await t.pair() + for (const rawBody of [ + JSON.stringify({ ...body, kind: 'runtime_attach' }), + JSON.stringify({ ...body, path: 'C:\\private' }), + JSON.stringify(body).replace('"kind":"settings_get"', '"kind":"settings_get","kind":"status_get"'), + 'x'.repeat(65537) + ]) { + expect((await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, rawBody })).status).toBe(400) + } + expect(calls).toBe(0) + } finally { await t.proxy.stop() } + }) + it('returns a fixed unknown-outcome code without leaking CLI diagnostics', async () => { + const t = await startProxy({ codexLinkControl: async () => { throw new Error('private-key-and-path') } }) + try { + const phone = await t.pair() + const result = await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body }) + expect(result.status).toBe(503) + expect(result.json).toEqual({ error: 'codex_link_outcome_unknown' }) + expect(result.text).not.toContain('private-key') + } finally { await t.proxy.stop() } + }) + it('rejects revoked phones and unsupported methods', async () => { + let calls = 0 + const t = await startProxy({ codexLinkControl: async () => { calls++; return {} } }) + try { + const phone = await t.pair() + expect((await call(t.port, 'GET', route, { ca: t.ca, token: phone.token })).status).toBe(405) + await t.proxy.revoke(phone.deviceId) + expect((await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body })).status).toBe(401) + expect(calls).toBe(0) + } finally { await t.proxy.stop() } + }) +}) + describe('LAN proxy: pairing (hello / claim)', () => { it('hello is unauthenticated and reports whether pairing is open', async () => { const t = await startProxy() diff --git a/apps/desktop/electron/mobile-lan-proxy.ts b/apps/desktop/electron/mobile-lan-proxy.ts index e10fc44..cb81d40 100644 --- a/apps/desktop/electron/mobile-lan-proxy.ts +++ b/apps/desktop/electron/mobile-lan-proxy.ts @@ -42,6 +42,9 @@ import http from 'node:http' import https from 'node:https' import net from 'node:net' import type { Duplex } from 'node:stream' +import { CODEX_LINK_REQUEST_BYTES, validateControlRequest } from '../codex-link-control' +import { parseStrictJson } from '../codex-link-json' +import type { CodexLinkControlRequest, CodexLinkControlResponse } from '../codex-link-control.types' import { loadOrCreateLanIdentity, type MobileLanIdentity, type MobileLanVault } from './mobile-lan-cert' import { @@ -706,6 +709,7 @@ export interface MobileDeviceView { export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled' export interface LanProxyOptions { + codexLinkControl?: (request: CodexLinkControlRequest) => Promise tls: { key: string; cert: string } /** Must resolve the LOCAL backend or throw (remote mode, backend down). */ resolveBackend: (profile: string) => Promise @@ -1108,6 +1112,35 @@ export function createLanProxy(options: LanProxyOptions) { return reply(req, res, path, 403, { error: 'forbidden' }) } + if (path === '/api/aiturk/codex-link/control') { + if (req.method !== 'POST') return reply(req, res, path, 405, { error: 'method_not_allowed' }) + if (!options.codexLinkControl) return reply(req, res, path, 503, { error: 'codex_link_unavailable' }) + if (!/^application\/json(?:;|$)/i.test(String(req.headers['content-type'] ?? ''))) { + return reply(req, res, path, 400, { error: 'invalid_request' }) + } + let request: CodexLinkControlRequest + try { + if (Number(req.headers['content-length'] ?? 0) > CODEX_LINK_REQUEST_BYTES) throw new Error('large_body') + let size = 0 + const chunks: Buffer[] = [] + for await (const chunk of req) { + size += chunk.length + if (size > CODEX_LINK_REQUEST_BYTES) throw new Error('large_body') + chunks.push(Buffer.from(chunk)) + } + const parsed = parseStrictJson(Buffer.concat(chunks), CODEX_LINK_REQUEST_BYTES) + validateControlRequest(parsed) + request = parsed + } catch { return reply(req, res, path, 400, { error: 'invalid_request' }) } + // Pairing can be revoked while a slow body is being read. + if (!devices.has(device.tokenHash)) return reply(req, res, path, 401, { error: 'unauthorized' }) + try { + const result = await options.codexLinkControl(request) + if (!devices.has(device.tokenHash)) return reply(req, res, path, 401, { error: 'unauthorized' }) + return reply(req, res, path, 200, result) + } catch { return reply(req, res, path, 503, { error: 'codex_link_outcome_unknown' }) } + } + const backend = await localBackend(url) if (res.destroyed) return @@ -1510,6 +1543,7 @@ export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState } export interface MobileLinkDeps { + codexLinkControl?: (request: CodexLinkControlRequest) => Promise vault: MobileLanVault store: MobileLinkStore resolveBackend: (profile: string) => Promise @@ -1820,6 +1854,7 @@ export function createMobileLink(deps: MobileLinkDeps) { } proxy = createLanProxy({ + codexLinkControl: deps.codexLinkControl, tls: identity, resolveBackend: async profile => { if (deps.isRemoteMode()) { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a01fec0..0729e16 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true const launchFlags = ipcRenderer.sendSync('hermes:launch-flags') contextBridge.exposeInMainWorld('hermesDesktop', { + codexLink: { control: request => ipcRenderer.invoke('aiturk:codex-link:control', request) }, setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale), glassSupported: translucencySupport?.glass === true, translucencySupported: translucencySupport?.translucency === true, diff --git a/apps/desktop/scripts/check-codex-link-layout.mjs b/apps/desktop/scripts/check-codex-link-layout.mjs new file mode 100644 index 0000000..6b7ed79 --- /dev/null +++ b/apps/desktop/scripts/check-codex-link-layout.mjs @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process' +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from '@playwright/test' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const output = process.argv[2] +if (!output || !path.isAbsolute(output)) throw new Error('An absolute evidence output directory is required') +await mkdir(output, { recursive: true }) +const port = 5178 +const server = spawn(process.execPath, [path.resolve(root, '../../node_modules/vite/bin/vite.js'), '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) +let log = '' +const capture = chunk => { log = (log + String(chunk)).slice(-12000) } +server.stdout.on('data', capture); server.stderr.on('data', capture) +let browser +try { + let ready = false + for (let i = 0; i < 100; i++) { + if (server.exitCode !== null) throw new Error('Preview server exited: ' + log) + try { ready = (await fetch(`http://127.0.0.1:${port}/e2e/fixtures/codex-link-settings.html`)).ok } catch {} + if (ready) break + await new Promise(resolve => setTimeout(resolve, 300)) + } + if (!ready) throw new Error('Preview server not ready: ' + log) + browser = await chromium.launch({ channel: 'chrome', headless: true }) + const evidence = [] + for (const width of [360, 390, 414, 1280]) { + const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: 'reduce' }) + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto(`http://127.0.0.1:${port}/e2e/fixtures/codex-link-settings.html`, { waitUntil: 'domcontentloaded', timeout: 90000 }) + await page.getByText('Proje sohbeti · örnek kayıt').waitFor({ timeout: 90000 }) + await page.evaluate(() => document.fonts.ready) + const layout = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth, buttons: [...document.querySelectorAll('button:not([role=switch]), input, select, label[for="codex-link-enabled"], label[for="codex-link-compression"]')].map(element => ({ text: element.textContent || element.id, height: element.getBoundingClientRect().height })) })) + if (layout.scroll > width) throw new Error(`Horizontal overflow at ${width}: ${layout.scroll}`) + await writeFile(path.join(output, `layout-${width}.json`), JSON.stringify(layout, null, 2) + '\n') + await page.screenshot({ path: path.join(output, `codex-link-${width}.png`), fullPage: true }) + if (layout.buttons.some(button => button.height < 43.5)) throw new Error(`Small touch target at ${width}: ${JSON.stringify(layout.buttons.filter(button => button.height < 43.5))}`) + if (errors.length) throw new Error(errors.join('\n')) + // Tap the label as a finger would, then verify the manager-confirmed value. + await page.locator('label[for="codex-link-enabled"]').click() + await page.waitForFunction(() => document.querySelector('#codex-link-enabled')?.getAttribute('aria-checked') === 'false') + await page.getByRole('button', { name: 'Şimdi sıkıştır' }).scrollIntoViewIfNeeded() + await page.screenshot({ path: path.join(output, `codex-link-${width}-history.png`), fullPage: true }) + evidence.push({ width, horizontal_overflow: false, touch_targets_passed: true, preference_update_passed: true, errors }) + await page.close() + } + await writeFile(path.join(output, 'layout-result.json'), JSON.stringify({ synthetic_fixture: true, real_manager: false, checks: evidence }, null, 2) + '\n') + console.log(JSON.stringify({ passed: evidence.length, output })) +} finally { + await browser?.close() + server.kill() + await writeFile(path.join(output, 'preview-server.log'), log) +} diff --git a/apps/desktop/src/app/settings/codex-link-settings.test.tsx b/apps/desktop/src/app/settings/codex-link-settings.test.tsx new file mode 100644 index 0000000..94f6e91 --- /dev/null +++ b/apps/desktop/src/app/settings/codex-link-settings.test.tsx @@ -0,0 +1,196 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n/context' + +import type { + CodexLinkContextView, + CodexLinkControlRequest, + CodexLinkControlResponse, + CodexLinkSettings as SavedSettings +} from '../../../codex-link-control.types' + +import { CodexLinkSettings } from './codex-link-settings' + +let settings: SavedSettings +let views: CodexLinkContextView[] +let writes: CodexLinkControlRequest[] +let failWrite: boolean + +const control = vi.fn(async (request: CodexLinkControlRequest): Promise => { + const base = { + contract_version: 'codex-link/1' as const, + request_id: request.request_id, + operation_id: request.operation_id + } + + if (request.kind === 'status_get') { + return { + ...base, + kind: 'status_result', + status: { + settings_revision: settings.settings_revision, + runtime_mode: 'managed', + native_available: true, + external_admission: 'open', + needs_restart: false, + catalog_state: 'fresh', + active_turns: 0, + uncertain_operations: 0, + last_operation_id: null, + error: null + } + } + } + + if (request.kind === 'context_views_get') { + return { ...base, kind: 'context_views_result', settings_revision: settings.settings_revision, views } + } + + if (request.kind === 'settings_update') { + writes.push(request) + + if (failWrite) { + throw new Error('outcome_unknown') + } + + settings = { ...settings, ...request.patch, settings_revision: settings.settings_revision + 1 } + } else if (request.kind !== 'settings_get') { + writes.push(request) + + return { + ...base, + kind: 'control_accepted', + settings_revision: settings.settings_revision, + operation_state: 'accepted', + error: null + } + } + + return { ...base, kind: 'settings_result', settings, available_sources: [] } +}) + +const mount = () => + render( + + + + ) + +beforeEach(() => { + sessionStorage.clear() + control.mockClear() + writes = [] + views = [] + failWrite = false + settings = { + settings_revision: 7, + enabled: false, + compression_enabled: true, + threshold_tokens: 250000, + source_binding_handle: null, + compact_route_id: 'Compact', + legacy_intent_imported: true, + ownership_manifest_revision: 2, + activation_state: 'disabled' + } + vi.stubGlobal('hermesDesktop', { codexLink: { control } } satisfies Pick, 'codexLink'>) +}) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('Codex connection settings', () => { + it('shows the persisted disabled preference, without turning it on during mount', async () => { + mount() + const toggle = await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' }) + expect(toggle.getAttribute('aria-checked')).toBe('false') + expect(writes).toEqual([]) + expect((screen.getByLabelText('Sıkıştırma eşiği (token)') as HTMLInputElement).value).toBe('250000') + }) + it('uses a checkpoint-safe settings patch and rejects decimal/unsafe thresholds', async () => { + mount() + const input = await screen.findByLabelText('Sıkıştırma eşiği (token)') + fireEvent.change(input, { target: { value: '1.5' } }) + expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: '9007199254740992' } }) + expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: '125000' } }) + fireEvent.click(screen.getByRole('button', { name: 'Kaydet' })) + await waitFor(() => expect(writes).toHaveLength(1)) + expect(writes[0]).toMatchObject({ + kind: 'settings_update', + expected_settings_revision: 7, + patch: { threshold_tokens: 125000 } + }) + }) + it('keeps an uncertain operation across remount and reconciles the exact ID/body', async () => { + failWrite = true + const first = mount() + fireEvent.click(await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' })) + await screen.findByRole('button', { name: 'İşlemi kontrol et' }) + expect(writes).toHaveLength(1) + const original = writes[0] + first.unmount() + mount() + failWrite = false + fireEvent.click(await screen.findByRole('button', { name: 'İşlemi kontrol et' })) + await waitFor(() => expect(writes).toHaveLength(2)) + expect(writes[1]).toEqual(original) + await waitFor(() => expect(sessionStorage.getItem('aiturk.codex-link.pending-control.v1')).toBeNull()) + }) + it('does not present unknown token measurement as zero or enable unsafe compaction', async () => { + views = [ + { + view_handle: 'view-a', + can_compact: false, + label: 'Ayrı sohbet', + checkpoint_revision: 3, + current_model_label: 'Model A', + limiting_target_id: null, + target_budgets: null, + active_summary_count: 2, + summary_tokens: null, + summary_token_measurement: null, + last_operation_id: null, + last_operation_state: null, + last_operation_error: null, + compact_attempts: [] + } + ] + mount() + await screen.findByText('Ayrı sohbet') + expect(screen.getAllByText(/Henüz ölçülmedi/).length).toBeGreaterThanOrEqual(3) + expect((screen.getByRole('button', { name: 'Şimdi sıkıştır' }) as HTMLButtonElement).disabled).toBe(true) + }) + it('binds manual compaction to a manager view and its exact checkpoint revision', async () => { + views = [ + { + view_handle: 'opaque-view-a', + can_compact: true, + label: 'Sohbet A', + checkpoint_revision: 9, + current_model_label: 'Model A', + limiting_target_id: null, + target_budgets: null, + active_summary_count: 0, + summary_tokens: null, + summary_token_measurement: null, + last_operation_id: null, + last_operation_state: null, + last_operation_error: null, + compact_attempts: [] + } + ] + mount() + fireEvent.click(await screen.findByRole('button', { name: 'Şimdi sıkıştır' })) + await waitFor(() => expect(writes).toHaveLength(1)) + expect(writes[0]).toMatchObject({ + kind: 'context_compact_view', + view_handle: 'opaque-view-a', + expected_checkpoint_revision: 9 + }) + expect(writes[0]).not.toHaveProperty('thread_id') + }) +}) diff --git a/apps/desktop/src/app/settings/codex-link-settings.tsx b/apps/desktop/src/app/settings/codex-link-settings.tsx new file mode 100644 index 0000000..66d41fb --- /dev/null +++ b/apps/desktop/src/app/settings/codex-link-settings.tsx @@ -0,0 +1,465 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { useCodexLinkCopy } from '@/i18n/codex-link' +import { Cpu } from '@/lib/icons' + +import { validateControlRequest } from '../../../codex-link-control' +import type { + CodexLinkContextView, + CodexLinkControlRequest, + CodexLinkControlResponse, + CodexLinkSettingsResult, + CodexLinkSettingsUpdate, + CodexLinkStatusSnapshot +} from '../../../codex-link-control.types' + +import { SettingsContent, SettingsSection } from './primitives' + +const PENDING_KEY = 'aiturk.codex-link.pending-control.v1' + +function envelope() { + return { + contract_version: 'codex-link/1' as const, + request_id: crypto.randomUUID(), + operation_id: crypto.randomUUID() + } +} + +function readPending(): CodexLinkControlRequest | null { + try { + const raw = sessionStorage.getItem(PENDING_KEY) + + if (!raw) { + return null + } + + const value: unknown = JSON.parse(raw) + validateControlRequest(value) + + return ['settings_update', 'catalog_sync', 'context_compact_view'].includes(value.kind) ? value : null + } catch { + return null + } +} + +function savedPending(value: CodexLinkControlRequest | null) { + if (value) { + sessionStorage.setItem(PENDING_KEY, JSON.stringify(value)) + } else { + sessionStorage.removeItem(PENDING_KEY) + } +} + +function Preference({ + id, + label, + description, + checked, + disabled, + onChange +}: { + id: string + label: string + description: string + checked: boolean + disabled: boolean + onChange: (value: boolean) => void +}) { + return ( + + ) +} + +export function CodexLinkSettings() { + const copy = useCodexLinkCopy() + const [settings, setSettings] = useState(null) + const [status, setStatus] = useState(null) + const [views, setViews] = useState([]) + const [threshold, setThreshold] = useState('') + const [busy, setBusy] = useState(false) + const [pending, setPending] = useState(readPending) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const mounted = useRef(true) + const writeLock = useRef(false) + const readLock = useRef(false) + const generation = useRef(0) + const savedRevision = useRef(-1) + const bridge = window.hermesDesktop?.codexLink + + const refresh = useCallback(async () => { + if (!bridge || readLock.current || writeLock.current) { + return + } + + readLock.current = true + const current = generation.current + + try { + const results = await Promise.all( + ['settings_get', 'status_get', 'context_views_get'].map(kind => + bridge.control({ ...envelope(), kind } as CodexLinkControlRequest) + ) + ) + + if (!mounted.current || current !== generation.current) { + return + } + + const [saved, live, chats] = results + + if (saved.kind !== 'settings_result' || live.kind !== 'status_result' || chats.kind !== 'context_views_result') { + throw new Error('read_failed') + } + + // A write in another window may fall between these three reads. Never paint a mixed revision. + if ( + saved.settings.settings_revision !== live.status.settings_revision || + saved.settings.settings_revision !== chats.settings_revision + ) { + return + } + + if (saved.settings.settings_revision < savedRevision.current) { + return + } + + if (savedRevision.current !== saved.settings.settings_revision) { + setThreshold(String(saved.settings.threshold_tokens)) + } + + savedRevision.current = saved.settings.settings_revision + setSettings(saved) + setStatus(live.status) + setViews(chats.views) + setError('') + } catch { + if (mounted.current && current === generation.current) { + setError(copy.failed) + } + } finally { + readLock.current = false + } + }, [bridge, copy.failed]) + + // This is a component lifetime flag, not a mirror of a reactive atom/value. + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + mounted.current = true + void refresh() + + const timer = setInterval(() => { + if (document.visibilityState === 'visible') { + void refresh() + } + }, 5000) + + return () => { + mounted.current = false + // The current numeric generation is invalidated; it is not a captured DOM ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + generation.current++ + clearInterval(timer) + } + }, [refresh]) + + const send = async (request: CodexLinkControlRequest) => { + if (!bridge || writeLock.current) { + return + } + + writeLock.current = true + generation.current++ + setBusy(true) + setError('') + setNotice('') + + // Persist the exact operation before dispatch: an uncertain reply must reuse this ID and body. + try { + savedPending(request) + setPending(request) + } catch { + writeLock.current = false + setBusy(false) + setError(copy.failed) + + return + } + + try { + const result: CodexLinkControlResponse = await bridge.control(request) + + if (!mounted.current) { + return + } + + if ( + (result.kind === 'control_error' && result.error.recovery_action === 'reconcile_only') || + (result.kind === 'control_accepted' && result.operation_state === 'indeterminate') + ) { + setError(copy.unknown) + + return + } + + savedPending(null) + setPending(null) + + if (result.kind === 'control_error') { + setNotice( + result.error.code.includes('conflict') || result.error.code.includes('cas') + ? copy.conflict + : result.error.message + ) + } else if (result.kind === 'settings_result') { + savedRevision.current = result.settings.settings_revision + setSettings(result) + setThreshold(String(result.settings.threshold_tokens)) + setNotice(copy.saved) + } else if (result.kind === 'control_accepted') { + setNotice(result.error?.message ?? copy.accepted) + } + } catch { + if (mounted.current) { + setError(copy.unknown) + } + } finally { + writeLock.current = false + + if (mounted.current) { + setBusy(false) + void refresh() + } + } + } + + const update = (patch: CodexLinkSettingsUpdate['patch']) => { + if (settings && !pending) { + void send({ + ...envelope(), + kind: 'settings_update', + expected_settings_revision: settings.settings.settings_revision, + patch + }) + } + } + + const thresholdValue = /^[0-9]+$/.test(threshold) ? Number(threshold) : NaN + const validThreshold = Number.isSafeInteger(thresholdValue) && thresholdValue > 0 + const locked = busy || pending !== null + const number = (n: number | null) => (n === null ? copy.unmeasured : n.toLocaleString()) + + return ( + +
+ +

{copy.intro}

+ {!bridge ? ( +

{copy.unavailable}

+ ) : !settings ? ( +

{error || copy.loading}

+ ) : ( + <> + update({ enabled })} + /> + update({ compression_enabled })} + /> +
+ +
+ setThreshold(event.target.value)} + value={threshold} + /> + +
+ +
+
+ + {settings.available_sources.length ? ( + + ) : ( +

{copy.noSource}

+ )} +
+

+ {copy.activation[settings.settings.activation_state]} + {status ? ` · ${copy.admission[status.external_admission]}` : ''} +

+ {status?.needs_restart &&

{copy.restart}

} + {status?.error && ( +

+ {status.error.message} +

+ )} +
+ + +
+ + )} + {pending && ( +
+

+ {copy.unknown} +

+ +
+ )} + {error && settings && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} +
+ {settings && ( + + {!views.length &&

{copy.empty}

} + {views.map(view => { + const target = view.target_budgets?.find(item => item.target_id === view.limiting_target_id) + + const hardLimit = target + ? Math.min( + target.max_input_tokens ?? Number.MAX_SAFE_INTEGER, + target.context_window_tokens - target.output_cap_tokens - target.safety_margin_tokens + ) + : null + + const limit = + hardLimit === null + ? null + : settings.settings.compression_enabled + ? Math.min(settings.settings.threshold_tokens, hardLimit) + : hardLimit + + return ( +
+

{view.label}

+

{view.current_model_label}

+
+
{copy.current}
+
{number(target?.input_tokens ?? null)}
+
{copy.limit}
+
{number(limit)}
+
{copy.summaries}
+
+ {view.active_summary_count} · {number(view.summary_tokens)}{' '} + {view.summary_tokens !== null ? copy.tokens : ''} +
+
+ {view.last_operation_state && ( +

+ {copy.operation[view.last_operation_state]} +

+ )} + {view.last_operation_error && ( +

+ {view.last_operation_error.message} +

+ )} + {view.compact_attempts.map(attempt => ( +

+ {attempt.member_label} · {copy.operation[attempt.state]} +

+ ))} + +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index ea36d7f..6e192c6 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -9,6 +9,7 @@ import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { useAiturkCopy } from '@/i18n/aiturk' +import { useCodexLinkCopy } from '@/i18n/codex-link' import { triggerHaptic } from '@/lib/haptics' import { Archive, @@ -47,6 +48,7 @@ import { AboutSettings } from './about-settings' import { AppearanceSettings } from './appearance-settings' import { BillingSettings } from './billing' import { ConfigSettings } from './config-settings' +import { CodexLinkSettings } from './codex-link-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' import { KeybindSettings } from './keybind-settings' @@ -61,6 +63,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ ...SECTIONS.map(s => `config:${s.id}` as SettingsViewId), 'providers', 'gateway', + 'codex-link', // Legacy alias: the Connections page merged into Gateways. Kept in the enum // so saved `?tab=connections` deep links still resolve (redirected below). 'connections', @@ -76,6 +79,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { const { t } = useI18n() const aiturkCopy = useAiturkCopy() + const codexLinkCopy = useCodexLinkCopy() const navigate = useNavigate() const { hash, pathname, search } = useLocation() @@ -252,6 +256,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.gateway, onSelect: () => setActiveView('gateway') }, + { + active: activeView === 'codex-link', + icon: Cpu, + id: 'codex-link', + label: codexLinkCopy.title, + onSelect: () => setActiveView('codex-link') + }, // "Telefon bağlantısı" lives in its own small window (QR + code); this // entry only opens it. Present only where the desktop bridge offers it. ...(window.hermesDesktop?.aiturkMobile @@ -320,7 +331,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set onSelect: () => setActiveView('about') } ], - [activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView] + [activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView] ) // Type-to-search: printable keystrokes on the Settings surface (outside any @@ -414,6 +425,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set // 'connections' renders the unified page too so the frame before // the alias redirect lands doesn't flash the fallback view. + ) : activeView === 'codex-link' ? ( + ) : activeView === 'keybinds' ? ( ) : activeView.startsWith('config:') ? ( diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 5ecdf27..01e3e52 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -8,6 +8,7 @@ export type SettingsView = | 'about' | 'billing' | 'connections' + | 'codex-link' | 'gateway' | 'keybinds' | 'keys' diff --git a/apps/desktop/src/app/starmap/share-controls.test.tsx b/apps/desktop/src/app/starmap/share-controls.test.tsx index 5c90841..e0d77de 100644 --- a/apps/desktop/src/app/starmap/share-controls.test.tsx +++ b/apps/desktop/src/app/starmap/share-controls.test.tsx @@ -39,7 +39,7 @@ describe('ShareControls', () => { const dialog = await screen.findByRole('dialog') const input = within(dialog).getByRole('textbox') fireEvent.change(input, { target: { value: 'not a valid code !!!' } }) - fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true })) + fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle' })) expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy() expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!') }) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index db4ac71..c7e0c58 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -2,6 +2,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared' import type { TranslucencyState } from '@hermes/shared/translucency' import type { PoolLimits } from '../electron/pool-limits' +import type { CodexLinkControlRequest, CodexLinkControlResponse } from '../codex-link-control.types' import type { WakeIndicatorState } from './lib/wake-indicator' import type { @@ -17,6 +18,7 @@ export {} declare global { interface Window { hermesDesktop: { + codexLink?: { control: (request: CodexLinkControlRequest) => Promise } setUiLanguage?: (locale: string) => void // Resolve a backend connection. Omit `profile` (or pass the primary) for // the window's backend; pass a named profile to lazily spawn/reuse that diff --git a/apps/desktop/src/i18n/codex-link.ts b/apps/desktop/src/i18n/codex-link.ts new file mode 100644 index 0000000..9f5e9f1 --- /dev/null +++ b/apps/desktop/src/i18n/codex-link.ts @@ -0,0 +1,121 @@ +import { useI18n } from './context' + +const en = { + title: 'Connect Codex', + intro: 'Use this IDE’s models and combos in Codex. Each chat keeps its own memory.', + enabled: 'Connect models to Codex', + enabledHelp: 'Enabled on first installation. Your choice is saved.', + compression: 'Automatic compaction', + compressionHelp: + 'Checks the complete request before sending. Earlier summaries are retained as the available context allows.', + threshold: 'Compaction threshold (tokens)', + thresholdHelp: + 'The effective threshold is lower when a model has less available input space. Output space is reserved too.', + invalidThreshold: 'Enter a positive whole number.', + save: 'Save', + source: 'Connected account', + noSource: 'No authorized model source is available.', + sync: 'Refresh models', + refresh: 'Refresh status', + loading: 'Reading saved settings…', + unavailable: 'The local Codex connection service is unavailable. Check the AITURK installation.', + failed: 'Could not read the latest state.', + unknown: 'The result is not confirmed. Check this same operation before making another change.', + reconcile: 'Check operation', + conflict: 'Settings changed elsewhere. The latest saved state has been loaded.', + saved: 'Settings saved.', + accepted: 'Operation accepted. Its progress appears below.', + chats: 'Chat memory', + empty: 'No managed Codex chat is currently visible.', + compact: 'Compact now', + summaries: 'Saved summaries', + tokens: 'tokens', + unmeasured: 'Not measured yet', + current: 'Current input', + limit: 'Effective threshold', + restart: 'The next Codex launch will use the new runtime. Existing chats keep their current process.', + activation: { + disabled: 'Disabled', + pending_runtime_acceptance: 'Runtime verification pending', + ready: 'Ready', + restart_required: 'Restart required', + conflict: 'Configuration conflict', + recovery_pending: 'Recovering the saved state' + }, + admission: { + open: 'External models available', + closed: 'External models unavailable', + draining: 'Finishing active work', + recovery_required: 'Operation recovery required' + }, + operation: { + accepted: 'Accepted', + prepared: 'Prepared', + running: 'Running', + completed: 'Completed', + failed: 'Failed', + indeterminate: 'Result unknown' + } +} + +const tr: typeof en = { + title: 'Codex Bağlama', + intro: 'Bu IDE’deki modelleri ve comboları Codex’te kullanın. Her sohbet kendi hafızasını tutar.', + enabled: 'Modelleri Codex’e bağla', + enabledHelp: 'İlk kurulumda açıktır. Değiştirdiğiniz tercih kaydedilir.', + compression: 'Otomatik sıkıştırma', + compressionHelp: + 'Gönderilmeden önce isteğin tamamı kontrol edilir. Önceki özetler kullanılabilir bağlamın izin verdiği ölçüde korunur.', + threshold: 'Sıkıştırma eşiği (token)', + thresholdHelp: + 'Modelin kullanılabilir giriş alanı daha küçükse etkin eşik düşer. Yanıt için gereken alan da ayrılır.', + invalidThreshold: 'Pozitif bir tam sayı girin.', + save: 'Kaydet', + source: 'Bağlı hesap', + noSource: 'Yetkili model kaynağı bulunamadı.', + sync: 'Modelleri yenile', + refresh: 'Durumu yenile', + loading: 'Kayıtlı ayarlar okunuyor…', + unavailable: 'Yerel Codex bağlantı hizmetine ulaşılamıyor. AITURK kurulumunu kontrol edin.', + failed: 'Güncel durum okunamadı.', + unknown: 'İşlemin sonucu henüz doğrulanmadı. Yeni değişiklikten önce aynı işlemin durumunu kontrol edin.', + reconcile: 'İşlemi kontrol et', + conflict: 'Ayarlar başka bir yerde değişti. Güncel kayıtlı ayarlar yüklendi.', + saved: 'Ayarlar kaydedildi.', + accepted: 'İşlem kabul edildi. İlerlemesi aşağıda gösterilir.', + chats: 'Sohbet hafızası', + empty: 'Şu anda görünen yönetilen Codex sohbeti yok.', + compact: 'Şimdi sıkıştır', + summaries: 'Kayıtlı özetler', + tokens: 'token', + unmeasured: 'Henüz ölçülmedi', + current: 'Mevcut giriş', + limit: 'Etkin eşik', + restart: 'Yeni runtime sonraki Codex açılışında kullanılacak. Açık sohbetler mevcut süreçleriyle devam eder.', + activation: { + disabled: 'Kapalı', + pending_runtime_acceptance: 'Runtime doğrulaması bekleniyor', + ready: 'Hazır', + restart_required: 'Yeniden açılış gerekli', + conflict: 'Ayar çakışması', + recovery_pending: 'Kayıtlı durum kurtarılıyor' + }, + admission: { + open: 'Harici modeller kullanılabilir', + closed: 'Harici modeller kullanılamıyor', + draining: 'Etkin işler bitiriliyor', + recovery_required: 'İşlem durumu uzlaştırılmalı' + }, + operation: { + accepted: 'Kabul edildi', + prepared: 'Hazırlandı', + running: 'Çalışıyor', + completed: 'Tamamlandı', + failed: 'Başarısız', + indeterminate: 'Sonuç belirsiz' + } +} + +export function useCodexLinkCopy() { + return useI18n().locale === 'tr' ? tr : en +} diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json index 058f132..38d3bf1 100644 --- a/apps/desktop/tsconfig.electron.json +++ b/apps/desktop/tsconfig.electron.json @@ -16,6 +16,6 @@ "rootDir": "..", "outDir": "build/electron-types" }, - "include": ["electron", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"], + "include": ["electron", "codex-link-control.ts", "codex-link-control.types.ts", "codex-link-control.schema.json", "codex-link-json.ts", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"], "exclude": ["src", "electron/**/*.e2e.mts"] } diff --git a/scripts/generate-codex-link-control.py b/scripts/generate-codex-link-control.py new file mode 100644 index 0000000..5e686d1 --- /dev/null +++ b/scripts/generate-codex-link-control.py @@ -0,0 +1,70 @@ +"""Generate the IDE's narrow control contract from the reviewed gateway schema. + +Usage: python generate-codex-link-control.py PATH_TO_SCHEMA [--check] +No runtime/namespace/archive messages are included in this surface. +""" +import hashlib +import json +from pathlib import Path +import sys + +root = Path(__file__).resolve().parents[1] +schema = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +definitions = schema["$defs"] +selected = {} + +def collect(name): + if name in selected: + return + selected[name] = definitions[name] + def visit(value): + if isinstance(value, dict): + if "$ref" in value: + collect(value["$ref"].removeprefix("#/$defs/")) + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + visit(selected[name]) + +for name in ("control_request", "control_response"): + collect(name) + +def typename(name): + return "CodexLink" + "".join(part.title() for part in name.split("_")) + +def ts(rule): + if "$ref" in rule: + return typename(rule["$ref"].split("/")[-1]) + if "const" in rule: + return json.dumps(rule["const"], ensure_ascii=False) + if "enum" in rule: + return " | ".join(json.dumps(x, ensure_ascii=False) for x in rule["enum"]) + for key in ("oneOf", "anyOf"): + if key in rule: + return "(" + " | ".join(ts(x) for x in rule[key]) + ")" + kind = rule["type"] + if kind == "object": + return "{ " + "; ".join(json.dumps(k) + ("" if k in rule["required"] else "?") + ": " + ts(v) for k, v in rule["properties"].items()) + " }" + if kind == "array": + return "Array<" + ts(rule["items"]) + ">" + return {"integer": "number", "boolean": "boolean", "string": "string", "null": "null"}[kind] + +subset = {"$defs": {k: selected[k] for k in sorted(selected)}} +canonical = json.dumps(subset, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() +digest = "sha256:" + hashlib.sha256(canonical).hexdigest() +types = "// Generated by scripts/generate-codex-link-control.py. Do not edit.\n" +types += "export const CODEX_LINK_CONTROL_SCHEMA_HASH = " + json.dumps(digest) + "\n" +types += "\n".join("export type " + typename(k) + " = " + ts(v) for k, v in sorted(selected.items())) + "\n" +outputs = { + root / "apps/desktop/codex-link-control.schema.json": json.dumps(subset, ensure_ascii=False, indent=2) + "\n", + root / "apps/desktop/codex-link-control.types.ts": types, +} +for path, content in outputs.items(): + if "--check" in sys.argv: + if not path.exists() or path.read_text(encoding="utf-8") != content: + raise SystemExit("Generated control contract is stale: " + str(path)) + else: + path.write_text(content, encoding="utf-8", newline="\n") +print(digest)