clavarium-tether 0.0.0-stage → 0.1.0-next.2

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Clavarium Tether authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ # Clavarium Tether
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An AI agent reaches outside its own machine, kept on a tether: a level per
4
+ target, a person's approval, a journal, and a stop.
5
+
6
+ A target is one access given to an agent: a computer, a machine reached by
7
+ SSH, browser tabs, an online folder. On each, the agent has a level:
8
+
9
+ | Level | The agent... |
10
+ |---|---|
11
+ | Watch | reads only |
12
+ | Propose | reads; each action waits for a person |
13
+ | Frame | reads, acts where a rule allows (`git status`, under `~/Projects`); the rest waits |
14
+ | Free | does everything; the journal says it all |
15
+
16
+ tether your targets, your level on each
17
+ tether run web "sudo apt upgrade -y" --why "Installs 14 updates."
18
+ tether files web read ~/site/index.html
19
+ tether open web; tether type web "top" --enter; tether read web
20
+
21
+ A call that waits shows the person the exact command and the agent's one
22
+ line why: **Accept**, **Refuse**, or **Always** (a rule from the command,
23
+ and it is not asked again). Not answered in time, it is refused.
24
+
25
+ An MCP server gives the same verbs to an AI agent. A person sees the
26
+ requests, the levels and the journal on its screen (`ui/`), drawn by Tether
27
+ in the page of the application that runs it.
28
+
29
+ A sibling of [Clavarium Parley](https://github.com/clavarium/clavarium-parley),
30
+ [Clavarium Docket](https://github.com/clavarium/clavarium-docket) and
31
+ [Clavarium Binder](https://github.com/clavarium/clavarium-binder), built the
32
+ same way, universal (nothing in it is specific to Clavarium).
33
+
34
+ ## What an agent cannot do
35
+
36
+ - **Hold a key.** Passwords and keys stay with the keeper (the application,
37
+ or `tether serve`), from the application's secret store. The agent asks
38
+ through its own folder; it never connects, and never sees a secret.
39
+ - **Approve.** Only a person does, on the screen or wherever the
40
+ application asks them.
41
+ - **Hide.** Each call is a line of the journal: who, where, what exactly,
42
+ the decision and by whom, when, the size of the result and its exit code.
43
+ Never a secret, never a file's content.
44
+ - **Go on after Stop.** One button cuts every target of an agent: what
45
+ waits is refused, terminals are closed, every call after is refused until
46
+ a person resumes.
47
+
48
+ ## Reach a server over SSH
49
+
50
+ A target of kind `ssh` is a machine that already runs an SSH server. The
51
+ keeper connects to it as an ordinary SSH client — like ansible or a deploy
52
+ job — through the `ssh2` library (pure JS; no system `ssh`, and **nothing is
53
+ installed on the far machine**). The agent gets the same verbs as on any
54
+ other target: `tether run`, `tether read`, `tether files …`, each through its
55
+ level and a person's approval.
56
+
57
+ The target says `host`, `port`, `user`, optionally a host-key `pin` to check
58
+ the server's identity and a `root` folder that files may not leave. The
59
+ private key is held by the keeper, from the application's secret store, and
60
+ named in the target — never the key itself; it never reaches the agent, the
61
+ journal or any log.
62
+
63
+ tether serve --side ./.tether=Assistant \
64
+ --ssh deploy@example.com --ssh-key ~/.ssh/deploy_ed25519 \
65
+ --ssh-root /srv/app --ssh-pin SHA256:… --ssh-title "App server"
66
+
67
+ declares one SSH target without an application. `--ssh-key FILE` reads the key
68
+ from that file (its content is the secret; the path is not stored); the target
69
+ starts at level Propose. A host-key `--ssh-pin` refuses a server whose key does
70
+ not match.
71
+
72
+ ## Reach an online folder
73
+
74
+ A target of kind `files` is an online folder on a server the person runs or
75
+ owns. The keeper talks to it as an ordinary file-sync client over HTTPS (no
76
+ extra dependency — Node's own `fetch`); **nothing is installed anywhere**, and
77
+ the agent never connects. The agent gets the `files.*` verbs — `tether files …
78
+ list / read / search / write / move / delete` — each through its level and a
79
+ person's approval. Two kinds of server are reached:
80
+
81
+ **Nextcloud (and any WebDAV).** The target says the WebDAV `url`, a Basic-auth
82
+ `user`, and optionally a `root` folder files may not leave. The password — a
83
+ Nextcloud **app password** is ideal — is held by the keeper, from the
84
+ application's secret store, and only named in the target.
85
+
86
+ tether serve --side ./.tether=Assistant \
87
+ --webdav https://cloud.example.com/remote.php/dav/files/alice \
88
+ --webdav-user alice --webdav-pass-env NEXTCLOUD_APP_PASSWORD \
89
+ --webdav-root /Projects --webdav-title "Cloud"
90
+
91
+ **SharePoint / OneDrive (Microsoft Graph).** The target says the `drive` id and
92
+ optionally a `root`. V1 takes a bearer token the host keeps fresh (named in the
93
+ target, read here from an environment variable); a client-credentials flow with
94
+ a client secret is also supported (see the protocol). Large-file upload sessions
95
+ are left for a later version.
96
+
97
+ tether serve --side ./.tether=Assistant \
98
+ --graph-drive <drive-id> --graph-token-env GRAPH_TOKEN \
99
+ --graph-root /Shared --graph-title "Team drive"
100
+
101
+ `--webdav-pass-env` and `--graph-token-env` name an environment variable; the
102
+ secret is never passed on the command line, written to the store or journalled.
103
+ Both targets start at level Propose.
104
+ ## Reach a browser
105
+
106
+ A target of kind `browser` is a set of tabs a person grants to an agent through
107
+ the **Tether extension** (Chrome and Firefox, one codebase; `extension/`). The
108
+ extension connects OUT to the keeper over a WebSocket — nothing is installed as a
109
+ server, and the browser may sit behind any firewall. The agent gets browser
110
+ verbs scoped to the granted tabs only: `tabs`, `open`, `shot`, `html`,
111
+ `console`, `eval`, `click`, `type`, `key`, `scroll`, `drag`.
112
+
113
+ tether serve --browser --port 8787 --side ./.tether=Assistant
114
+
115
+ serves the browser endpoint at `ws://127.0.0.1:8787/browser` (a browser-safe
116
+ port — browsers refuse 4190). The person opens the popup, which shows a short
117
+ code; they type it in the keeper page to pair. A long-lived token is then kept
118
+ in the extension's storage — never logged, never shown to the agent. In the
119
+ popup the person turns tabs on one by one, or allows a whole site.
120
+
121
+ - **Granted tabs only.** A verb on a tab that was not granted is refused, on both
122
+ the extension's side and the keeper's. `browser.open` of a new tab grants it.
123
+ - **Propose by default.** Each action waits for the person; sites are allowed one
124
+ at a time. Always ask before a payment, a post or a send.
125
+ - **A visible indicator.** While the agent acts in a tab, the extension shows a
126
+ badge on its icon and a red border around the page.
127
+
128
+ Build the extension with WXT (`cd extension && npm install && npm run build`
129
+ for Chrome, `npm run build:firefox` for Firefox); load the unpacked build from
130
+ `extension/.output/`.
131
+
132
+ ## Without an application
133
+
134
+ tether serve --store ~/tether-data --side ./.tether=Assistant --local ~/Projects
135
+
136
+ keeps the targets and shows their page on `http://127.0.0.1:4190/`.
137
+ `--local FOLDER` gives the agents this machine (a shell, and that folder),
138
+ at level Propose; it is off unless asked for. Put the store where the agents
139
+ cannot write.
140
+
141
+ See [docs/protocol.md](docs/protocol.md). One dependency, `ssh2` (the SSH
142
+ connector); Node 22 or later; tmux for the local connector's terminals.
143
+
144
+ **Status:** first version (0.1), not yet published. The `local`, `ssh`,
145
+ `webdav` (Nextcloud/WebDAV) and `graph` (SharePoint/OneDrive) connectors are
146
+ built. A `screen` slot is ready for a host to plug an engine into (see docs).
147
+ The `computer` and `browser` connectors come next.
148
+ **Status:** first version (0.1), not yet published. The `local`, `ssh` and
149
+ `browser` connectors are built (the browser one with its WebExtension in
150
+ `extension/`). A `screen` slot is ready for a host to plug an engine into (see
151
+ docs). The `computer` connector and online folders come next.
152
+
153
+ License: MIT.
package/bin/cli.mjs ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ /* THE COMMAND: the verbs for any agent or script that can run a command. It
3
+ * asks through the same folder as the MCP server (server/mcp.mjs), and prints
4
+ * the keeper's answer. `serve` IS a keeper: the targets, their approvals and
5
+ * their page, for use without an application.
6
+ */
7
+ import { realpathSync, readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { ask, findFolder } from '../server/mcp.mjs';
10
+ import { NAME, VERSION, FOLDER, ENV_DIR } from '../server/name.mjs';
11
+
12
+ export const HELP = `${NAME}: act on the targets you were given (machines, folders, browsers).
13
+
14
+ ${NAME} your targets, your level on each, its rules
15
+ ${NAME} run TARGET "command" [--timeout S] run a command: its output and exit code
16
+ ${NAME} open TARGET open a terminal
17
+ ${NAME} read TARGET [--lines N] read the terminal's screen
18
+ ${NAME} type TARGET "keys" [--enter] type in the terminal
19
+ ${NAME} files TARGET list [PATH]
20
+ ${NAME} files TARGET read PATH
21
+ ${NAME} files TARGET search "text" [--in PATH]
22
+ ${NAME} files TARGET write PATH --text "..." | --from LOCAL_FILE
23
+ ${NAME} files TARGET move FROM TO
24
+ ${NAME} files TARGET delete PATH
25
+ ${NAME} browser TARGET tabs the tabs a person granted you
26
+ ${NAME} browser TARGET open URL [--tab N] open a URL (a granted tab, or a new one)
27
+ ${NAME} browser TARGET shot [--tab N] a screenshot of a granted tab
28
+ ${NAME} browser TARGET html [--tab N] [--selector S] [--readable]
29
+ ${NAME} browser TARGET console [--tab N] [--lines N]
30
+ ${NAME} browser TARGET eval "script" [--tab N]
31
+ ${NAME} browser TARGET click [SELECTOR | --x N --y N] [--tab N]
32
+ ${NAME} browser TARGET type "text" [--selector S] [--tab N]
33
+ ${NAME} browser TARGET key KEY [--selector S] [--tab N]
34
+ ${NAME} browser TARGET scroll [--dx N] [--dy N] [--selector S] [--tab N]
35
+ ${NAME} browser TARGET drag FROM TO [--tab N]
36
+ ${NAME} serve [--store DIR] [--port N] [--side FOLDER=NAME] [--local FOLDER[=TITLE]]
37
+ keep targets yourself, with their page (no application)
38
+
39
+ Add --why "one short line" when you act: a person may read it before deciding.
40
+ Depending on your level a person approves first; the command waits for them.
41
+ Exit: 0 done, 1 refused, 2 no folder, 3 nobody keeps the targets, 4 not answered in time, 64 not understood.
42
+ The folder used is --dir FOLDER, else $${ENV_DIR}, else the nearest ${FOLDER} folder from here up.
43
+ ${NAME} ${VERSION}`;
44
+
45
+ const FILE_VERBS = ['list', 'read', 'search', 'write', 'move', 'delete'];
46
+ const BROWSER_VERBS = ['tabs', 'open', 'shot', 'html', 'console', 'eval', 'click', 'type', 'key', 'scroll', 'drag'];
47
+
48
+ /* A command line, read: [verb, args] or { refused }. A --from file is read by run(). */
49
+ export function read(argv) {
50
+ const flags = {}; const words = [];
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === '--enter') { flags.enter = true; continue; }
54
+ if (a === '--readable') { flags.readable = true; continue; }
55
+ if (a.startsWith('--')) { if (argv[i + 1] === undefined) return { refused: `${a} needs a value` }; flags[a.slice(2)] = argv[++i]; continue; }
56
+ words.push(a);
57
+ }
58
+ const [verb = 'targets', target, ...rest] = words;
59
+ const why = flags.why !== undefined ? { why: flags.why } : {};
60
+ const need = (ok, usage) => (ok ? null : { refused: `${NAME} ${usage}` });
61
+ switch (verb) {
62
+ case 'targets': return ['targets', {}];
63
+ case 'run': return need(target && rest.length, 'run TARGET "command"') ?? ['terminal.run', { target, command: rest.join(' '), ...(flags.timeout !== undefined ? { timeout: Number(flags.timeout) } : {}), ...why }];
64
+ case 'open': return need(target, 'open TARGET') ?? ['terminal.open', { target, ...why }];
65
+ case 'read': return need(target, 'read TARGET') ?? ['terminal.read', { target, ...(flags.lines !== undefined ? { lines: Number(flags.lines) } : {}) }];
66
+ case 'type': return need(target && rest.length, 'type TARGET "keys"') ?? ['terminal.type', { target, text: rest.join(' '), enter: !!flags.enter, ...why }];
67
+ case 'files': {
68
+ const [what, ...p] = rest;
69
+ if (!target || !FILE_VERBS.includes(what)) return { refused: `${NAME} files TARGET ${FILE_VERBS.join('|')} ...` };
70
+ switch (what) {
71
+ case 'list': return ['files.list', { target, path: p[0] ?? '.' }];
72
+ case 'read': return need(p[0], 'files TARGET read PATH') ?? ['files.read', { target, path: p[0] }];
73
+ case 'search': return need(p.length, 'files TARGET search "text"') ?? ['files.search', { target, text: p.join(' '), path: flags.in ?? '.' }];
74
+ case 'write':
75
+ if (!p[0] || (flags.text === undefined) === (flags.from === undefined)) return { refused: `${NAME} files TARGET write PATH --text "..." | --from LOCAL_FILE` };
76
+ return ['files.write', { target, path: p[0], ...(flags.text !== undefined ? { text: flags.text } : { from: flags.from }), ...why }];
77
+ case 'move': return need(p.length === 2, 'files TARGET move FROM TO') ?? ['files.move', { target, from: p[0], to: p[1], ...why }];
78
+ default: return need(p[0], 'files TARGET delete PATH') ?? ['files.delete', { target, path: p[0], ...why }];
79
+ }
80
+ }
81
+ case 'browser': {
82
+ const [what, ...p] = rest;
83
+ if (!target || !BROWSER_VERBS.includes(what)) return { refused: `${NAME} browser TARGET ${BROWSER_VERBS.join('|')} ...` };
84
+ const tab = flags.tab !== undefined ? { tab: Number(flags.tab) } : {};
85
+ const sel = flags.selector !== undefined ? { selector: flags.selector } : {};
86
+ switch (what) {
87
+ case 'tabs': return ['browser.tabs', { target }];
88
+ case 'open': return need(p[0], 'browser TARGET open URL') ?? ['browser.open', { target, url: p[0], ...tab, ...why }];
89
+ case 'shot': return ['browser.shot', { target, ...tab }];
90
+ case 'html': return ['browser.html', { target, ...tab, ...sel, ...(flags.readable ? { readable: true } : {}) }];
91
+ case 'console': return ['browser.console', { target, ...tab, ...(flags.lines !== undefined ? { lines: Number(flags.lines) } : {}) }];
92
+ case 'eval': return need(p.length, 'browser TARGET eval "script"') ?? ['browser.eval', { target, script: p.join(' '), ...tab, ...why }];
93
+ case 'click': return ['browser.click', { target, ...tab, ...(p[0] ? { selector: p[0] } : sel), ...(flags.x !== undefined ? { x: Number(flags.x) } : {}), ...(flags.y !== undefined ? { y: Number(flags.y) } : {}), ...(flags.button ? { button: flags.button } : {}), ...why }];
94
+ case 'type': return need(p.length, 'browser TARGET type "text"') ?? ['browser.type', { target, text: p.join(' '), ...tab, ...sel, ...why }];
95
+ case 'key': return need(p[0], 'browser TARGET key KEY') ?? ['browser.key', { target, key: p[0], ...tab, ...sel, ...why }];
96
+ case 'scroll': return ['browser.scroll', { target, ...tab, ...sel, ...(flags.dx !== undefined ? { dx: Number(flags.dx) } : {}), ...(flags.dy !== undefined ? { dy: Number(flags.dy) } : {}), ...why }];
97
+ default: return need(p.length === 2, 'browser TARGET drag FROM TO') ?? ['browser.drag', { target, from: p[0], to: p[1], ...tab, ...why }];
98
+ }
99
+ }
100
+ default: return { refused: `Unknown: ${verb}. Run "${NAME} --help".` };
101
+ }
102
+ }
103
+
104
+ /* Runs one command line. Resolves with { code, out, err }: nothing is printed here. */
105
+ export async function run(argv, { env = process.env, cwd = process.cwd(), wait, onHeld } = {}) {
106
+ const args = [...argv];
107
+ let dir;
108
+ const at = args.indexOf('--dir');
109
+ if (at >= 0) { dir = args[at + 1]; if (!dir) return { code: 64, out: '', err: '--dir needs a folder.\n' }; args.splice(at, 2); }
110
+ if (['--help', '-h', 'help'].includes(args[0])) return { code: 0, out: `${HELP}\n`, err: '' };
111
+ if (args[0] === '--version') return { code: 0, out: `${VERSION}\n`, err: '' };
112
+ const what = read(args);
113
+ if (what.refused) return { code: 64, out: '', err: `${what.refused}\n` };
114
+ if (what[1].from !== undefined) {
115
+ try { what[1].text = readFileSync(what[1].from, 'utf8'); } catch (e) { return { code: 64, out: '', err: `cannot read ${what[1].from}: ${e.code ?? e.message}\n` }; }
116
+ delete what[1].from;
117
+ }
118
+ const folder = findFolder({ dir, env, from: cwd });
119
+ if (!folder) return { code: 2, out: '', err: `No ${FOLDER} folder here or above. Run it from your work folder, or give --dir FOLDER.\n` };
120
+ const r = await ask(folder, what[0], what[1], { ...(wait ? { wait } : {}), onHeld });
121
+ if (r.ok) return { code: 0, out: `${r.text}\n`, err: '' };
122
+ return { code: r.nokeeper ? 3 : r.late ? 4 : 1, out: '', err: `Not done: ${r.text}\n` };
123
+ }
124
+
125
+ const main = (() => { try { return realpathSync(process.argv[1] ?? '') === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
126
+ if (main) {
127
+ const argv = process.argv.slice(2);
128
+ if (argv[0] === 'serve') {
129
+ const { serveMain } = await import('../host/serve.mjs');
130
+ await serveMain(argv.slice(1));
131
+ } else {
132
+ const r = await run(argv, { onHeld: (until) => process.stderr.write(`Waiting for a person (until ${new Date(until).toLocaleTimeString()})...\n`) });
133
+ if (r.out) process.stdout.write(r.out);
134
+ if (r.err) process.stderr.write(r.err);
135
+ process.exitCode = r.code;
136
+ }
137
+ }