diff --git a/README.md b/README.md index 6711c040..af4de1ed 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,7 @@ See [Optional Graphify Setup](https://github.com/codemower-ai/code-mower/blob/ma - [Builder Experiments](https://github.com/codemower-ai/code-mower/blob/main/docs/builder-experiments.md) - [Orchestrator Prompt Pack](https://github.com/codemower-ai/code-mower/blob/main/docs/orchestrator-prompt-pack.md) - [Optional Devin Setup Prompt](https://github.com/codemower-ai/code-mower/blob/main/docs/devin-setup-prompt.md) +- [Optional Private Slack Setup and Runbook](https://github.com/codemower-ai/code-mower/blob/main/docs/slack-setup.md) (v1.5.0 candidate; default setup unchanged) - [Provider Matrix](https://github.com/codemower-ai/code-mower/blob/main/docs/provider-matrix.md) - [Provider Calibration Scorecard](https://github.com/codemower-ai/code-mower/blob/main/docs/provider-calibration-scorecard.md) - [Devin Peer-Support Qualification](https://github.com/codemower-ai/code-mower/blob/main/docs/devin-peer-support-qualification.md) diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index f2d022d1..3b239152 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -237,6 +237,11 @@ "source": "docs/slack-ingress.md", "target": "docs/slack-ingress.md" }, + { + "kind": "doc", + "source": "docs/slack-setup.md", + "target": "docs/slack-setup.md" + }, { "kind": "doc", "source": "docs/supervisor-contract.md", @@ -1607,6 +1612,16 @@ "source": "src/code_mower/slack_ingress.py", "target": "src/code_mower/slack_ingress.py" }, + { + "kind": "core", + "source": "src/code_mower/slack_readiness.py", + "target": "src/code_mower/slack_readiness.py" + }, + { + "kind": "core", + "source": "src/code_mower/slack_setup.py", + "target": "src/code_mower/slack_setup.py" + }, { "kind": "core", "source": "src/code_mower/supervisor.py", @@ -1822,6 +1837,11 @@ "source": "src/code_mower/templates/slack/app-manifest.json", "target": "src/code_mower/templates/slack/app-manifest.json" }, + { + "kind": "template", + "source": "src/code_mower/templates/slack/hosted-app-manifest.json", + "target": "src/code_mower/templates/slack/hosted-app-manifest.json" + }, { "kind": "workflow", "source": "generated", diff --git a/docs/slack-ingress.md b/docs/slack-ingress.md index 08aaebd8..b25947ce 100644 --- a/docs/slack-ingress.md +++ b/docs/slack-ingress.md @@ -1,5 +1,9 @@ # Slack authenticated ingress +For the hosted v1.5.0 operator path, use [Optional Slack setup](slack-setup.md). +Its separate hosted manifest uses `/codemower` and the hosted command/modal +routes. The `/code-mower` manifest below remains the standalone OSS seam example. + Issue #917 implements `code_mower.slack_ingress`, a stdlib-only request seam for [slack_contract](slack-contract.md). There is no server, OAuth installation, network client, worker dispatch, or deployment. Default installation is still diff --git a/docs/slack-setup.md b/docs/slack-setup.md new file mode 100644 index 00000000..cd4effc3 --- /dev/null +++ b/docs/slack-setup.md @@ -0,0 +1,242 @@ +# Optional Slack setup and operator runbook + +Slack v1.5.0 is an explicit opt-in for **one private workspace**, an authorized +private, unshared channel, and a bound repository. Initial setup remains +Claude + Codex: no Slack prompt, dependency, login or service. Slack conveys requests; +the qualified supervisor owns execution. Hosted Devin is a bounded builder, +never an orchestrator qualification. + +This guide describes the source candidate for v1.5.0. The published v1.4.2 +package does not contain these commands. Use a reviewed candidate for offline +preparation; live operation requires the final immutable v1.5.0 package and +separately qualified hosted deployment. The private bridge verifies its +implementation lock and rejects editable/VCS installs for live operation. +Version alone is insufficient. Live completion/cancellation qualification +belongs to #923; this guide authorizes neither spend nor deployment. Telemetry +readiness, Board/cloud links, a general integrations picker, Slack Connect, +public channels and rich Slack UX are deferred to v1.5.1. + +## Fresh installation + +1. Install Code Mower normally. Only if you want Slack, prepare the dedicated + **hosted** manifest in an existing local directory. Choose either interactive + or scripted opt-in: + + ```sh + code-mower slack setup --manifest slack-app.json --interactive + code-mower slack setup --manifest slack-app.json --yes + ``` + + The command exclusively creates a mode-0600 static manifest, refusing existing + files and symlinks. It performs no network request, credential lookup, policy + change or service installation. The old `templates/slack/app-manifest.json` + belongs to the OSS `/code-mower` seam; the new `hosted-app-manifest.json` is + for the `/codemower` operator path. + +2. An authorized administrator imports the generated manifest into the private + Slack app. Preserve exactly the bot `commands` scope, no user scopes or Events + API subscriptions, no organization-wide install, and token rotation. The + hosted command/interactivity/OAuth routes are fixed in the manifest; do not + substitute previews, localhost or private URLs. Verify the installed settings + match: a generated file does not prove installation. See Slack's official + [manifest](https://docs.slack.dev/reference/app-manifest/), + [OAuth](https://docs.slack.dev/authentication/installing-with-oauth/) and + [token rotation](https://docs.slack.dev/authentication/using-token-rotation/) + documentation. + +3. The hosted operator verifies the accepted OAuth, interaction and supervisor + bridge migrations and upgrade rehearsal. Configure app/client IDs, client + secret, signing secret, fixed redirect and encryption key versions through + the service's secret manager. Never put credentials in CLI arguments, shell + history, source, tickets or diagnostic snapshots. OSS setup does not receive + or store them. Verify suppression of Slack OAuth queries, headers, bodies, + response URLs and routing identities at every platform, application, tracing, + database and export layer. Leave Slack disabled if suppression is unverified. + +4. Enable the hosted control plane only for the private installation. A signed-in + owner/admin uses **Setup → Manage Slack integration**, supplies the expected + immutable workspace (and enterprise identity where applicable), and completes + OAuth as the same administrator. Wrong app/workspace, revoked membership, + expired/reused OAuth state or missing rotating credentials must deny. Inspect + connection/rotation health privately. Recover uncertain rotation with a fresh + OAuth attempt; never replay a refresh token. + +5. Map the exact Slack user ID to an existing active member, never a display + name/email. Select an authorized catalog repository and alias. Bind the exact + private, unshared channel in the installed workspace and renew verification + within one hour. Connect, public channels and DMs are unsupported for this + release. A changed person/repository behind a mapping requires removal and a + new binding; an existing grant cannot change meaning. Observer permits + status; operator permits start, answer and owned cancellation. Team-wide + cancellation requires separate explicit admin authority. + +6. Connect the maintained **Codex supervisor v2** adapter with effective + orchestrator qualification, current generation/session lease, scoped + authorization, independent eligible review broker and fresh heartbeat. The + currently maintained adapter qualifies Codex; registering Claude or Devin + does not extend that qualification. Legacy registrations require explicit v2 + registration and new admission, never an imported claim. A saved registration + is not proof of a reachable supervisor. + +7. Configure hosted Devin through its existing private credential/repository + authorization path; check transport readiness without creating paid work. + Record owner-approved numeric **task and aggregate campaign ACU caps**, task + count/expiry, runtime-call/time, review spend and review/answer/fix ceilings. + The ingress open-task cap is not a monetary cap. At least one answer allowance + is needed for this runbook; the bridge defaults to zero. Recovery creates + remain zero. Reserve the full task allowance once and never refund it on + cancellation, timeout or unsettled billing. Credits are not authorization. + +8. Obtain fresh scoped readiness observations below. Enable hosted interaction + and bridge flags only after their owner-controlled deployment/logging gates + pass. Explicitly enable host composition too; web configuration starts no + worker. Missing supervision leaves work waiting/denied and prevents dispatch. + Qualify the two capped #923 canaries before treating the candidate as a + supported live installation. + +## Readiness and redaction + +```sh +code-mower slack doctor +code-mower doctor --slack --json +# Explicitly selected trusted private host adapter: +code-mower slack doctor --probe /absolute/operator/slack-readiness-probe --json +# Saved observations are offline diagnostics only: +code-mower slack doctor --snapshot observation.json --json +``` + +Default doctor contacts nothing and reports not ready. `--slack` runs only Slack +checks, excluding generic doctor's repository/path/provider output. Text/JSON +contain fixed component names, states and remediation. No exception, raw output, +timestamp, nonce, cap amount, identity, channel, mapping, URL, provider reference +or task prose is rendered or uploaded. Exit 0 means fresh live observations pass +all checks; exit 1 means a gap; exit 2 means invalid arguments. +`dispatch_authorized` is always false: the supervisor independently reauthorizes +execution. An offline snapshot never passes live readiness. + +The probe is a **trusted private host adapter**, not a Slack API endpoint or a +script generated by setup. This package has no private host credentials, +database or runtime connection. The host operator supplies the adapter using +those existing authenticated interfaces. Without one this CLI cannot establish +live readiness; do not substitute a hand-written all-green snapshot. Supplying +the adapter is an explicit operator prerequisite. Do not select repository +scripts or untrusted downloaded executables as probes. + +The executable receives one JSON line on stdin: +`{"schema":"code_mower.slack_probe.v1","nonce":""}`. +It has five seconds, no shell/arguments, and a combined 16 KiB stdout/stderr +budget. Timeout/overflow terminates its process group. It must perform read-only +checks without task/provider creation or model calls, returning one closed JSON +object. Stderr is discarded, never logged. The inherited host environment is +trusted and may supply its existing authentication; this is not a sandbox for +untrusted code. + +The response has exactly these fields: + +| Field | Required observation | +| --- | --- | +| `schema`, `nonce` | Same schema and current request nonce | +| `observed_at`, `expires_at` | Epoch seconds; observation begins after this request, expires within 120 seconds, and remains fresh at return | +| `components` | Exactly the twelve components below; closed states in `slack_readiness.COMPONENTS` | +| `supervisor_product`, `supervisor_contract` | Effective runtime product and `code_mower.supervisor.v2`; other versions project as `unsupported` | +| `caps` | Explicit effective numeric limits/reservations with exactly the keys below | + +For each invocation the host must resolve one immutable installation/user/ +channel/repository scope using its independently authenticated administrator and +policy resolver, revalidate it at return, then project only the facts. Never send +the admin health response directly: it contains private bindings. Do not log +the nonce or scope. The nonce rejects cached responses; it is not authentication +or proof against a malicious probe. + +| Component | Ready state and host check | +| --- | --- | +| `ingress` | `enabled`: owner-authorized control-plane/interaction flags and verified private logging gate | +| `bridge` | `enabled`: hosted flag and host composition enabled with the qualified immutable package | +| `manifest` | `matched`: installed routes, exact scopes, rotation and workspace settings match | +| `installation` | `active`: current installation, unexpired credential generation, no rotation/uninstall uncertainty | +| `oauth` | `configured`: exact app/workspace/redirect and rotating bot credentials agree | +| `identity` | `bound`: immutable mapping to a currently authorized member | +| `repository` | `authorized`: current repository/catalog grant for that member | +| `channel` | `private_verified`: exact private, unshared channel with verification under one hour | +| `registration` | `configured`: enabled, scoped v2 orchestrator registration | +| `supervisor` | `qualified_reachable`: actual connected runtime, effective qualification, matching generation/lease/claim and heartbeat under two minutes; never registration alone | +| `transport` | `ready`: implemented hosted Devin transport, scoped credentials and read-only reachability, no unresolved mutation | +| `campaign` | `active`: unexpired owner authorization, immutable revision, matching allowances | + +Caps: `task_acu` (1–100), `campaign_acu`, `reserved_acu`, `task_limit` (1–50), +`reserved_tasks`, `runtime_calls` (2–32), `runtime_seconds` (1–300), +`review_rounds` (1–9), `review_budget_usd` (positive), `clarification_answers` +(1–32 for this path), `fix_requests` (0–8), and `recovery_creates` (zero). +All except review spend are integers. Remaining campaign allowance must cover +the entire next task, with task-count capacity remaining. Missing/extra fields, +nonfinite values and wrong types reject. The host enforces ceilings at admission +and around mutations. Doctor reserves no budget and changes no allowance. + +## Start, status, answer and cancel + +Use the bound private, unshared channel and private configured alias. Replies +stay requester-private; keep task prose out of diagnostics. + +| Command/action | Expectation | +| --- | --- | +| `/codemower help` | Private help; no work created | +| `/codemower start ` | Opens the bounded task modal. Submit once. Receipt/queued is not execution or completion. | +| `/codemower status ` | Observes existing work and refreshes its private reply route without creating work. | +| `/codemower answer ` | Opens an answer form for the current waiting-for-user checkpoint. Cannot approve provider permissions or merge. | +| `/codemower cancel ` | Request, then use the private confirmation button. Observe status until builder and reviewer exit are confirmed. | + +Completion requires verified implementation, exact PR/head, writer exit, +independent eligible review and authoritative gate evidence. It does not mean +merged or billing settled. Cancel acknowledgement does not prove exit. Unknown +exit keeps the original task occupied: never replace it or reset a claim. +After timeout/ambiguous mutation reconcile the original receipt/lifecycle through +the authorized operator. Provider approvals use the existing authorized provider +interface; Slack answers never change safe mode. Expired private delivery must +not fall back to a channel post. + +## Troubleshooting, upgrade and removal + +| Diagnostic | Bounded remediation | +| --- | --- | +| Disabled/missing installation | Verify owner enablement and complete fresh administrator OAuth. | +| Revoked/expired/uncertain credentials | Block execution; inspect the original rotation and reconnect if needed. Never replay refresh. | +| Identity/repository/channel mismatch | Recheck the immutable tuple privately; remove/recreate the wrong binding, never broaden grants. | +| Stale channel policy | Reverify private/unshared state and renew within one hour. | +| Registered but supervisor unavailable | Restore qualified v2 connection/heartbeat and reconcile claims. Devin cannot become supervisor. | +| Transport unreachable/uncertain | Inspect the original lifecycle privately; no automatic retry/create/fallback. | +| Missing/exhausted/mismatched caps | Stop admission; get a new owner decision before any increase. Retain old reservations. | +| Probe missing/stale/malformed | Repair the trusted host adapter and obtain fresh observations. Private errors are intentionally hidden. | + +**Upgrade:** pause admission; inventory live/uncertain work privately. Reconcile +or cancel and confirm exit before changing runtimes. Install the reviewed +immutable release, apply hosted migrations through their owned rollout, verify +the implementation lock and re-register v2. Preserve bindings and receipt/ +reservation evidence. Compare the installed manifest/scopes, renew channel +policy, and repeat fresh readiness and approved canaries. Upgrade never enables +Slack automatically. + +**Disable:** stop admission with the hosted interaction/bridge flags and host +enablement control. Ingress disable is not provider cancellation. Keep the +authorized lifecycle recovery path available to cancel/reconcile active work and +observe exit. Use private admin Disable to invalidate OAuth attempts/credentials; +verify local denial even when remote uninstall fails. Inspect removal in Slack +privately if needed; preserve original work evidence. + +**Rollback:** keep Slack disabled and preserve receipts, claims and full budget +reservations. Restore only the previously reviewed compatible runtime/deployment +through its owner-controlled rollback. Do not downgrade live v2 claims, revert +schema destructively, clear uncertainty or refund allowances. Re-enable only +after fresh qualification and explicit owner decision. + +**Uninstall:** after confirmed writer/reviewer exit and reconciliation, disable +and verify app removal, then use private admin Delete and its retention/backup +process. Preserve required campaign reservation tombstones and reconciliation +evidence under hosted policy; never delete records to reopen spend. Remove the +local generated manifest if desired. Reinstall requires fresh OAuth and policy. +There is no local Slack service/dependency to remove. + +Retain only approved check names/states, pass/fail counts, public immutable +release/PR/head and review/gate outcomes as release evidence. Never upload +snapshots, host logs, credentials, identities, mappings, URLs, provider output, +prompts, source/diffs or task/message prose. These commands emit no cloud +telemetry. Live deployment and capped canary evidence remain separate. diff --git a/src/code_mower/cli.py b/src/code_mower/cli.py index 2595832b..375c7c19 100644 --- a/src/code_mower/cli.py +++ b/src/code_mower/cli.py @@ -83,6 +83,7 @@ def _source_checkout_install_spec() -> str: from . import release_qualify as code_mower_release_qualify from . import reviewer_metrics from . import session as code_mower_session +from . import slack_setup as code_mower_slack from . import saas_reviewer_labeler from . import trailer_comment_labeler from . import work_orders as code_mower_work_orders @@ -441,6 +442,7 @@ def _local_llm_main(argv: list[str]) -> int: COMMAND_DESCRIPTIONS: dict[str, str] = { "session": "Prepare session briefs and manage private remote work.", + "slack": "Prepare optional private Slack setup and inspect redacted readiness.", "antigravity-cli": "Run an Antigravity/Gemini CLI structured audit lane.", "blind-review": "Coordinate hidden/blind review artifacts.", "board": "Serve, record, or manage a persistent local lane visibility board.", @@ -641,6 +643,7 @@ def _top_level_help(show_all: bool) -> str: "reviewer-metrics": reviewer_metrics.main, "saas-reviewer-labeler": saas_reviewer_labeler.main, "session": code_mower_session.main, + "slack": code_mower_slack.main, "telemetry": code_mower_telemetry.main, "trailer-comment-labeler": trailer_comment_labeler.main, "tracker": code_mower_jira_mutations.main, diff --git a/src/code_mower/doctor.py b/src/code_mower/doctor.py index 31a1b90d..028482a6 100644 --- a/src/code_mower/doctor.py +++ b/src/code_mower/doctor.py @@ -154,7 +154,16 @@ def _doctor_config_error_message(exc: Exception, *, config_arg: str) -> str: def main(argv: Sequence[str] | None = None) -> int: + # Slack has a deliberately separate output boundary: generic doctor output + # includes repository paths and provider details unsuitable for this surface. + raw_args = list(sys.argv[1:] if argv is None else argv) + if "--slack" in raw_args: + from code_mower import slack_setup + + raw_args.remove("--slack") + return slack_setup.main(["doctor", *raw_args]) parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--slack", action="store_true", help="Run only the optional redacted Slack doctor; use --slack --help for probe options.") parser.add_argument('--context-online', action='store_true', help='Deliberately verify selected context authorization; never searches') parser.add_argument('--context-state-dir', type=Path, help='Private context store outside repositories') # Defaulted after parsing so that an explicit positional selection stays diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index d9c74446..2fcbb5bd 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -347,6 +347,9 @@ ("src/code_mower/remote_session_cli.py", "src/code_mower/remote_session_cli.py", "core"), ("src/code_mower/remote_session.schema.json", "src/code_mower/remote_session.schema.json", "schema"), ("src/code_mower/slack_ingress.py", "src/code_mower/slack_ingress.py", "core"), + ("src/code_mower/slack_readiness.py", "src/code_mower/slack_readiness.py", "core"), + ("src/code_mower/slack_setup.py", "src/code_mower/slack_setup.py", "core"), + ("src/code_mower/templates/slack/hosted-app-manifest.json", "src/code_mower/templates/slack/hosted-app-manifest.json", "template"), ("src/code_mower/templates/slack/app-manifest.json", "src/code_mower/templates/slack/app-manifest.json", "template"), ("templates/slack/app-manifest.json", "templates/slack/app-manifest.json", "template"), ("src/code_mower/slack_contract.py", "src/code_mower/slack_contract.py", "core"), @@ -622,6 +625,7 @@ ), ("docs/context-provider-contract.md", "docs/context-provider-contract.md", "doc"), ("docs/slack-ingress.md", "docs/slack-ingress.md", "doc"), + ("docs/slack-setup.md", "docs/slack-setup.md", "doc"), ("docs/slack-contract.md", "docs/slack-contract.md", "doc"), ("docs/supervisor-contract.md", "docs/supervisor-contract.md", "doc"), ("docs/graphify-evaluation.md", "docs/graphify-evaluation.md", "doc"), diff --git a/src/code_mower/slack_readiness.py b/src/code_mower/slack_readiness.py new file mode 100644 index 00000000..d2828331 --- /dev/null +++ b/src/code_mower/slack_readiness.py @@ -0,0 +1,257 @@ +"""Closed, read-only Slack operator diagnostics. Never an execution grant. + +The private host owns authentication and immutable policy resolution. Its probe +must observe that authority afresh; a registration or an uploaded report cannot +stand in for a reachable qualified supervisor. No credential discovery here. +""" +from __future__ import annotations + +import json +import math +import os +from pathlib import Path +import secrets +import selectors +import signal +import stat +import subprocess +import time + +from .context_store import strict_json +from .supervisor_contract_v2 import SCHEMA as SUPERVISOR_SCHEMA + +SCHEMA = "code_mower.slack_readiness.v1" +PROBE_SCHEMA = "code_mower.slack_probe.v1" +MAX_BYTES = 16384 +PROBE_TIMEOUT = 5 +MAX_AGE = 120 + +# Every diagnostic leaf is selected here, never interpolated from private input. +COMPONENTS = { + "ingress": ("enabled", {"disabled", "unreachable"}, + "Verify owner-authorized control-plane and interaction enablement after the private logging gate."), + "bridge": ("enabled", {"disabled", "unreachable", "mismatched"}, + "Verify explicit hosted bridge and host composition enablement with the qualified immutable package."), + "manifest": ("matched", {"missing", "mismatched"}, + "Import the hosted manifest; preserve commands-only scopes, rotation and the fixed routes."), + "installation": ("active", {"missing", "disabled", "revoked", "expired", "uncertain"}, + "Have an authorized administrator inspect installation health and reconnect through OAuth if needed; do not retry an uncertain rotation."), + "oauth": ("configured", {"missing", "mismatched", "revoked"}, + "Verify the app, workspace, redirect and rotating bot credentials privately in the installation service."), + "identity": ("bound", {"missing", "revoked", "mismatched"}, + "Bind the exact workspace user to a current member; remove and recreate a changed mapping."), + "repository": ("authorized", {"missing", "revoked", "mismatched"}, + "Bind the repository from the authorized catalog; remove and recreate a changed alias binding."), + "channel": ("private_verified", {"missing", "revoked", "stale", "mismatched", "shared", "public"}, + "Verify the exact private, unshared channel in the installed workspace and renew its policy within one hour."), + "registration": ("configured", {"missing", "disabled", "stale", "mismatched"}, + "Register the maintained v2 orchestrator explicitly; registration alone does not establish reachability."), + "supervisor": ("qualified_reachable", {"unqualified", "unreachable", "stale", "revoked", "mismatched"}, + "Restore the qualified supervisor connection and fresh heartbeat; reconcile any existing claim before allowing dispatch."), + "transport": ("ready", {"missing", "disabled", "revoked", "unreachable", "unsupported", "uncertain"}, + "Check the hosted Devin transport and its scoped authorization privately without creating a session; reconcile uncertain work before any retry."), + "campaign": ("active", {"missing", "disabled", "expired", "revoked", "mismatched"}, + "Record an explicit unexpired owner-approved campaign with task, aggregate, runtime and review limits."), +} +CAP_KEYS = {"task_acu", "campaign_acu", "reserved_acu", "task_limit", "reserved_tasks", + "runtime_calls", "runtime_seconds", "review_rounds", "review_budget_usd", + "clarification_answers", "fix_requests", "recovery_creates"} + + +class ReadinessError(ValueError): + """Only fixed error codes may cross this boundary.""" + + +def _number(value): + return type(value) in {int, float} and math.isfinite(value) and 0 <= value <= 10**12 + + +def decode(raw: bytes) -> dict: + try: + if type(raw) is not bytes or len(raw) > MAX_BYTES: + raise ValueError + value = strict_json(raw) + if (type(value) is not dict or set(value) != { + "schema", "nonce", "observed_at", "expires_at", "components", + "supervisor_product", "supervisor_contract", "caps", + } or value["schema"] != PROBE_SCHEMA): + raise ValueError + nonce = value["nonce"] + if (type(nonce) is not str or len(nonce) != 64 + or any(c not in "0123456789abcdef" for c in nonce)): + raise ValueError + if any(not _number(value[k]) for k in ("observed_at", "expires_at")): + raise ValueError + components = value["components"] + if type(components) is not dict or set(components) != set(COMPONENTS): + raise ValueError + for key, (ready, failures, _) in COMPONENTS.items(): + if type(components[key]) is not str or components[key] not in {ready, *failures}: + raise ValueError + if (value["supervisor_product"] not in ("codex", "claude", "devin", "none") + or value["supervisor_contract"] not in (SUPERVISOR_SCHEMA, "unsupported", "none")): + raise ValueError + caps = value["caps"] + if type(caps) is not dict or set(caps) != CAP_KEYS or any(not _number(v) for v in caps.values()): + raise ValueError + for key in CAP_KEYS - {"review_budget_usd"}: + if type(caps[key]) is not int: + raise ValueError + return value + except Exception: + raise ReadinessError("invalid_observation") from None + + +def _check(name, state, passed, remediation): + return {"name": name, "state": state, "status": "pass" if passed else "fail", + "remediation": "" if passed else remediation} + + +def report(observation: dict | None = None, *, now=None, live=False, error=None) -> dict: + """Project safe facts, not identities, caps, nonces, times or provider output. + + Even an all-green live report is advisory: the supervisor must independently + reauthorize at execution. Offline snapshots never establish live readiness. + """ + checks = [] + if error not in {None, "probe_required", "probe_failed", "probe_timeout", "invalid_observation", + "probe_mismatch", "snapshot_unavailable"}: + error = "invalid_observation" + if observation is not None: + try: + observation = decode(json.dumps(observation, allow_nan=False).encode()) + except Exception: + observation, error = None, "invalid_observation" + if observation is None: + checks.append(_check("observation", error or "probe_required", False, + "Use the trusted private host's read-only readiness probe; never paste private admin or provider responses into diagnostics.")) + for name, (_, _, remediation) in COMPONENTS.items(): + checks.append(_check(name, "not_observed", False, remediation)) + checks.append(_check("budgets", "not_observed", False, + "Verify the owner-approved task and aggregate campaign caps on the private host.")) + else: + now = time.time() if now is None else now + observed, expires = observation["observed_at"], observation["expires_at"] + fresh = (_number(now) and 0 <= now - observed < MAX_AGE + and observed < expires <= observed + MAX_AGE and now < expires) + checks.append(_check("observation", "fresh" if fresh else "stale", fresh, + "Obtain a fresh observation from the same authorized private host; do not reuse a saved readiness result.")) + for name, (ready, _, remediation) in COMPONENTS.items(): + state = observation["components"][name] + checks.append(_check(name, state, state == ready, remediation)) + qualified = (observation["supervisor_product"] == "codex" + and observation["supervisor_contract"] == SUPERVISOR_SCHEMA) + checks.append(_check("supervisor_contract", "supported" if qualified else "unqualified", qualified, + "Use the currently maintained Codex supervisor v2 adapter; a Claude registration or hosted Devin builder does not qualify that adapter.")) + caps = observation["caps"] + configured = all(caps[k] > 0 for k in ( + "task_acu", "campaign_acu", "task_limit", "runtime_calls", "runtime_seconds", + "review_rounds", "review_budget_usd", "clarification_answers")) + bounded = (configured and caps["task_acu"] <= caps["campaign_acu"] + and 1 <= caps["task_acu"] <= 100 and 1 <= caps["task_limit"] <= 50 + and 2 <= caps["runtime_calls"] <= 32 and 1 <= caps["runtime_seconds"] <= 300 + and 1 <= caps["review_rounds"] <= 9 and 1 <= caps["clarification_answers"] <= 32 + and 0 <= caps["fix_requests"] <= 8 and caps["recovery_creates"] == 0) + available = (bounded and caps["reserved_acu"] + caps["task_acu"] <= caps["campaign_acu"] + and caps["reserved_tasks"] < caps["task_limit"]) + state = ("missing" if not configured else "mismatched" if not bounded + else "available" if available else "exhausted") + checks.append(_check("budgets", state, available, + "Configure positive task and campaign ACU caps, task count, runtime and review ceilings with zero recovery creates; retain reservations after cancellation or uncertain billing.")) + if not live: + checks.append(_check("live_probe", "not_observed", False, + "Run the trusted host probe explicitly; an offline snapshot is not proof of current reachability.")) + return {"schema": SCHEMA, "basis": "live_probe" if live else "offline", + "ready": all(c["status"] == "pass" for c in checks), + "dispatch_authorized": False, "checks": checks} + + +def probe(executable: Path) -> dict: + """One explicitly selected trusted host executable, no shell/retry/log files. + + This is an operator trust boundary, not a sandbox for arbitrary commands. + The host supplies credentials itself and must implement read-only inspection. + Bound both pipes together and the process group, including inherited pipes. + """ + process = None + nonce = secrets.token_hex(32) + started = time.time() + deadline = time.monotonic() + PROBE_TIMEOUT + try: + info = executable.stat() + if (not executable.is_absolute() or not stat.S_ISREG(info.st_mode) + or not os.access(executable, os.X_OK)): + raise ReadinessError("probe_failed") + process = subprocess.Popen([str(executable)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + request = {"schema": PROBE_SCHEMA, "nonce": nonce} + process.stdin.write(json.dumps(request).encode() + b"\n") + process.stdin.close() + output = bytearray() + size = 0 + with selectors.DefaultSelector() as selector: + for pipe in (process.stdout, process.stderr): + os.set_blocking(pipe.fileno(), False) + selector.register(pipe, selectors.EVENT_READ) + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ReadinessError("probe_timeout") + for key, _ in selector.select(remaining): + chunk = os.read(key.fd, MAX_BYTES + 1) + if not chunk: + selector.unregister(key.fileobj) + else: + size += len(chunk) + if size > MAX_BYTES: + raise ReadinessError("invalid_observation") + if key.fileobj is process.stdout: + output.extend(chunk) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ReadinessError("probe_timeout") + if process.wait(timeout=remaining) != 0: + raise ReadinessError("probe_failed") + value = decode(bytes(output)) + if value["nonce"] != nonce or value["observed_at"] < started: + raise ReadinessError("probe_mismatch") + return report(value, live=True) + except subprocess.TimeoutExpired: + return report(live=True, error="probe_timeout") + except ReadinessError as exc: + return report(live=True, error=str(exc)) + except Exception: + return report(live=True, error="probe_failed") + finally: + if process is not None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + for pipe in (process.stdin, process.stdout, process.stderr): + pipe.close() + + +def read_snapshot(path: Path) -> dict: + """Read bounded regular files without following a final symlink or a FIFO.""" + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ReadinessError("snapshot_unavailable") + return report(decode(stream.read(MAX_BYTES + 1))) + except ReadinessError as exc: + return report(error=str(exc)) + except Exception: + return report(error="snapshot_unavailable") + + +def render(value: dict) -> str: + lines = ["Slack readiness: " + ("observed ready" if value["ready"] else "not ready"), + "Advisory only; execution requires fresh supervisor authorization."] + for check in value["checks"]: + lines.append(f"{check['status']}: {check['name']}: {check['state']}") + if check["remediation"]: + lines.append(" " + check["remediation"]) + return "\n".join(lines) diff --git a/src/code_mower/slack_setup.py b/src/code_mower/slack_setup.py new file mode 100644 index 00000000..e2172ed1 --- /dev/null +++ b/src/code_mower/slack_setup.py @@ -0,0 +1,63 @@ +"""Explicit optional Slack preparation; default installation never calls this.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys + +from . import slack_readiness + +MANIFEST = Path(__file__).parent / "templates" / "slack" / "hosted-app-manifest.json" + + +class Parser(argparse.ArgumentParser): + def error(self, message): + # argparse normally reflects unrecognized arguments, including secrets. + self.exit(2, "Slack command arguments are invalid; use --help.\n") + + +def main(argv=None): + parser = Parser(prog="code-mower slack", description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + setup = commands.add_parser("setup", help="Prepare an optional hosted app manifest; no remote changes.") + setup.add_argument("--manifest", type=Path, required=True, help="New file to create; existing files are never overwritten.") + consent = setup.add_mutually_exclusive_group(required=True) + consent.add_argument("--yes", action="store_true", help="Explicitly opt in to local preparation.") + consent.add_argument("--interactive", action="store_true", help="Confirm local preparation interactively.") + doctor = commands.add_parser("doctor", help="Read-only, redacted Slack readiness; no dispatch.") + source = doctor.add_mutually_exclusive_group() + source.add_argument("--probe", type=Path, help="Explicit trusted private host executable implementing the read-only probe protocol.") + source.add_argument("--snapshot", type=Path, help="Inspect a bounded offline observation; never establishes live readiness.") + doctor.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + if args.command == "doctor": + result = (slack_readiness.probe(args.probe) if args.probe else + slack_readiness.read_snapshot(args.snapshot) if args.snapshot else + slack_readiness.report()) + print(json.dumps(result, sort_keys=True) if args.json else slack_readiness.render(result)) + return 0 if result["ready"] else 1 + try: + if args.interactive: + if not sys.stdin.isatty(): + print("Interactive setup requires a terminal; scripted opt-in uses --yes.") + return 1 + if input("Prepare optional Slack setup for one private workspace? [y/N] ").strip().lower() != "y": + print("Slack setup not prepared.") + return 0 + raw = MANIFEST.read_bytes() + # Exclusive creation also rejects symlinks. The manifest contains only + # public routes and static scope configuration, never installation data. + fd = os.open(args.manifest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(raw) + except (OSError, EOFError): + print("Slack setup could not create a new manifest; choose an unused file in an existing writable directory.") + return 1 + print("Optional Slack manifest prepared. Follow docs/slack-setup.md for administrator OAuth, immutable policy, supervision and caps. No service or login was started.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/code_mower/templates/slack/hosted-app-manifest.json b/src/code_mower/templates/slack/hosted-app-manifest.json new file mode 100644 index 00000000..3810b07d --- /dev/null +++ b/src/code_mower/templates/slack/hosted-app-manifest.json @@ -0,0 +1,30 @@ +{ + "_metadata": {"major_version": 1, "minor_version": 1}, + "display_information": { + "name": "Code Mower", + "description": "Private requests to a qualified Code Mower supervisor" + }, + "features": { + "bot_user": {"display_name": "Code Mower", "always_online": false}, + "slash_commands": [{ + "command": "/codemower", + "url": "https://codemower.com/api/slack/commands", + "description": "Request a private supervised task action", + "usage_hint": "help|start|status|answer|cancel [alias]", + "should_escape": false + }] + }, + "oauth_config": { + "redirect_urls": ["https://codemower.com/api/slack/oauth/callback"], + "scopes": {"bot": ["commands"]} + }, + "settings": { + "interactivity": { + "is_enabled": true, + "request_url": "https://codemower.com/api/slack/interactions" + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": true + } +} diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 737529b6..da952998 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -548,6 +548,7 @@ def test_cli_command_registry_is_single_source_of_truth(self) -> None: "reviewer-metrics", "saas-reviewer-labeler", "session", + "slack", "telemetry", "trailer-comment-labeler", "tracker", diff --git a/tests/test_slack_setup.py b/tests/test_slack_setup.py new file mode 100644 index 00000000..03ec9e8d --- /dev/null +++ b/tests/test_slack_setup.py @@ -0,0 +1,269 @@ +"""Synthetic operator/probe tests; no Slack, credential or provider calls.""" +from contextlib import redirect_stdout, redirect_stderr +from copy import deepcopy +from io import StringIO +import json +import os +from pathlib import Path +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +from code_mower import cli, doctor, slack_readiness as readiness, slack_setup + + +def observation(): + now = time.time() + return {"schema": readiness.PROBE_SCHEMA, "nonce": "0" * 64, + "observed_at": now, "expires_at": now + 100, + "components": {key: rule[0] for key, rule in readiness.COMPONENTS.items()}, + "supervisor_product": "codex", "supervisor_contract": readiness.SUPERVISOR_SCHEMA, + "caps": {"task_acu": 1, "campaign_acu": 2, "reserved_acu": 0, + "task_limit": 2, "reserved_tasks": 0, "runtime_calls": 4, + "runtime_seconds": 120, "review_rounds": 1, "review_budget_usd": 2, + "clarification_answers": 1, "fix_requests": 0, "recovery_creates": 0}} + + +class ReadinessTests(unittest.TestCase): + def check(self, value, name): + return next(c for c in value["checks"] if c["name"] == name) + + def test_live_readiness_is_advisory_and_offline_cannot_pass(self): + value = observation() + self.assertTrue(readiness.report(value, live=True)["ready"]) + self.assertFalse(readiness.report(value, live=True)["dispatch_authorized"]) + self.assertFalse(readiness.report(value)["ready"]) + self.assertFalse(readiness.report()["ready"]) + self.assertEqual(self.check(readiness.report(), "supervisor")["state"], "not_observed") + + def test_every_component_failure_blocks_with_separate_remediation(self): + for name, (_, states, _) in readiness.COMPONENTS.items(): + for state in states: + with self.subTest(name=name, state=state): + value = observation() + value["components"][name] = state + result = readiness.report(value, live=True) + self.assertFalse(result["ready"]) + self.assertEqual(self.check(result, name)["state"], state) + self.assertTrue(self.check(result, name)["remediation"]) + + def test_registration_does_not_imply_qualified_reachability(self): + for state in ("unqualified", "unreachable", "stale", "revoked", "mismatched"): + value = observation() + value["components"]["supervisor"] = state + result = readiness.report(value, live=True) + self.assertEqual(self.check(result, "registration")["status"], "pass") + self.assertEqual(self.check(result, "supervisor")["status"], "fail") + for product in ("claude", "devin", "none"): + value = observation() + value["supervisor_product"] = product + self.assertFalse(readiness.report(value, live=True)["ready"]) + value = observation() + value["supervisor_contract"] = "unsupported" + self.assertFalse(readiness.report(value, live=True)["ready"]) + + def test_stale_future_expired_and_overlong_observations_fail(self): + for start, end in ((-121, 1), (1, 100), (-1, -1), (-1, 121), (0, 0)): + value = observation() + value.update(observed_at=1000 + start, expires_at=1000 + end) + result = readiness.report(value, live=True, now=1000) + self.assertFalse(result["ready"]) + self.assertEqual(self.check(result, "observation")["state"], "stale") + + def test_numeric_caps_and_nonrefundable_reservations(self): + cases = [({"task_acu": 0}, "missing"), ({"campaign_acu": 0}, "missing"), + ({"review_budget_usd": 0}, "missing"), ({"runtime_calls": 0}, "missing"), + ({"runtime_seconds": 0}, "missing"), ({"review_rounds": 0}, "missing"), + ({"clarification_answers": 0}, "missing"), ({"runtime_calls": 1}, "mismatched"), + ({"runtime_seconds": 301}, "mismatched"), ({"review_rounds": 10}, "mismatched"), + ({"clarification_answers": 33}, "mismatched"), ({"fix_requests": 9}, "mismatched"), + ({"task_acu": 3}, "mismatched"), ({"task_limit": 51}, "mismatched"), + ({"recovery_creates": 1}, "mismatched"), ({"reserved_acu": 2}, "exhausted"), + ({"reserved_tasks": 2}, "exhausted")] + for updates, expected in cases: + with self.subTest(updates=updates): + value = observation() + value["caps"].update(updates) + result = readiness.report(value, live=True) + self.assertFalse(result["ready"]) + self.assertEqual(self.check(result, "budgets")["state"], expected) + + def test_malformed_and_private_input_never_appears_in_diagnostics(self): + private = "synthetic-private-identity-channel-repository-token-url-task" + original = observation() + values = [private, [], None] + for key in original: + value = deepcopy(original) + value[key] = private + values.append(value) + for key in original["components"]: + value = deepcopy(original) + value["components"][key] = private + values.append(value) + for bad in (True, -1, float("inf"), float("nan"), "1", None, 1.5): + value = deepcopy(original) + value["caps"]["task_acu"] = bad + values.append(value) + value = deepcopy(original) + value["private"] = private + values.append(value) + for value in values: + result = readiness.report(value, live=True) + self.assertFalse(result["ready"]) + self.assertNotIn(private, json.dumps(result) + readiness.render(result)) + result = readiness.report(original, live=True) + for omitted in ("nonce", "observed_at", "expires_at", "task_acu", "reserved_tasks"): + self.assertNotIn(omitted, json.dumps(result)) + + def test_parser_rejects_duplicate_keys_and_oversize(self): + raw = json.dumps(observation()).encode() + for value in (b'{"schema":"bad",' + raw[1:], b" " * (readiness.MAX_BYTES + 1), + b"[" * 100, b"\xff", b'{"x":NaN}'): + with self.assertRaisesRegex(readiness.ReadinessError, "invalid_observation"): + readiness.decode(value) + + def test_snapshot_is_bounded_offline_and_rejects_symlink_fifo(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "observation.json" + path.write_text(json.dumps(observation())) + self.assertFalse(readiness.read_snapshot(path)["ready"]) + alias = Path(root) / "alias" + alias.symlink_to(path) + self.assertEqual(self.check(readiness.read_snapshot(alias), "observation")["state"], "snapshot_unavailable") + fifo = Path(root) / "fifo" + os.mkfifo(fifo) + self.assertEqual(self.check(readiness.read_snapshot(fifo), "observation")["state"], "snapshot_unavailable") + path.write_bytes(b" " * (readiness.MAX_BYTES + 1)) + self.assertEqual(self.check(readiness.read_snapshot(path), "observation")["state"], "invalid_observation") + + +class ProbeTests(unittest.TestCase): + def run_probe(self, body): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "probe" + path.write_text(f"#!{sys.executable}\nimport json, sys, time, os\n" + body) + path.chmod(0o700) + return readiness.probe(path) + + def test_live_nonce_bound_read_only_protocol(self): + result = self.run_probe("request = json.load(sys.stdin)\n" + + "value = " + repr(observation()) + "\n" + + "value.update(nonce=request['nonce'], observed_at=time.time(), expires_at=time.time()+60)\n" + + "print(json.dumps(value))\n") + self.assertTrue(result["ready"]) + self.assertFalse(result["dispatch_authorized"]) + + def test_cached_nonce_and_pre_request_observation_are_rejected(self): + result = self.run_probe("print(" + repr(json.dumps(observation())) + ")\n") + self.assertFalse(result["ready"]) + self.assertEqual(result["checks"][0]["state"], "probe_mismatch") + result = self.run_probe("request = json.load(sys.stdin)\n" + + "value = " + repr(observation()) + "\n" + + "value['nonce'] = request['nonce']\nprint(json.dumps(value))\n") + self.assertEqual(result["checks"][0]["state"], "probe_mismatch") + + def test_probe_failure_overflow_timeout_and_private_errors_are_redacted(self): + private = "synthetic-private-provider-response" + for body, expected in ( + (f"print({private!r}, file=sys.stderr)\nsys.exit(1)\n", "probe_failed"), + ("sys.stdout.write('x' * 20000)\n", "invalid_observation"), + ("sys.stderr.write('x' * 20000)\n", "invalid_observation"), + ("time.sleep(10)\n", "probe_timeout"), + ("os.close(1)\nos.close(2)\ntime.sleep(10)\n", "probe_timeout"), + ): + timeout = 1 if expected == "probe_timeout" else 5 + with self.subTest(expected=expected), patch.object(readiness, "PROBE_TIMEOUT", timeout): + started = time.monotonic() + result = self.run_probe(body) + self.assertLess(time.monotonic() - started, 6) + self.assertFalse(result["ready"]) + self.assertEqual(result["checks"][0]["state"], expected) + self.assertNotIn(private, json.dumps(result)) + + def test_inherited_pipes_cannot_extend_deadline(self): + with patch.object(readiness, "PROBE_TIMEOUT", 1): + result = self.run_probe("if os.fork() == 0: time.sleep(10)\nelse: sys.exit(0)\n") + self.assertEqual(result["checks"][0]["state"], "probe_timeout") + + def test_relative_and_missing_probe_fail_without_path_disclosure(self): + for path in (Path("private-probe"), Path("/nonexistent/synthetic-private-probe")): + result = readiness.probe(path) + self.assertFalse(result["ready"]) + self.assertNotIn("private-probe", json.dumps(result)) + + +class SetupTests(unittest.TestCase): + def call(self, args): + output = StringIO() + with redirect_stdout(output), redirect_stderr(output): + try: + code = slack_setup.main(args) + except SystemExit as exc: + code = exc.code + return code, output.getvalue() + + def test_scripted_opt_in_exports_only_hosted_manifest_and_never_overwrites(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "manifest.json" + args = ["setup", "--manifest", str(path)] + self.assertEqual(self.call(args)[0], 2) + self.assertFalse(path.exists()) + self.assertEqual(self.call([*args, "--yes"])[0], 0) + manifest = json.loads(path.read_bytes()) + self.assertEqual(manifest["oauth_config"]["scopes"], {"bot": ["commands"]}) + self.assertTrue(manifest["settings"]["token_rotation_enabled"]) + self.assertNotIn("event_subscriptions", manifest["settings"]) + self.assertEqual(manifest["features"]["slash_commands"][0]["command"], "/codemower") + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(self.call([*args, "--yes"])[0], 1) + self.assertEqual(json.loads(path.read_bytes()), manifest) + + def test_interactive_yes_no_and_no_terminal(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "manifest.json" + args = ["setup", "--manifest", str(path), "--interactive"] + with patch.object(sys.stdin, "isatty", return_value=False): + self.assertEqual(self.call(args)[0], 1) + with patch.object(sys.stdin, "isatty", return_value=True), patch("builtins.input", return_value="n"): + self.assertEqual(self.call(args)[0], 0) + self.assertFalse(path.exists()) + with patch.object(sys.stdin, "isatty", return_value=True), patch("builtins.input", return_value="y"): + self.assertEqual(self.call(args)[0], 0) + self.assertTrue(path.exists()) + + def test_no_probe_does_not_start_process_or_read_environment(self): + with patch.object(readiness.subprocess, "Popen") as process: + code, output = self.call(["doctor", "--json"]) + self.assertEqual(code, 1) + self.assertFalse(json.loads(output)["ready"]) + process.assert_not_called() + + def test_doctor_alias_bypasses_generic_private_output(self): + output = StringIO() + with redirect_stdout(output), patch.object(doctor, "run_doctor") as generic: + self.assertEqual(cli.main(["doctor", "--slack", "--json"]), 1) + generic.assert_not_called() + self.assertEqual(json.loads(output.getvalue())["schema"], readiness.SCHEMA) + with redirect_stdout(StringIO()): + self.assertEqual(cli.main(["slack", "doctor", "--json"]), 1) + + def test_bad_arguments_and_paths_do_not_echo_private_values(self): + private = "synthetic-private-identity" + for args in (["doctor", "--" + private], ["doctor", "--snapshot", private], + ["setup", "--yes", "--manifest", private + "/missing"]): + code, output = self.call(args) + self.assertNotEqual(code, 0) + self.assertNotIn(private, output) + + def test_default_participants_and_first_user_help_unchanged(self): + self.assertNotIn("slack", cli.FIRST_USER_COMMANDS) + # The packaged defaults contain no Slack hook, dependency or prompt. + root = Path(slack_setup.__file__).parent + for relative in ("templates/code-mower.example.yml", "templates/providers.yml", "init.py"): + self.assertNotIn("slack", (root / relative).read_text().lower()) + + +if __name__ == "__main__": + unittest.main()