Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1282de09ba | ||
|
|
0fa42332c1 | ||
|
|
21aa3c2247 | ||
|
|
55a1beaf9d | ||
|
|
1acb5dd90f |
@@ -101,7 +101,10 @@ jobs:
|
|||||||
$sayilmayan | ForEach-Object { " sayilmadi: $_" }
|
$sayilmayan | ForEach-Object { " sayilmadi: $_" }
|
||||||
& $venv -m pytest tests/tools/test_protected_instruction_unattended.py $f `
|
& $venv -m pytest tests/tools/test_protected_instruction_unattended.py $f `
|
||||||
tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py `
|
tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py `
|
||||||
tests/cli/test_single_query_session_finalize.py @deselect -q -p no:cacheprovider -rf
|
tests/cli/test_single_query_session_finalize.py `
|
||||||
|
tests/hermes_cli/test_plugin_catalog.py tests/hermes_cli/test_aiturk_plugin_catalog.py `
|
||||||
|
tests/hermes_cli/test_plugin_install_ref.py `
|
||||||
|
tests/tui_gateway/test_mobile_subagents.py @deselect -q -p no:cacheprovider -rf
|
||||||
$kod = $LASTEXITCODE
|
$kod = $LASTEXITCODE
|
||||||
"pytest cikis kodu: $kod"
|
"pytest cikis kodu: $kod"
|
||||||
exit $kod
|
exit $kod
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, any>
|
||||||
|
const definitions: Record<string, Rule> = 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Generated by scripts/generate-codex-link-control.py. Do not edit.
|
||||||
|
export const CODEX_LINK_CONTROL_SCHEMA_HASH = "sha256:4c46b591ccd508670835f6dacd7f61c30793e98181d63089512b7d812d1b85b8"
|
||||||
|
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<CodexLinkTargetBudget> | 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" | "provider_estimate" } | 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<CodexLinkContextView> }
|
||||||
|
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" | "provider_estimate"; "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) }
|
||||||
@@ -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<string, any>
|
||||||
|
|
||||||
|
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<object>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<string, string> = {}) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof setTimeout>))
|
||||||
|
|
||||||
|
return async function control(raw: unknown): Promise<CodexLinkControlResponse> {
|
||||||
|
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'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws'
|
|||||||
|
|
||||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||||
|
import { createCodexLinkClient } from './codex-link-client'
|
||||||
import { createMobileBackendResolver } from './mobile-backend'
|
import { createMobileBackendResolver } from './mobile-backend'
|
||||||
import {
|
import {
|
||||||
createMobileLink,
|
createMobileLink,
|
||||||
@@ -17266,6 +17267,16 @@ const aiturkLogin = createAiturkLogin({
|
|||||||
now: () => Date.now()
|
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.
|
// Only the primary window's top-level frame may drive a sign-in.
|
||||||
function assertAiturkLoginSender(event) {
|
function assertAiturkLoginSender(event) {
|
||||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
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')
|
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||||
|
|
||||||
mobileLink = createMobileLink({
|
mobileLink = createMobileLink({
|
||||||
|
codexLinkControl,
|
||||||
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
|
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
|
||||||
vault: new MobileVault(directory, safeStorage),
|
vault: new MobileVault(directory, safeStorage),
|
||||||
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
|
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
|
||||||
|
|||||||
@@ -271,6 +271,63 @@ async function startProxy(over: Record<string, unknown> = {}) {
|
|||||||
return { proxy, port, identity, clock, logs, saved, ended, pair, ca: identity.cert }
|
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)', () => {
|
describe('LAN proxy: pairing (hello / claim)', () => {
|
||||||
it('hello is unauthenticated and reports whether pairing is open', async () => {
|
it('hello is unauthenticated and reports whether pairing is open', async () => {
|
||||||
const t = await startProxy()
|
const t = await startProxy()
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import http from 'node:http'
|
|||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
import type { Duplex } from 'node:stream'
|
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 { loadOrCreateLanIdentity, type MobileLanIdentity, type MobileLanVault } from './mobile-lan-cert'
|
||||||
import {
|
import {
|
||||||
@@ -706,6 +709,7 @@ export interface MobileDeviceView {
|
|||||||
export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
|
export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
|
||||||
|
|
||||||
export interface LanProxyOptions {
|
export interface LanProxyOptions {
|
||||||
|
codexLinkControl?: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse>
|
||||||
tls: { key: string; cert: string }
|
tls: { key: string; cert: string }
|
||||||
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */
|
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */
|
||||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||||
@@ -1108,6 +1112,35 @@ export function createLanProxy(options: LanProxyOptions) {
|
|||||||
return reply(req, res, path, 403, { error: 'forbidden' })
|
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)
|
const backend = await localBackend(url)
|
||||||
|
|
||||||
if (res.destroyed) return
|
if (res.destroyed) return
|
||||||
@@ -1510,6 +1543,7 @@ export type MobileLinkEvent =
|
|||||||
{ type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
|
{ type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
|
||||||
|
|
||||||
export interface MobileLinkDeps {
|
export interface MobileLinkDeps {
|
||||||
|
codexLinkControl?: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse>
|
||||||
vault: MobileLanVault
|
vault: MobileLanVault
|
||||||
store: MobileLinkStore
|
store: MobileLinkStore
|
||||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||||
@@ -1820,6 +1854,7 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
proxy = createLanProxy({
|
proxy = createLanProxy({
|
||||||
|
codexLinkControl: deps.codexLinkControl,
|
||||||
tls: identity,
|
tls: identity,
|
||||||
resolveBackend: async profile => {
|
resolveBackend: async profile => {
|
||||||
if (deps.isRemoteMode()) {
|
if (deps.isRemoteMode()) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true
|
|||||||
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||||
|
codexLink: { control: request => ipcRenderer.invoke('aiturk:codex-link:control', request) },
|
||||||
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
|
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
|
||||||
glassSupported: translucencySupport?.glass === true,
|
glassSupported: translucencySupport?.glass === true,
|
||||||
translucencySupported: translucencySupport?.translucency === true,
|
translucencySupported: translucencySupport?.translucency === true,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta.19",
|
"version": "1.0.0-beta.21",
|
||||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -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}/visual-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}/visual-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)
|
||||||
|
}
|
||||||
@@ -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<CodexLinkControlResponse> => {
|
||||||
|
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(
|
||||||
|
<I18nProvider configClient={null} initialLocale="tr">
|
||||||
|
<CodexLinkSettings />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<NonNullable<typeof window.hermesDesktop>, '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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
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 (
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-center gap-4 py-3" htmlFor={id}>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-medium">{label}</span>
|
||||||
|
<span className="mt-1 block text-sm text-muted-foreground">{description}</span>
|
||||||
|
</span>
|
||||||
|
<Switch aria-label={label} checked={checked} disabled={disabled} id={id} onCheckedChange={onChange} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexLinkSettings() {
|
||||||
|
const copy = useCodexLinkCopy()
|
||||||
|
const [settings, setSettings] = useState<CodexLinkSettingsResult | null>(null)
|
||||||
|
const [status, setStatus] = useState<CodexLinkStatusSnapshot | null>(null)
|
||||||
|
const [views, setViews] = useState<CodexLinkContextView[]>([])
|
||||||
|
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 (
|
||||||
|
<SettingsContent>
|
||||||
|
<div className="min-w-0 py-4" data-testid="codex-link-settings">
|
||||||
|
<SettingsSection icon={Cpu} title={copy.title}>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">{copy.intro}</p>
|
||||||
|
{!bridge ? (
|
||||||
|
<p role="alert">{copy.unavailable}</p>
|
||||||
|
) : !settings ? (
|
||||||
|
<p role="status">{error || copy.loading}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Preference
|
||||||
|
checked={settings.settings.enabled}
|
||||||
|
description={copy.enabledHelp}
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-enabled"
|
||||||
|
label={copy.enabled}
|
||||||
|
onChange={enabled => update({ enabled })}
|
||||||
|
/>
|
||||||
|
<Preference
|
||||||
|
checked={settings.settings.compression_enabled}
|
||||||
|
description={copy.compressionHelp}
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-compression"
|
||||||
|
label={copy.compression}
|
||||||
|
onChange={compression_enabled => update({ compression_enabled })}
|
||||||
|
/>
|
||||||
|
<div className="my-4 space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="codex-link-threshold">
|
||||||
|
{copy.threshold}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
aria-describedby="codex-link-threshold-help"
|
||||||
|
aria-invalid={!validThreshold}
|
||||||
|
className="min-h-11 w-full min-w-0 sm:w-48"
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-threshold"
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={event => setThreshold(event.target.value)}
|
||||||
|
value={threshold}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="min-h-11"
|
||||||
|
disabled={locked || !validThreshold || thresholdValue === settings.settings.threshold_tokens}
|
||||||
|
onClick={() => update({ threshold_tokens: thresholdValue })}
|
||||||
|
>
|
||||||
|
{copy.save}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground" id="codex-link-threshold-help">
|
||||||
|
{validThreshold ? copy.thresholdHelp : copy.invalidThreshold}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="my-4 space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="codex-link-source">
|
||||||
|
{copy.source}
|
||||||
|
</label>
|
||||||
|
{settings.available_sources.length ? (
|
||||||
|
<select
|
||||||
|
className="min-h-11 w-full min-w-0 rounded-md border bg-background px-3 text-sm"
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-source"
|
||||||
|
onChange={event => update({ source_binding_handle: event.target.value })}
|
||||||
|
value={settings.settings.source_binding_handle ?? ''}
|
||||||
|
>
|
||||||
|
{!settings.settings.source_binding_handle && (
|
||||||
|
<option disabled value="">
|
||||||
|
{copy.noSource}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{settings.available_sources.map(source => (
|
||||||
|
<option
|
||||||
|
disabled={!source.authorized}
|
||||||
|
key={source.source_binding_handle}
|
||||||
|
value={source.source_binding_handle}
|
||||||
|
>
|
||||||
|
{source.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.noSource}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" role="status">
|
||||||
|
{copy.activation[settings.settings.activation_state]}
|
||||||
|
{status ? ` · ${copy.admission[status.external_admission]}` : ''}
|
||||||
|
</p>
|
||||||
|
{status?.needs_restart && <p className="mt-2 text-sm text-muted-foreground">{copy.restart}</p>}
|
||||||
|
{status?.error && (
|
||||||
|
<p className="mt-2 text-sm" role="alert">
|
||||||
|
{status.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
className="min-h-11"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => void send({ ...envelope(), kind: 'catalog_sync' })}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{copy.sync}
|
||||||
|
</Button>
|
||||||
|
<Button className="min-h-11" disabled={busy} onClick={() => void refresh()} variant="outline">
|
||||||
|
{copy.refresh}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{pending && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<p className="text-sm" role="alert">
|
||||||
|
{copy.unknown}
|
||||||
|
</p>
|
||||||
|
<Button className="min-h-11" disabled={busy} onClick={() => void send(pending)}>
|
||||||
|
{copy.reconcile}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && settings && (
|
||||||
|
<p className="mt-3 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<p className="mt-3 text-sm" role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
{settings && (
|
||||||
|
<SettingsSection icon={Cpu} title={copy.chats}>
|
||||||
|
{!views.length && <p className="text-sm text-muted-foreground">{copy.empty}</p>}
|
||||||
|
{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 (
|
||||||
|
<article className="min-w-0 border-b py-4 last:border-b-0" key={view.view_handle}>
|
||||||
|
<h3 className="break-words text-sm font-medium">{view.label}</h3>
|
||||||
|
<p className="break-words text-sm text-muted-foreground">{view.current_model_label}</p>
|
||||||
|
<dl className="my-3 grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2 text-sm">
|
||||||
|
<dt>{copy.current}</dt>
|
||||||
|
<dd className="break-words">
|
||||||
|
{number(target?.input_tokens ?? null)}
|
||||||
|
{target?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
||||||
|
{target?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
||||||
|
</dd>
|
||||||
|
<dt>{copy.limit}</dt>
|
||||||
|
<dd className="break-words">{number(limit)}</dd>
|
||||||
|
<dt>{copy.summaries}</dt>
|
||||||
|
<dd>
|
||||||
|
{view.active_summary_count} · {number(view.summary_tokens)}{' '}
|
||||||
|
{view.summary_tokens !== null ? copy.tokens : ''}
|
||||||
|
{view.summary_token_measurement?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
||||||
|
{view.summary_token_measurement?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{view.last_operation_state && (
|
||||||
|
<p className="my-2 text-sm" role="status">
|
||||||
|
{copy.operation[view.last_operation_state]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{view.last_operation_error && (
|
||||||
|
<p className="my-2 text-sm" role="alert">
|
||||||
|
{view.last_operation_error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{view.compact_attempts.map(attempt => (
|
||||||
|
<p className="break-words text-sm text-muted-foreground" key={attempt.attempt_id}>
|
||||||
|
{attempt.member_label} · {copy.operation[attempt.state]}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
className="mt-2 min-h-11"
|
||||||
|
disabled={locked || !view.can_compact}
|
||||||
|
onClick={() =>
|
||||||
|
void send({
|
||||||
|
...envelope(),
|
||||||
|
kind: 'context_compact_view',
|
||||||
|
view_handle: view.view_handle,
|
||||||
|
expected_checkpoint_revision: view.checkpoint_revision
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{copy.compact}
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { useAiturkCopy } from '@/i18n/aiturk'
|
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||||
|
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
@@ -47,6 +48,7 @@ import { AboutSettings } from './about-settings'
|
|||||||
import { AppearanceSettings } from './appearance-settings'
|
import { AppearanceSettings } from './appearance-settings'
|
||||||
import { BillingSettings } from './billing'
|
import { BillingSettings } from './billing'
|
||||||
import { ConfigSettings } from './config-settings'
|
import { ConfigSettings } from './config-settings'
|
||||||
|
import { CodexLinkSettings } from './codex-link-settings'
|
||||||
import { SECTIONS } from './constants'
|
import { SECTIONS } from './constants'
|
||||||
import { GatewaySettings } from './gateway-settings'
|
import { GatewaySettings } from './gateway-settings'
|
||||||
import { KeybindSettings } from './keybind-settings'
|
import { KeybindSettings } from './keybind-settings'
|
||||||
@@ -61,6 +63,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||||
'providers',
|
'providers',
|
||||||
'gateway',
|
'gateway',
|
||||||
|
'codex-link',
|
||||||
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
|
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
|
||||||
// so saved `?tab=connections` deep links still resolve (redirected below).
|
// so saved `?tab=connections` deep links still resolve (redirected below).
|
||||||
'connections',
|
'connections',
|
||||||
@@ -76,6 +79,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const aiturkCopy = useAiturkCopy()
|
const aiturkCopy = useAiturkCopy()
|
||||||
|
const codexLinkCopy = useCodexLinkCopy()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { hash, pathname, search } = useLocation()
|
const { hash, pathname, search } = useLocation()
|
||||||
|
|
||||||
@@ -252,6 +256,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
label: t.settings.nav.gateway,
|
label: t.settings.nav.gateway,
|
||||||
onSelect: () => setActiveView('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
|
// "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.
|
// entry only opens it. Present only where the desktop bridge offers it.
|
||||||
...(window.hermesDesktop?.aiturkMobile
|
...(window.hermesDesktop?.aiturkMobile
|
||||||
@@ -320,7 +331,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
onSelect: () => setActiveView('about')
|
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
|
// 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
|
// 'connections' renders the unified page too so the frame before
|
||||||
// the alias redirect lands doesn't flash the fallback view.
|
// the alias redirect lands doesn't flash the fallback view.
|
||||||
<GatewaySettings />
|
<GatewaySettings />
|
||||||
|
) : activeView === 'codex-link' ? (
|
||||||
|
<CodexLinkSettings />
|
||||||
) : activeView === 'keybinds' ? (
|
) : activeView === 'keybinds' ? (
|
||||||
<KeybindSettings />
|
<KeybindSettings />
|
||||||
) : activeView.startsWith('config:') ? (
|
) : activeView.startsWith('config:') ? (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type SettingsView =
|
|||||||
| 'about'
|
| 'about'
|
||||||
| 'billing'
|
| 'billing'
|
||||||
| 'connections'
|
| 'connections'
|
||||||
|
| 'codex-link'
|
||||||
| 'gateway'
|
| 'gateway'
|
||||||
| 'keybinds'
|
| 'keybinds'
|
||||||
| 'keys'
|
| 'keys'
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('ShareControls', () => {
|
|||||||
const dialog = await screen.findByRole('dialog')
|
const dialog = await screen.findByRole('dialog')
|
||||||
const input = within(dialog).getByRole('textbox')
|
const input = within(dialog).getByRole('textbox')
|
||||||
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
|
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(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
|
||||||
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+2
@@ -2,6 +2,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared'
|
|||||||
import type { TranslucencyState } from '@hermes/shared/translucency'
|
import type { TranslucencyState } from '@hermes/shared/translucency'
|
||||||
|
|
||||||
import type { PoolLimits } from '../electron/pool-limits'
|
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 { WakeIndicatorState } from './lib/wake-indicator'
|
||||||
import type {
|
import type {
|
||||||
@@ -17,6 +18,7 @@ export {}
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
hermesDesktop: {
|
hermesDesktop: {
|
||||||
|
codexLink?: { control: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse> }
|
||||||
setUiLanguage?: (locale: string) => void
|
setUiLanguage?: (locale: string) => void
|
||||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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',
|
||||||
|
estimated: 'Estimate',
|
||||||
|
upperBound: 'Verified upper bound',
|
||||||
|
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ş',
|
||||||
|
estimated: 'Tahmin',
|
||||||
|
upperBound: 'Doğrulanmış üst sınır',
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Synthetic, isolated visual acceptance fixture. Never contacts the installed manager.
|
||||||
|
import '../styles.css'
|
||||||
|
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import type { CodexLinkControlRequest, CodexLinkControlResponse, CodexLinkSettings as Settings } from '../../codex-link-control.types'
|
||||||
|
import { CodexLinkSettings } from '../app/settings/codex-link-settings'
|
||||||
|
import { I18nProvider } from '../i18n/context'
|
||||||
|
import { ThemeProvider } from '../themes/context'
|
||||||
|
|
||||||
|
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 } }
|
||||||
|
|
||||||
|
const bridge: Pick<NonNullable<typeof window.hermesDesktop>, 'codexLink'> = { codexLink: { control: async (request: CodexLinkControlRequest): Promise<CodexLinkControlResponse> => {
|
||||||
|
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 }] }
|
||||||
|
} } }
|
||||||
|
|
||||||
|
Object.assign(window, { hermesDesktop: bridge })
|
||||||
|
createRoot(document.getElementById('root')!).render(<I18nProvider configClient={null} initialLocale="tr"><ThemeProvider><main style={{ maxWidth: 820, margin: '0 auto' }}><CodexLinkSettings /></main></ThemeProvider></I18nProvider>)
|
||||||
@@ -16,6 +16,6 @@
|
|||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"outDir": "build/electron-types"
|
"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"]
|
"exclude": ["src", "electron/**/*.e2e.mts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="tr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Codex Bağlama kabul görünümü</title></head><body><div id="root"></div><script type="module" src="../src/visual-fixtures/codex-link-settings.tsx"></script></body></html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# AITURK IDE beta.20 mobile adapter
|
||||||
|
|
||||||
|
The mobile catalog now uses the official live Hermes catalog, its cache and
|
||||||
|
removed-entry list. Parser and nine official offline seed entries are ported
|
||||||
|
from NousResearch/hermes-agent ad12263a5ed43109d3942a8af04d4cef210fa6ae.
|
||||||
|
Only PyYAML import compatibility differs in the parser. The existing installer
|
||||||
|
checks out the catalog's exact commit; client URLs cannot override catalog names.
|
||||||
|
Version/platform incompatibility is reported before installation.
|
||||||
|
|
||||||
|
Mobile subagent roster/tail RPCs are connected to the existing live registry.
|
||||||
|
Roster supports durable conversation lineage after reconnect, like upstream.
|
||||||
|
Tail and interrupt retain exact live-session/transport authority. Transcript
|
||||||
|
reads are bounded and confined to the profile's delegation log directory.
|
||||||
|
The existing mobile fork fallback already uses session.branch only for -32601;
|
||||||
|
it was not a missing feature and is unchanged.
|
||||||
|
|
||||||
|
No new model, team, cron job, permission, or account is installed by these changes.
|
||||||
|
The mobile Keys/Files/Vault/Connectors exclusions remain as before; this release
|
||||||
|
does not claim those unsupported remote screens are implemented. Android beta.5
|
||||||
|
can use the new endpoints without a new APK. Physical-phone acceptance is separate.
|
||||||
@@ -769,7 +769,10 @@ def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
|
|||||||
openai/codex#36793). ``process_group`` only changes which group the child
|
openai/codex#36793). ``process_group`` only changes which group the child
|
||||||
belongs to; it does not detach the terminal or alter the fast path.
|
belongs to; it does not detach the terminal or alter the fast path.
|
||||||
"""
|
"""
|
||||||
result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env())
|
env = noninteractive_git_env()
|
||||||
|
# Read-only probes must not fetch missing trees in partial clones.
|
||||||
|
env["GIT_NO_LAZY_FETCH"] = "1"
|
||||||
|
result = bounded_probe_run(argv, timeout=timeout, env=env)
|
||||||
if result is None or result.returncode != 0:
|
if result is None or result.returncode != 0:
|
||||||
return ""
|
return ""
|
||||||
return (result.stdout or "").strip()
|
return (result.stdout or "").strip()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""AITURK adapter for the upstream catalog and existing pinned installer.
|
||||||
|
|
||||||
|
Catalog parsing/cache comes from NousResearch/hermes-agent ad12263a5ed4.
|
||||||
|
The catalog supplies the repository and immutable revision, never the client.
|
||||||
|
"""
|
||||||
|
from dataclasses import asdict
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from hermes_cli import plugin_catalog as catalog
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_payload():
|
||||||
|
from hermes_cli.plugins_cmd import _discover_all_plugins, _read_install_metadata
|
||||||
|
|
||||||
|
installed = {row[0] for row in _discover_all_plugins()}
|
||||||
|
metadata = _read_install_metadata()
|
||||||
|
removed = catalog.resolved_removed_entries()
|
||||||
|
entries = []
|
||||||
|
for entry in catalog.load_catalog_live():
|
||||||
|
if catalog.match_removed(entry.name, removed) or catalog.match_removed(entry.repo, removed):
|
||||||
|
continue
|
||||||
|
row = entry.to_dict()
|
||||||
|
record = metadata.get(entry.name, {})
|
||||||
|
revision = record.get("revision")
|
||||||
|
row.update(installed=entry.name in installed, installed_sha=revision,
|
||||||
|
update_available=bool(revision and revision != entry.sha),
|
||||||
|
sha_short=entry.sha[:12], capability_summary=catalog.entry_capability_summary(entry))
|
||||||
|
entries.append(row)
|
||||||
|
return {"entries": entries, "removed": [asdict(row) for row in removed]}
|
||||||
|
|
||||||
|
|
||||||
|
def install_catalog_plugin(name, *, force=False, enable=True):
|
||||||
|
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||||
|
|
||||||
|
entry = catalog.get_live_catalog_entry(name)
|
||||||
|
if entry is None:
|
||||||
|
return {"ok": False, "error": "Eklenti katalogda bulunamadı."}
|
||||||
|
removed = catalog.resolved_removed_entries()
|
||||||
|
if catalog.match_removed(name, removed) or catalog.match_removed(entry.repo, removed):
|
||||||
|
return {"ok": False, "error": "Bu eklenti katalogdan kaldırılmış."}
|
||||||
|
platform = {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform)
|
||||||
|
supported = {"win": "windows", "win32": "windows", "darwin": "macos", "mac": "macos"}
|
||||||
|
if entry.platforms and platform not in {supported.get(p.lower(), p.lower()) for p in entry.platforms}:
|
||||||
|
return {"ok": False, "error": "Bu eklenti bilgisayarın işletim sistemini desteklemiyor."}
|
||||||
|
if entry.requires_hermes:
|
||||||
|
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||||
|
from hermes_cli import __version__
|
||||||
|
try:
|
||||||
|
compatible = __version__ in SpecifierSet(entry.requires_hermes)
|
||||||
|
except InvalidSpecifier:
|
||||||
|
return {"ok": False, "error": "Katalogdaki sürüm koşulu geçersiz; eklenti kurulmadı."}
|
||||||
|
if not compatible:
|
||||||
|
return {"ok": False, "error": f"Bu eklenti Hermes {entry.requires_hermes} gerektiriyor."}
|
||||||
|
return dashboard_install_plugin(entry.install_identifier, force=force, enable=enable, ref=entry.sha)
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
|
||||||
|
|
||||||
|
Mirrors the ``optional-mcps/`` MCP-catalog pattern: one YAML file per entry under the in-tree
|
||||||
|
``plugin-catalog/`` directory, pinned to an exact 40-character commit SHA. Presence in the directory IS
|
||||||
|
the human-merged approval gate; SHA bumps are new, re-reviewed PRs; ``removed.yaml`` is the kill list
|
||||||
|
(installs of a removed name/repo are refused with the recorded reason). Full policy:
|
||||||
|
``plugin-catalog/README.md``.
|
||||||
|
|
||||||
|
Live refresh: the docs build publishes the same data as ONE JSON document
|
||||||
|
(``website/scripts/extract-plugins.py`` → ``/docs/api/plugin-catalog.json``, like the skills index), so
|
||||||
|
an installed Hermes sees new entries and removals without updating. A fetch failure reuses the last valid
|
||||||
|
cached copy regardless of age, then falls back to the in-tree copy when no valid cache exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CATALOG_TIERS = ("official", "community")
|
||||||
|
# Browse taxonomy for the catalog page / picker. Entries without one land on the Desktop shelf
|
||||||
|
# (the common case for community submissions); "general" is for plugins that fit no shelf.
|
||||||
|
CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
|
||||||
|
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
|
||||||
|
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
|
||||||
|
# Offline pins expire, but cached removals remain a permanent kill list.
|
||||||
|
LIVE_CATALOG_MAX_STALE_SECONDS = 24 * 60 * 60
|
||||||
|
LIVE_CATALOG_FAILURE_TTL_SECONDS = 60.0
|
||||||
|
_REQUEST_TIMEOUT = 5.0
|
||||||
|
_MAX_LIVE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$")
|
||||||
|
# Catalog images may only come from GitHub: the Desktop catalog browser never fans out to
|
||||||
|
# third-party hosts, and a raw URL pinned to the entry's commit is as immutable as the sha.
|
||||||
|
IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com")
|
||||||
|
IMAGE_HOST_SUFFIX = ".githubusercontent.com"
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_image_url(url: str) -> bool:
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
parts = urlsplit(url)
|
||||||
|
host = (parts.hostname or "").lower()
|
||||||
|
return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX))
|
||||||
|
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RemovedEntry:
|
||||||
|
name: str
|
||||||
|
repo: str = ""
|
||||||
|
reason: str = ""
|
||||||
|
date: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CatalogCapabilities:
|
||||||
|
provides_tools: List[str] = field(default_factory=list)
|
||||||
|
provides_hooks: List[str] = field(default_factory=list)
|
||||||
|
provides_middleware: List[str] = field(default_factory=list)
|
||||||
|
requires_env: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PluginCatalogEntry:
|
||||||
|
name: str # catalog key, [a-z0-9_-]{1,64}
|
||||||
|
repo: str # https:// git URL
|
||||||
|
sha: str # 40-hex pinned commit — mandatory
|
||||||
|
description: str
|
||||||
|
maintainer: str
|
||||||
|
tier: str = "community"
|
||||||
|
category: str = "desktop"
|
||||||
|
requires_hermes: str = ""
|
||||||
|
subdir: str = ""
|
||||||
|
docs_url: str = ""
|
||||||
|
version: str = "" # human label for the pinned sha ("1.4.0"); cosmetic, never parsed
|
||||||
|
image: str = "" # https image URL on a GitHub host; shown on catalog cards
|
||||||
|
screenshots: List[str] = field(default_factory=list) # GitHub-hosted https URLs; gallery on /docs/plugins/<name>
|
||||||
|
readme: bool = False # docs site renders the README from the pinned commit on the entry's page
|
||||||
|
platforms: List[str] = field(default_factory=list) # empty = all OSes
|
||||||
|
title: str = "" # human name ("NVIDIA App"); empty = derived from ``name``
|
||||||
|
onboarding: bool = False # curated: offered on the desktop onboarding card
|
||||||
|
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
|
||||||
|
known_issues: List[str] = field(default_factory=list) # #124058: informational; drivers come from plugin-catalog/*.yaml
|
||||||
|
|
||||||
|
@property
|
||||||
|
def install_identifier(self) -> str:
|
||||||
|
"""``_install_plugin_core`` identifier (``repo#subdir`` for monorepo entries)."""
|
||||||
|
return f"{self.repo}#{self.subdir}" if self.subdir else self.repo
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
caps = self.capabilities
|
||||||
|
return {
|
||||||
|
"name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description,
|
||||||
|
"maintainer": self.maintainer, "tier": self.tier, "category": self.category,
|
||||||
|
"requires_hermes": self.requires_hermes,
|
||||||
|
"subdir": self.subdir, "docs_url": self.docs_url, "version": self.version, "image": self.image,
|
||||||
|
"screenshots": list(self.screenshots), "readme": self.readme,
|
||||||
|
"platforms": list(self.platforms), "title": self.title, "onboarding": self.onboarding,
|
||||||
|
"capabilities": {
|
||||||
|
"provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks),
|
||||||
|
"provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env),
|
||||||
|
},
|
||||||
|
"known_issues": list(self.known_issues),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_catalog_dir() -> Path:
|
||||||
|
"""The ``plugin-catalog/`` directory shipped with this checkout."""
|
||||||
|
return Path(__file__).resolve().parent.parent / "plugin-catalog"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parsing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _str_list(raw: Any) -> List[str]:
|
||||||
|
return [str(x) for x in raw if isinstance(x, (str, int, float))] if isinstance(raw, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
|
||||||
|
"""Validate one entry mapping (YAML file or live-JSON element); ``None`` + warning on any failure."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("Plugin catalog: %s: entry must be a mapping", label)
|
||||||
|
return None
|
||||||
|
name = str(data.get("name") or "")
|
||||||
|
repo = str(data.get("repo") or "")
|
||||||
|
sha = str(data.get("sha") or "").strip().lower()
|
||||||
|
tier = str(data.get("tier") or "community")
|
||||||
|
category = str(data.get("category") or "desktop")
|
||||||
|
problem = (
|
||||||
|
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
|
||||||
|
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
|
||||||
|
else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha)
|
||||||
|
else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS
|
||||||
|
else f"category must be one of {'/'.join(CATALOG_CATEGORIES)} (got {category!r})"
|
||||||
|
if category not in CATALOG_CATEGORIES
|
||||||
|
else None)
|
||||||
|
if problem:
|
||||||
|
logger.warning("Plugin catalog: %s: %s", label, problem)
|
||||||
|
return None
|
||||||
|
caps_raw = data.get("capabilities")
|
||||||
|
caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {}
|
||||||
|
version = str(data.get("version") or "").strip()
|
||||||
|
if version and not _VERSION_RE.match(version):
|
||||||
|
logger.warning("Plugin catalog: %s: ignoring version %r (max 32 chars of [A-Za-z0-9._+-])", label, version)
|
||||||
|
version = ""
|
||||||
|
image = str(data.get("image") or "").strip()
|
||||||
|
if image and not is_allowed_image_url(image):
|
||||||
|
logger.warning("Plugin catalog: %s: ignoring image %r (must be https on a GitHub host)", label, image)
|
||||||
|
image = ""
|
||||||
|
screenshots = [s for s in _str_list(data.get("screenshots")) if is_allowed_image_url(s)]
|
||||||
|
if len(screenshots) != len(_str_list(data.get("screenshots"))):
|
||||||
|
logger.warning("Plugin catalog: %s: ignoring screenshots off GitHub hosts", label)
|
||||||
|
return PluginCatalogEntry(
|
||||||
|
name=name, repo=repo, sha=sha,
|
||||||
|
description=str(data.get("description") or "").strip(),
|
||||||
|
maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category,
|
||||||
|
requires_hermes=str(data.get("requires_hermes") or "").strip(),
|
||||||
|
subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(),
|
||||||
|
version=version, image=image, screenshots=screenshots, readme=data.get("readme") is not False,
|
||||||
|
platforms=_str_list(data.get("platforms")),
|
||||||
|
title=str(data.get("title") or "").strip(), onboarding=data.get("onboarding") is True,
|
||||||
|
known_issues=_str_list(data.get("known_issues")),
|
||||||
|
capabilities=CatalogCapabilities(
|
||||||
|
provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")),
|
||||||
|
provides_middleware=_str_list(caps.get("provides_middleware")),
|
||||||
|
requires_env=_str_list(caps.get("requires_env"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_yaml(path: Path) -> Any:
|
||||||
|
try:
|
||||||
|
return yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _removed_from_list(raw_list: Any) -> List[RemovedEntry]:
|
||||||
|
if not isinstance(raw_list, list):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
RemovedEntry(name=str(r["name"]), repo=str(r.get("repo") or ""), reason=str(r.get("reason") or ""),
|
||||||
|
date=str(r.get("date") or ""))
|
||||||
|
for r in raw_list if isinstance(r, dict) and r.get("name")]
|
||||||
|
|
||||||
|
|
||||||
|
# ── In-tree catalog ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_catalog(catalog_dir: Optional[Path] = None) -> List[PluginCatalogEntry]:
|
||||||
|
"""Every valid ``*.yaml`` entry in the catalog dir (``removed.yaml`` excluded), sorted by file name.
|
||||||
|
Malformed entries are skipped with a warning — never raises."""
|
||||||
|
root = catalog_dir or get_catalog_dir()
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
entries = []
|
||||||
|
for path in sorted(root.glob("*.yaml")):
|
||||||
|
if path.name == "removed.yaml":
|
||||||
|
continue
|
||||||
|
data = _read_yaml(path)
|
||||||
|
entry = entry_from_mapping(data, str(path)) if data is not None else None
|
||||||
|
if entry is not None:
|
||||||
|
entries.append(entry)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def load_removed_list(catalog_dir: Optional[Path] = None) -> List[RemovedEntry]:
|
||||||
|
"""``removed.yaml``'s ``removed:`` list; missing/malformed → empty."""
|
||||||
|
path = (catalog_dir or get_catalog_dir()) / "removed.yaml"
|
||||||
|
data = _read_yaml(path) if path.is_file() else None
|
||||||
|
return _removed_from_list(data.get("removed")) if isinstance(data, dict) else []
|
||||||
|
|
||||||
|
|
||||||
|
def get_catalog_entry(name: str, catalog_dir: Optional[Path] = None) -> Optional[PluginCatalogEntry]:
|
||||||
|
return next((e for e in load_catalog(catalog_dir) if e.name == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_entries(entries: List[PluginCatalogEntry], query: str) -> List[PluginCatalogEntry]:
|
||||||
|
"""Case-insensitive substring match over name, description and declared tools; empty query = all."""
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
if not q:
|
||||||
|
return entries
|
||||||
|
return [e for e in entries
|
||||||
|
if any(q in h.lower() for h in (e.name, e.description, *e.capabilities.provides_tools))]
|
||||||
|
|
||||||
|
|
||||||
|
def search_catalog(query: str) -> List[PluginCatalogEntry]:
|
||||||
|
return filter_entries(load_catalog(), query)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Removed / blocklist ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SCP_URL_RE = re.compile(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$") # git@host:owner/repo
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_repo(url: str) -> str:
|
||||||
|
"""Canonical ``host/path`` for a repo URL: scheme, user, ``www.``, ``.git`` and trailing slashes are
|
||||||
|
spelling, not identity — the kill list must match ``git@github.com:Evil/Bad.git`` when it names
|
||||||
|
``https://github.com/evil/bad``."""
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
text = url.strip()
|
||||||
|
scp = _SCP_URL_RE.match(text)
|
||||||
|
if scp:
|
||||||
|
host, path = scp.group(1), scp.group(2)
|
||||||
|
elif "://" in text:
|
||||||
|
parts = urlsplit(text)
|
||||||
|
host, path = parts.hostname or "", parts.path
|
||||||
|
else:
|
||||||
|
host, path = "", text
|
||||||
|
host = host.lower().removeprefix("www.")
|
||||||
|
path = path.strip("/").removesuffix(".git").rstrip("/").lower()
|
||||||
|
return f"{host}/{path}" if host else path
|
||||||
|
|
||||||
|
|
||||||
|
def find_removed(name_or_repo: str, catalog_dir: Optional[Path] = None) -> Optional[RemovedEntry]:
|
||||||
|
"""Match a catalog name or repo URL (``.git``/trailing-slash insensitive) against the kill list.
|
||||||
|
|
||||||
|
The in-tree list and the live-fetched list are UNIONED: a removal published after this checkout
|
||||||
|
shipped must still block, and a stale live cache must not un-block an in-tree removal.
|
||||||
|
"""
|
||||||
|
if not name_or_repo:
|
||||||
|
return None
|
||||||
|
entries = load_removed_list(catalog_dir)
|
||||||
|
if catalog_dir is None:
|
||||||
|
entries = entries + live_removed_list()
|
||||||
|
return match_removed(name_or_repo, entries)
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_removed_entries() -> List[RemovedEntry]:
|
||||||
|
"""The full kill list (in-tree UNION live) in one resolution. Callers that match many candidates
|
||||||
|
— e.g. a plugins-hub rebuild annotating every installed plugin — resolve the list once instead
|
||||||
|
of paying a live-catalog fetch per candidate."""
|
||||||
|
return load_removed_list() + live_removed_list()
|
||||||
|
|
||||||
|
|
||||||
|
def cached_removed_entries() -> List[RemovedEntry]:
|
||||||
|
"""In-tree list UNION the last fetched live copy, with NO network round-trip — for the load-time and
|
||||||
|
``enable`` checks that run in every process and must never block on a dead catalog host."""
|
||||||
|
cached = _stale_live_cache(_live_cache_path()) or {}
|
||||||
|
return load_removed_list() + _removed_from_list(cached.get("removed"))
|
||||||
|
|
||||||
|
|
||||||
|
def match_removed(
|
||||||
|
candidate: str, entries: List[RemovedEntry]
|
||||||
|
) -> Optional[RemovedEntry]:
|
||||||
|
"""One candidate against a pre-resolved kill list: exact name or normalized repo URL match."""
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
text = candidate.strip()
|
||||||
|
text_repo = _normalize_repo(text)
|
||||||
|
for entry in entries:
|
||||||
|
if text == entry.name or (
|
||||||
|
entry.repo and text_repo == _normalize_repo(entry.repo)
|
||||||
|
):
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Live catalog ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _live_cache_path() -> Path:
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
return get_hermes_home() / "cache" / "plugin-catalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_live_cache_for_home(home: Path) -> None:
|
||||||
|
"""Best-effort removal of the cached live catalog under *home* (any profile's home).
|
||||||
|
|
||||||
|
``hermes update`` drops it for every profile after the checkout changes: a snapshot fetched
|
||||||
|
before the bump would otherwise out-vote the newer in-tree catalog (pins the update just
|
||||||
|
changed, entries it just added) for the rest of :data:`LIVE_CATALOG_TTL_SECONDS` (#119340).
|
||||||
|
The next :func:`fetch_live_catalog` re-fetches the published doc, or falls back to the
|
||||||
|
in-tree catalog while the network is down — both newer than what was deleted. Safe when the
|
||||||
|
cache is absent (first run, other profiles that never opened the plugins hub).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
(Path(home) / "cache" / "plugin-catalog.json").unlink(missing_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Plugin catalog: could not drop the live cache under %s: %s", home, exc)
|
||||||
|
|
||||||
|
|
||||||
|
# Wall-clock deadline of the last failed live fetch. Without it a dead catalog host costs one
|
||||||
|
# full request timeout PER CALL (the plugins hub and ``plugins list`` used to ask once per
|
||||||
|
# installed plugin), so the dashboard event loop stalled for minutes.
|
||||||
|
_live_fetch_failed_until = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_live_cache(cache: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
"""A previously fetched copy still beats the in-tree one when the network is down — for
|
||||||
|
:data:`LIVE_CATALOG_MAX_STALE_SECONDS`. Past that its pins may trail the checkout's own catalog
|
||||||
|
(a 90-day-old cache outranked a freshly updated in-tree pin), so the entries are dropped and the
|
||||||
|
caller falls back to in-tree; the removals are kept."""
|
||||||
|
try:
|
||||||
|
if not cache.is_file():
|
||||||
|
return None
|
||||||
|
data = json.loads(cache.read_text(encoding="utf-8-sig"))
|
||||||
|
if time.time() - cache.stat().st_mtime > LIVE_CATALOG_MAX_STALE_SECONDS:
|
||||||
|
data = {**data, "entries": []}
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
|
||||||
|
"""The published ``plugin-catalog.json`` (``{"entries": [...], "removed": [...]}``), cached under
|
||||||
|
``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall
|
||||||
|
back to the in-tree catalog. A failed network attempt is remembered for
|
||||||
|
:data:`LIVE_CATALOG_FAILURE_TTL_SECONDS` so a dead host costs one timeout per TTL window, not one
|
||||||
|
per call (``force`` bypasses both caches)."""
|
||||||
|
global _live_fetch_failed_until
|
||||||
|
cache = _live_cache_path()
|
||||||
|
try:
|
||||||
|
if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS:
|
||||||
|
return json.loads(cache.read_text(encoding="utf-8-sig"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
|
||||||
|
if not force and time.time() < _live_fetch_failed_until:
|
||||||
|
return _stale_live_cache(cache)
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
from hermes_constants import mkdir_under_hermes_home
|
||||||
|
from utils import atomic_write_text
|
||||||
|
|
||||||
|
resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
if len(resp.content) > _MAX_LIVE_BYTES:
|
||||||
|
raise ValueError("live catalog payload too large")
|
||||||
|
data = resp.json()
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
||||||
|
raise ValueError("unexpected live catalog payload")
|
||||||
|
mkdir_under_hermes_home(cache.parent)
|
||||||
|
# Atomic: a concurrent reader (gateway, TUI, a second CLI) must never see a half-written
|
||||||
|
# document, which would read as a fetch failure and start its own 60 s failure window.
|
||||||
|
atomic_write_text(cache, json.dumps(data), tmp_prefix=f"{cache.name}.tmp-")
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Plugin catalog: live fetch failed: %s", exc)
|
||||||
|
_live_fetch_failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS
|
||||||
|
return _stale_live_cache(cache)
|
||||||
|
|
||||||
|
|
||||||
|
_in_tree_catalog_time: Optional[float] = -1.0 # -1 = not resolved yet; None = no usable in-tree time
|
||||||
|
|
||||||
|
|
||||||
|
def in_tree_catalog_time() -> Optional[float]:
|
||||||
|
"""Commit time (epoch) of the last change to this checkout's ``plugin-catalog/``, or ``None`` when
|
||||||
|
the install is not a git checkout (a release/pip install cannot be newer than the published doc).
|
||||||
|
On a git checkout whose path history is unreadable without network — the treeless ``tree:0`` layout
|
||||||
|
``hermes update`` produces — the newest checked-out catalog file's mtime stands in for it, so a
|
||||||
|
freshly updated checkout still outranks a doc fetched before the bump. Resolved once per process."""
|
||||||
|
global _in_tree_catalog_time
|
||||||
|
if _in_tree_catalog_time != -1.0:
|
||||||
|
return _in_tree_catalog_time
|
||||||
|
root = get_catalog_dir().parent
|
||||||
|
resolved: Optional[float] = None
|
||||||
|
if (root / ".git").exists():
|
||||||
|
try:
|
||||||
|
from hermes_cli._subprocess_compat import bounded_git_probe
|
||||||
|
|
||||||
|
# Path history needs missing trees on tree:0 clones. The shared probe disables
|
||||||
|
# lazy fetch only for its child and bounds timeout cleanup on every platform.
|
||||||
|
out = bounded_git_probe(
|
||||||
|
["git", "-C", str(root), "log", "-1", "--format=%ct", "--", "plugin-catalog"],
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resolved = float(out) if out else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Plugin catalog: could not date the in-tree catalog: %s", exc)
|
||||||
|
if resolved is None:
|
||||||
|
# "History unreadable without network" must not collapse into "not a git checkout":
|
||||||
|
# ``None`` feeds the frozen-copy rule that defers to the live doc, which would let a
|
||||||
|
# cache fetched before ``hermes update`` re-pin the old sha on exactly the installs
|
||||||
|
# that just bumped it. A treeless clone still has its checked-out files — only the
|
||||||
|
# historical trees are missing — and their mtime dates the checkout that wrote them.
|
||||||
|
resolved = _catalog_worktree_mtime()
|
||||||
|
_in_tree_catalog_time = resolved
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_worktree_mtime() -> Optional[float]:
|
||||||
|
"""Newest mtime under the checkout's ``plugin-catalog/``: a no-network freshness signal for clones
|
||||||
|
whose path history is unreachable (treeless or offline). ``None`` when nothing is checked out."""
|
||||||
|
try:
|
||||||
|
times = [p.stat().st_mtime for p in get_catalog_dir().rglob("*") if p.is_file()]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return max(times) if times else None
|
||||||
|
|
||||||
|
|
||||||
|
def _live_generated_time(data: Dict[str, Any]) -> Optional[float]:
|
||||||
|
raw = data.get("generated_at")
|
||||||
|
if not isinstance(raw, str) or not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_in_tree_entry(tree: PluginCatalogEntry, live: PluginCatalogEntry, tree_is_newer: Optional[bool]) -> bool:
|
||||||
|
"""For one entry present in both sources with a different pin: the newer catalog wins. Newer is
|
||||||
|
decided by the checkout's catalog commit time vs the doc's ``generated_at`` when both resolve;
|
||||||
|
otherwise by the entries' ``version`` labels when both parse; otherwise the live doc wins (a release
|
||||||
|
install's in-tree copy is frozen at release time)."""
|
||||||
|
if tree.sha == live.sha:
|
||||||
|
return False
|
||||||
|
if tree_is_newer is not None:
|
||||||
|
return tree_is_newer
|
||||||
|
if tree.version and live.version:
|
||||||
|
try:
|
||||||
|
from packaging.version import Version
|
||||||
|
return Version(tree.version) > Version(live.version)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog_live() -> List[PluginCatalogEntry]:
|
||||||
|
"""Entries from the live (or cached) catalog, else the in-tree catalog. When both name an entry at
|
||||||
|
different pins the NEWER source supplies it — right after ``hermes update`` bumps an in-tree pin,
|
||||||
|
a cache fetched before the bump must not re-install the old one (see :func:`_prefer_in_tree_entry`)."""
|
||||||
|
data = fetch_live_catalog()
|
||||||
|
if data is None:
|
||||||
|
return load_catalog()
|
||||||
|
entries = [e for i, raw in enumerate(data["entries"])
|
||||||
|
if (e := entry_from_mapping(raw, f"{LIVE_CATALOG_URL}#{i}")) is not None]
|
||||||
|
if not entries:
|
||||||
|
return load_catalog()
|
||||||
|
in_tree = {e.name: e for e in load_catalog()}
|
||||||
|
live_t, tree_t = _live_generated_time(data), in_tree_catalog_time()
|
||||||
|
tree_is_newer = (tree_t > live_t) if (live_t is not None and tree_t is not None) else None
|
||||||
|
raw_by_name = {str(raw.get("name")): raw for raw in data["entries"] if isinstance(raw, dict)}
|
||||||
|
return [in_tree[e.name] if e.name in in_tree and _prefer_in_tree_entry(in_tree[e.name], e, tree_is_newer)
|
||||||
|
else _with_curated_fields(e, in_tree.get(e.name), raw_by_name.get(e.name) or {})
|
||||||
|
for e in entries]
|
||||||
|
|
||||||
|
|
||||||
|
# Curated display fields a published doc older than the field does not carry. ``generated_at`` is the
|
||||||
|
# docs build time, not the content time, so a rebuild of an older catalog outranks a checkout that added
|
||||||
|
# the field; a doc that has the key (even ``false``) decides.
|
||||||
|
_CURATED_FIELDS = ("onboarding", "title")
|
||||||
|
|
||||||
|
|
||||||
|
def _with_curated_fields(live: PluginCatalogEntry, tree: Optional[PluginCatalogEntry], raw: Dict[str, Any]
|
||||||
|
) -> PluginCatalogEntry:
|
||||||
|
if tree is None or tree.sha != live.sha:
|
||||||
|
return live
|
||||||
|
missing = {key: getattr(tree, key) for key in _CURATED_FIELDS if key not in raw}
|
||||||
|
return dataclasses.replace(live, **missing) if missing else live
|
||||||
|
|
||||||
|
|
||||||
|
def live_removed_list() -> List[RemovedEntry]:
|
||||||
|
data = fetch_live_catalog()
|
||||||
|
return _removed_from_list(data.get("removed")) if data else []
|
||||||
|
|
||||||
|
|
||||||
|
def get_live_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
|
||||||
|
return next((e for e in load_catalog_live() if e.name == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Human summaries ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
|
||||||
|
"""One paragraph shown at install/enable prompts: what the user is granting."""
|
||||||
|
caps = entry.capabilities
|
||||||
|
parts = [f"{label} {', '.join(items)}" for label, items in (
|
||||||
|
("registers tool(s):", caps.provides_tools), ("hook(s):", caps.provides_hooks),
|
||||||
|
("middleware:", caps.provides_middleware), ("requires env var(s):", caps.requires_env)) if items]
|
||||||
|
bits = [f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})"]
|
||||||
|
if entry.description:
|
||||||
|
bits.append(entry.description)
|
||||||
|
bits.append(f"This plugin {'; '.join(parts) if parts else 'declares no tools, hooks, middleware, or env vars'}.")
|
||||||
|
if entry.platforms:
|
||||||
|
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
|
||||||
|
if entry.requires_hermes:
|
||||||
|
bits.append(f"Requires Hermes {entry.requires_hermes}.")
|
||||||
|
if entry.known_issues:
|
||||||
|
# #124058: informational — the catalog documents traps (unsupported
|
||||||
|
# install-method/mode combinations); surface them at install prompts
|
||||||
|
# without blocking the install.
|
||||||
|
bits.append(f"Known issues: {'; '.join(entry.known_issues)}.")
|
||||||
|
return " ".join(bits)
|
||||||
@@ -564,6 +564,22 @@ def _install_metadata_path() -> Path:
|
|||||||
return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE
|
return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_plugin_tree(path: Path) -> None:
|
||||||
|
"""Remove one validated plugin tree, including read-only Git objects on Windows."""
|
||||||
|
import stat
|
||||||
|
target = path.resolve()
|
||||||
|
if _plugins_dir().resolve() not in target.parents:
|
||||||
|
raise ValueError("Plugin cleanup must stay strictly inside the plugin directory")
|
||||||
|
|
||||||
|
def retry_readonly(function, failed_path, exc_info):
|
||||||
|
if os.name != "nt" or not isinstance(exc_info[1], PermissionError):
|
||||||
|
raise exc_info[1]
|
||||||
|
os.chmod(failed_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
function(failed_path)
|
||||||
|
|
||||||
|
shutil.rmtree(path, onerror=retry_readonly)
|
||||||
|
|
||||||
|
|
||||||
def _read_install_metadata() -> dict[str, dict[str, object]]:
|
def _read_install_metadata() -> dict[str, dict[str, object]]:
|
||||||
"""Read profile-local, non-secret plugin source metadata from disk."""
|
"""Read profile-local, non-secret plugin source metadata from disk."""
|
||||||
path = _install_metadata_path()
|
path = _install_metadata_path()
|
||||||
@@ -867,7 +883,7 @@ def _install_plugin_core(
|
|||||||
_write_install_metadata(new_metadata)
|
_write_install_metadata(new_metadata)
|
||||||
except Exception:
|
except Exception:
|
||||||
if target.exists():
|
if target.exists():
|
||||||
shutil.rmtree(target)
|
_remove_plugin_tree(target)
|
||||||
if replaced_existing and backup.exists():
|
if replaced_existing and backup.exists():
|
||||||
os.replace(backup, target)
|
os.replace(backup, target)
|
||||||
if old_metadata:
|
if old_metadata:
|
||||||
@@ -1208,7 +1224,7 @@ def _remove_plugin_core(target: Path) -> None:
|
|||||||
"""Remove one plugin and its metadata without splitting their state."""
|
"""Remove one plugin and its metadata without splitting their state."""
|
||||||
metadata = _read_install_metadata()
|
metadata = _read_install_metadata()
|
||||||
if target.name not in metadata:
|
if target.name not in metadata:
|
||||||
shutil.rmtree(target)
|
_remove_plugin_tree(target)
|
||||||
return
|
return
|
||||||
|
|
||||||
updated = dict(metadata)
|
updated = dict(metadata)
|
||||||
@@ -1230,7 +1246,7 @@ def _remove_plugin_core(target: Path) -> None:
|
|||||||
) from restore_exc
|
) from restore_exc
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
shutil.rmtree(staging)
|
_remove_plugin_tree(staging)
|
||||||
|
|
||||||
|
|
||||||
def cmd_remove(name: str) -> None:
|
def cmd_remove(name: str) -> None:
|
||||||
@@ -2641,6 +2657,7 @@ def dashboard_install_plugin(
|
|||||||
*,
|
*,
|
||||||
force: bool,
|
force: bool,
|
||||||
enable: bool,
|
enable: bool,
|
||||||
|
ref: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
@@ -2657,6 +2674,7 @@ def dashboard_install_plugin(
|
|||||||
target, installed_manifest, installed_name = _install_plugin_core(
|
target, installed_manifest, installed_name = _install_plugin_core(
|
||||||
identifier,
|
identifier,
|
||||||
force=force,
|
force=force,
|
||||||
|
ref=ref,
|
||||||
)
|
)
|
||||||
except PluginScanBlocked as exc:
|
except PluginScanBlocked as exc:
|
||||||
findings = []
|
findings = []
|
||||||
|
|||||||
@@ -747,9 +747,11 @@ class FontSetBody(BaseModel):
|
|||||||
# --- from web_server.py (originally lines 19681-19684) ---
|
# --- from web_server.py (originally lines 19681-19684) ---
|
||||||
|
|
||||||
class _AgentPluginInstallBody(BaseModel):
|
class _AgentPluginInstallBody(BaseModel):
|
||||||
identifier: str
|
identifier: str = ""
|
||||||
force: bool = False
|
force: bool = False
|
||||||
enable: bool = True
|
enable: bool = True
|
||||||
|
catalog_name: Optional[str] = None
|
||||||
|
ref: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# --- from web_server.py (originally lines 19896-19898) ---
|
# --- from web_server.py (originally lines 19896-19898) ---
|
||||||
@@ -763,4 +765,3 @@ class _PluginProvidersPutBody(BaseModel):
|
|||||||
|
|
||||||
class _PluginVisibilityBody(BaseModel):
|
class _PluginVisibilityBody(BaseModel):
|
||||||
hidden: bool
|
hidden: bool
|
||||||
|
|
||||||
|
|||||||
@@ -19070,15 +19070,27 @@ async def get_plugins_hub(request: Request):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
|
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/dashboard/plugins/catalog")
|
||||||
|
async def get_plugins_catalog(request: Request):
|
||||||
|
_require_token(request)
|
||||||
|
from hermes_cli.aiturk_plugin_catalog import catalog_payload
|
||||||
|
return await asyncio.to_thread(catalog_payload)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/dashboard/agent-plugins/install")
|
@app.post("/api/dashboard/agent-plugins/install")
|
||||||
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
|
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
|
||||||
_require_token(request)
|
_require_token(request)
|
||||||
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||||
|
|
||||||
result = dashboard_install_plugin(
|
if body.catalog_name:
|
||||||
body.identifier.strip(),
|
from hermes_cli.aiturk_plugin_catalog import install_catalog_plugin
|
||||||
force=body.force,
|
result = await asyncio.to_thread(
|
||||||
enable=body.enable,
|
install_catalog_plugin, body.catalog_name, force=body.force, enable=body.enable,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
dashboard_install_plugin, body.identifier.strip(), force=body.force,
|
||||||
|
enable=body.enable, ref=body.ref,
|
||||||
)
|
)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"version": "1.0.0-beta.19",
|
"version": "1.0.0-beta.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/core": "0.2.23",
|
"@assistant-ui/core": "0.2.23",
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Hermes Plugin Catalog
|
||||||
|
|
||||||
|
Curated, Nous-approved Hermes plugins. Each YAML file in this directory
|
||||||
|
(except `removed.yaml`) is one catalog entry, discoverable via
|
||||||
|
`hermes plugins catalog` / `hermes plugins search` and installable with
|
||||||
|
`hermes plugins install <name>`.
|
||||||
|
|
||||||
|
## Admission policy
|
||||||
|
|
||||||
|
Presence in this directory **is** the trust signal. The rules that keep it
|
||||||
|
meaningful:
|
||||||
|
|
||||||
|
<!-- admission-rules:start (mirrored in website/docs/developer-guide/plugins/catalog-submission.md; tests/website/test_catalog_rules_mirror.py keeps them identical) -->
|
||||||
|
1. **Human-merged gate.** Entries are added *only* via a PR to the
|
||||||
|
`hermes-agent` repository, reviewed and merged by a maintainer. There is
|
||||||
|
no self-serve registry, no automated ingestion.
|
||||||
|
2. **Exact SHA pins are mandatory.** Every entry pins a full 40-character
|
||||||
|
commit SHA. Branches, tags, and short SHAs are rejected by the loader.
|
||||||
|
Installs clone the repository and check out exactly that commit.
|
||||||
|
3. **No self-updating code.** A listed plugin must not fetch and replace
|
||||||
|
its own files (in-app "check for updates", signed release downloaders,
|
||||||
|
remote `plugin.js` loaders). The exact SHA pin *is* the trust model; a
|
||||||
|
self-updater lets an installed copy move to a commit nobody reviewed.
|
||||||
|
Updates reach users only through a SHA-bump PR here plus
|
||||||
|
`hermes plugins update <name>`. Keep the updater in the standalone
|
||||||
|
distribution if you want one; strip it from the catalog build.
|
||||||
|
4. **SHA bumps are new PRs.** Updating an entry's pin is a new PR whose diff
|
||||||
|
(old SHA → new SHA) is re-reviewed like any other change — reviewers are
|
||||||
|
expected to look at the upstream commit range being adopted.
|
||||||
|
5. **Owner-or-major-contributor submissions, or a maintainer-curated sweep.**
|
||||||
|
An entry may be submitted by the plugin repository's owner or a major
|
||||||
|
contributor to it; drive-by submissions of third-party repos are declined.
|
||||||
|
Hermes maintainers may also add entries in batches from a reviewed sweep
|
||||||
|
of community plugins (every pin validated and scanned at the pinned
|
||||||
|
commit, self-updater and credential-store checks run, English-first UI).
|
||||||
|
Authors of swept-in entries keep control: a PR from the owner adjusting
|
||||||
|
or removing their entry is accepted on request, and SHA bumps stay
|
||||||
|
owner-or-maintainer PRs under rule 4.
|
||||||
|
6. **Declared capabilities must match reality.** The `capabilities:` block
|
||||||
|
(tools, hooks, middleware, env vars) must match what the plugin actually
|
||||||
|
registers at the pinned commit. Validation fails the entry otherwise —
|
||||||
|
undeclared capability creep is treated as a security issue.
|
||||||
|
7. **The install scanner runs at admission.** `hermes plugins validate` includes
|
||||||
|
the `security scan` check: `dangerous` fails the entry; `caution` findings
|
||||||
|
appear as warnings in the CI log and the reviewer reads them before merging.
|
||||||
|
In exchange, installs at the pinned SHA accept `caution` without a prompt
|
||||||
|
(`dangerous` still blocks). Review the warnings; do not merge past them.
|
||||||
|
8. **Desktop plugins stay inside the SDK surface.** A `desktop/plugin.js` runs
|
||||||
|
in the Desktop renderer with the app's full authority (the loader isolates
|
||||||
|
errors, not capabilities), so a listed one may only use the plugin SDK:
|
||||||
|
no prototype patching (`X.prototype.y =`, `Object.defineProperty(...prototype`),
|
||||||
|
no `eval`/`new Function`, no `import()` of anything but `@hermes/plugin-sdk`
|
||||||
|
/ `react` (app bundle chunks, blob or http URLs included), no script-tag
|
||||||
|
injection, no reaching into the app's internal stores or its own markup
|
||||||
|
(querying `data-slot` / `data-tour` / `data-sidebar` / `data-testid`
|
||||||
|
elements from `document`, or a `document.body` MutationObserver, to restyle,
|
||||||
|
hide, click or rewrite core UI). `hermes plugins validate` refuses these at
|
||||||
|
admission (`desktop surface` check); a plugin that needs a capability the
|
||||||
|
SDK lacks asks for an SDK hook instead of patching around it.
|
||||||
|
9. **No runtime overrides of Hermes core.** A listed plugin extends Hermes only
|
||||||
|
through public surfaces: hooks, middleware, provider profiles and the
|
||||||
|
other `register_*` APIs, and Desktop SDK slots and routes. It must not
|
||||||
|
replace, wrap or rebind core functions, methods, module attributes or
|
||||||
|
private dicts in place (`AIAgent.<method> = ...`, `setattr(server, ...)`,
|
||||||
|
`sys.modules[...]`, writes into a core module's tables). Two plugins
|
||||||
|
patching the same seam silently break each other, and every core release
|
||||||
|
can break both. `hermes plugins validate` refuses these at admission (`no
|
||||||
|
core override` check). If the hook you need does not exist, open an issue
|
||||||
|
describing it: we would rather add the seam than list a patch.
|
||||||
|
10. **Dependency security policy is the plugin's.** Hermes's 14-day
|
||||||
|
`exclude-newer` quarantine covers Hermes's own dependencies only; a plugin's
|
||||||
|
`python_dependencies` / `pyproject.toml` install under the plugin's policy
|
||||||
|
(no quarantine, still inside Hermes's core constraints). Reviewers read the
|
||||||
|
dependency list at the pinned SHA: bare floors (`>=X` with no upper bound)
|
||||||
|
and floors on the newest release get a request for the oldest
|
||||||
|
API-compatible floor plus an upper bound, and authors are strongly
|
||||||
|
recommended to run their own release quarantine (`uv --exclude-newer` in
|
||||||
|
their CI) — see the developer guide's *Dependency security policy*. A
|
||||||
|
recent floor alone is not grounds to hold an entry.
|
||||||
|
|
||||||
|
11. **Credentials stay with their owner.** A plugin reads the credentials it is
|
||||||
|
configured with: the env vars in `requires_env` and its own `config_schema`
|
||||||
|
secrets. Reading another tool's login (a vendor CLI's token file, a browser
|
||||||
|
profile) must be disclosed in the PR and is a trust-tier call for a
|
||||||
|
maintainer. Refreshing, rotating or writing another client's OAuth tokens, or
|
||||||
|
presenting itself as another vendor's client, is not admitted without an
|
||||||
|
explicit maintainer ruling; a read-only build is the usual way through.
|
||||||
|
12. **Approvals and unattended runs are respected.** A plugin never routes around
|
||||||
|
Hermes's approval system: no auto-approving, no disabling guards, and no
|
||||||
|
spawning Hermes or shell children that inherit YOLO or non-interactive mode
|
||||||
|
to run commands nobody approved. Anything that waits for a person (a prompt,
|
||||||
|
an OAuth browser flow) fails cleanly or times out under cron, the messaging
|
||||||
|
gateway and other unattended runs instead of hanging the agent.
|
||||||
|
13. **Risky behaviour is disclosed.** What a user would want to know before
|
||||||
|
installing goes in the PR description and the plugin's README: network calls
|
||||||
|
to third-party services, reads outside the plugin's own data, shell commands,
|
||||||
|
long-running background processes, stored credentials. Telemetry and usage
|
||||||
|
reporting are opt-in. Reviewers summarise these as disclosure lines on the
|
||||||
|
entry PR; undisclosed behaviour found in review is a request for changes.
|
||||||
|
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
|
||||||
|
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
|
||||||
|
(the loader skips the plugin otherwise). `version` matches the pinned code,
|
||||||
|
and Python dependencies resolve under Hermes's core constraints
|
||||||
|
(`hermes plugins validate --install-deps` is what CI runs).
|
||||||
|
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
|
||||||
|
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
|
||||||
|
are not listed under Nous branding.
|
||||||
|
16. **One listing per plugin lineage.** A fork of a listed community plugin is
|
||||||
|
listed only when it is materially different from the original: a different
|
||||||
|
transport or architecture, or capability the original lacks and its author
|
||||||
|
declined or has not answered a PR for 30 days. Improvements to a listed plugin
|
||||||
|
go upstream as a PR to its author. A fork that renames, rebrands or adds small
|
||||||
|
changes is declined in favour of the original. A listed fork names its origin
|
||||||
|
in its disclosure line (`Derived from <entry>`).
|
||||||
|
<!-- admission-rules:end -->
|
||||||
|
|
||||||
|
The step-by-step submission guide, with the same rules and what reviewers check,
|
||||||
|
lives at
|
||||||
|
[Submitting to the plugin catalog](https://hermes-agent.nousresearch.com/docs/developer-guide/plugins/catalog-submission).
|
||||||
|
|
||||||
|
## Entry schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: example-plugin # [a-z0-9_-]{1,64}, the catalog key
|
||||||
|
repo: https://github.com/owner/repo # https:// only
|
||||||
|
sha: <40-hex commit sha> # mandatory exact pin
|
||||||
|
subdir: "" # optional; plain relative path inside the repo
|
||||||
|
# ([A-Za-z0-9._/-]+ only: no '..', '.', empty
|
||||||
|
# segments, absolute, or backslash forms)
|
||||||
|
description: One-line description.
|
||||||
|
maintainer: OwnerName
|
||||||
|
tier: official # official | community (default community)
|
||||||
|
category: memory # desktop | memory | platform | web | tools | voice | automation | models | general
|
||||||
|
# (default desktop) — the shelf the entry sits on at /docs/plugins
|
||||||
|
requires_hermes: ">=0.19" # optional
|
||||||
|
docs_url: "" # optional
|
||||||
|
version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234"
|
||||||
|
image: "" # optional https image on a GitHub host, 2:1 (e.g. 1200x600), e.g.
|
||||||
|
# https://raw.githubusercontent.com/owner/repo/<sha>/docs/banner.png
|
||||||
|
screenshots: [] # optional, up to 6 https images on a GitHub host; gallery on /docs/plugins/<name>
|
||||||
|
readme: true # optional, default true; the README at the PINNED SHA renders on /docs/plugins/<name>
|
||||||
|
platforms: [] # optional, e.g. [linux, macos]; empty = all
|
||||||
|
title: "" # optional human name on cards ("NVIDIA App"); default = name
|
||||||
|
onboarding: false # optional; offered on the Desktop onboarding card (official entries only)
|
||||||
|
known_issues: [] # optional strings shown at the install prompt; informational, never blocks
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
|
```
|
||||||
|
|
||||||
|
`version`, `image`, `screenshots` and `readme` are cosmetic: none is parsed or
|
||||||
|
used to pick what installs. The sha stays the release; bump `version` in the
|
||||||
|
same PR that bumps `sha` so the label on the card matches the code. Images must
|
||||||
|
live on `raw.githubusercontent.com`, `github.com` or `*.githubusercontent.com`
|
||||||
|
so the Desktop catalog and the docs site never fetch from third-party hosts;
|
||||||
|
pin the raw URL to the entry's commit and the picture is as immutable as the
|
||||||
|
code.
|
||||||
|
|
||||||
|
Every entry gets a page at `https://hermes-agent.nousresearch.com/docs/plugins/<name>`
|
||||||
|
and every maintainer a page at `/docs/plugins/by/<maintainer>`, both generated
|
||||||
|
from these files at docs build time. `screenshots:` fills the page's gallery;
|
||||||
|
the build fetches the README (from `subdir` if set, else the repo root) **at the
|
||||||
|
pinned sha** — GitHub and GitLab repos, on by default, `readme: false` opts out — and renders it
|
||||||
|
through an allowlist (raw HTML dropped, links and images resolved against the
|
||||||
|
pinned tree). The page therefore shows the README the reviewer read, and it
|
||||||
|
changes only when a reviewed re-pin lands.
|
||||||
|
|
||||||
|
## removed.yaml — the blocklist
|
||||||
|
|
||||||
|
When an entry is pulled from the catalog for security or policy reasons, it
|
||||||
|
is recorded in `removed.yaml` with a reason and date. The installer refuses
|
||||||
|
to install anything matching a removed entry's name or repo URL, so a
|
||||||
|
malicious plugin cannot be re-installed from a stale identifier after
|
||||||
|
removal. A plugin that lands on the list after a user installed it stops
|
||||||
|
updating, cannot be enabled and is refused at load time. Removals, like
|
||||||
|
additions, land via reviewed PRs.
|
||||||
|
|
||||||
|
Delisting is different from removal: an entry that is unmaintained, superseded, squatting a
|
||||||
|
name it is not affiliated with, or no longer meets a rule is deleted from the catalog (plain
|
||||||
|
file removal, users who already installed it are unaffected). It is welcome back once the
|
||||||
|
problem is fixed; a name squatter comes back under a distinct name. When a new rule affects
|
||||||
|
entries that are already listed, their authors get an issue on their repository explaining
|
||||||
|
the change and time to update before anything is delisted.
|
||||||
|
|
||||||
|
## Names
|
||||||
|
|
||||||
|
The catalog key and the manifest `name:` are what users search, install, and — for memory
|
||||||
|
providers — put in `memory.provider`. A `memory` / `exclusive` entry must not reuse the name of
|
||||||
|
another provider or of a well-known upstream project it is not affiliated with: two providers
|
||||||
|
registering the same `register_memory_provider` name make `memory.provider` ambiguous
|
||||||
|
(whichever loads last wins). Reviewers check the registered provider name, not just the file
|
||||||
|
name, and the affiliated project gets the bare key.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: blender
|
||||||
|
repo: https://github.com/NousResearch/hermes-plugin-blender
|
||||||
|
sha: d1929b593bddd2241a8939fb355e0a69b8e077af
|
||||||
|
version: "0.1.0"
|
||||||
|
description: >-
|
||||||
|
Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3,
|
||||||
|
with a Blender workflow skill. Requires Blender 5.1+, its separately
|
||||||
|
installed MCP add-on, Git and uv. First startup downloads Python dependencies;
|
||||||
|
prepare the uv environment before connecting. Disclosure: tools execute
|
||||||
|
arbitrary Python in Blender with the user's privileges. No automatic
|
||||||
|
application/version/liveness gate; server discovery does not prove scene readiness.
|
||||||
|
title: Blender
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: tools
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-plugin-blender#readme
|
||||||
|
platforms: [windows, macos, linux]
|
||||||
|
onboarding: true
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: claude-subscription-directsdk
|
||||||
|
repo: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk
|
||||||
|
sha: 31b591fd04737a7807183f3d7f3d389b19f94687
|
||||||
|
description: Experimental model provider that runs Hermes turns on a Claude Pro/Max subscription through
|
||||||
|
the official Claude Code CLI (the Agent SDK path), so no separate API key or per-token bill. Requires
|
||||||
|
the claude CLI installed and logged in; the plugin checks for it at load, in hermes model, in the pickers
|
||||||
|
and before every request, and refuses cleanly with the install hint when it is missing. Disclosure — every
|
||||||
|
turn is billed against your Claude subscription's Agent SDK allowance, and the provider inherits whatever
|
||||||
|
account claude is logged into.
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: models
|
||||||
|
requires_hermes: ">=0.21.4"
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk#readme
|
||||||
|
version: "0.3.2"
|
||||||
|
platforms: []
|
||||||
|
image: https://raw.githubusercontent.com/NousResearch/hermes-plugin-claude-subscription-directsdk/31b591fd04737a7807183f3d7f3d389b19f94687/assets/card.png
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: hermes-memory-wiki
|
||||||
|
repo: https://github.com/NousResearch/hermes-memory-wiki
|
||||||
|
sha: 9bc3913b8474eaf4d7eec32e97af4d77957c36df
|
||||||
|
description: 'Memory Wiki dashboard tab: browsable subject pages and daily logs derived from local session
|
||||||
|
history, plus a read-only Persistent Memory (MEMORY.md / USER.md) audit panel.'
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
requires_hermes: ">=0.21"
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-memory-wiki#readme
|
||||||
|
platforms: []
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: hermes-telegram-business
|
||||||
|
repo: https://github.com/NousResearch/hermes-telegram-business
|
||||||
|
sha: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757
|
||||||
|
description: 'Observe-with-approval Telegram Business Mode (secretary bot): every drafted customer reply
|
||||||
|
needs owner approval before it is sent.'
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: platform
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-telegram-business
|
||||||
|
platforms: []
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: homeassistant
|
||||||
|
repo: https://github.com/NousResearch/hermes-homeassistant
|
||||||
|
sha: ba30cb0cf86c52bdb5cde98974bc062e97966529
|
||||||
|
description: 'Home Assistant gateway platform (state-change events in, persistent notifications and cron notify
|
||||||
|
delivery out) plus the homeassistant toolset (ha_list_entities, ha_get_state, ha_list_services, ha_call_service).
|
||||||
|
Formerly built into Hermes core; existing users are migrated automatically.'
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: platform
|
||||||
|
requires_hermes: ">=0.21.5"
|
||||||
|
version: "2.0.1"
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-homeassistant
|
||||||
|
platforms: []
|
||||||
|
capabilities:
|
||||||
|
provides_tools:
|
||||||
|
- ha_list_entities
|
||||||
|
- ha_get_state
|
||||||
|
- ha_list_services
|
||||||
|
- ha_call_service
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env:
|
||||||
|
- HASS_TOKEN
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: nvidia-app
|
||||||
|
repo: https://github.com/NousResearch/hermes-nvidia
|
||||||
|
sha: 65bb8d89263f920e8599432823d2d4e05cc113e3
|
||||||
|
subdir: nvidia-app
|
||||||
|
description: "NVIDIA App control through its local MCP server: overlay capture and state, driver status
|
||||||
|
and release notes, game optimization, and app launch. Requires NVIDIA App 11.0 or newer on Windows;
|
||||||
|
the install is refused when the app is absent, and the tools appear while the app's service is running.
|
||||||
|
The plugin reads the endpoint and token NVIDIA App publishes in its own McpServer server.json on every
|
||||||
|
connection, so a changed port or token needs no reconfiguration. Disclosure: the tools read and change
|
||||||
|
NVIDIA App settings on this machine; nothing leaves loopback."
|
||||||
|
title: NVIDIA App
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: tools
|
||||||
|
requires_hermes: ">=0.21.4"
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-app
|
||||||
|
version: "0.1.0"
|
||||||
|
platforms: [windows]
|
||||||
|
onboarding: true
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: nvidia-broadcast
|
||||||
|
repo: https://github.com/NousResearch/hermes-nvidia
|
||||||
|
sha: 65bb8d89263f920e8599432823d2d4e05cc113e3
|
||||||
|
subdir: nvidia-broadcast
|
||||||
|
description: "NVIDIA Broadcast control through its local MCP gateway: camera, microphone and speaker
|
||||||
|
effects, Studio Voice profiles, device selection, camera resolution, and effects on local media files.
|
||||||
|
Requires NVIDIA Broadcast 2.3 or newer on Windows (2.2 and older have no gateway); the install is
|
||||||
|
refused when the app is absent, and the tools appear while NVIDIA Broadcast is running in the user's
|
||||||
|
desktop session. Disclosure: the tools change live audio and video effects on this machine; nothing
|
||||||
|
leaves loopback."
|
||||||
|
title: NVIDIA Broadcast
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: tools
|
||||||
|
requires_hermes: ">=0.21.4"
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-broadcast
|
||||||
|
version: "0.1.0"
|
||||||
|
platforms: [windows]
|
||||||
|
onboarding: true
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Blocklist for plugins pulled from the catalog for security or policy
|
||||||
|
# reasons. The installer refuses to install anything whose name or repo URL
|
||||||
|
# matches an entry here (unless the caller explicitly bypasses the check).
|
||||||
|
# Each entry: {name, repo, reason, date}. Removals land via reviewed PRs,
|
||||||
|
# same as additions.
|
||||||
|
#
|
||||||
|
# Name only. Do not set repo: the replacement entry is the same repository,
|
||||||
|
# and a repo match would refuse that install too.
|
||||||
|
removed:
|
||||||
|
- name: or-picker
|
||||||
|
reason: "Folded into openrouter-picker. Installing the provider alone left the picker broken. Install openrouter-picker."
|
||||||
|
date: "2026-09-28"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: snyk
|
||||||
|
repo: https://github.com/NousResearch/hermes-plugin-snyk
|
||||||
|
sha: 2a41a07f81e45125bf82a19af1b13396ace4b81f
|
||||||
|
description: 'Scan code (SAST), dependencies, container images, IaC and SBOMs with Snyk through its
|
||||||
|
first-party MCP server (pinned snyk@1.1306.0 over npx stdio, CLI analytics disabled), with the
|
||||||
|
snyk-security-scan workflow skill bundled. Portable Agent Plugins v1 package (mcp.json + skills/);
|
||||||
|
needs Node.js/npm on PATH and a free Snyk account (browser login via the snyk_auth tool).'
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: tools
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-plugin-snyk
|
||||||
|
platforms: []
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: touchdesigner
|
||||||
|
repo: https://github.com/NousResearch/hermes-plugin-touchdesigner
|
||||||
|
sha: 88a7a7884e5eda33cfe4b1dd5e69df7cb15d5898
|
||||||
|
description: 'Drive a live TouchDesigner session through the twozero MCP server (Streamable HTTP on
|
||||||
|
127.0.0.1:40404), with the touchdesigner-mcp workflow skill bundled. Portable Agent Plugins v1 package
|
||||||
|
(mcp.json + skills/); TouchDesigner itself runs on macOS/Windows.'
|
||||||
|
maintainer: NousResearch
|
||||||
|
tier: official
|
||||||
|
category: tools
|
||||||
|
docs_url: https://github.com/NousResearch/hermes-plugin-touchdesigner
|
||||||
|
platforms: []
|
||||||
|
capabilities:
|
||||||
|
provides_tools: []
|
||||||
|
provides_hooks: []
|
||||||
|
provides_middleware: []
|
||||||
|
requires_env: []
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from hermes_cli import aiturk_plugin_catalog as adapter
|
||||||
|
from hermes_cli import plugin_catalog as catalog
|
||||||
|
from hermes_cli import plugins_cmd
|
||||||
|
|
||||||
|
SHA = "a" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def entry(**extra):
|
||||||
|
return catalog.entry_from_mapping(dict(name="demo", repo="https://github.com/owner/demo",
|
||||||
|
sha=SHA, description="test", maintainer="owner", tier="community", **extra), "test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_uses_catalog_pin(monkeypatch):
|
||||||
|
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry())
|
||||||
|
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *args, **kwargs: seen.append((args, kwargs)) or {"ok": True})
|
||||||
|
assert adapter.install_catalog_plugin("demo")["ok"]
|
||||||
|
assert seen == [(("https://github.com/owner/demo",), {"force": False, "enable": True, "ref": SHA})]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_or_incompatible_catalog_entry_is_rejected(monkeypatch):
|
||||||
|
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: None)
|
||||||
|
assert not adapter.install_catalog_plugin("missing")["ok"]
|
||||||
|
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
|
||||||
|
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(requires_hermes=">=999.0"))
|
||||||
|
assert not adapter.install_catalog_plugin("demo")["ok"]
|
||||||
|
monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(platforms=["nonexistent"]))
|
||||||
|
assert not adapter.install_catalog_plugin("demo")["ok"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_tracks_installed_revision(monkeypatch):
|
||||||
|
monkeypatch.setattr(catalog, "load_catalog_live", lambda: [entry()])
|
||||||
|
monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: [])
|
||||||
|
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: [("demo",)])
|
||||||
|
monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {"demo": {"revision": "b" * 40}})
|
||||||
|
row = adapter.catalog_payload()["entries"][0]
|
||||||
|
assert row["installed"] and row["update_available"] and row["sha"] == SHA
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_http_auth_and_install_contract(monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from hermes_cli import web_server as web
|
||||||
|
client = TestClient(web.app)
|
||||||
|
assert client.get("/api/dashboard/plugins/catalog").status_code in (401, 403)
|
||||||
|
client.headers[web._SESSION_HEADER_NAME] = web._SESSION_TOKEN
|
||||||
|
monkeypatch.setattr(adapter, "catalog_payload", lambda: {"entries": [entry().to_dict()], "removed": []})
|
||||||
|
response = client.get("/api/dashboard/plugins/catalog")
|
||||||
|
assert response.status_code == 200 and response.json()["entries"][0]["sha"] == SHA
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(adapter, "install_catalog_plugin", lambda name, **kwargs: seen.append(name) or {"ok": True, "after_install_path": "private"})
|
||||||
|
monkeypatch.setattr(web, "_get_dashboard_plugins", lambda **kwargs: [])
|
||||||
|
monkeypatch.setattr(web, "_invalidate_plugins_hub_cache", lambda: None)
|
||||||
|
response = client.post("/api/dashboard/agent-plugins/install", json={"catalog_name": "demo", "identifier": "https://untrusted.invalid/repo"})
|
||||||
|
assert response.status_code == 200 and seen == ["demo"]
|
||||||
|
assert "after_install_path" not in response.json()
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Plugin catalog contracts (hermes_cli/plugin_catalog.py): the in-tree seed is valid, bad entries are
|
||||||
|
skipped not raised, kill-list matching is name-or-repo, and the live catalog degrades to in-tree."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from hermes_cli import plugin_catalog as pc
|
||||||
|
|
||||||
|
SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(name="good-plugin", **over):
|
||||||
|
data = {"name": name, "repo": "https://github.com/owner/repo", "sha": SHA, "description": "d",
|
||||||
|
"maintainer": "owner", "tier": "community", "capabilities": {"provides_tools": ["t1"]}}
|
||||||
|
data.update(over)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_shipped_catalog_entries_are_all_valid_and_pinned():
|
||||||
|
"""Every file in plugin-catalog/ (minus removed.yaml) must parse — a dropped entry is a silent
|
||||||
|
shipping regression the admission CI only catches on changed files."""
|
||||||
|
root = pc.get_catalog_dir()
|
||||||
|
files = [p for p in root.glob("*.yaml") if p.name != "removed.yaml"]
|
||||||
|
entries = pc.load_catalog()
|
||||||
|
assert len(entries) == len(files) >= 1
|
||||||
|
assert all(pc._SHA_RE.match(e.sha) and e.repo.startswith("https://") for e in entries)
|
||||||
|
assert all(e.install_identifier.startswith(e.repo) for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path):
|
||||||
|
"""``category`` is the browse shelf: absent → ``desktop``; a value outside CATALOG_CATEGORIES is an
|
||||||
|
invalid entry (skipped with a warning) so a typo cannot create a phantom shelf on the site."""
|
||||||
|
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("no-cat")))
|
||||||
|
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("mem", category="memory")))
|
||||||
|
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("typo", category="memmory")))
|
||||||
|
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
|
||||||
|
assert set(entries) == {"no-cat", "mem"}
|
||||||
|
assert entries["no-cat"].category == "desktop" and entries["mem"].category == "memory"
|
||||||
|
assert entries["mem"].to_dict()["category"] == "memory"
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_and_image_are_cosmetic_and_offhost_images_are_dropped(tmp_path):
|
||||||
|
"""``version``/``image`` label the pin, they never gate it: a bad value is dropped with a warning and
|
||||||
|
the entry survives; an image off GitHub is dropped because the Desktop browser must never fetch
|
||||||
|
from third-party hosts."""
|
||||||
|
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("labelled", version="1.4.0", image="https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png")))
|
||||||
|
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("offhost", version="1.4.0 beta", image="https://evil.example/x.png")))
|
||||||
|
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
|
||||||
|
assert set(entries) == {"labelled", "offhost"}
|
||||||
|
assert entries["labelled"].version == "1.4.0" and entries["labelled"].image == "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png"
|
||||||
|
assert entries["offhost"].version == "" and entries["offhost"].image == ""
|
||||||
|
assert entries["labelled"].to_dict()["version"] == "1.4.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_screenshots_and_readme_are_parsed_and_readme_defaults_on(tmp_path):
|
||||||
|
shot = "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/docs/1.png"
|
||||||
|
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("paged", screenshots=[shot, "https://evil.example/x.png"], readme=True)))
|
||||||
|
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("plain", readme=False)))
|
||||||
|
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("bare")))
|
||||||
|
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
|
||||||
|
assert entries["paged"].screenshots == [shot] and entries["paged"].readme is True
|
||||||
|
assert entries["plain"].screenshots == [] and entries["plain"].readme is False
|
||||||
|
assert entries["bare"].readme is True
|
||||||
|
assert entries["paged"].to_dict()["screenshots"] == [shot] and entries["paged"].to_dict()["readme"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_entries_are_skipped_not_raised(tmp_path):
|
||||||
|
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok", description="café")), encoding="utf-8-sig")
|
||||||
|
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123")))
|
||||||
|
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("http-repo", repo="http://x/y")))
|
||||||
|
(tmp_path / "d.yaml").write_text(yaml.safe_dump(_entry("Bad Name")))
|
||||||
|
(tmp_path / "e.yaml").write_text("- not\n- a mapping\n")
|
||||||
|
entries = pc.load_catalog(tmp_path)
|
||||||
|
assert [e.name for e in entries] == ["ok"]
|
||||||
|
assert entries[0].description == "café"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_removed_matches_name_or_normalized_repo(tmp_path):
|
||||||
|
(tmp_path / "removed.yaml").write_text(yaml.safe_dump({"removed": [
|
||||||
|
{"name": "evil", "repo": "https://github.com/x/evil.git", "reason": "malware", "date": "2026-01-01"}]}))
|
||||||
|
assert pc.find_removed("evil", tmp_path).reason == "malware"
|
||||||
|
assert pc.find_removed("https://github.com/x/EVIL/", tmp_path) is not None
|
||||||
|
assert pc.find_removed("https://github.com/x/fine", tmp_path) is None
|
||||||
|
# Scheme/host/user spellings are not identity: every git way of naming the repo is blocked.
|
||||||
|
for spelling in ("git@github.com:x/evil.git", "ssh://git@github.com/x/evil", "http://github.com/x/evil",
|
||||||
|
"https://www.github.com/x/evil/", "git://github.com/x/evil.git"):
|
||||||
|
assert pc.find_removed(spelling, tmp_path) is not None, spelling
|
||||||
|
assert pc.find_removed("git@gitlab.com:x/evil.git", tmp_path) is None # different host stays distinct
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stale", [False, True])
|
||||||
|
def test_live_catalog_falls_back_to_in_tree_and_unions_removals(tmp_path, monkeypatch, stale):
|
||||||
|
"""Offline live pins expire, but a cached removal keeps blocking installs."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
requests = []
|
||||||
|
get = httpx.get
|
||||||
|
|
||||||
|
def record_get(*args, **kwargs):
|
||||||
|
requests.append(args)
|
||||||
|
return get(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", record_get)
|
||||||
|
cache = tmp_path / "cache" / "plugin-catalog.json"
|
||||||
|
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
|
||||||
|
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # unreachable
|
||||||
|
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
|
||||||
|
|
||||||
|
cache.parent.mkdir(parents=True)
|
||||||
|
cache.write_text(json.dumps({"entries": [_entry("live-only", description="café")],
|
||||||
|
"removed": [{"name": "pulled-live", "reason": "cve"}]},
|
||||||
|
ensure_ascii=False), encoding="utf-8-sig")
|
||||||
|
if stale:
|
||||||
|
os.utime(cache, (1, 1))
|
||||||
|
requests.clear()
|
||||||
|
# The unreachable fetch above armed the failure window; a stale cache must still
|
||||||
|
# trigger a real retry once that window has passed.
|
||||||
|
monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0)
|
||||||
|
entries = pc.load_catalog_live()
|
||||||
|
if stale:
|
||||||
|
assert [e.name for e in entries] == [e.name for e in pc.load_catalog()]
|
||||||
|
else:
|
||||||
|
assert [e.name for e in entries] == ["live-only"]
|
||||||
|
assert entries[0].description == "café"
|
||||||
|
assert bool(requests) is stale
|
||||||
|
assert pc.find_removed("pulled-live").reason == "cve"
|
||||||
|
cache.write_text("{", encoding="utf-8-sig")
|
||||||
|
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_cache(tmp_path, monkeypatch, doc: dict):
|
||||||
|
cache = tmp_path / "cache" / "plugin-catalog.json"
|
||||||
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache.write_text(json.dumps(doc))
|
||||||
|
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
|
||||||
|
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # never reached / unreachable
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_newer_in_tree_pin_outranks_a_fresh_live_cache(tmp_path, monkeypatch):
|
||||||
|
"""Right after `hermes update` bumps an in-tree pin, a live cache fetched BEFORE the bump must not
|
||||||
|
re-install the old sha: for the same entry the newer catalog wins (checkout catalog commit time vs
|
||||||
|
the doc's generated_at). Live-only entries survive; an older checkout still defers to the doc."""
|
||||||
|
old, new = SHA, "a" * 40
|
||||||
|
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z",
|
||||||
|
"entries": [_entry("shared", sha=old), _entry("live-only")], "removed": []})
|
||||||
|
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")])
|
||||||
|
bump_time = pc._live_generated_time({"generated_at": "2026-09-22T10:00:00Z"}) + 3600
|
||||||
|
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time)
|
||||||
|
by_name = {e.name: e for e in pc.load_catalog_live()}
|
||||||
|
assert by_name["shared"].sha == new and "live-only" in by_name
|
||||||
|
# Control: a checkout whose catalog predates the doc takes the live pin.
|
||||||
|
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time - 7200)
|
||||||
|
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_cache_write_never_truncates_the_previous_copy(tmp_path, monkeypatch):
|
||||||
|
"""The cache is replaced atomically: a reader racing the writer (or a write that dies half-way)
|
||||||
|
sees the whole previous document, never a truncated one."""
|
||||||
|
cache = _fresh_cache(tmp_path, monkeypatch, {"entries": [_entry("previous")], "removed": []})
|
||||||
|
previous = cache.read_text()
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
content = json.dumps({"entries": [_entry("fresh")], "removed": []}).encode()
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self): return json.loads(self.content)
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import utils
|
||||||
|
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp())
|
||||||
|
pc._live_fetch_failed_until = 0.0
|
||||||
|
|
||||||
|
def _dies(*_a, **_k):
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
monkeypatch.setattr(utils, "atomic_replace", _dies)
|
||||||
|
pc.fetch_live_catalog(force=True) # the failed write is swallowed like any other fetch failure
|
||||||
|
assert cache.read_text() == previous
|
||||||
|
assert not list(cache.parent.glob("*.tmp*")) # no leftover temp file either
|
||||||
|
|
||||||
|
|
||||||
|
def test_curated_fields_the_published_doc_lacks_come_from_the_checkout(tmp_path, monkeypatch):
|
||||||
|
"""The docs build stamps generated_at at build time, so a doc rebuilt from an older catalog is "newer"
|
||||||
|
than a checkout that just added `onboarding`. At the same pin, a curated field the doc does not carry
|
||||||
|
comes from the checkout; a doc that carries it (even false) decides, and a different pin never merges."""
|
||||||
|
live = [_entry("same"), {**_entry("says-no"), "onboarding": False}, _entry("repinned", sha="b" * 40)]
|
||||||
|
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z", "entries": live, "removed": []})
|
||||||
|
tree = [pc.entry_from_mapping({**_entry(n), "onboarding": True, "title": "T"}, n) for n in ("same", "says-no", "repinned")]
|
||||||
|
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: tree)
|
||||||
|
monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: 0.0) # checkout older than the doc
|
||||||
|
by_name = {e.name: e for e in pc.load_catalog_live()}
|
||||||
|
assert (by_name["same"].onboarding, by_name["same"].title) == (True, "T")
|
||||||
|
assert by_name["says-no"].onboarding is False and by_name["says-no"].title == "T"
|
||||||
|
assert by_name["repinned"].onboarding is False and by_name["repinned"].sha == "b" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_tree_catalog_time_does_not_lazy_fetch_on_treeless_clones(tmp_path, monkeypatch):
|
||||||
|
"""``in_tree_catalog_time`` dates the checkout with a pathspec'd ``git log``; the pathspec makes git
|
||||||
|
open every commit's tree. On a treeless (``tree:0``) partial clone — the layout ``hermes update``
|
||||||
|
produces — none of those trees exist locally, so an unrestricted probe lazy-fetches each one against
|
||||||
|
the remote: measured 40-80 s per inventory request, past the desktop's 30s RPC limit (#125683).
|
||||||
|
The probe must scope ``GIT_NO_LAZY_FETCH`` to itself so missing objects fail fast into the
|
||||||
|
worktree-mtime fallback — never into network work — while a full checkout keeps resolving its time.
|
||||||
|
This clone is never checked out, so there is no mtime to fall back to either: ``None`` all the way."""
|
||||||
|
import shutil
|
||||||
|
import subprocess as sp
|
||||||
|
if shutil.which("git") is None:
|
||||||
|
pytest.skip("git not available")
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "plugin-catalog").mkdir(parents=True)
|
||||||
|
(src / "plugin-catalog" / "a.yaml").write_text("name: a\n")
|
||||||
|
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||||
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||||
|
sp.run(["git", "init", "-q"], cwd=src, check=True, env=env)
|
||||||
|
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
|
||||||
|
sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env)
|
||||||
|
sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env)
|
||||||
|
|
||||||
|
def local_object_types(repo):
|
||||||
|
out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo,
|
||||||
|
capture_output=True, text=True, env=env)
|
||||||
|
return {line.split()[1] for line in out.stdout.splitlines() if line}
|
||||||
|
|
||||||
|
dst = tmp_path / "treeless"
|
||||||
|
# file:// (not a plain path) so the clone goes through upload-pack and honors the filter.
|
||||||
|
sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)],
|
||||||
|
check=True, env=env)
|
||||||
|
if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect
|
||||||
|
pytest.skip("file:// transport did not honor --filter=tree:0")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog")
|
||||||
|
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh in this test
|
||||||
|
assert pc.in_tree_catalog_time() is None
|
||||||
|
assert "tree" not in local_object_types(dst) # the probe lazy-fetched nothing
|
||||||
|
|
||||||
|
monkeypatch.setattr(pc, "get_catalog_dir", lambda: src / "plugin-catalog")
|
||||||
|
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0)
|
||||||
|
assert pc.in_tree_catalog_time() is not None # a full checkout still resolves its commit time
|
||||||
|
|
||||||
|
|
||||||
|
def test_treeless_checkout_still_outranks_a_doc_fetched_before_the_bump(tmp_path, monkeypatch):
|
||||||
|
"""On the treeless layout the history probe fails fast, and ``None`` would feed the frozen-copy
|
||||||
|
rule that lets the live doc win — re-pinning the old sha on exactly the checkouts ``hermes
|
||||||
|
update`` just bumped. The checked-out files' mtime must stand in for the unreadable commit time
|
||||||
|
(fresh checkout ⇒ in-tree pin wins; catalog files older than the doc ⇒ live pin wins), with no
|
||||||
|
tree lazy-fetch anywhere (#125716 review)."""
|
||||||
|
import shutil
|
||||||
|
import subprocess as sp
|
||||||
|
if shutil.which("git") is None:
|
||||||
|
pytest.skip("git not available")
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "plugin-catalog").mkdir(parents=True)
|
||||||
|
(src / "plugin-catalog" / "a.yaml").write_text("name: a\n")
|
||||||
|
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||||
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||||
|
sp.run(["git", "init", "-q"], cwd=src, check=True, env=env)
|
||||||
|
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
|
||||||
|
sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env)
|
||||||
|
(src / "plugin-catalog" / "a.yaml").write_text("name: a2\n")
|
||||||
|
sp.run(["git", "add", "-A"], cwd=src, check=True, env=env)
|
||||||
|
sp.run(["git", "commit", "-qm", "bump"], cwd=src, check=True, env=env)
|
||||||
|
sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env)
|
||||||
|
|
||||||
|
def local_object_types(repo):
|
||||||
|
out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo,
|
||||||
|
capture_output=True, text=True, env=env)
|
||||||
|
return {line.split()[1] for line in out.stdout.splitlines() if line}
|
||||||
|
|
||||||
|
dst = tmp_path / "treeless"
|
||||||
|
# file:// (not a plain path) so the clone goes through upload-pack and honors the filter.
|
||||||
|
sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)],
|
||||||
|
check=True, env=env)
|
||||||
|
if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect
|
||||||
|
pytest.skip("file:// transport did not honor --filter=tree:0")
|
||||||
|
# Checking out only fetches HEAD's trees; the seed commit's tree stays missing, so the
|
||||||
|
# pathspec'd probe keeps failing — while the worktree files (and their mtimes) land.
|
||||||
|
sp.run(["git", "-C", str(dst), "checkout", "-q"], check=True, env=env)
|
||||||
|
probe = sp.run(["git", "-C", str(dst), "log", "-1", "--format=%ct", "--", "plugin-catalog"],
|
||||||
|
capture_output=True, env={**env, "GIT_NO_LAZY_FETCH": "1"})
|
||||||
|
assert probe.returncode != 0 # the probe really is blind on this layout, like a real update
|
||||||
|
|
||||||
|
old, new = SHA, "a" * 40
|
||||||
|
_fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z",
|
||||||
|
"entries": [_entry("shared", sha=old)], "removed": []})
|
||||||
|
monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")])
|
||||||
|
monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog")
|
||||||
|
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh: probe fails, mtime stands in
|
||||||
|
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == new
|
||||||
|
|
||||||
|
# Control: catalog files older than the doc mean the checkout is the stale side — live pin wins.
|
||||||
|
for p in (dst / "plugin-catalog").rglob("*"):
|
||||||
|
os.utime(p, (0, 0))
|
||||||
|
monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0)
|
||||||
|
assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old
|
||||||
@@ -358,7 +358,8 @@ def test_reinstall_after_manual_directory_removal_retains_pin(monkeypatch, tmp_p
|
|||||||
target, _manifest, _name = _install_plugin_core(
|
target, _manifest, _name = _install_plugin_core(
|
||||||
repo.as_uri(), force=False, ref=old_sha
|
repo.as_uri(), force=False, ref=old_sha
|
||||||
)
|
)
|
||||||
shutil.rmtree(target)
|
from hermes_cli.plugins_cmd import _remove_plugin_tree
|
||||||
|
_remove_plugin_tree(target)
|
||||||
|
|
||||||
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
|
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from tui_gateway import mobile_subagents as mobile
|
||||||
|
|
||||||
|
|
||||||
|
def test_roster_is_session_and_transport_scoped(monkeypatch):
|
||||||
|
from tools import delegate_tool as delegate
|
||||||
|
transport, session = object(), {}
|
||||||
|
own = dict(subagent_id="own", owner_session_id="s", owner_transport=transport,
|
||||||
|
owner_session_record=session, agent=object(), goal="test", started_at=1)
|
||||||
|
other = {**own, "subagent_id": "other", "owner_transport": object()}
|
||||||
|
reused = {**own, "subagent_id": "reused", "owner_session_record": {}}
|
||||||
|
monkeypatch.setattr(delegate, "_active_subagents", {"own": own, "other": other, "reused": reused})
|
||||||
|
rows = mobile.owned_records("s", transport, session)
|
||||||
|
assert [row["subagent_id"] for row in rows] == ["own"]
|
||||||
|
assert mobile.owned_records("s", None, session) == []
|
||||||
|
assert mobile.owned_records("wrong", transport, session) == []
|
||||||
|
public = mobile.roster(rows)["subagents"][0]
|
||||||
|
assert public["elapsed_seconds"] > 0
|
||||||
|
assert "agent" not in public and "owner_session_record" not in public
|
||||||
|
|
||||||
|
|
||||||
|
def test_tail_is_bounded_and_unknown_child_is_unavailable(tmp_path, monkeypatch):
|
||||||
|
from tools import delegation_live_log
|
||||||
|
monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path)
|
||||||
|
path = tmp_path / "transcript.jsonl"
|
||||||
|
path.write_bytes(b"x" * 20000 + b"end")
|
||||||
|
rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(path)))]
|
||||||
|
result = mobile.transcript_tail(rows, "own")
|
||||||
|
assert result["available"] and result["truncated"]
|
||||||
|
assert result["bytes_read"] == 16384 and result["text"].endswith("end")
|
||||||
|
assert not mobile.transcript_tail(rows, "other")["available"]
|
||||||
|
path.unlink()
|
||||||
|
assert not mobile.transcript_tail(rows, "own")["available"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tail_rejects_outside_log_root(tmp_path, monkeypatch):
|
||||||
|
from tools import delegation_live_log
|
||||||
|
monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path / "logs")
|
||||||
|
secret = tmp_path / "private.txt"
|
||||||
|
secret.write_text("not a transcript")
|
||||||
|
rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(secret)))]
|
||||||
|
assert not mobile.transcript_tail(rows, "own")["available"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_roster_survives_resume_but_tail_keeps_exact_authority(monkeypatch):
|
||||||
|
from tools import delegate_tool as delegate
|
||||||
|
parent = SimpleNamespace(session_id="conversation")
|
||||||
|
record = dict(subagent_id="child", owner_session_id="old-runtime",
|
||||||
|
owner_agent_session_id="conversation", agent=None)
|
||||||
|
monkeypatch.setattr(delegate, "_active_subagents", {"child": record})
|
||||||
|
session = {"agent": parent}
|
||||||
|
assert mobile.owned_records("new-runtime", object(), session) == []
|
||||||
|
assert len(mobile.owned_records("new-runtime", object(), session, include_lineage=True)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_rpcs_reject_wrong_transport(monkeypatch):
|
||||||
|
from tui_gateway import server
|
||||||
|
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: ({"session_key": "s"}, None))
|
||||||
|
monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (None, None))
|
||||||
|
for name in ("subagent.list", "subagent.tail", "subagent.interrupt"):
|
||||||
|
response = server._methods[name](1, {"session_id": "s", "subagent_id": "child"})
|
||||||
|
assert response["error"]["code"] == 4001
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_list_returns_owned_children(monkeypatch):
|
||||||
|
from tui_gateway import server
|
||||||
|
from tools import delegate_tool as delegate
|
||||||
|
transport, session = object(), {"session_key": "s"}
|
||||||
|
record = dict(subagent_id="child", owner_session_id="s", owner_transport=transport,
|
||||||
|
owner_session_record=session, goal="test")
|
||||||
|
monkeypatch.setattr(delegate, "_active_subagents", {"child": record})
|
||||||
|
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: (session, None))
|
||||||
|
monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (transport, session))
|
||||||
|
response = server._methods["subagent.list"](1, {"session_id": "s"})
|
||||||
|
assert response["result"]["subagents"][0]["subagent_id"] == "child"
|
||||||
@@ -3572,14 +3572,53 @@ def _(rid, params: dict) -> dict:
|
|||||||
return _ok(rid, {"paused": set_spawn_paused(paused)})
|
return _ok(rid, {"paused": set_spawn_paused(paused)})
|
||||||
|
|
||||||
|
|
||||||
|
@method("subagent.list")
|
||||||
|
def _(rid, params: dict) -> dict:
|
||||||
|
from tui_gateway.mobile_subagents import owned_records, roster
|
||||||
|
_, err = _sess_nowait(params, rid)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
session_id = str(params.get("session_id") or "").strip()
|
||||||
|
transport, session = _current_session_steer_authority(session_id)
|
||||||
|
if transport is None or session is None:
|
||||||
|
return _err(rid, 4001, "session not found or not owned by this transport")
|
||||||
|
return _ok(rid, roster(owned_records(session_id, transport, session, include_lineage=True)))
|
||||||
|
|
||||||
|
|
||||||
|
@method("subagent.tail")
|
||||||
|
def _(rid, params: dict) -> dict:
|
||||||
|
from tui_gateway.mobile_subagents import owned_records, transcript_tail
|
||||||
|
_, err = _sess_nowait(params, rid)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
subagent_id = str(params.get("subagent_id") or "").strip()
|
||||||
|
if not subagent_id:
|
||||||
|
return _err(rid, 4000, "subagent_id required")
|
||||||
|
session_id = str(params.get("session_id") or "").strip()
|
||||||
|
transport, session = _current_session_steer_authority(session_id)
|
||||||
|
if transport is None or session is None:
|
||||||
|
return _err(rid, 4001, "session not found or not owned by this transport")
|
||||||
|
with _session_home_scope(session):
|
||||||
|
result = transcript_tail(owned_records(session_id, transport, session), subagent_id)
|
||||||
|
return _ok(rid, result)
|
||||||
|
|
||||||
|
|
||||||
@method("subagent.interrupt")
|
@method("subagent.interrupt")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
from tools.delegate_tool import interrupt_subagent
|
from tui_gateway.mobile_subagents import owned_records
|
||||||
|
from agent.interrupt_compat import request_hard_interrupt
|
||||||
|
|
||||||
subagent_id = str(params.get("subagent_id") or "").strip()
|
subagent_id = str(params.get("subagent_id") or "").strip()
|
||||||
if not subagent_id:
|
if not subagent_id:
|
||||||
return _err(rid, 4000, "subagent_id required")
|
return _err(rid, 4000, "subagent_id required")
|
||||||
ok = interrupt_subagent(subagent_id)
|
session_id = str(params.get("session_id") or "").strip()
|
||||||
|
transport, session = _current_session_steer_authority(session_id)
|
||||||
|
if transport is None or session is None:
|
||||||
|
return _err(rid, 4001, "session not found or not owned by this transport")
|
||||||
|
record = next((r for r in owned_records(session_id, transport, session)
|
||||||
|
if r.get("subagent_id") == subagent_id), None)
|
||||||
|
child = record.get("agent") if record else None
|
||||||
|
ok = bool(request_hard_interrupt(child, "Interrupted via mobile")) if child is not None else False
|
||||||
return _ok(rid, {"found": ok, "subagent_id": subagent_id})
|
return _ok(rid, {"found": ok, "subagent_id": subagent_id})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Read-only mobile subagent views, scoped to the invoking live session."""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def owned_records(session_id, transport, session, *, include_lineage=False):
|
||||||
|
from tools.delegate_tool import _active_subagents, _active_subagents_lock, _owns_subagent_record
|
||||||
|
if transport is None or session is None:
|
||||||
|
return []
|
||||||
|
with _active_subagents_lock:
|
||||||
|
records = [dict(row) for row in _active_subagents.values()]
|
||||||
|
return [row for row in records
|
||||||
|
if (row.get("owner_session_id") == session_id
|
||||||
|
and row.get("owner_transport") is transport
|
||||||
|
and row.get("owner_session_record") is session)
|
||||||
|
or (include_lineage and _owns_subagent_record(row, session.get("agent")))]
|
||||||
|
|
||||||
|
|
||||||
|
def roster(records):
|
||||||
|
fields = ("subagent_id", "parent_id", "depth", "goal", "delegation_id", "model",
|
||||||
|
"started_at", "status", "tool_count", "last_tool", "accepting_steer")
|
||||||
|
rows = []
|
||||||
|
for record in records:
|
||||||
|
row = {key: record[key] for key in fields if key in record}
|
||||||
|
started = record.get("started_at")
|
||||||
|
if isinstance(started, (int, float)):
|
||||||
|
row["elapsed_seconds"] = max(0, time.time() - started)
|
||||||
|
rows.append(row)
|
||||||
|
return {"subagents": rows, "delegations": []}
|
||||||
|
|
||||||
|
|
||||||
|
def transcript_tail(records, subagent_id):
|
||||||
|
result = {"subagent_id": subagent_id, "available": False, "text": "", "bytes_read": 0, "truncated": False}
|
||||||
|
record = next((r for r in records if r.get("subagent_id") == subagent_id), None)
|
||||||
|
if record is None:
|
||||||
|
return result
|
||||||
|
path = getattr(record.get("agent"), "_live_transcript_path", None)
|
||||||
|
if not isinstance(path, (str, Path)):
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
# Only the registry's own file is read; no client-supplied path is accepted.
|
||||||
|
from tools.delegation_live_log import live_transcript_root
|
||||||
|
path = Path(path)
|
||||||
|
root = live_transcript_root().resolve()
|
||||||
|
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
||||||
|
return result
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
size = stream.seek(0, 2)
|
||||||
|
stream.seek(max(0, size - 16384))
|
||||||
|
raw = stream.read(16384)
|
||||||
|
result.update(available=True, text=raw.decode("utf-8", errors="replace"),
|
||||||
|
bytes_read=len(raw), truncated=size > len(raw))
|
||||||
|
except OSError:
|
||||||
|
pass # A just-completed child's temporary transcript may already be gone.
|
||||||
|
return result
|
||||||
Reference in New Issue
Block a user