heyecco 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- heyecco-0.1.0/.gitignore +72 -0
- heyecco-0.1.0/PKG-INFO +157 -0
- heyecco-0.1.0/README.md +141 -0
- heyecco-0.1.0/ecco_cli/__init__.py +1 -0
- heyecco-0.1.0/ecco_cli/bootstrap.py +282 -0
- heyecco-0.1.0/ecco_cli/discovery.py +161 -0
- heyecco-0.1.0/ecco_cli/files.py +195 -0
- heyecco-0.1.0/ecco_cli/main.py +178 -0
- heyecco-0.1.0/ecco_cli/metadata.py +122 -0
- heyecco-0.1.0/ecco_cli/migration.py +87 -0
- heyecco-0.1.0/ecco_cli/serialization.py +110 -0
- heyecco-0.1.0/pyproject.toml +40 -0
- heyecco-0.1.0/tests/test_bootstrap.py +253 -0
- heyecco-0.1.0/tests/test_command.py +172 -0
- heyecco-0.1.0/tests/test_discovery.py +129 -0
- heyecco-0.1.0/tests/test_files.py +74 -0
- heyecco-0.1.0/tests/test_metadata.py +101 -0
- heyecco-0.1.0/tests/test_migration.py +96 -0
- heyecco-0.1.0/tests/test_native_clients.py +117 -0
- heyecco-0.1.0/tests/test_serialization.py +73 -0
- heyecco-0.1.0/uv.lock +56 -0
heyecco-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
dist/
|
|
14
|
+
build/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
|
|
17
|
+
# Node / Next.js
|
|
18
|
+
node_modules/
|
|
19
|
+
.next/
|
|
20
|
+
out/
|
|
21
|
+
.turbo/
|
|
22
|
+
*.tsbuildinfo
|
|
23
|
+
next-env.d.ts
|
|
24
|
+
|
|
25
|
+
# Env / secrets — credentials live in Railway, fetched via `railway run`
|
|
26
|
+
.env
|
|
27
|
+
.env.local
|
|
28
|
+
.env.*.local
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
31
|
+
|
|
32
|
+
# IDE
|
|
33
|
+
.vscode/
|
|
34
|
+
.idea/
|
|
35
|
+
*.swp
|
|
36
|
+
*.swo
|
|
37
|
+
|
|
38
|
+
# Claude Code — agent worktrees are ephemeral scratch, never commit them
|
|
39
|
+
.claude/worktrees/
|
|
40
|
+
|
|
41
|
+
# OS
|
|
42
|
+
.DS_Store
|
|
43
|
+
Thumbs.db
|
|
44
|
+
|
|
45
|
+
# Local data
|
|
46
|
+
*.db
|
|
47
|
+
*.sqlite
|
|
48
|
+
*.sqlite3
|
|
49
|
+
data/
|
|
50
|
+
state/
|
|
51
|
+
|
|
52
|
+
# Build artifacts
|
|
53
|
+
.cache/
|
|
54
|
+
.parcel-cache/
|
|
55
|
+
|
|
56
|
+
# Lorah harness guard — these belong in lorah-app repo, not ecco (ECO-89)
|
|
57
|
+
backend/ecco/lorah_scheduler/
|
|
58
|
+
backend/tests/test_linear_queue_priority.py
|
|
59
|
+
|
|
60
|
+
# Test artifacts
|
|
61
|
+
frontend/coverage/
|
|
62
|
+
frontend/playwright-report/
|
|
63
|
+
frontend/test-results/
|
|
64
|
+
frontend/package-lock.json
|
|
65
|
+
|
|
66
|
+
# stray local screenshot (untracked 2026-08-20)
|
|
67
|
+
railway.png
|
|
68
|
+
|
|
69
|
+
docs/ai-powered-development-phases.md
|
|
70
|
+
|
|
71
|
+
# ECO-1198: Sanity Studio local state
|
|
72
|
+
studio/.sanity/
|
heyecco-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: heyecco
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Start and resume an Ecco agent board without an account
|
|
5
|
+
Project-URL: Homepage, https://heyecco.com/for-agents
|
|
6
|
+
Project-URL: Documentation, https://heyecco.com/for-agents
|
|
7
|
+
License-Expression: AGPL-3.0-only
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Topic :: Office/Business :: Groupware
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: tomlkit<1,>=0.13.2
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Ecco CLI
|
|
18
|
+
|
|
19
|
+
This package supplies signup-free agent boards and safe connector migration.
|
|
20
|
+
Python 3.11+ is required. Start from any directory without a GitHub checkout or
|
|
21
|
+
Ecco account:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uvx heyecco new "Launch Acme" --task "Draft the launch plan"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For local development:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
cd cli
|
|
31
|
+
uv sync
|
|
32
|
+
uv run ecco --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Start a board without an account
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
heyecco new "Launch Acme" \
|
|
39
|
+
--task "Draft the launch plan" \
|
|
40
|
+
--task-body "## Outcome\nA reviewable launch plan exists."
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The public PyPI package is named `heyecco` because the `ecco` package name belongs
|
|
44
|
+
to an unrelated project. It installs both `heyecco` and `ecco` commands; existing
|
|
45
|
+
`--task-title` calls remain supported as an alias for `--task`.
|
|
46
|
+
|
|
47
|
+
The command creates caller-owned random worker, claim and recovery secrets, writes
|
|
48
|
+
them to a private state file before making the HTTPS request, and prints one JSON
|
|
49
|
+
receipt containing public board metadata plus the state-file and claim-link-file
|
|
50
|
+
paths. It never prints a raw secret or writes one into a repository or native
|
|
51
|
+
client configuration. The board accepts 100 task creations and agent access ends
|
|
52
|
+
after seven days; the private claim link remains usable for 30 more days.
|
|
53
|
+
|
|
54
|
+
If the first response is lost, the CLI retries the exact request once with the
|
|
55
|
+
stored recovery proof. If the outcome remains unknown, use the reported state:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
ecco resume --state /private/path/ATTEMPT.json --retry-bootstrap
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
To run an agent with the saved bearer without printing it, place the agent command
|
|
62
|
+
after `--`. The child receives `ECCO_AGENT_TOKEN`, `ECCO_MCP_URL`, and
|
|
63
|
+
`ECCO_WORKSPACE_ID` in its environment:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
ecco resume --state /private/path/ATTEMPT.json -- your-agent-command
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The child or its supported MCP configuration should read the token from
|
|
70
|
+
`ECCO_AGENT_TOKEN`. The full human handoff URL is in the mode-0600
|
|
71
|
+
`*.claim-link.txt` file named in the receipt; share it only with the intended
|
|
72
|
+
owner.
|
|
73
|
+
|
|
74
|
+
### Supported paths
|
|
75
|
+
|
|
76
|
+
| Platform | `ecco new` / `resume` | Private state |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| macOS | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
|
|
79
|
+
| Linux | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
|
|
80
|
+
| Windows | Supported | `%LOCALAPPDATA%\ecco\boards`, protected by the user profile ACL |
|
|
81
|
+
|
|
82
|
+
Connector migration below remains POSIX-only because it uses advisory file locks.
|
|
83
|
+
|
|
84
|
+
## Migrate an existing connector
|
|
85
|
+
|
|
86
|
+
Close the client before applying a change. Preview from the project where the
|
|
87
|
+
connector is used; select its existing name and scope, not a new identity:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For Codex use `--client codex`. Claude supports `user`, `project`, and `local`;
|
|
94
|
+
Codex supports `user` and `project`. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are
|
|
95
|
+
honored. No credential-store files are read and no Keychain process is invoked.
|
|
96
|
+
Preview is read-only and prints a digest, not configuration contents.
|
|
97
|
+
|
|
98
|
+
Repeat with the returned digest to apply:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
|
|
102
|
+
--apply --expected-digest DIGEST_FROM_PREVIEW --client-closed
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Only the exact legacy Ecco Railway URL is replaced by
|
|
106
|
+
`https://api.heyecco.com/mcp`. Existing connector names, OAuth registration,
|
|
107
|
+
permissions, unrelated settings and formatting are retained. The canonical URL
|
|
108
|
+
is a no-op. Public OAuth metadata must match before a changed file is written.
|
|
109
|
+
Its check has a 12-second wall-clock deadline and never sends credentials.
|
|
110
|
+
|
|
111
|
+
Duplicated names across local config layers, selected profiles, known managed
|
|
112
|
+
files, unsafe files and changed previews stop migration. The command never edits
|
|
113
|
+
managed policy, trust settings or OAuth caches. On-disk discovery does not prove
|
|
114
|
+
the effective configuration of a running app: cloud policy, launch overrides and
|
|
115
|
+
desktop-specific behavior remain the native client's responsibility. If the
|
|
116
|
+
client still reports a different URL, inspect that client's active source; do not
|
|
117
|
+
delete other connectors or weaken resource validation.
|
|
118
|
+
|
|
119
|
+
Apply returns `reload_required`, **not connected**. Reload the connector. A native
|
|
120
|
+
OAuth client may require authorization for the canonical resource because cached
|
|
121
|
+
grants can be bound to the old resource or client registration. This command does
|
|
122
|
+
not transplant refresh tokens, trigger login or assume the old grant is valid for
|
|
123
|
+
the new URL. Under the no-Keychain policy, do not launch macOS Claude OAuth login
|
|
124
|
+
until a supported non-Keychain path has been selected. Configuration parsing,
|
|
125
|
+
authorization and an authenticated `list_projects` call are separate proofs.
|
|
126
|
+
|
|
127
|
+
## Recovery
|
|
128
|
+
|
|
129
|
+
Apply retains a private mode-0600 backup beside the config and reports its path.
|
|
130
|
+
It may contain existing configuration secrets: never share or commit it. To undo,
|
|
131
|
+
preview the current file again and use its new digest:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
|
|
135
|
+
--rollback /absolute/config.ecco-backup-SUFFIX \
|
|
136
|
+
--expected-digest CURRENT_DIGEST --client-closed
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Rollback works offline and refuses to overwrite later edits. An `inspect_required`
|
|
140
|
+
result means replacement occurred but filesystem durability could not be confirmed;
|
|
141
|
+
inspect the file and retained backup rather than retrying blindly. Ecco's advisory
|
|
142
|
+
lock coordinates its own processes, not arbitrary editors.
|
|
143
|
+
|
|
144
|
+
## Verification
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
uv run ruff check .
|
|
148
|
+
uv run ruff format --check .
|
|
149
|
+
uv run python -m unittest discover -s tests -v
|
|
150
|
+
uv build --wheel
|
|
151
|
+
# macOS only, with installed Claude and Codex; no auth or model calls:
|
|
152
|
+
ECCO_NATIVE_CLIENT_SMOKE=1 uv run python -m unittest discover -s tests -p test_native_clients.py -v
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The opt-in native checks use disposable configs and deny network, Keychain and
|
|
156
|
+
real-client configuration access at the OS boundary. They verify parsing, not
|
|
157
|
+
the normal-permission end-to-end login acceptance owned by ECO-1032.
|
heyecco-0.1.0/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Ecco CLI
|
|
2
|
+
|
|
3
|
+
This package supplies signup-free agent boards and safe connector migration.
|
|
4
|
+
Python 3.11+ is required. Start from any directory without a GitHub checkout or
|
|
5
|
+
Ecco account:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uvx heyecco new "Launch Acme" --task "Draft the launch plan"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For local development:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
cd cli
|
|
15
|
+
uv sync
|
|
16
|
+
uv run ecco --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Start a board without an account
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
heyecco new "Launch Acme" \
|
|
23
|
+
--task "Draft the launch plan" \
|
|
24
|
+
--task-body "## Outcome\nA reviewable launch plan exists."
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The public PyPI package is named `heyecco` because the `ecco` package name belongs
|
|
28
|
+
to an unrelated project. It installs both `heyecco` and `ecco` commands; existing
|
|
29
|
+
`--task-title` calls remain supported as an alias for `--task`.
|
|
30
|
+
|
|
31
|
+
The command creates caller-owned random worker, claim and recovery secrets, writes
|
|
32
|
+
them to a private state file before making the HTTPS request, and prints one JSON
|
|
33
|
+
receipt containing public board metadata plus the state-file and claim-link-file
|
|
34
|
+
paths. It never prints a raw secret or writes one into a repository or native
|
|
35
|
+
client configuration. The board accepts 100 task creations and agent access ends
|
|
36
|
+
after seven days; the private claim link remains usable for 30 more days.
|
|
37
|
+
|
|
38
|
+
If the first response is lost, the CLI retries the exact request once with the
|
|
39
|
+
stored recovery proof. If the outcome remains unknown, use the reported state:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
ecco resume --state /private/path/ATTEMPT.json --retry-bootstrap
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To run an agent with the saved bearer without printing it, place the agent command
|
|
46
|
+
after `--`. The child receives `ECCO_AGENT_TOKEN`, `ECCO_MCP_URL`, and
|
|
47
|
+
`ECCO_WORKSPACE_ID` in its environment:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
ecco resume --state /private/path/ATTEMPT.json -- your-agent-command
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The child or its supported MCP configuration should read the token from
|
|
54
|
+
`ECCO_AGENT_TOKEN`. The full human handoff URL is in the mode-0600
|
|
55
|
+
`*.claim-link.txt` file named in the receipt; share it only with the intended
|
|
56
|
+
owner.
|
|
57
|
+
|
|
58
|
+
### Supported paths
|
|
59
|
+
|
|
60
|
+
| Platform | `ecco new` / `resume` | Private state |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| macOS | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
|
|
63
|
+
| Linux | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
|
|
64
|
+
| Windows | Supported | `%LOCALAPPDATA%\ecco\boards`, protected by the user profile ACL |
|
|
65
|
+
|
|
66
|
+
Connector migration below remains POSIX-only because it uses advisory file locks.
|
|
67
|
+
|
|
68
|
+
## Migrate an existing connector
|
|
69
|
+
|
|
70
|
+
Close the client before applying a change. Preview from the project where the
|
|
71
|
+
connector is used; select its existing name and scope, not a new identity:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For Codex use `--client codex`. Claude supports `user`, `project`, and `local`;
|
|
78
|
+
Codex supports `user` and `project`. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are
|
|
79
|
+
honored. No credential-store files are read and no Keychain process is invoked.
|
|
80
|
+
Preview is read-only and prints a digest, not configuration contents.
|
|
81
|
+
|
|
82
|
+
Repeat with the returned digest to apply:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
|
|
86
|
+
--apply --expected-digest DIGEST_FROM_PREVIEW --client-closed
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Only the exact legacy Ecco Railway URL is replaced by
|
|
90
|
+
`https://api.heyecco.com/mcp`. Existing connector names, OAuth registration,
|
|
91
|
+
permissions, unrelated settings and formatting are retained. The canonical URL
|
|
92
|
+
is a no-op. Public OAuth metadata must match before a changed file is written.
|
|
93
|
+
Its check has a 12-second wall-clock deadline and never sends credentials.
|
|
94
|
+
|
|
95
|
+
Duplicated names across local config layers, selected profiles, known managed
|
|
96
|
+
files, unsafe files and changed previews stop migration. The command never edits
|
|
97
|
+
managed policy, trust settings or OAuth caches. On-disk discovery does not prove
|
|
98
|
+
the effective configuration of a running app: cloud policy, launch overrides and
|
|
99
|
+
desktop-specific behavior remain the native client's responsibility. If the
|
|
100
|
+
client still reports a different URL, inspect that client's active source; do not
|
|
101
|
+
delete other connectors or weaken resource validation.
|
|
102
|
+
|
|
103
|
+
Apply returns `reload_required`, **not connected**. Reload the connector. A native
|
|
104
|
+
OAuth client may require authorization for the canonical resource because cached
|
|
105
|
+
grants can be bound to the old resource or client registration. This command does
|
|
106
|
+
not transplant refresh tokens, trigger login or assume the old grant is valid for
|
|
107
|
+
the new URL. Under the no-Keychain policy, do not launch macOS Claude OAuth login
|
|
108
|
+
until a supported non-Keychain path has been selected. Configuration parsing,
|
|
109
|
+
authorization and an authenticated `list_projects` call are separate proofs.
|
|
110
|
+
|
|
111
|
+
## Recovery
|
|
112
|
+
|
|
113
|
+
Apply retains a private mode-0600 backup beside the config and reports its path.
|
|
114
|
+
It may contain existing configuration secrets: never share or commit it. To undo,
|
|
115
|
+
preview the current file again and use its new digest:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
|
|
119
|
+
--rollback /absolute/config.ecco-backup-SUFFIX \
|
|
120
|
+
--expected-digest CURRENT_DIGEST --client-closed
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Rollback works offline and refuses to overwrite later edits. An `inspect_required`
|
|
124
|
+
result means replacement occurred but filesystem durability could not be confirmed;
|
|
125
|
+
inspect the file and retained backup rather than retrying blindly. Ecco's advisory
|
|
126
|
+
lock coordinates its own processes, not arbitrary editors.
|
|
127
|
+
|
|
128
|
+
## Verification
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
uv run ruff check .
|
|
132
|
+
uv run ruff format --check .
|
|
133
|
+
uv run python -m unittest discover -s tests -v
|
|
134
|
+
uv build --wheel
|
|
135
|
+
# macOS only, with installed Claude and Codex; no auth or model calls:
|
|
136
|
+
ECCO_NATIVE_CLIENT_SMOKE=1 uv run python -m unittest discover -s tests -p test_native_clients.py -v
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The opt-in native checks use disposable configs and deny network, Keychain and
|
|
140
|
+
real-client configuration access at the OS boundary. They verify parsing, not
|
|
141
|
+
the normal-permission end-to-end login acceptance owned by ECO-1032.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic Ecco client setup; never a model or an auth server."""
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Signup-free board bootstrap with caller-owned, private recovery state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
CONTRACT = "ecco.signup-free.v1"
|
|
20
|
+
DEFAULT_API = "https://api.heyecco.com"
|
|
21
|
+
MAX_RESPONSE = 262_144
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BootstrapFailure(ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
29
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_OPENER = urllib.request.build_opener(_NoRedirect())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sha(value: str) -> str:
|
|
37
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _state_root() -> Path:
|
|
41
|
+
if os.name == "nt":
|
|
42
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
43
|
+
if not base:
|
|
44
|
+
raise BootstrapFailure("LOCALAPPDATA is required for private state.")
|
|
45
|
+
return Path(base) / "ecco" / "boards"
|
|
46
|
+
base = os.environ.get("XDG_STATE_HOME")
|
|
47
|
+
root = Path(base) if base else Path.home() / ".local" / "state"
|
|
48
|
+
return root / "ecco" / "boards"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _private_directory(path: Path) -> None:
|
|
52
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
53
|
+
if path.is_symlink() or not path.is_dir():
|
|
54
|
+
raise BootstrapFailure("State directory must be a real directory.")
|
|
55
|
+
if os.name != "nt":
|
|
56
|
+
status = path.stat()
|
|
57
|
+
if status.st_uid != os.getuid() or status.st_mode & 0o077:
|
|
58
|
+
raise BootstrapFailure("State directory must be private (mode 0700).")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _write_private(path: Path, data: bytes) -> None:
|
|
62
|
+
_private_directory(path.parent)
|
|
63
|
+
fd, temporary = tempfile.mkstemp(prefix=".ecco-state-", dir=path.parent)
|
|
64
|
+
try:
|
|
65
|
+
if os.name != "nt":
|
|
66
|
+
os.fchmod(fd, 0o600)
|
|
67
|
+
with os.fdopen(fd, "wb") as stream:
|
|
68
|
+
stream.write(data)
|
|
69
|
+
stream.flush()
|
|
70
|
+
os.fsync(stream.fileno())
|
|
71
|
+
os.replace(temporary, path)
|
|
72
|
+
temporary = ""
|
|
73
|
+
if os.name != "nt" and path.stat().st_mode & 0o077:
|
|
74
|
+
raise BootstrapFailure("Private state permissions could not be enforced.")
|
|
75
|
+
finally:
|
|
76
|
+
if temporary:
|
|
77
|
+
Path(temporary).unlink(missing_ok=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_private(path: Path) -> dict[str, Any]:
|
|
81
|
+
path = path.absolute()
|
|
82
|
+
if path != path.resolve() or not path.is_file():
|
|
83
|
+
raise BootstrapFailure("State must be a direct path to a regular file.")
|
|
84
|
+
status = path.stat()
|
|
85
|
+
if os.name != "nt" and (status.st_uid != os.getuid() or status.st_mode & 0o077):
|
|
86
|
+
raise BootstrapFailure("State file must be private (mode 0600).")
|
|
87
|
+
try:
|
|
88
|
+
value = json.loads(path.read_bytes())
|
|
89
|
+
except (OSError, ValueError, TypeError):
|
|
90
|
+
raise BootstrapFailure("State file is unavailable or invalid.") from None
|
|
91
|
+
if not isinstance(value, dict) or value.get("contract_version") != CONTRACT:
|
|
92
|
+
raise BootstrapFailure("State file uses an unsupported contract.")
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _api_origin(value: str) -> str:
|
|
97
|
+
parsed = urllib.parse.urlsplit(value)
|
|
98
|
+
if (
|
|
99
|
+
parsed.scheme != "https"
|
|
100
|
+
or not parsed.hostname
|
|
101
|
+
or parsed.username
|
|
102
|
+
or parsed.password
|
|
103
|
+
or parsed.path not in {"", "/"}
|
|
104
|
+
or parsed.query
|
|
105
|
+
or parsed.fragment
|
|
106
|
+
):
|
|
107
|
+
raise BootstrapFailure("API base must be an exact HTTPS origin.")
|
|
108
|
+
return value.rstrip("/")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _request_json(
|
|
112
|
+
url: str, payload: dict[str, Any], *, attempt_id: str, recovery: str | None
|
|
113
|
+
) -> tuple[int, dict[str, Any]]:
|
|
114
|
+
headers = {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
"Accept": "application/json",
|
|
117
|
+
"User-Agent": "ecco-cli/0.1",
|
|
118
|
+
"Idempotency-Key": attempt_id,
|
|
119
|
+
}
|
|
120
|
+
if recovery is not None:
|
|
121
|
+
headers["Ecco-Recovery"] = recovery
|
|
122
|
+
request = urllib.request.Request(
|
|
123
|
+
url, data=json.dumps(payload, separators=(",", ":")).encode(), headers=headers
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
response = _OPENER.open(request, timeout=15)
|
|
127
|
+
except urllib.error.HTTPError as exc:
|
|
128
|
+
body = exc.read(MAX_RESPONSE + 1)
|
|
129
|
+
if len(body) > MAX_RESPONSE:
|
|
130
|
+
raise BootstrapFailure(f"Bootstrap failed with HTTP {exc.code}.") from None
|
|
131
|
+
try:
|
|
132
|
+
document = json.loads(body)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
document = {}
|
|
135
|
+
code = document.get("code") if isinstance(document, dict) else None
|
|
136
|
+
if not isinstance(code, str) or re.fullmatch(r"[a-z0-9_]{1,64}", code) is None:
|
|
137
|
+
code = None
|
|
138
|
+
raise BootstrapFailure(
|
|
139
|
+
f"Bootstrap failed with HTTP {exc.code}"
|
|
140
|
+
+ (f" ({code})." if code is not None else ".")
|
|
141
|
+
) from None
|
|
142
|
+
with response:
|
|
143
|
+
body = response.read(MAX_RESPONSE + 1)
|
|
144
|
+
if len(body) > MAX_RESPONSE:
|
|
145
|
+
raise BootstrapFailure("Bootstrap returned an oversized response.")
|
|
146
|
+
try:
|
|
147
|
+
document = json.loads(body)
|
|
148
|
+
except (ValueError, TypeError):
|
|
149
|
+
raise BootstrapFailure("Bootstrap returned invalid JSON.") from None
|
|
150
|
+
if not isinstance(document, dict) or document.get("contract_version") != CONTRACT:
|
|
151
|
+
raise BootstrapFailure("Bootstrap returned an unsupported contract.")
|
|
152
|
+
return response.status, document
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create(
|
|
156
|
+
*,
|
|
157
|
+
board_name: str,
|
|
158
|
+
task_title: str | None,
|
|
159
|
+
task_body: str | None,
|
|
160
|
+
client_name: str,
|
|
161
|
+
client_version: str,
|
|
162
|
+
api_base: str = DEFAULT_API,
|
|
163
|
+
state_directory: Path | None = None,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
api_base = _api_origin(api_base)
|
|
166
|
+
attempt_id = str(uuid4())
|
|
167
|
+
# Worker authentication intentionally reserves exact 64-character hex
|
|
168
|
+
# credentials; other bearer shapes are routed through OAuth.
|
|
169
|
+
worker = secrets.token_hex(32)
|
|
170
|
+
capability = secrets.token_urlsafe(32)
|
|
171
|
+
recovery = secrets.token_urlsafe(32)
|
|
172
|
+
payload: dict[str, Any] = {
|
|
173
|
+
"contract_version": CONTRACT,
|
|
174
|
+
"board_name": board_name,
|
|
175
|
+
"client": {"name": client_name, "version": client_version},
|
|
176
|
+
"worker_token_sha256": _sha(worker),
|
|
177
|
+
"claim_capability_sha256": _sha(capability),
|
|
178
|
+
"recovery_secret_sha256": _sha(recovery),
|
|
179
|
+
}
|
|
180
|
+
if task_title is not None:
|
|
181
|
+
payload["first_task"] = {"title": task_title, "body": task_body}
|
|
182
|
+
selected_root = state_directory or _state_root()
|
|
183
|
+
if selected_root.is_symlink():
|
|
184
|
+
raise BootstrapFailure("State directory cannot be a symbolic link.")
|
|
185
|
+
root = selected_root.expanduser().resolve()
|
|
186
|
+
state_path = root / f"{attempt_id}.json"
|
|
187
|
+
state: dict[str, Any] = {
|
|
188
|
+
"contract_version": CONTRACT,
|
|
189
|
+
"attempt_id": attempt_id,
|
|
190
|
+
"api_base": api_base,
|
|
191
|
+
"request": payload,
|
|
192
|
+
"worker_token": worker,
|
|
193
|
+
"claim_capability": capability,
|
|
194
|
+
"recovery_secret": recovery,
|
|
195
|
+
"manifest": None,
|
|
196
|
+
}
|
|
197
|
+
_write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
|
|
198
|
+
try:
|
|
199
|
+
_, manifest = _request_json(
|
|
200
|
+
api_base + "/agent-start",
|
|
201
|
+
payload,
|
|
202
|
+
attempt_id=attempt_id,
|
|
203
|
+
recovery=None,
|
|
204
|
+
)
|
|
205
|
+
except (urllib.error.URLError, TimeoutError):
|
|
206
|
+
try:
|
|
207
|
+
_, manifest = _request_json(
|
|
208
|
+
api_base + "/agent-start",
|
|
209
|
+
payload,
|
|
210
|
+
attempt_id=attempt_id,
|
|
211
|
+
recovery=recovery,
|
|
212
|
+
)
|
|
213
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
214
|
+
raise BootstrapFailure(
|
|
215
|
+
f"Bootstrap outcome is unknown; retry with state {state_path}."
|
|
216
|
+
) from exc
|
|
217
|
+
state["manifest"] = manifest
|
|
218
|
+
claim_base = manifest.get("claim", {}).get("url_base")
|
|
219
|
+
if not isinstance(claim_base, str):
|
|
220
|
+
raise BootstrapFailure("Bootstrap response omitted the claim URL.")
|
|
221
|
+
claim_file = root / f"{attempt_id}.claim-link.txt"
|
|
222
|
+
full_claim = claim_base + "#capability=" + urllib.parse.quote(capability, safe="")
|
|
223
|
+
_write_private(claim_file, (full_claim + "\n").encode())
|
|
224
|
+
_write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
|
|
225
|
+
return _public_result(state_path, claim_file, manifest)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def retry(state_path: Path) -> dict[str, Any]:
|
|
229
|
+
state = _read_private(state_path)
|
|
230
|
+
manifest = state.get("manifest")
|
|
231
|
+
if manifest is None:
|
|
232
|
+
_, manifest = _request_json(
|
|
233
|
+
_api_origin(str(state["api_base"])) + "/agent-start",
|
|
234
|
+
state["request"],
|
|
235
|
+
attempt_id=str(state["attempt_id"]),
|
|
236
|
+
recovery=str(state["recovery_secret"]),
|
|
237
|
+
)
|
|
238
|
+
state["manifest"] = manifest
|
|
239
|
+
_write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
|
|
240
|
+
claim_file = state_path.with_name(state_path.stem + ".claim-link.txt")
|
|
241
|
+
claim_base = manifest["claim"]["url_base"]
|
|
242
|
+
value = (
|
|
243
|
+
claim_base
|
|
244
|
+
+ "#capability="
|
|
245
|
+
+ urllib.parse.quote(str(state["claim_capability"]), safe="")
|
|
246
|
+
)
|
|
247
|
+
_write_private(claim_file, (value + "\n").encode())
|
|
248
|
+
return _public_result(state_path, claim_file, manifest)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def run(state_path: Path, command: list[str]) -> int:
|
|
252
|
+
if not command:
|
|
253
|
+
raise BootstrapFailure("Resume requires a command after --.")
|
|
254
|
+
state = _read_private(state_path)
|
|
255
|
+
manifest = state.get("manifest")
|
|
256
|
+
if not isinstance(manifest, dict):
|
|
257
|
+
raise BootstrapFailure("Bootstrap is incomplete; retry it first.")
|
|
258
|
+
environment = os.environ.copy()
|
|
259
|
+
environment.update(
|
|
260
|
+
{
|
|
261
|
+
"ECCO_AGENT_TOKEN": str(state["worker_token"]),
|
|
262
|
+
"ECCO_MCP_URL": str(manifest["connection"]["mcp_url"]),
|
|
263
|
+
"ECCO_WORKSPACE_ID": str(manifest["board"]["workspace_id"]),
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
return subprocess.run(command, env=environment, check=False).returncode
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _public_result(
|
|
270
|
+
state_path: Path, claim_file: Path, manifest: dict[str, Any]
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
return {
|
|
273
|
+
"status": "ready",
|
|
274
|
+
"board": manifest.get("board"),
|
|
275
|
+
"first_task": manifest.get("first_task"),
|
|
276
|
+
"connection": manifest.get("connection"),
|
|
277
|
+
"limits": manifest.get("limits"),
|
|
278
|
+
"claim_url_base": manifest.get("claim", {}).get("url_base"),
|
|
279
|
+
"state_file": str(state_path),
|
|
280
|
+
"claim_link_file": str(claim_file),
|
|
281
|
+
"secrets_printed": False,
|
|
282
|
+
}
|