Initial SQL-only 1C adapter baseline

This commit is contained in:
2026-07-22 03:03:47 +03:00
commit e2503b77e7
545 changed files with 184711 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.env
*.sqlite
*.db
reports/
data/
+35
View File
@@ -0,0 +1,35 @@
# 1C Adapter Connector example environment.
# Do not put real passwords into repository files.
ONEC_ADAPTER_HOST=0.0.0.0
ONEC_ADAPTER_PORT=8011
# Required outside isolated local development. Generate a random secret and pass
# the same value to MCP/agent as ONEC_ADAPTER_TOKEN.
ONEC_ADAPTER_SERVICE_TOKEN=
# Required per-base configuration. Use password_env for each base.
# Example:
# ONEC_SQL_BASES_JSON={"upo_test":{"server":"sql-host.example.local","database":"upo_test","user":"configured_login","password_env":"ONEC_SQL_PASSWORD_UPO_TEST"}}
ONEC_SQL_BASES_JSON=
ONEC_SQL_BASES_JSON_FILE=
# Example secret referenced by ONEC_SQL_BASES_JSON password_env.
ONEC_SQL_PASSWORD_UPO_TEST=
# Protected 1C runtime bridge for Configurator-user password operations.
# Keep bridge tokens only in environment variables referenced by token_env.
# Example (test networks may explicitly opt in to HTTP):
# ONEC_INFOBASE_USER_ADMIN_BASES_JSON={"upo_test":{"url":"https://onec-runtime.example.local","token_env":"ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST"}}
ONEC_INFOBASE_USER_ADMIN_BASES_JSON=
ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE=/data/onec-infobase-user-admin.json
ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST=
# Test stands only: permits password mutation calls and runtime bridge requests
# without Bearer tokens. Never enable for production or an untrusted network.
ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=false
ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir pymssql==2.3.2
COPY connector/adapter_1c_server.py /app/adapter_1c_server.py
COPY connector/repository_control.py /app/repository_control.py
COPY connector/admin /app/admin
COPY parser /app/parser
ENV PYTHONUNBUFFERED=1
ENV ONEC_ADAPTER_HOST=0.0.0.0
ENV ONEC_ADAPTER_PORT=8011
ENV ONEC_SQL_BASES_JSON=
EXPOSE 8011
CMD ["python", "/app/adapter_1c_server.py"]
+512
View File
@@ -0,0 +1,512 @@
# 1C Connector
Standalone-ready service for safe interaction with live 1C databases.
The connector is read-first and optimized for an operational coding loop where full XML export and EDT sync are too slow for every task.
Preferred live architecture:
- read-only SQL connector for fast diagnostics and data samples;
- lightweight 1C agent for metadata, forms, commands, and BSL modules;
- cached metadata/module snapshots with freshness checks;
- change proposals as reviewable artifacts, not direct production writes.
The connector is responsible for:
- metadata reads;
- BSL module search/read;
- read-only query validation and execution;
- metadata/module snapshots;
- change proposals without direct apply.
Contracts:
- `contracts/openapi.yaml`
- `policies/read-only-query.yaml`
- `policies/change-workflow.yaml`
- `policies/config-layer-write-policy.yaml`
- `policies/sql-base-access-policy.yaml`
- `policies/xml-decoding-reference-policy.yaml`
The model must use this connector instead of inventing metadata or directly changing a live database.
XML exports are development-time evidence only. They may be analyzed by
repository scripts to infer and test generic SQL payload decoders, but the
running connector is configured only with a SQL entry for `base_id`. It does
not mount or read XML and rejects XML path arguments in runtime requests.
## Standalone boundary
This directory is the service boundary for the adapter. It is still developed
inside the current monorepo, but it should be kept movable as an independent
project.
Service-owned files:
- `adapter_1c_server.py`
- `contracts/openapi.yaml`
- `policies/*.yaml`
- `Dockerfile`
- `docker-compose.yml`
- `.env.example`
- `pyproject.toml`
- `service.yaml`
- sibling package `../parser`
Repository-owned integration files:
- `plugins/1c/mcp/adapter_1c_mcp.py`
- `plugins/1c/agent/`
- `plugins/1c/rag/`
- `plugins/1c/training/`
- top-level health and contract scripts under `scripts/`
The connector must not depend on RAG, training, or agent code. MCP and agent
code may depend on the connector contract.
## Local Run
From `plugins/1c`:
```powershell
python connector/adapter_1c_server.py
```
From `plugins/1c/connector` after installing package dependencies:
```powershell
python adapter_1c_server.py
```
Health without a concrete base:
```powershell
Invoke-RestMethod http://localhost:8011/health
```
Live database calls require `base_id` and SQL connection configuration.
## Configuration repository operations
Repository access is configured per `base_id`, preferably as a `repository`
object inside the same JSON entry used by `ONEC_SQL_BASES_JSON_FILE`. The
repository backend is never inferred from a bridge name or endpoint. Set
`backend` explicitly to `direct` or `karman_bridge`; both backends execute the
standard 1C Designer repository commands, while a Karman/Filebox bridge only
relays the native opaque TCP stream.
See `config/1c_repository_bases.example.json` for a secret-free example.
Passwords are resolved only from the configured environment-variable names.
The adapter does not return them or store them in lock-session state.
When the adapter runs in a Linux container and Designer is installed on the
Windows Docker host, use `runner.kind=http`. Run
`scripts/run_1c_repository_runner.py` on Windows with its own external base
configuration (example: `config/1c_repository_runner_bases.example.json`). The
container sends only `base_id`, action, public object names, and commit comment;
infobase/repository credentials remain on the Windows runner. Protect the
runner with `ONEC_REPOSITORY_RUNNER_TOKEN` and a host firewall rule limited to
the Docker host/container network.
The guarded workflow is:
1. `repository.status` (optionally `probe=true`);
2. `repository.lock.plan` with public 1C object names;
3. `repository.lock` with `allow_repository_lock=true`;
4. pass the returned `lock_session_id` to write preflight/apply;
5. `repository.commit.plan` and explicit `repository.commit`, or
`repository.unlock` for only that adapter-owned session.
Apply operations are blocked for a repository-configured base unless an active
adapter-owned lock session is supplied. Structural add/delete/rename plans are
kept blocked for confirmation because parent and reference objects can also be
required.
## Docker Run
Create a local `.env` from `.env.example`, keep real passwords outside git, and
run:
```powershell
docker compose -f plugins/1c/connector/docker-compose.yml --env-file plugins/1c/connector/.env up -d --build
```
The compose build context is `plugins/1c` because the adapter imports the
sibling `parser` package. If this service is moved to a separate repository,
copy `plugins/1c/parser` into that repository or publish it as a package.
## Standalone Extraction Checklist
When the adapter is eventually moved out of this monorepo:
1. Copy `connector/` and `parser/`.
2. Keep `contracts/openapi.yaml` versioned with releases.
3. Keep policies with the service.
4. Keep `service.yaml`, `pyproject.toml`, `Dockerfile`, `docker-compose.yml`,
and `.env.example`.
5. Move or duplicate contract checks that assert public behavior:
`check_1c_write_plan_contract.py`,
`check_1c_extension_action_contract.py`,
`check_1c_module_origin_contract.py`, and
`check_1c_code_symbol_contract.py`.
6. Do not move RAG datasets, training configs, or agent prompts into the
adapter service unless they become runtime dependencies.
## Live database access
### Web management
The runtime SQL connection list can be viewed and edited at
`http://<adapter-host>:8011/admin/`. The screen supports adding, editing, and
deleting entries and writes them atomically to `ONEC_SQL_BASES_JSON_FILE`
(normally `/data/onec-sql-bases.json`). Production-style deployments should set
`ONEC_ADAPTER_SERVICE_TOKEN`; the browser keeps it only in session storage. For
the isolated test profile, `ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN=true`
explicitly permits access without a token. Stored SQL passwords are never
returned by the API in either profile.
When `ONEC_SQL_BASES_JSON` is set directly, web editing is disabled because the
environment value would override the file. Move the connection map to the
configured JSON file before using the screen.
The adapter is not tied to one 1C database. Requests that read database-specific
sources must pass `base_id`; otherwise the adapter returns `base_id_required`.
### Mandatory SQL base access rule
`base_id` is the required settings key. Its entry contains the SQL server IP or
host, SQL database name, login, and password (preferably through
`password_env`). The adapter uses only that entry's existing credentials. It
must never create or change SQL logins, database users, roles, or permissions.
Application data and the metadata structure are read-only. The only SQL write
exception is a reviewed metadata saved-state change:
- base configuration metadata → `ConfigSave`;
- extension metadata → `ConfigCASSave`.
The exception does not permit writes to application-data tables, `Config`, or
`ConfigCAS`, and does not activate the saved configuration. Saved-state writes
remain gated by explicit opt-in, SHA-1 precondition, backup, transaction, and
readback verification. The binding policy is
`policies/sql-base-access-policy.yaml`.
Configure every base explicitly. Prefer `password_env` so secrets stay outside
repository files:
```json
{
"upo_test": {
"server": "sql-host.example.local",
"database": "upo_test",
"user": "configured_login",
"password_env": "ONEC_SQL_PASSWORD_UPO_TEST"
}
}
```
Set it as `ONEC_SQL_BASES_JSON` and pass the password separately as
`ONEC_SQL_PASSWORD_UPO_TEST`, or mount the same JSON outside the repository and
set `ONEC_SQL_BASES_JSON_FILE` to its container path. There is no implicit or
default database connection.
Current live methods:
- `query.validate`
- `query.run`
- `extensions.list`
- `schema.tables.list`
- `storage.files.list`
- `storage.file.get`
- `metadata.dbnames.summary`
- `metadata.kinds`
- `metadata.objects.list`
- `metadata.object.get`
- `metadata.object.properties`
- `metadata.object.decode`
- `metadata.object.parts`
- `metadata.object.modules`
- `metadata.object.related`
- `metadata.object.forms`
- `metadata.object.templates`
- `metadata.object.template.details`
- `templates.read`
- `templates.analyze`
- `templates.map`
- `metadata.route.resolve`
- `metadata.form.decode`
- `metadata.object.attributes`
- `metadata.object.full`
- `metadata.snapshot`
- `codec.decode`
- `codec.encode`
- `extension.objects.find`
- `modules.search`
- `modules.read`
Metadata methods require `base_id` and read live `Params`, `Config`, and
`ConfigCAS` storage through SQL. They do not use filesystem route indexes as a
source of truth. High-level metadata methods return 1C-facing data by default:
object identity, synonyms, decoded semantic sections, forms, modules, and
counts. Physical SQL table names, `_Fld...` columns, DBNames indexes, and
storage routes are internal diagnostics and are exposed only by low-level
methods (`storage.*`, `schema.*`, `query.*`, `metadata.dbnames.*`) or by
passing `include_storage=true`.
Object-scoped adapter methods accept the same public selector shapes:
`ref`, `kind` + `name`, `guid`, or MCP-friendly
`object_type`/`object_name`/`object_guid`. `ref` may use Russian or English
qualified metadata names such as `Обработка.<Name>` or `Document.<Name>`.
Client, MCP, and agent code must not add conditions for concrete object names;
the adapter owns generic selector normalization.
Layer write policy:
- `Config` and `ConfigCAS` are **active-applied** and must be treated as read-only in adapter workflows.
- `ConfigSave` and `ConfigCASSave` are **saved, not yet applied** layers and are the only writable targets for connector staging changes.
- Base vs extension mapping:
- base config → `ConfigSave`
- extension config → `ConfigCASSave`
- Production apply to active layers is out of scope for this connector and requires a separate human-controlled deployment path.
Agent-facing code write rule:
- BSL edits must use `code.write`, not low-level SQL/write helpers.
- The agent passes 1C names (`object_type`/`object_name`/`routine_name`) or a
public path such as `<extension>.<form>.<routine>` plus full code text.
- `code.write` automatically targets the saved-state layer and reports
`write_mode.target=saved_state` with `activation_state=not_activated`.
- Use `code.read`/`code.search` with the default working state for current
programming-time code; use `state=both` only when an explicit saved vs active
comparison is needed.
`metadata.object.get` returns a live object card and decoded semantic sections
without physical SQL/storage traces by default. `metadata.object.decode` also
returns a 1C-facing decoded object profile by default; pass
`include_storage=true` only when adapter diagnostics need the underlying decoded
payload metadata, record containers, or DBNames/storage routes.
`metadata.object.properties` is the unified property endpoint for every 1C
metadata kind. It selects a kind-specific SQL decoder for `Configuration`,
`Constant`, `DocumentNumerator`, `IntegrationService`, `CommandGroup`,
`ScheduledJob`, and `DocumentJournal`, and otherwise returns the generic live
semantic profile. XML exports are analysis evidence only and are never a
runtime source for this method or any other adapter method.
Managed form bodies in base `Config` are resolved from the public form GUID to
the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command
names when their SQL binding points to a common command or a recognized
platform standard command; standard reference field `-5` is exposed as a
public `...Ref` data path. Callers never need the internal GUIDs or field codes.
Element event GUIDs are converted to platform event names (for example
`OnChange`, `ChoiceProcessing`, `AutoComplete`, `Selection`, and table row
events) and linked to their BSL handlers when the routine is present.
`metadata.object.attributes` is the preferred method for "show object
attributes/requisites" questions. It returns 1C metadata attribute names and
tabular section names from the live Config payload. For tabular sections, it
also returns decoded column names when nested column records are present.
Attributes and columns include decoded type evidence (`date`, `boolean`,
`string`, `number`, `reference`) and visible type parameters such as string
length, number precision/scale, or reference type GUID. Reference type GUIDs are
resolved back to live metadata object names and synonyms when the referenced
type exists in the base metadata. It must be preferred over SQL table/column
inspection for user-facing answers. The object can be selected by `guid`, by
`kind` + `name`, or by 1-based `ordinal` within `metadata.objects.list` for that
kind.
`metadata.object.full` is the preferred high-level method for agent answers like
"show everything about this document". It combines the live object card,
semantic sections, decoded forms, BSL module profiles, and counts in one
1C-facing response. Module profiles include routine lists and lightweight BSL
structural validation. Streams with BSL markers that are not complete modules
are kept, but marked as `completeness: fragment_or_invalid`. Full module text is
returned only with `include_module_text=true`. The method hides SQL/storage
traces by default; pass `include_storage=true` only for adapter diagnostics.
`metadata.object.parts` returns object part roles by evidence: metadata
payloads, form payloads, BSL stream containers, templates, and help/html
payloads. Physical Config part keys and numeric suffixes are hidden by default
and returned only with `include_storage=true`.
`metadata.object.modules` lists BSL stream modules discovered in those live
parts. Public responses use 1C-facing names such as `Модуль объекта`; physical
`module_id` values are returned only with `include_storage=true`.
`metadata.object.related` reads live related `Config` records referenced by
known object-kind sections, such as document forms and templates. Missing
references are returned explicitly with `source_missing`. Physical section paths
and Config file names are hidden unless `include_storage=true`.
`metadata.object.forms` resolves object forms through `metadata.object.related`
and then reads each form's live parts, including root `4` form payloads. Public
responses show form names and part roles; physical payload keys are hidden unless
`include_storage=true`.
`extension.objects.find` is the preferred first step for extension-specific
tasks. It searches live extension metadata by `extension`, `query`, `kind`, or
`guid`, returns object/template routes, and provides safe `read_selector`
payloads for follow-up calls. It can recover extension manifest routes even
when DBNames-Ext is incomplete; owner mismatches are returned as diagnostics
instead of silently hiding the object.
`metadata.route.resolve` resolves ConfigCAS/DBNames routes for extension
objects and child objects. Use it when a previous search returned a route
handle or when the caller has a CAS file name but needs the live object route.
`templates.read` and `templates.analyze` read MXL/MOXCEL templates by owner
selector, template selector, or direct ConfigCAS route. They return decoded
template structure: dimensions, named areas with row/column ranges, text and
parameter cells, column widths, cell text identifiers, cell parameters,
area-to-cell coverage, area intersections, shape variants, and explicit
capability flags. `merged_ranges` are reserved for authoritative merged-cell
records; until the MOXCEL merge record is decoded, possible merges are exposed
as `merged_range_candidates` with `confidence: low`.
Use `view=summary|structure|full`, `sections`, and `max_*` limits to keep
responses small for agents. `templates.map` is the compact agent-facing wrapper
over `templates.analyze`; it defaults to `view=summary` and is preferred when an
agent needs a quick layout map instead of all decoded lists.
1C templates are not only tabular MXL/MOXCEL documents. The 1C template
constructor offers these template types:
- `Табличный документ` - tabular document, MXL/MOXCEL. This is the current deep
decoder focus.
- `Текстовый документ` - plain or structured text payload.
- `Двоичные данные` - arbitrary binary payload.
- `Active document` - Active document payload.
- `HTML документ` - HTML payload.
- `Географическая схема` - geographic schema.
- `Графическая схема` - graphical schema.
- `Схема компоновки данных` - data composition schema.
- `Макет оформления компоновки данных` - data composition appearance template.
- `Внешняя компонента` - external component payload.
Always identify the template type before applying a decoder. Current
`templates.*` decoding is evidence-first for tabular documents; non-tabular
templates should be surfaced with type, raw route, payload markers, preview, and
explicit capability gaps until dedicated decoders are implemented.
For MOXCEL reverse engineering, request `sections=moxel_records,diagnostics`
and optionally `max_moxel_records`. The response includes parser-level record
head counts, grouped head samples with `tree_position`, and coordinate-like
samples. Use `top_level_records` with a larger `max_moxel_records` to inspect
ordered MOXCEL sections around a specific tree position. These diagnostics are
not authoritative merged-cell records. For a narrow ordered window, pass
`moxel_record_start` and `moxel_record_end`, for example `431..460`.
Use `moxel_record_heads` to keep only selected top-level record head codes,
for example `1049761,1413047`. Add `moxel_record_context` to include neighbor
records around matched top-level records; context records are marked with
`match: false`. `top_level_record_summary` summarizes the returned record
window with position range, head counts, match count, and compact numeric-field
variation by head. Its `field_hints` are low-confidence labels such as
`flag_like`, `small_enum_like`, or `coordinate_or_offset_like`; use them as
navigation hints, not as authoritative MOXCEL decoding. `numeric_field_matrix`
then shows those hinted/varying field values per `tree_position` without
returning every numeric item again, and `field_runs` compresses adjacent equal
values in that matrix. `field_transitions` lists the switch points between
those runs. `cell_style_candidates` exposes inline MOXCEL text cells with
nearby scalar style evidence and following metadata nodes; treat it as a
controlled-diff aid until border/font/alignment semantics are decoded.
`top_level_shapes`
groups top-level records by structural shape
(`head`, list length, numeric/string counts) and includes sample positions.
`top_level_shape_candidates` ranks rare/long/numeric-heavy shapes as
low-confidence hints for manual layout/merge investigation. Each candidate can
include `rank` and `suggested_windows` with a ready `request_hint` for the next
focused `templates.map` call. Pass `moxel_candidate_rank` to focus
`top_level_records` on that 1-based candidate rank without copying the request
hint manually. Use `moxel_candidate_window_index` to select a later suggested
window from the same candidate when the structural shape appears more than
once. Use `moxel_candidate_reasons`, for example
`coordinate_like_prefix,long_record`, to return only candidates containing all
requested reason codes. Use `moxel_candidate_min_score` to keep only candidates
above a heuristic score threshold. `top_level_candidate_summary` reports score
and reason distributions plus the count returned after filters. Use
`moxel_candidate_heads` to filter the candidate list by head code; use
`moxel_record_heads` when filtering actual top-level records in a focused
window. Use `moxel_candidate_start`/`moxel_candidate_end` to filter candidates
by their top-level positions; use `moxel_record_start`/`moxel_record_end` when
filtering returned records.
`metadata.form.decode` decodes one form payload into an evidence-first profile:
event handlers, form items, attributes, commands, auxiliary table/command-bar
records, and the embedded form module summary. Form records include stable
paths back into the decoded tree for names, ids, localized titles, handler
names, and known platform event ids. Counts include both returned and total
record numbers so truncated responses are explicit. The profile also links form
events and form commands to module routines, links command buttons to commands
by GUID evidence, and marks handlers as `resolved` or `missing`.
`modules.search` searches live BSL text and returns snippets by default.
Physical module ids and payload coordinates are hidden unless
`include_storage=true`. Every public match includes a `read_selector` with
`method: "modules.read"` and either an object selector or an opaque
`module_ref`; agents should pass that selector to the next read call instead of
requesting storage details. When `resolve_owners=true`, results also include
`counts.owner_resolved`, `counts.owner_unresolved`, and
`diagnostics.owner_resolution` so incomplete owner recovery is explicit.
`modules.read` reads by object selector (`ref`, `guid`, `kind` + `name`,
`object_type`/`object_name`/`object_guid`, or `kind` + 1-based object
`ordinal`) and optional 1-based `module_ordinal`; it also accepts `module_ref`
from a prior search result. The response hides source and payload metadata
unless `include_storage=true`.
`code.search` is the agent-facing wrapper over module search. Its items include
`read_selector.method: "code.read"` and preserve `module_ref` when that is the
best available safe handle. `code.read` can consume that selector directly.
`metadata.definition.find` accepts public object references such as
`Обработка.<Name>` or `Document.<Name>` in `query` and the common object
selector aliases for scoped lookup. A single metadata object match is promoted
to the top-level `object` field and the response includes `related_selectors`
for the next public calls (`metadata.object.get`,
`metadata.object.full`, `metadata.object.modules`, `metadata.object.form.details`,
`code.search`, `modules.search`, and similar selectors allowed by the object's
capabilities).
`metadata.adapter.audit` reports recognized metadata kinds, public kind counts,
missing supported kinds when `include_missing=true`, and unmapped DBNames roles
when `include_unmapped=true`.
`codec.decode` and `codec.encode` are low-level lossless helpers. A no-op encode
from a live source keeps the original bytes exactly; modified text/tree payloads
are encoded back using the original compression and text encoding envelope.
`changes.propose` reads one live storage payload, checks an optional
`expected_sha1`, applies `edits` to decoded brace-tree paths in memory, and
returns the re-encoded payload metadata for review. It never writes to SQL.
Each edit has `path`, `value`, optional `node_type` (`auto`, `atom`, `string`),
and optional `expected_old`. For stream payloads, an edit can use
`stream_index` with either full `text` replacement or `replace: {old, new}`,
plus optional `expected_contains`; stream headers are rebuilt with updated
byte lengths before the payload is encoded back. Diagnostic `source.module_id`
values returned by `metadata.object.modules` with `include_storage=true` or
accepted by `modules.read` can be used directly; when the module id includes
`#stream:<index>`, stream edits inherit that index unless an edit specifies its
own `stream_index`. The response
includes `validation`, produced by re-decoding the encoded proposal in memory.
For BSL stream edits, validation also runs lightweight structural checks for
routine, region, and preprocessor-block balance. Stream edits can also target
a whole BSL routine with `routine: {operation, name, text}` where operation is
`replace`, `append`, or `upsert`. Routine edits accept
`expected_old_contains` and `expected_old_sha1` as live preconditions against
the current routine text; failed preconditions reject the proposal before any
encoded review artifact is returned.
`storage.*` methods read 1C storage rows directly from SQL tables
`Params`, `Config`, `ConfigSave`, `ConfigCAS`, and `ConfigCASSave`. They are
diagnostic building blocks for the live metadata decoder; they do not create or
read filesystem indexes.
## Cache policy
The source of truth is the live database. A filesystem cache may be added only
as a derived acceleration layer for expensive decoded metadata/module payloads,
not for current table data. Cache entries must carry `base_id`, source
fingerprint, generation time, TTL, and `fresh/stale` status. If freshness cannot
be proven, the adapter must re-read live SQL or return an explicit stale-cache
error.
Operational runbook: `docs/runbooks/1c-operational-coding.md`.
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
const $ = (id) => document.getElementById(id);
const state = { bases: [], requests: [] };
function headers(json = false) {
const value = {};
if (json) value["Content-Type"] = "application/json";
return value;
}
async function request(path, options = {}) {
const response = await fetch(path, { ...options, headers: { ...headers(Boolean(options.body)), ...(options.headers || {}) } });
let data = {};
try { data = await response.json(); } catch (_) { /* empty response */ }
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
return data;
}
function notice(message = "", error = false) {
$("notice").hidden = !message;
$("notice").textContent = message;
$("notice").className = `notice${error ? " error" : ""}`;
}
function render() {
$("bases").replaceChildren(...state.bases.map((base) => {
const row = document.createElement("tr");
const cells = [base.base_id, base.server, base.database, base.user];
cells.forEach((value) => { const td = document.createElement("td"); td.textContent = value; row.append(td); });
const password = document.createElement("td");
password.innerHTML = `<span class="badge ${base.has_password ? "" : "missing"}">${base.password_env ? "ENV · " + escapeHtml(base.password_env) : base.has_password ? "Сохранён" : "Не задан"}</span>`;
row.append(password);
const actions = document.createElement("td"); actions.className = "actions";
const edit = document.createElement("button"); edit.className = "text-button"; edit.textContent = "Изменить"; edit.onclick = () => openEditor(base);
const remove = document.createElement("button"); remove.className = "text-button danger"; remove.textContent = "Удалить"; remove.onclick = () => deleteBase(base);
actions.append(edit, remove); row.append(actions); return row;
}));
$("empty").hidden = state.bases.length > 0;
$("count").textContent = `${state.bases.length} ${state.bases.length === 1 ? "подключение" : "подключений"}`;
}
function renderRequests() {
$("requests").replaceChildren(...state.requests.map((item) => {
const row = document.createElement("tr");
const values = [item.request_id, item.base_id, item.status, (item.objects || []).join(", "), item.created_at ? new Date(item.created_at * 1000).toLocaleString("ru-RU") : "—"];
values.forEach((value, index) => { const td = document.createElement("td"); td.textContent = value || "—"; if (index === 2) td.className = `request-status ${item.status || ""}`; if (index === 3) td.className = "request-objects"; row.append(td); });
return row;
}));
$("requestsEmpty").hidden = state.requests.length > 0;
$("requestCount").textContent = `${state.requests.length} заявок`;
}
function escapeHtml(value) {
const div = document.createElement("div"); div.textContent = value; return div.innerHTML;
}
async function load() {
try {
notice();
const [data, repository] = await Promise.all([request("/admin/api/bases"), request("/admin/api/repository/requests")]);
state.bases = data.bases || [];
state.requests = repository.requests || [];
$("statusDot").classList.add("online"); $("connectionText").textContent = "Адаптер подключён";
render();
renderRequests();
} catch (error) {
$("statusDot").classList.remove("online"); $("connectionText").textContent = "Требуется подключение";
notice(error.message === "bearer_token_required" ? "Введите Bearer-токен адаптера." : error.message, true);
}
}
function openEditor(base = null) {
$("editorTitle").textContent = base ? "Редактировать базу" : "Новая база";
$("originalId").value = base?.base_id || "";
$("baseId").value = base?.base_id || ""; $("server").value = base?.server || "";
$("database").value = base?.database || ""; $("user").value = base?.user || "";
$("password").value = ""; $("passwordEnv").value = base?.password_env || "";
$("repositoryEnabled").checked = Boolean(base?.repository);
$("repositoryBackend").value = base?.repository?.backend || "direct";
$("repositoryLayer").value = base?.repository?.layer || "base";
$("repositoryLockMode").value = base?.repository?.lock_mode || "automatic";
$("repositoryBridgeId").value = base?.repository?.bridge_id || "";
$("repositoryUser").value = base?.repository?.repository_user || "";
$("passwordHint").textContent = base?.has_password ? "Пароль уже задан. Оставьте пустым, чтобы сохранить текущий." : "Задайте пароль или переменную окружения.";
$("editor").showModal(); setTimeout(() => $("baseId").focus(), 30);
}
async function save(event) {
event.preventDefault();
if (!$("baseForm").reportValidity()) return;
const original = $("originalId").value;
const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), repository: $("repositoryEnabled").checked ? { backend: $("repositoryBackend").value, layer: $("repositoryLayer").value, lock_mode: $("repositoryLockMode").value, bridge_id: $("repositoryBridgeId").value.trim(), repository_user: $("repositoryUser").value.trim() } : { enabled: false } };
$("saveButton").disabled = true;
try {
await request(original ? `/admin/api/bases/${encodeURIComponent(original)}` : "/admin/api/bases", { method: original ? "PUT" : "POST", body: JSON.stringify(payload) });
$("editor").close(); await load(); notice(original ? "Подключение обновлено." : "Подключение добавлено.");
} catch (error) { notice(error.message, true); } finally { $("saveButton").disabled = false; }
}
async function deleteBase(base) {
if (!confirm(`Удалить подключение «${base.base_id}»?`)) return;
try { await request(`/admin/api/bases/${encodeURIComponent(base.base_id)}`, { method: "DELETE" }); await load(); notice("Подключение удалено."); }
catch (error) { notice(error.message, true); }
}
$("refreshButton").onclick = load; $("addButton").onclick = () => openEditor(); $("baseForm").onsubmit = save;
load();
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>1С Adapter · SQL-базы</title>
<link rel="stylesheet" href="/admin/style.css">
</head>
<body>
<header class="topbar">
<div><span class="mark">1C</span><strong>Adapter Control</strong></div>
<div class="connection"><span id="statusDot" class="dot"></span><span id="connectionText">Не подключено</span></div>
</header>
<main>
<section class="intro">
<div><p class="eyebrow">Подключения</p><h1>SQL-базы 1С</h1><p>Управление адресами и учётными данными адаптера.</p></div>
<button id="addButton" class="primary">+ Добавить базу</button>
</section>
<section class="panel table-panel">
<div class="panel-head"><div><h2>Настроенные базы</h2><span id="count">0 подключений</span></div><button id="refreshButton" class="icon" title="Обновить" aria-label="Обновить"></button></div>
<div id="notice" class="notice" hidden></div>
<div class="table-wrap">
<table><thead><tr><th>Base ID</th><th>SQL Server</th><th>База данных</th><th>Логин</th><th>Пароль</th><th></th></tr></thead><tbody id="bases"></tbody></table>
<div id="empty" class="empty">Подключения ещё не настроены.</div>
</div>
</section>
<section class="panel table-panel requests-panel">
<div class="panel-head"><div><h2>Заявки на захват</h2><span id="requestCount">0 заявок</span></div></div>
<div class="table-wrap">
<table><thead><tr><th>Заявка</th><th>База</th><th>Статус</th><th>Объекты</th><th>Создана</th></tr></thead><tbody id="requests"></tbody></table>
<div id="requestsEmpty" class="empty">Заявок пока нет.</div>
</div>
</section>
</main>
<dialog id="editor">
<form id="baseForm" method="dialog">
<div class="dialog-head"><div><p class="eyebrow">SQL-подключение</p><h2 id="editorTitle">Новая база</h2></div><button value="cancel" class="close" aria-label="Закрыть">×</button></div>
<input id="originalId" type="hidden">
<div class="grid">
<label>Base ID<input id="baseId" required pattern="[A-Za-z0-9_.-]+" placeholder="upo_test"></label>
<label>SQL Server / IP<input id="server" required placeholder="192.168.1.10"></label>
<label>Имя базы SQL<input id="database" required placeholder="upo_test"></label>
<label>SQL-логин<input id="user" required autocomplete="username" placeholder="onec_reader"></label>
<label class="wide">Пароль<input id="password" type="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"><small id="passwordHint">Пароль сохраняется в защищённом runtime-файле и никогда не отображается.</small></label>
<label class="wide">Или переменная окружения<input id="passwordEnv" placeholder="ONEC_SQL_PASSWORD_UPO_TEST"><small>Если заполнено, имеет приоритет над введённым паролем.</small></label>
<label class="wide"><input id="repositoryEnabled" type="checkbox"> Конфигурация подключена к хранилищу</label>
<label>Доступ к хранилищу<select id="repositoryBackend"><option value="direct">Прямой</option><option value="karman_bridge">Через Карман</option></select></label>
<label>Слой<select id="repositoryLayer"><option value="base">Основная конфигурация</option><option value="extension">Расширение</option></select></label>
<label>Захват объектов<select id="repositoryLockMode"><option value="manual">Вручную пользователем (SQL-only)</option><option value="automatic" disabled>Через внешнюю 1С (следующая версия)</option></select></label>
<label>ID моста (из настройки)<input id="repositoryBridgeId" placeholder="Необязательно"></label>
<label>Пользователь хранилища<input id="repositoryUser" autocomplete="off" placeholder="Например, adm"></label>
</div>
<div class="dialog-actions"><button value="cancel" class="secondary">Отмена</button><button id="saveButton" value="default" class="primary">Сохранить</button></div>
</form>
</dialog>
<script src="/admin/app.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
:root{--ink:#17211b;--muted:#68736c;--line:#d7ddd8;--paper:#f3f4f0;--surface:#fff;--accent:#b8dc2e;--accent-dark:#27380c;--danger:#b33a2e;--shadow:0 18px 50px rgba(30,42,34,.11);font-family:"Segoe UI Variable","Aptos",sans-serif;color:var(--ink);background:var(--paper)}*{box-sizing:border-box}body{margin:0}.topbar{height:58px;padding:0 max(24px,calc((100vw - 1180px)/2));display:flex;align-items:center;justify-content:space-between;background:#18211c;color:#f7faf6}.topbar>div{display:flex;align-items:center;gap:10px}.mark{display:grid;place-items:center;width:30px;height:30px;background:var(--accent);color:#17210b;font-weight:900;border-radius:7px}.connection{font-size:13px;color:#bdc6bf}.dot{width:8px;height:8px;border-radius:50%;background:#78827b}.dot.online{background:var(--accent);box-shadow:0 0 0 4px rgba(184,220,46,.12)}main{max-width:1180px;margin:auto;padding:42px 24px 70px}.intro{display:flex;align-items:end;justify-content:space-between;margin-bottom:26px}.eyebrow{margin:0 0 6px;text-transform:uppercase;letter-spacing:.13em;font-size:11px;font-weight:800;color:#71804f}.intro h1{font-family:Georgia,serif;font-size:43px;line-height:1;margin:0}.intro p:not(.eyebrow){color:var(--muted);margin:12px 0 0}.panel{background:var(--surface);border:1px solid var(--line);box-shadow:0 2px 0 rgba(23,33,27,.03)}.auth{display:grid;grid-template-columns:minmax(260px,1fr) auto;gap:12px;align-items:end;padding:18px;margin-bottom:18px}.auth small{grid-column:1/-1}.auth label,.grid label{display:grid;gap:7px;font-size:12px;font-weight:700}.auth input,.grid input{width:100%;border:1px solid #bfc8c1;background:#fbfcfa;border-radius:5px;padding:11px 12px;font:inherit;color:var(--ink);outline:none}.auth input:focus,.grid input:focus{border-color:#6f861c;box-shadow:0 0 0 3px rgba(184,220,46,.18)}button{font:inherit;cursor:pointer}.primary,.secondary,.icon,.close{border:0;border-radius:5px;min-height:40px;padding:0 16px;font-weight:750}.primary{background:var(--accent);color:var(--accent-dark)}.primary:hover{filter:brightness(.94)}.secondary{background:#edf0ec;color:#344038}.table-panel{overflow:hidden}.panel-head{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}.panel-head h2{font-size:17px;margin:0 0 3px}.panel-head span{font-size:12px;color:var(--muted)}.icon,.close{font-size:22px;background:transparent;padding:0 10px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:820px}th,td{text-align:left;padding:14px 18px;border-bottom:1px solid #e5e9e6;font-size:13px}th{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#728078;background:#fafbf9}td:first-child{font-family:Consolas,monospace;font-weight:700}.actions{display:flex;justify-content:flex-end;gap:6px}.text-button{border:0;background:transparent;padding:5px;color:#516058}.text-button:hover{color:#17211b}.text-button.danger:hover{color:var(--danger)}.badge{display:inline-flex;align-items:center;gap:6px;color:#3c4a40}.badge:before{content:"";width:7px;height:7px;border-radius:50%;background:var(--accent)}.badge.missing:before{background:#d2a342}.empty{text-align:center;padding:55px;color:var(--muted)}.notice{margin:14px 18px 0;padding:11px 13px;border-left:3px solid #d2a342;background:#fff8df;font-size:13px}.notice.error{border-color:var(--danger);background:#fff0ee;color:#78271f}dialog{border:0;border-radius:8px;padding:0;width:min(660px,calc(100vw - 30px));box-shadow:var(--shadow)}dialog::backdrop{background:rgba(15,23,18,.58);backdrop-filter:blur(2px)}dialog form{padding:24px}.dialog-head{display:flex;justify-content:space-between}.dialog-head h2{font-family:Georgia,serif;font-size:28px;margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:17px;margin:25px 0}.grid .wide{grid-column:1/-1}.grid small,.auth small{color:var(--muted);font-weight:400}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;border-top:1px solid var(--line);padding-top:18px}@media(max-width:700px){main{padding:28px 14px}.intro{align-items:start;gap:20px}.intro h1{font-size:34px}.auth{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.grid .wide{grid-column:auto}.topbar{padding:0 14px}.connection span:last-child{display:none}}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
services:
onec-adapter:
build:
context: ..
dockerfile: connector/Dockerfile
image: onec-adapter-connector:0.1.0
env_file:
- .env
ports:
- "${ONEC_ADAPTER_PORT:-8011}:8011"
volumes:
- onec-adapter-data:/data
restart: unless-stopped
healthcheck:
test:
- CMD
- python
- -c
- "import json, urllib.request; print(json.load(urllib.request.urlopen('http://127.0.0.1:8011/health', timeout=5)).get('status'))"
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
onec-adapter-data:
@@ -0,0 +1,50 @@
id: 1c-change-workflow-policy
status: active
default_mode: propose-only
rules:
- "Application data is read-only; no workflow may insert, update, or delete rows in application tables."
- "SQL identities and permissions are out of scope and must never be created or changed by this connector."
- "The model must not directly apply changes to a live 1C database."
- "Write operations to 1C configuration data must target only ConfigSave (base config) and ConfigCASSave (extension config) as the saved layer."
- "Do not write to Config or ConfigCAS from connector workflows; these are active-applied layers and read-only in the adapter path."
- "Before any write proposal, resolve user-facing targets to full 1C canonical paths or concrete saved-state references."
- "Before any write proposal, read origin/layer evidence for the effective target."
- "Before any apply method, require metadata.write.plan allowed=true for the same target and intent."
- "When the base repository is configured, require a verified adapter-owned repository lock session before any saved-state apply."
- "Repository backend, endpoint, bridge identity, runtime, and credentials must come from the selected base runtime settings; never infer them from hard-coded names."
- "Repository commit requires an explicit approval flag and a non-empty version comment."
- "Concrete saved-state references must be compatible with the selected target kind; do not use form_guid for module writes or module_ref for form writes."
- "Do not treat a local BSL symbol path as a metadata path until it is resolved inside the current code context."
- "Do not write effective module or form text directly; route through a write plan with layer provenance."
- "After modifying saved layers, require explicit compare and human approval before any production apply step."
- "The model may generate a change proposal, patch, or review checklist."
- "Human approval is required before apply."
- "Production changes require backup, test run, and rollback plan."
stages:
- propose_change
- static_review
- run_tests
- expert_review
- manual_approve
- apply_change
- verify
- rollback_if_needed
required_for_approval:
- risk_summary
- affected_objects
- canonical_paths
- layer_provenance
- references_found
- test_plan
- rollback_plan
denied_without_approval:
- modify_configuration
- update_database
- run_data_processor
- delete_objects
- change_roles_or_permissions
- write_active_configuration
- write_ambiguous_target
- write_without_origin_evidence
- write_when_plan_blocked
- write_concrete_reference_kind_mismatch
@@ -0,0 +1,36 @@
id: 1c-config-layer-write-policy
status: active
default_mode: deny
summary: "Writes to 1C configuration storage are read-first, save-layer-only."
rules:
- "This exception permits metadata saved-state payloads only; it never permits application-data writes."
- "Active-applied layers are read-only in adapter workflows: Config and ConfigCAS."
- "Saved, not yet applied layers are the only writable targets for configuration edits: ConfigSave and ConfigCASSave."
- "Base configuration changes map to ConfigSave; extension configuration changes map to ConfigCASSave."
- "Comparisons of pending changes must be run as ConfigSave↔Config and ConfigCASSave↔ConfigCAS before proposing production apply."
- "Any claim of applied state must be backed by live reads from Config/ConfigCAS only after explicit apply workflow."
- "Agent-facing write intents must resolve to a full 1C canonical path or concrete saved-state reference before planning."
- "Effective views are read targets only; write plans must identify base, extension, generated extension source, or saved-state ownership."
- "Concrete references must match the planned target kind: module targets may use module_ref, module_id, or module file_name; form targets may use form file_name or form_guid."
- "If metadata.write.plan returns allowed=false, metadata.write must not call lower-level apply methods."
- "Extension code changes must preserve the operation type: insert_before, insert_after, replace, or replace_with_control."
denied_actions:
- "write_to_Config"
- "write_to_ConfigCAS"
- "auto_apply_to_active_state"
- "direct_sql_apply_to_live_config"
- "write_effective_view_directly"
- "write_ambiguous_short_name"
- "write_plan_blocked_apply"
- "write_concrete_reference_kind_mismatch"
allowed_actions:
- "propose_save_layer_change"
- "plan_full_path_change"
- "read_Config"
- "read_ConfigSave"
- "read_ConfigCAS"
- "read_ConfigCASSave"
- "compare_saved_state"
notes:
- "Use this policy together with change-workflow to avoid mixing saved and active layers."
- "If a path requires production writes, treat it as out-of-band and human-controlled only."
@@ -0,0 +1,69 @@
id: 1c-designer-sql-decoding-policy
status: active
summary: "Controlled changes in a disposable 1C base may be made only through 1C clients; the adapter observes and decodes SQL without writing application data."
scope:
default_base_id: upo_test
allowed_base_class: disposable_test
forbidden_base_class: [production, unclassified]
platform_mutation_authority:
application_data: 1c_enterprise_client
metadata_working_state: 1c_designer
adapter_role: sql_observer_and_decoder
credentials:
persistence: forbidden_in_repository
accepted_sources: [process_environment, operating_system_credential_store, interactive_session]
rules:
- "Do not put 1C user passwords, SQL passwords, tokens, or connection strings containing secrets in project files, reports, fixtures, or command examples."
- "Redact credentials from process reports and captured command lines."
experiment:
isolation: one_intended_change_per_run
required_phases:
- identify_public_1c_target
- capture_sql_before
- change_through_1c
- save_in_1c
- capture_sql_after
- diff_sql
- decode_semantic_rule
- verify_with_second_value_or_object
- rollback_through_1c
- verify_rollback_in_sql
target_selectors: [public_ref, kind_and_name, form_and_element_name, record_ref]
forbidden_selectors_for_callers: [sql_number, physical_table, internal_guid_only]
sql_observation:
adapter_access: read_only
allowed: [SELECT, metadata_schema_inspection, ConfigSave_read, ConfigCASSave_read, application_table_read]
forbidden:
- direct_application_data_write
- direct_Config_write
- direct_ConfigCAS_write
- sql_identity_or_permission_change
- trigger_or_profiler_installation
rule: "All experimental mutations happen through 1C; SQL is evidence, not the mutation transport."
metadata_layers:
designer_save:
observe: [ConfigSave, ConfigCASSave]
apply_configuration: false
applied_configuration:
observe: [Config, ConfigCAS, physical_schema]
gate: explicit_experiment_requirement
extensions:
rule: "Capture the base and every extension as separate layers and record load order and ownership."
xml:
role: offline_schema_reference_only
runtime_source: forbidden
rule: "XML may name the intended property and validate a learned rule, but live before/after evidence must come from SQL."
promotion_gates:
- "The SQL diff is isolated from pre-existing Designer and configuration-check noise."
- "A stable public 1C property or value name is resolved without requiring callers to know GUIDs or SQL numbers."
- "The rule is reproduced with a second value or a second object of the same shape."
- "A regression fixture and decoder test are added."
- "Rollback through 1C restores the SQL evidence or the experiment documents an irreversible schema migration."
@@ -0,0 +1,40 @@
id: 1c-readonly-query-policy
status: active
default_mode: deny
allowed:
- select
limits:
max_rows: 1000
default_rows: 100
timeout_seconds: 30
max_timeout_seconds: 120
deny_patterns:
- "(?i)\\bВЫБРАТЬ\\s+РАЗРЕШЕННЫЕ\\b.*\\bПОМЕСТИТЬ\\b"
- "(?i)\\bПОМЕСТИТЬ\\b"
- "(?i)\\bУНИЧТОЖИТЬ\\b"
- "(?i)\\bОБНОВИТЬ\\b"
- "(?i)\\bВСТАВИТЬ\\b"
- "(?i)\\bУДАЛИТЬ\\b"
- "(?i)\\bALTER\\b"
- "(?i)\\bDROP\\b"
- "(?i)\\bUPDATE\\b"
- "(?i)\\bINSERT\\b"
- "(?i)\\bDELETE\\b"
- "(?i)\\bCREATE\\s+(LOGIN|USER|ROLE)\\b"
- "(?i)\\bALTER\\s+(LOGIN|USER|ROLE)\\b"
- "(?i)\\bDROP\\s+(LOGIN|USER|ROLE)\\b"
- "(?i)\\b(GRANT|DENY|REVOKE)\\b"
masking:
enabled: true
fields:
- "(?i).*пароль.*"
- "(?i).*телефон.*"
- "(?i).*email.*"
- "(?i).*почта.*"
- "(?i).*паспорт.*"
- "(?i).*инн.*"
audit:
log_queries: true
log_params: false
log_result_rows: false
notes: "Read-only query runner policy. Connection identity comes only from the explicit base_id settings. Validate before execution and apply row limits/masking."
@@ -0,0 +1,65 @@
id: 1c-sql-base-access-policy
status: active
default_mode: deny
summary: "Every 1C base uses only its explicitly configured SQL connection; data is read-only and metadata writes are saved-state-only."
base_settings:
selector: base_id
source:
- ONEC_SQL_BASES_JSON
- ONEC_SQL_BASES_JSON_FILE
required_fields:
- server
- database
- user
secret_fields_one_of:
- password
- password_env
rules:
- "Every live request must contain an explicit base_id."
- "Resolve server, database, user, and password only from the settings entry for that base_id."
- "Do not substitute another base, infer a SQL database name, or use shared/default SQL credentials."
- "Do not persist connection passwords in repository or project files."
read_scope:
application_data: read_only
metadata_structure: read_only
configuration_tables:
Config: read_only
ConfigCAS: read_only
ConfigSave: read_only_except_saved_state_metadata_write
ConfigCASSave: read_only_except_saved_state_metadata_write
write_scope:
allowed:
base_metadata_saved_state: ConfigSave
extension_metadata_saved_state: ConfigCASSave
forbidden:
- application_data_tables
- Config
- ConfigCAS
- SQL_system_tables
- SQL_security_objects
constraints:
- "The payload must be a metadata saved-state change, never application data."
- "The target table must be exactly ConfigSave or ConfigCASSave."
- "Require explicit saved-state write opt-in, expected SHA-1, backup, transaction, and readback verification."
- "A saved-state write must not activate or apply the configuration."
sql_identity_management:
mode: forbidden
forbidden_actions:
- CREATE_LOGIN
- ALTER_LOGIN
- DROP_LOGIN
- CREATE_USER
- ALTER_USER
- DROP_USER
- CREATE_ROLE
- ALTER_ROLE
- DROP_ROLE
- GRANT
- DENY
- REVOKE
rule: "Use the login and password already stored in the selected base settings; never create or modify adapter-owned SQL identities or permissions."
@@ -0,0 +1,45 @@
id: 1c-xml-decoding-reference-policy
status: active
summary: "XML exports are offline decoding evidence only; the running adapter is SQL-only."
offline_analysis:
allowed: true
purposes:
- discover_metadata_kinds
- enumerate_declared_properties
- correlate_object_guids
- infer_binary_config_paths
- build_decoder_tests_and_fixtures
layer_rule: "Base configuration and every extension must be analyzed as separate layers."
output_rule: "Promote only generic, evidence-backed format rules and tests into the adapter; do not promote configuration-specific XML values as live answers."
runtime:
source: sql_only
configured_by: base_id
settings:
- server
- database
- user
- password_or_password_env
xml_mount_required: false
xml_environment_variables_allowed: false
rejected_payload_arguments:
- xml_path
- xml_root
- meta_xml_path
- form_xml_path
- configuration_xml
- configuration_xml_path
- config_dump_info
- config_dump_info_path
rules:
- "Runtime metadata and data answers must be derived from the selected base_id SQL connection."
- "Runtime must not read Configuration.xml, ConfigDumpInfo.xml, form XML, extension XML, or an XML-derived object-value cache."
- "XML-derived decoder rules must remain generic and must be verified against live SQL bytes."
- "An XML export may differ from live extensions and therefore cannot establish the current runtime extension state."
writes:
rule: "This policy does not broaden SQL write scope. Only the saved-state exceptions in sql-base-access-policy.yaml apply."
allowed_tables:
- ConfigSave
- ConfigCASSave
+21
View File
@@ -0,0 +1,21 @@
[project]
name = "onec-adapter-connector"
version = "0.1.0"
description = "Read-first 1C adapter connector for metadata, BSL modules, safe write planning, and saved-state staging."
requires-python = ">=3.11"
dependencies = [
"pymssql==2.3.2",
]
[project.optional-dependencies]
dev = [
"pytest",
"PyYAML",
]
[project.scripts]
onec-adapter = "adapter_1c_server:main"
[tool.pytest.ini_options]
testpaths = ["../../../tests/1c"]
pythonpath = ["..", ".", "../parser"]
+722
View File
@@ -0,0 +1,722 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from typing import Any
METHOD_STATUS = "repository.status"
METHOD_LOCK_PLAN = "repository.lock.plan"
METHOD_LOCK_REQUEST = "repository.lock.request"
METHOD_LOCK_REQUEST_STATUS = "repository.lock.request.status"
METHOD_LOCK_REQUEST_CANCEL = "repository.lock.request.cancel"
METHOD_LOCK = "repository.lock"
METHOD_CONFIRM = "repository.lock.confirm"
METHOD_VERIFY = "repository.lock.verify"
METHOD_CLOSE = "repository.lock.close"
METHOD_UNLOCK = "repository.unlock"
METHOD_COMMIT_PLAN = "repository.commit.plan"
METHOD_COMMIT = "repository.commit"
METHODS = {METHOD_STATUS, METHOD_LOCK_PLAN, METHOD_LOCK_REQUEST, METHOD_LOCK_REQUEST_STATUS, METHOD_LOCK_REQUEST_CANCEL, METHOD_LOCK, METHOD_CONFIRM, METHOD_VERIFY, METHOD_CLOSE, METHOD_UNLOCK, METHOD_COMMIT_PLAN, METHOD_COMMIT}
SUPPORTED_BACKENDS = {"direct", "karman_bridge"}
SUPPORTED_LOCK_MODES = {"automatic", "manual"}
_BASE_LOCKS: dict[str, threading.Lock] = {}
_BASE_LOCKS_GUARD = threading.Lock()
_STATE_LOCK = threading.RLock()
def external_1c_enabled() -> bool:
return str(os.environ.get("ONEC_ADAPTER_ENABLE_EXTERNAL_1C") or "").strip().casefold() in {"1", "true", "yes", "on"}
def _base_lock(base_id: str, layer: str) -> threading.Lock:
key = f"{base_id}:{layer}"
with _BASE_LOCKS_GUARD:
return _BASE_LOCKS.setdefault(key, threading.Lock())
def _load_json_map(env_name: str, file_env_name: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
raw = os.environ.get(env_name)
path = os.environ.get(file_env_name)
if not raw and path:
try:
raw = Path(path).read_text(encoding="utf-8-sig")
except Exception as exc:
return None, {"status": "invalid_config", "message": f"Cannot read {file_env_name}: {exc}"}
if not raw:
return None, {
"status": "not_configured",
"message": f"Set {env_name} or {file_env_name} with an explicit entry for this base_id.",
}
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
return None, {"status": "invalid_config", "message": f"{env_name} is not valid JSON: {exc}"}
if not isinstance(value, dict):
return None, {"status": "invalid_config", "message": f"{env_name} must be an object keyed by base_id."}
return value, None
def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
values, error = _load_json_map("ONEC_SQL_BASES_JSON", "ONEC_SQL_BASES_JSON_FILE")
base_item = values.get(base_id) if values and isinstance(values.get(base_id), dict) else None
item = base_item.get("repository") if isinstance(base_item, dict) else None
if item is None:
values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE")
if repository_error and error:
return None, repository_error
item = values.get(base_id) if values else None
if item is None:
return None, {"status": "not_configured", "message": f"No repository configuration for base_id '{base_id}'."}
if not isinstance(item, dict):
return None, {"status": "invalid_config", "message": f"Repository configuration for '{base_id}' must be an object."}
configured = dict(item)
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
configured["layer"] = str(configured.get("layer") or "base").strip().casefold()
configured["lock_mode"] = str(configured.get("lock_mode") or "automatic").strip().casefold()
if configured["backend"] not in SUPPORTED_BACKENDS:
return None, {"status": "invalid_config", "message": "repository backend must be direct or karman_bridge."}
if configured["lock_mode"] not in SUPPORTED_LOCK_MODES:
return None, {"status": "invalid_config", "message": "repository lock_mode must be automatic or manual."}
runner = configured.get("runner") if isinstance(configured.get("runner"), dict) else {}
configured["runner"] = runner
runner_kind = str(runner.get("kind") or "local").strip().casefold()
if runner_kind not in {"local", "http"}:
return None, {"status": "invalid_config", "message": "repository runner.kind must be local or http."}
runner["kind"] = runner_kind
required = ("endpoint", "designer_path") if runner_kind == "local" and configured["lock_mode"] == "automatic" else ()
for key in required:
if not str(configured.get(key) or "").strip():
return None, {"status": "invalid_config", "message": f"Repository configuration requires {key}."}
if runner_kind == "http" and configured["lock_mode"] == "automatic" and not str(runner.get("url") or "").strip():
return None, {"status": "invalid_config", "message": "repository runner.url is required for runner.kind=http."}
infobase = configured.get("infobase")
if runner_kind == "local" and configured["lock_mode"] == "automatic" and (not isinstance(infobase, dict) or sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) != 1):
return None, {"status": "invalid_config", "message": "infobase must contain exactly one of file, server, or name for runner.kind=local."}
return configured, None
def _secret(config: dict[str, Any], field: str) -> str:
env_name = str(config.get(f"{field}_env") or "").strip()
return os.environ.get(env_name, "") if env_name else ""
def _public_config(config: dict[str, Any]) -> dict[str, Any]:
return {
"backend": config.get("backend"),
"layer": config.get("layer"),
"lock_mode": config.get("lock_mode"),
"adapter_access_mode": "sql_only" if not external_1c_enabled() else "sql_and_external_1c",
"automatic_repository_operations_available": external_1c_enabled(),
"endpoint": config.get("endpoint"),
"bridge_id": config.get("bridge_id") if config.get("backend") == "karman_bridge" else None,
"runtime_version": config.get("runtime_version"),
"runner_kind": (config.get("runner") or {}).get("kind"),
"runner_url": (config.get("runner") or {}).get("url"),
"runner_token_env": (config.get("runner") or {}).get("token_env"),
"repository_user": str(config.get("repository_user") or ""),
"repository_password_env": str(config.get("repository_password_env") or ""),
"repository_user_configured": bool(str(config.get("repository_user") or "").strip()),
"repository_password_configured": bool(_secret(config, "repository_password")),
"infobase_user": str(config.get("infobase_user") or ""),
"infobase_password_env": str(config.get("infobase_password_env") or ""),
"infobase_user_configured": bool(str(config.get("infobase_user") or "").strip()),
"infobase_password_configured": bool(_secret(config, "infobase_password")),
}
def _infobase_args(config: dict[str, Any]) -> list[str]:
infobase = config["infobase"]
if infobase.get("file"):
args = ["/F", str(infobase["file"])]
elif infobase.get("server"):
args = ["/S", str(infobase["server"])]
else:
args = ["/IBName", str(infobase["name"])]
user = str(config.get("infobase_user") or "").strip()
if user:
args += ["/N", user]
password = _secret(config, "infobase_password")
if password:
args += ["/P", password]
return args
def _repository_args(config: dict[str, Any]) -> list[str]:
args = ["/ConfigurationRepositoryF", str(config["endpoint"])]
user = str(config.get("repository_user") or "").strip()
if user:
args += ["/ConfigurationRepositoryN", user]
password = _secret(config, "repository_password")
if password:
args += ["/ConfigurationRepositoryP", password]
extension = str(config.get("extension") or "").strip()
if extension:
args += ["-Extension", extension]
return args
def _safe_excerpt(value: str, config: dict[str, Any], limit: int = 4000) -> str:
safe = value
for secret in (_secret(config, "repository_password"), _secret(config, "infobase_password")):
if secret:
safe = safe.replace(secret, "[REDACTED]")
return safe[-limit:]
def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds: int) -> dict[str, Any]:
started = time.monotonic()
with tempfile.TemporaryDirectory(prefix="onec-repository-") as directory:
log_path = Path(directory) / "designer.log"
args = [str(config["designer_path"]), "DESIGNER"]
args += _infobase_args(config)
args += ["/DisableStartupMessages", "/DisableStartupDialogs", "/Out", str(log_path)]
args += _repository_args(config)
args += operation
try:
completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout_seconds, check=False)
except subprocess.TimeoutExpired as exc:
return {
"status": "timeout",
"exit_code": None,
"duration_ms": round((time.monotonic() - started) * 1000),
"output": _safe_excerpt(str(exc.stdout or "") + str(exc.stderr or ""), config),
}
except OSError as exc:
return {"status": "runner_error", "exit_code": None, "message": str(exc), "duration_ms": round((time.monotonic() - started) * 1000)}
log = ""
try:
log = log_path.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
pass
output = "\n".join(part for part in (completed.stdout, completed.stderr, log) if part)
return {
"status": "ok" if completed.returncode == 0 else "failed",
"exit_code": completed.returncode,
"duration_ms": round((time.monotonic() - started) * 1000),
"output": _safe_excerpt(output, config),
}
def _execute_repository(
base_id: str,
config: dict[str, Any],
action: str,
timeout_seconds: int,
*,
objects: list[str] | None = None,
comment: str = "",
keep_locked: bool = False,
) -> dict[str, Any]:
if not external_1c_enabled():
return {
"status": "external_1c_disabled",
"message": "This adapter version is SQL-only. Use repository.lock.plan and the manual confirmation workflow.",
}
runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"}
if runner.get("kind") == "http":
url = str(runner.get("url") or "").rstrip("/") + "/repository/execute"
body = json.dumps(
{"base_id": base_id, "action": action, "objects": objects or [], "comment": comment, "keep_locked": keep_locked},
ensure_ascii=False,
).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
token_env = str(runner.get("token_env") or "").strip()
token = os.environ.get(token_env, "") if token_env else ""
if token:
headers["Authorization"] = f"Bearer {token}"
try:
with urllib.request.urlopen(urllib.request.Request(url, data=body, headers=headers, method="POST"), timeout=timeout_seconds) as response:
result = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
result = json.loads(exc.read().decode("utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
result = {"status": "runner_error", "message": f"Repository runner returned HTTP {exc.code}."}
return result if isinstance(result, dict) else {"status": "runner_error", "message": f"Repository runner returned HTTP {exc.code}."}
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
return {"status": "runner_error", "message": str(exc)}
return result if isinstance(result, dict) else {"status": "runner_error", "message": "Repository runner returned a non-object response."}
if action == "report":
with tempfile.TemporaryDirectory(prefix="onec-repository-report-") as directory:
report = Path(directory) / "report.txt"
return _run_designer(config, ["/ConfigurationRepositoryReport", str(report), "-NBegin", "-1", "-ReportFormat", "txt"], timeout_seconds)
with tempfile.TemporaryDirectory(prefix="onec-repository-objects-") as directory:
objects_path = Path(directory) / "objects.txt"
objects_path.write_text("\n".join(objects or []) + "\n", encoding="utf-8")
if action == "lock":
operation = ["/ConfigurationRepositoryLock", "-Objects", str(objects_path)]
elif action == "unlock":
operation = ["/ConfigurationRepositoryUnlock", "-Objects", str(objects_path)]
elif action == "commit":
operation = ["/ConfigurationRepositoryCommit", "-Objects", str(objects_path), "-Comment", comment]
if keep_locked:
operation.append("-KeepLocked")
else:
return {"status": "runner_error", "message": f"Unsupported repository action: {action}"}
return _run_designer(config, operation, timeout_seconds)
_CHILD_MARKERS = re.compile(
r"\.(?:Реквизит|Attribute|ТабличнаяЧасть|TabularSection|Измерение|Dimension|Ресурс|Resource)\.",
re.IGNORECASE,
)
def development_object(ref: str) -> str:
value = ref.strip().strip(".")
match = _CHILD_MARKERS.search(value)
if match:
return value[: match.start()]
for marker in (".МодульОбъекта", ".ObjectModule", ".МодульМенеджера", ".ManagerModule"):
if value.casefold().endswith(marker.casefold()):
return value[: -len(marker)]
return value
def _requested_objects(payload: dict[str, Any]) -> tuple[list[str] | None, dict[str, Any] | None]:
raw = payload.get("objects")
if raw is None:
raw = [payload.get("object") or payload.get("ref") or payload.get("path")]
if not isinstance(raw, list) or not raw:
return None, {"status": "invalid_argument", "argument": "objects", "message": "Pass object/ref/path or a non-empty objects array."}
values: list[str] = []
for index, item in enumerate(raw):
if not isinstance(item, str) or not item.strip():
return None, {"status": "invalid_argument", "argument": f"objects[{index}]", "message": "Repository object must be a non-empty public 1C reference."}
resolved = development_object(item)
if resolved not in values:
values.append(resolved)
return values, None
def lock_plan(payload: dict[str, Any]) -> dict[str, Any]:
objects, error = _requested_objects(payload)
if error:
return {"schema": "onec_repository_lock_plan.v1", "method": METHOD_LOCK_PLAN, **error}
operation = str(payload.get("operation") or "modify").strip().casefold()
warnings: list[dict[str, str]] = []
if operation in {"add", "delete", "rename"}:
warnings.append({"code": "parent_scope_requires_confirmation", "message": "Structural operations can require the parent/root and referenced objects; confirm the complete set before lock/apply."})
result = {
"schema": "onec_repository_lock_plan.v1",
"method": METHOD_LOCK_PLAN,
"status": "ready" if not warnings else "needs_confirmation",
"operation": operation,
"requested_objects": [str(x) for x in (payload.get("objects") or [payload.get("object") or payload.get("ref") or payload.get("path")])],
"lock_objects": objects,
"warnings": warnings,
}
base_id = str(payload.get("base_id") or "").strip()
if base_id:
config, config_error = repository_config(base_id)
if config_error:
result["repository_problem"] = config_error
elif config.get("lock_mode") == "manual":
result["workflow"] = "manual"
result["next_method"] = METHOD_CONFIRM
result["user_action"] = {
"action": "lock_in_configurator",
"base_id": base_id,
"objects": objects,
"message": "Захватите перечисленные объекты в Конфигураторе, затем явно подтвердите тот же список через repository.lock.confirm.",
}
else:
result["workflow"] = "automatic"
result["next_method"] = METHOD_LOCK
return result
def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, **error}
plan = lock_plan(payload)
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
if plan.get("status") != "ready":
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "status": "blocked", "plan": plan}
request_id = "rreq-" + uuid.uuid4().hex
with _STATE_LOCK:
state = _read_state()
state.setdefault("requests", {})[request_id] = {
"base_id": base_id,
"layer": str(config.get("layer") or "base"),
"backend": config.get("backend"),
"operation": plan.get("operation"),
"objects": plan["lock_objects"],
"created_at": time.time(),
"status": "pending_user_lock",
"execution": "manual",
}
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
_write_state(state)
return {
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
"base_id": base_id, "status": "pending_user_lock", "request_id": request_id,
"objects": plan["lock_objects"], "automatically_locked": False,
"user_action": "Захватите перечисленные объекты в Конфигураторе и подтвердите заявку через repository.lock.confirm.",
"next_method": METHOD_CONFIRM,
}
def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
request_id = str(payload.get("request_id") or "").strip()
request = (_read_state().get("requests") or {}).get(request_id)
if not isinstance(request, dict):
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": "not_found", "request_id": request_id}
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
request_id = str(payload.get("request_id") or "").strip()
if payload.get("confirm_cancel") is not True:
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "confirmation_required", "request_id": request_id}
with _STATE_LOCK:
state = _read_state()
request = (state.get("requests") or {}).get(request_id)
if not isinstance(request, dict):
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "not_found", "request_id": request_id}
if request.get("status") != "pending_user_lock":
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id}
request["status"] = "cancelled"
request["cancelled_at"] = time.time()
_audit(state, "lock_request_cancelled", request_id=request_id, base_id=request.get("base_id"), objects=request.get("objects"))
_write_state(state)
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id}
def _state_path() -> Path:
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
def _read_state() -> dict[str, Any]:
try:
value = json.loads(_state_path().read_text(encoding="utf-8-sig"))
state = value if isinstance(value, dict) else {"sessions": {}, "requests": {}, "audit": []}
except (OSError, json.JSONDecodeError):
state = {"sessions": {}, "requests": {}, "audit": []}
now = time.time()
request_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_REQUEST_TTL_SECONDS") or 86400))
session_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS") or 7200))
for request in (state.get("requests") or {}).values():
if isinstance(request, dict) and request.get("status") == "pending_user_lock" and now - float(request.get("created_at") if request.get("created_at") is not None else now) > request_ttl:
request["status"] = "expired"
request["expired_at"] = now
for session in (state.get("sessions") or {}).values():
if isinstance(session, dict) and session.get("status") == "manual_confirmed" and now - float(session.get("created_at") if session.get("created_at") is not None else now) > session_ttl:
session["status"] = "expired"
session["expired_at"] = now
return state
def _write_state(value: dict[str, Any]) -> None:
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + f".{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(temporary, path)
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
rows = state.setdefault("audit", [])
rows.append({"event": event, "time": time.time(), **details})
if len(rows) > 5000:
del rows[:-5000]
def admin_state(base_id: str = "") -> dict[str, Any]:
state = _read_state()
requests = [{"request_id": key, **row} for key, row in (state.get("requests") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
sessions = [{"lock_session_id": key, **row} for key, row in (state.get("sessions") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
audit = [row for row in (state.get("audit") or []) if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
requests.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True)
sessions.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True)
audit.sort(key=lambda row: float(row.get("time") or 0), reverse=True)
return {"schema": "onec_repository_admin_state.v1", "base_id": base_id or None, "requests": requests, "sessions": sessions, "audit": audit[:200], "counts": {"requests": len(requests), "sessions": len(sessions), "audit": len(audit)}}
def status(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "connected": False, **error}
result: dict[str, Any] = {
"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id,
"status": "configured", "connected": True, "available": None, "repository": _public_config(config),
}
if not bool(payload.get("probe")):
return result
if not external_1c_enabled():
result["status"] = "sql_only"
result["available"] = None
result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."}
return result
if config.get("lock_mode") == "manual":
result["status"] = "manual_workflow"
result["available"] = None
result["probe"] = {"status": "not_applicable", "message": "Manual lock mode does not require Designer or a repository runner. Use repository.lock.plan."}
return result
timeout_seconds = int(payload.get("timeout_seconds") or 60)
executed = _execute_repository(base_id, config, "report", timeout_seconds)
result["probe"] = executed
result["available"] = executed.get("status") == "ok"
result["status"] = "ready" if result["available"] else "blocked_repository_unavailable"
return result
def lock(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error}
if config.get("lock_mode") == "manual":
return {
"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id,
"status": "manual_action_required", "plan": lock_plan(payload), "next_method": METHOD_CONFIRM,
}
plan = lock_plan(payload)
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
plan["scope_confirmed"] = True
if plan.get("status") != "ready":
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "plan": plan}
if not payload.get("allow_repository_lock") is True:
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "explicit_repository_lock_required", "plan": plan}
layer = str(config.get("layer") or "base")
with _base_lock(base_id, layer):
executed = _execute_repository(base_id, config, "lock", int(payload.get("timeout_seconds") or 120), objects=plan["lock_objects"])
if executed.get("status") != "ok":
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "repository_lock_failed", "plan": plan, "execution": executed}
session_id = "rlock-" + uuid.uuid4().hex
state = _read_state()
sessions = state.setdefault("sessions", {})
sessions[session_id] = {"base_id": base_id, "layer": layer, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
_write_state(state)
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
if config.get("lock_mode") != "manual":
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
if not external_1c_enabled() and not str(payload.get("request_id") or "").strip():
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_required", "next_method": METHOD_LOCK_REQUEST}
state = _read_state()
request_id = str(payload.get("request_id") or "").strip()
request = (state.get("requests") or {}).get(request_id) if request_id else None
if request_id and (not isinstance(request, dict) or request.get("base_id") != base_id):
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_found", "request_id": request_id}
if isinstance(request, dict) and request.get("status") != "pending_user_lock":
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id}
effective_payload = dict(payload)
if isinstance(request, dict):
effective_payload["objects"] = [str(item) for item in request.get("objects") or []]
plan = lock_plan(effective_payload)
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
if plan.get("status") != "ready":
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "plan": plan}
if payload.get("user_confirmed_locked") is not True:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "plan": plan}
session_id = "rlock-" + uuid.uuid4().hex
state.setdefault("sessions", {})[session_id] = {
"base_id": base_id, "layer": str(config.get("layer") or "base"), "backend": config.get("backend"),
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
"verification": "user_confirmation_only", "automatically_verified": False,
}
if isinstance(request, dict):
request["status"] = "confirmed_by_user"
request["confirmed_at"] = time.time()
request["lock_session_id"] = session_id
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"])
_write_state(state)
return {
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
"status": "manual_confirmed", "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
"automatically_verified": False,
"warning": "Адаптер принял явное подтверждение пользователя, но не проверял захват через API хранилища.",
}
def verify(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("lock_session_id") or "").strip()
session = (_read_state().get("sessions") or {}).get(session_id)
if not isinstance(session, dict):
return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": "not_found", "lock_session_id": session_id}
verify_status = "owned_by_adapter" if session.get("status") == "acquired" else ("manual_confirmation_unverified" if session.get("status") == "manual_confirmed" else session.get("status"))
return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": verify_status, "lock_session_id": session_id, "session": session}
def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("lock_session_id") or "").strip()
if payload.get("user_confirmed_released") is not True:
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "confirmation_required", "lock_session_id": session_id}
with _STATE_LOCK:
state = _read_state()
session = (state.get("sessions") or {}).get(session_id)
if not isinstance(session, dict):
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "not_found", "lock_session_id": session_id}
if session.get("status") != "manual_confirmed":
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "blocked", "error": "manual_confirmation_not_active", "lock_session_id": session_id}
session["status"] = "closed"
session["closed_at"] = time.time()
_audit(state, "manual_lock_closed", lock_session_id=session_id, base_id=session.get("base_id"), objects=session.get("objects"))
_write_state(state)
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "closed", "lock_session_id": session_id}
def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
if error and error.get("status") == "not_configured":
return {"required": False, "allowed": True, "status": "not_configured"}
if error:
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "problem": error}
session_id = str(payload.get("lock_session_id") or "").strip()
if not session_id:
return {
"required": True, "allowed": False, "status": "needs_repository_lock",
"backend": config.get("backend"), "next_method": METHOD_LOCK_PLAN,
}
session = (_read_state().get("sessions") or {}).get(session_id)
if not isinstance(session, dict) or session.get("base_id") != base_id or session.get("status") not in {"acquired", "manual_confirmed"}:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "lock_session_id": session_id}
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
requested = ""
for value in (
payload.get("repository_object"), target.get("repository_object"), target.get("canonical_path"),
payload.get("canonical_path"), target.get("path"), payload.get("path"), payload.get("ref"), payload.get("object"),
):
if isinstance(value, str) and value.strip():
requested = development_object(value)
break
if not requested:
kind = str(payload.get("object_type") or payload.get("kind") or target.get("object_type") or target.get("kind") or "").strip()
name = str(payload.get("object_name") or payload.get("name") or target.get("object_name") or target.get("name") or "").strip()
if kind and name:
requested = development_object(f"{kind}.{name}")
if not requested:
return {
"required": True, "allowed": False, "status": "blocked_repository_scope_unresolved",
"lock_session_id": session_id, "message": "Pass repository_object with the public 1C development-object reference for this low-level write route.",
}
locked = [str(item) for item in session.get("objects") or []]
if requested.casefold() not in {item.casefold() for item in locked}:
return {
"required": True, "allowed": False, "status": "blocked_repository_scope_mismatch",
"lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
}
return {
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"),
"lock_session_id": session_id, "requested_object": requested, "objects": locked,
"verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only",
}
def commit_plan(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("lock_session_id") or "").strip()
session = (_read_state().get("sessions") or {}).get(session_id)
if not isinstance(session, dict):
return {"schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN, "status": "not_found", "lock_session_id": session_id}
comment = str(payload.get("comment") or "").strip()
problems: list[dict[str, str]] = []
if session.get("status") != "acquired":
problems.append({"code": "lock_session_not_acquired", "message": "Commit requires an active adapter lock session."})
if not comment:
problems.append({"code": "commit_comment_required", "message": "A non-empty repository version comment is required."})
return {
"schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN,
"status": "ready" if not problems else "blocked", "allowed": not problems,
"lock_session_id": session_id, "base_id": session.get("base_id"),
"objects": session.get("objects") or [], "comment": comment, "problems": problems,
}
def commit(payload: dict[str, Any]) -> dict[str, Any]:
plan = commit_plan(payload)
session_id = str(payload.get("lock_session_id") or "").strip()
if not plan.get("allowed"):
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "lock_session_id": session_id, "plan": plan}
if payload.get("allow_repository_commit") is not True:
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "explicit_repository_commit_required", "lock_session_id": session_id, "plan": plan}
state = _read_state()
session = (state.get("sessions") or {}).get(session_id)
base_id = str(session.get("base_id") or "")
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "lock_session_id": session_id, **error}
with _base_lock(base_id, str(session.get("layer") or "base")):
executed = _execute_repository(base_id, config, "commit", int(payload.get("timeout_seconds") or 180), objects=[str(item) for item in session.get("objects") or []], comment=str(plan["comment"]), keep_locked=payload.get("keep_locked") is True)
if executed.get("status") != "ok":
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "repository_commit_failed", "lock_session_id": session_id, "execution": executed}
session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
session["committed_at"] = time.time()
session["commit_comment"] = str(plan["comment"])
_write_state(state)
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": session["status"], "lock_session_id": session_id, "committed": session.get("objects"), "keep_locked": payload.get("keep_locked") is True, "execution": executed}
def unlock(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("lock_session_id") or "").strip()
state = _read_state()
session = (state.get("sessions") or {}).get(session_id)
if not isinstance(session, dict):
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "not_found", "lock_session_id": session_id}
if session.get("status") != "acquired":
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": str(session.get("status")), "lock_session_id": session_id}
if not payload.get("allow_repository_unlock") is True:
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "explicit_repository_unlock_required", "lock_session_id": session_id}
base_id = str(session.get("base_id") or "")
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "lock_session_id": session_id, **error}
with _base_lock(base_id, str(session.get("layer") or "base")):
executed = _execute_repository(base_id, config, "unlock", int(payload.get("timeout_seconds") or 120), objects=[str(item) for item in session.get("objects") or []])
if executed.get("status") != "ok":
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "repository_unlock_failed", "lock_session_id": session_id, "execution": executed}
session["status"] = "released"
session["released_at"] = time.time()
_write_state(state)
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": session.get("objects"), "execution": executed}
def call(method: str, payload: dict[str, Any]) -> dict[str, Any]:
if method == METHOD_STATUS:
return status(payload)
if method == METHOD_LOCK_PLAN:
return lock_plan(payload)
if method == METHOD_LOCK_REQUEST:
return create_lock_request(payload)
if method == METHOD_LOCK_REQUEST_STATUS:
return lock_request_status(payload)
if method == METHOD_LOCK_REQUEST_CANCEL:
return cancel_lock_request(payload)
if method == METHOD_LOCK:
return lock(payload)
if method == METHOD_CONFIRM:
return confirm_manual_lock(payload)
if method == METHOD_VERIFY:
return verify(payload)
if method == METHOD_CLOSE:
return close_manual_lock(payload)
if method == METHOD_UNLOCK:
return unlock(payload)
if method == METHOD_COMMIT_PLAN:
return commit_plan(payload)
if method == METHOD_COMMIT:
return commit(payload)
return {"status": "method_not_found", "method": method}
+42
View File
@@ -0,0 +1,42 @@
id: onec-adapter-connector
name: 1C Adapter Connector
version: 0.1.0
status: standalone-ready
owner: local-llm-platform
runtime:
language: python
entrypoint: adapter_1c_server.py
host_env: ONEC_ADAPTER_HOST
port_env: ONEC_ADAPTER_PORT
default_port: 8011
contracts:
openapi: contracts/openapi.yaml
policies:
- policies/sql-base-access-policy.yaml
- policies/xml-decoding-reference-policy.yaml
- policies/designer-sql-decoding-policy.yaml
- policies/read-only-query.yaml
- policies/change-workflow.yaml
- policies/config-layer-write-policy.yaml
dependencies:
python:
- pymssql==2.3.2
local_packages:
- ../parser
data_dirs:
cache: /data/adapter-cache.sqlite
backups: /data/adapter-apply-backups
write_learning: /data/adapter-write-learning
security:
secrets_policy: "Each base_id stores its SQL server, database, login, and password/password_env in ONEC_SQL_BASES_JSON or a mounted ONEC_SQL_BASES_JSON_FILE; do not store SQL passwords in repository files."
api_auth: "Set ONEC_ADAPTER_SERVICE_TOKEN and pass it to clients as ONEC_ADAPTER_TOKEN. /health remains unauthenticated."
sql_identity: "Use only the existing credentials from the selected base_id settings. Never create, alter, or drop SQL logins, users, roles, grants, denies, or revokes."
data_access: "Application data and metadata structure are read-only. No application-data table may be changed."
active_layers: "Config and ConfigCAS are read-only in adapter workflows."
writable_layers: "Only ConfigSave and ConfigCASSave are staging targets."
health:
local_contracts:
- ../../../scripts/check_1c_write_plan_contract.py
- ../../../scripts/check_1c_extension_action_contract.py
- ../../../scripts/check_1c_module_origin_contract.py
- ../../../scripts/check_1c_code_symbol_contract.py