@pushary/openclaw 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +112 -2
- package/assets/icon.png +0 -0
- package/dist/api-key-shape.d.ts +3 -0
- package/dist/api-key-shape.js +5 -0
- package/dist/approval-hook.d.ts +23 -0
- package/dist/approval-hook.js +88 -0
- package/dist/approval-question.d.ts +19 -0
- package/dist/approval-question.js +66 -0
- package/dist/connection.d.ts +26 -0
- package/dist/connection.js +101 -0
- package/dist/decision-reply.d.ts +9 -0
- package/dist/decision-reply.js +6 -0
- package/dist/gate-result.d.ts +13 -0
- package/dist/gate-result.js +58 -0
- package/dist/gate.d.ts +49 -0
- package/dist/gate.js +173 -0
- package/dist/hook-budget.d.ts +10 -0
- package/dist/hook-budget.js +19 -0
- package/dist/host-api.d.ts +60 -0
- package/dist/host-api.js +1 -0
- package/dist/host-version.d.ts +12 -0
- package/dist/host-version.js +25 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +32 -0
- package/dist/openclaw-paths.d.ts +14 -0
- package/dist/openclaw-paths.js +43 -0
- package/dist/openclaw-write-guard.d.ts +5 -0
- package/dist/openclaw-write-guard.js +93 -0
- package/dist/protected-writes.d.ts +33 -0
- package/dist/protected-writes.js +217 -0
- package/dist/pushary-client.d.ts +42 -0
- package/dist/pushary-client.js +103 -0
- package/dist/pushary-hosts.d.ts +2 -0
- package/dist/pushary-hosts.js +3 -0
- package/dist/redaction.d.ts +9 -0
- package/dist/redaction.js +53 -0
- package/dist/replies.d.ts +33 -0
- package/dist/replies.js +65 -0
- package/dist/settings.d.ts +34 -0
- package/dist/settings.js +103 -0
- package/openclaw.plugin.json +32 -0
- package/package.json +74 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pushary
|
|
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,113 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pushary/openclaw
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Phone approvals for [OpenClaw](https://openclaw.ai). Before your OpenClaw agent runs a gated tool call, Pushary asks you on your phone. The call runs only after you tap Approve. Anything else blocks it.
|
|
4
|
+
|
|
5
|
+
It is built for an OpenClaw that runs for you alone, often always on, on a small VPS. It works on the Agent and Agent Pro plans, with push approvals on your phone. Phone calls are not available for OpenClaw yet.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Run the Pushary CLI on the machine that runs OpenClaw, as the user the gateway runs as:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx pushary@latest setup --agents openclaw
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Setup signs you in, saves your key in `~/.pushary/config.json`, writes this plugin to `~/.pushary/openclaw-plugin`, and runs:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
openclaw plugins install ~/.pushary/openclaw-plugin --force --accept-capabilities
|
|
19
|
+
openclaw plugins enable pushary --accept-capabilities
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The CLI carries the plugin itself, so `npx pushary@latest upgrade`, or running setup again, installs the newer copy. A running gateway picks up the plugin when the install finishes; a stopped one loads it at its next start. Check it with:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
openclaw plugins inspect pushary --runtime --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`typedHooks` should list `before_tool_call`. Requires OpenClaw 2026.11.3 or later and Node 24.16 or later.
|
|
29
|
+
|
|
30
|
+
If the gateway runs as another user, or as a service without that home folder, put the key in that user's `~/.openclaw/.env` and restart the gateway:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
PUSHARY_API_KEY=pk_your_key
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What it gates
|
|
37
|
+
|
|
38
|
+
- Every `exec` call.
|
|
39
|
+
- The `process` actions that send input to a running session: `write`, `send-keys`, `submit` and `paste`. `process send-keys` can type a command into a session that `exec` started in the background, so it asks like `exec` does. `list`, `poll`, `log`, `kill`, `clear` and `remove` do not ask.
|
|
40
|
+
- The actions that could switch the gate off or change OpenClaw itself: `plugins` `install`, `enable`, `disable`, `uninstall`, `reload` and `reloadAndResume`; `gateway` `update.run`; and every `openclaw` action that changes config, plugins, agents, channels or the gateway. Their read actions (`plugins` `list`, `inspect` and `search`; `gateway` `config.get` and `config.schema.lookup`; `openclaw` `status`, `config_get`, `plugin_list` and the other reads) do not ask. An action the plugin does not know asks.
|
|
41
|
+
|
|
42
|
+
- Every `automations` (also called `cron`) action except `status`, `list`, `get`, `runs` and `next_check`. A scheduled job can run a command outside `before_tool_call`, so adding, changing, running or waking one asks.
|
|
43
|
+
- Every `terminal` action except `read` and `list`. `terminal input` types into a shared operator terminal.
|
|
44
|
+
- A `sessions_spawn` with a `cwd` outside the agent's workspace or in its `hooks` folder, or with a `projectId`, `projectGitUrl` or `worktree`. The question names the folder.
|
|
45
|
+
- Every `write`, `edit` or `apply_patch` into OpenClaw's own files: anything inside an OpenClaw state directory, `~/.config/openclaw` (where Linux keeps `gateway.env`), `~/.pushary`, the `.env` in the gateway's working folder, the config file set by `OPENCLAW_CONFIG_PATH`, and the launchd and systemd files OpenClaw installs for its gateway. The one exception is the agent's own workspace when it lives inside the state directory, which is OpenClaw's default (`~/.openclaw/workspace`). The workspace `hooks` folder always asks, wherever the workspace is, because OpenClaw can load hooks from it as code. The plugin resolves `~`, `@` references, `file://` paths, relative paths against the agent's workspace, and symlinks before it decides, and a path it cannot resolve asks. Writes anywhere else run without asking.
|
|
46
|
+
|
|
47
|
+
To gate more tools, list their OpenClaw tool ids. The list adds to the defaults above; it never replaces them:
|
|
48
|
+
|
|
49
|
+
```json5
|
|
50
|
+
{
|
|
51
|
+
plugins: {
|
|
52
|
+
entries: {
|
|
53
|
+
pushary: {
|
|
54
|
+
enabled: true,
|
|
55
|
+
config: { gatedTools: ["message", "browser"] },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Ids are read the way OpenClaw names its tools: `bash` means `exec`, `cron` means `automations` and `tool_call` means `dispatch_action`. An entry that is not a tool id gates nothing, and the plugin logs it as an error when it starts. OpenClaw does not mark a tool call as a message send, a payment or a public post, so gate those by naming the tool that does it, such as `message`.
|
|
63
|
+
|
|
64
|
+
## What happens to a gated call
|
|
65
|
+
|
|
66
|
+
| Pushary says | The call |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| You answer yes | Runs |
|
|
69
|
+
| You answer anything else | Blocked, and the agent is told not to retry |
|
|
70
|
+
| No answer before the wait ends | Blocked, and the question is withdrawn |
|
|
71
|
+
| OpenClaw cancels the run | Blocked, and the question is withdrawn |
|
|
72
|
+
| The agent was stopped from Pushary | Blocked |
|
|
73
|
+
| No phone, browser or Slack is connected | Blocked, with a link to connect one |
|
|
74
|
+
| Pushary cannot be reached, rejects the key or sends a reply the plugin cannot read | Blocked |
|
|
75
|
+
| Your delivery mode keeps approvals off the phone, or a When away push window passes with no answer | Withdrawn in Pushary and handed to OpenClaw's own approval prompt, which blocks the call when nobody can answer there. On an OpenClaw older than 2026.11.3 the call is blocked instead |
|
|
76
|
+
|
|
77
|
+
A missing answer never becomes an approval. A plugin error or timeout blocks the call: OpenClaw fails closed when a `before_tool_call` hook throws or runs out of time, and the plugin turns its own errors into a block rather than relying on that.
|
|
78
|
+
|
|
79
|
+
## How long it waits
|
|
80
|
+
|
|
81
|
+
The plugin asks OpenClaw for a five minute `before_tool_call` budget and stops waiting ten seconds before it, so it has time to withdraw the question. If you set `plugins.entries.pushary.hooks.timeouts.before_tool_call` or `hooks.timeoutMs`, the plugin reads that value and ends its wait before it instead.
|
|
82
|
+
|
|
83
|
+
## Where the key comes from
|
|
84
|
+
|
|
85
|
+
1. `PUSHARY_API_KEY` in the gateway's environment, which OpenClaw also loads from `~/.openclaw/.env`.
|
|
86
|
+
2. `apiKey` in `~/.pushary/config.json`, or the file named by `PUSHARY_CONFIG_FILE`.
|
|
87
|
+
|
|
88
|
+
With no key, every gated call is blocked and the reason says how to fix it.
|
|
89
|
+
|
|
90
|
+
## Removing it
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npx pushary@latest clean
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Clean uninstalls the plugin and puts back the `plugins.entries.pushary` settings you had before setup. Without the plugin, OpenClaw's own exec approvals still apply, and nothing reaches your phone.
|
|
97
|
+
|
|
98
|
+
## Limits
|
|
99
|
+
|
|
100
|
+
- The gate covers the tools listed above and any you add. It does not see other tools: for example the file-transfer plugin's `file_write` is not gated unless you add it to `gatedTools`.
|
|
101
|
+
- ACP sessions set to approve everything, and CLI backends, run tools inside the external harness. Those tool calls never reach `before_tool_call`, so the plugin cannot gate them.
|
|
102
|
+
- This is an approval layer, not a sandbox. Pair it with OpenClaw's own sandboxing and `tools.fs.workspaceOnly`.
|
|
103
|
+
- The plugin talks only to `https://pushary.com` and `https://www.pushary.com`, the hosts the CLI uses. Any other `PUSHARY_BASE_URL` blocks every gated call, so a changed setting cannot point it at a server that always says yes.
|
|
104
|
+
- The plugin is not told which folder a tool runs in. It resolves a relative path against the agent's workspace, any folder inside it, and the gateway's working folder, and a spawned session in any other folder asks first. A run started some other way in a folder inside OpenClaw's state is not covered.
|
|
105
|
+
- Config `$include` files outside the state directory, including roots named by `OPENCLAW_INCLUDE_ROOTS`, are not protected.
|
|
106
|
+
- Windows has not been tested.
|
|
107
|
+
- Pushary's tool rules from the dashboard are not evaluated on the machine. Every gated call asks.
|
|
108
|
+
- Another plugin that rewrites tool parameters can change what runs, including a plugin that runs before this one: OpenClaw gives each plugin its own copy of the call and keeps the earlier plugin's rewrite. Pushary shows and approves the parameters it was given.
|
|
109
|
+
- The wait is held inside one gateway process. If the gateway restarts during a wait, OpenClaw drops the call and the question stays in Pushary until it expires.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
package/assets/icon.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Generated by scripts/generate-editor-redaction.mjs.
|
|
2
|
+
const API_KEY_PREFIX_SHAPE = 'pk_[a-f0-9]+';
|
|
3
|
+
export const API_KEY_PREFIX_PATTERN = new RegExp(`^${API_KEY_PREFIX_SHAPE}$`);
|
|
4
|
+
export const API_KEY_PATTERN = new RegExp(`^${API_KEY_PREFIX_SHAPE}\\.[a-f0-9]+$`);
|
|
5
|
+
export const isValidApiKey = (value) => API_KEY_PATTERN.test(value);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ConnectionReading, PusharyConnection } from './connection.js';
|
|
2
|
+
import { type GateClock } from './gate.js';
|
|
3
|
+
import { type HandBackSurface } from './gate-result.js';
|
|
4
|
+
import { type OpenClawBeforeToolCallHandler, type OpenClawPluginApi } from './host-api.js';
|
|
5
|
+
import type { FileWriteCall } from './protected-writes.js';
|
|
6
|
+
import type { PusharyQuestions } from './pushary-client.js';
|
|
7
|
+
import { type GateSettings } from './settings.js';
|
|
8
|
+
export declare const SESSION_ID_MAX = 128;
|
|
9
|
+
export interface GateEnvironment {
|
|
10
|
+
readonly readConnection: () => ConnectionReading;
|
|
11
|
+
readonly questionsFor: (connection: PusharyConnection) => PusharyQuestions;
|
|
12
|
+
readonly clock: GateClock;
|
|
13
|
+
readonly newRequestId: () => string;
|
|
14
|
+
}
|
|
15
|
+
export interface GateHandlerOptions {
|
|
16
|
+
readonly settings: GateSettings;
|
|
17
|
+
readonly holdMs: number;
|
|
18
|
+
readonly withdrawReserveMs: number;
|
|
19
|
+
readonly handBackSurface: HandBackSurface;
|
|
20
|
+
readonly reachesOpenClawState: (call: FileWriteCall) => boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare const createApprovalGateHandler: (options: GateHandlerOptions, environment: GateEnvironment) => OpenClawBeforeToolCallHandler;
|
|
23
|
+
export declare const registerApprovalGate: (api: OpenClawPluginApi, environment: GateEnvironment) => void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { approvalQuestionFor } from './approval-question.js';
|
|
3
|
+
import { runApprovalGate } from './gate.js';
|
|
4
|
+
import { notConnectedResultFor, openClawResultFor } from './gate-result.js';
|
|
5
|
+
import { holdMsWithin, hookBudgetFrom, REQUESTED_HOOK_TIMEOUT_MS } from './hook-budget.js';
|
|
6
|
+
import { BEFORE_TOOL_CALL_HOOK, } from './host-api.js';
|
|
7
|
+
import { handsBackToTheHost, hostSupportFrom, MIN_HOST_VERSION } from './host-version.js';
|
|
8
|
+
import { openClawWriteGuardFor } from './openclaw-write-guard.js';
|
|
9
|
+
import { askingAgentName, hookMatcherFor, hostToolId, needsApproval, readGateSettings } from './settings.js';
|
|
10
|
+
export const SESSION_ID_MAX = 128;
|
|
11
|
+
const MILLISECONDS_PER_SECOND = 1_000;
|
|
12
|
+
const UNEXPECTED_FAILURE_REASON = 'The Pushary approval gate failed unexpectedly, so this tool call was blocked. Nothing ran.';
|
|
13
|
+
const boundedSessionId = (value) => value.length <= SESSION_ID_MAX ? value : createHash('sha256').update(value, 'utf8').digest('hex');
|
|
14
|
+
const sessionIdOf = (context) => {
|
|
15
|
+
const session = (context.sessionId ?? context.sessionKey)?.trim();
|
|
16
|
+
return session ? boundedSessionId(session) : undefined;
|
|
17
|
+
};
|
|
18
|
+
export const createApprovalGateHandler = (options, environment) => async (event, context) => {
|
|
19
|
+
const reachesOpenClawState = options.reachesOpenClawState({ toolId: hostToolId(event.toolName), event, agentId: context.agentId });
|
|
20
|
+
if (!needsApproval(options.settings, event) && !reachesOpenClawState)
|
|
21
|
+
return undefined;
|
|
22
|
+
const agentName = askingAgentName(context.agentId);
|
|
23
|
+
const question = approvalQuestionFor(event, agentName);
|
|
24
|
+
const reading = environment.readConnection();
|
|
25
|
+
if (reading.kind !== 'ready')
|
|
26
|
+
return notConnectedResultFor(reading.kind, question.toolName);
|
|
27
|
+
const sessionId = sessionIdOf(context);
|
|
28
|
+
const outcome = await runApprovalGate({
|
|
29
|
+
question,
|
|
30
|
+
agentName,
|
|
31
|
+
requestId: environment.newRequestId(),
|
|
32
|
+
holdMs: options.holdMs,
|
|
33
|
+
withdrawReserveMs: options.withdrawReserveMs,
|
|
34
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
35
|
+
...(reading.connection.machineId === null ? {} : { machineId: reading.connection.machineId }),
|
|
36
|
+
...(context.abortSignal === undefined ? {} : { signal: context.abortSignal }),
|
|
37
|
+
}, { questions: environment.questionsFor(reading.connection), clock: environment.clock });
|
|
38
|
+
return openClawResultFor(outcome, question, options.handBackSurface);
|
|
39
|
+
};
|
|
40
|
+
const failingClosed = (handler, api) => async (event, context) => {
|
|
41
|
+
try {
|
|
42
|
+
return await handler(event, context);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
api.logger.error(`pushary: approval gate failed for ${event.toolName}: ${String(error)}`);
|
|
46
|
+
return { block: true, blockReason: UNEXPECTED_FAILURE_REASON };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const gatedToolsLine = (settings) => settings.gatedTools.join(', ');
|
|
50
|
+
const reportSettings = (api, reading) => {
|
|
51
|
+
if (reading.kind === 'invalid') {
|
|
52
|
+
api.logger.error(`pushary: plugins.entries.pushary.config.gatedTools must be a list of OpenClaw tool ids; it was ignored, and only ${gatedToolsLine(reading.settings)} ask`);
|
|
53
|
+
}
|
|
54
|
+
if (reading.kind === 'configured' && reading.rejected.length > 0) {
|
|
55
|
+
api.logger.error(`pushary: these gatedTools entries are not OpenClaw tool ids and gate nothing: ${reading.rejected.join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const reportHostSupport = (api, support) => {
|
|
59
|
+
if (support.kind === 'supported')
|
|
60
|
+
return;
|
|
61
|
+
const found = support.kind === 'too_old' ? `OpenClaw ${support.version}` : 'an OpenClaw that does not report its version';
|
|
62
|
+
api.logger.warn(`pushary: this plugin is built for OpenClaw ${MIN_HOST_VERSION} or later and found ${found}; approvals that would go to OpenClaw's own prompt are blocked instead`);
|
|
63
|
+
};
|
|
64
|
+
const handBackSurfaceFor = (support) => (handsBackToTheHost(support) ? 'openclaw_prompt' : 'none');
|
|
65
|
+
export const registerApprovalGate = (api, environment) => {
|
|
66
|
+
const reading = readGateSettings(api.pluginConfig);
|
|
67
|
+
reportSettings(api, reading);
|
|
68
|
+
const support = hostSupportFrom(api.runtime);
|
|
69
|
+
reportHostSupport(api, support);
|
|
70
|
+
const hookTimeoutMs = hookBudgetFrom(api.config, api.id).timeoutMs;
|
|
71
|
+
const holdMs = holdMsWithin(hookTimeoutMs);
|
|
72
|
+
const handlerOptions = {
|
|
73
|
+
settings: reading.settings,
|
|
74
|
+
holdMs,
|
|
75
|
+
withdrawReserveMs: hookTimeoutMs - holdMs,
|
|
76
|
+
handBackSurface: handBackSurfaceFor(support),
|
|
77
|
+
reachesOpenClawState: openClawWriteGuardFor(api),
|
|
78
|
+
};
|
|
79
|
+
const handler = failingClosed(createApprovalGateHandler(handlerOptions, environment), api);
|
|
80
|
+
try {
|
|
81
|
+
api.on(BEFORE_TOOL_CALL_HOOK, handler, { matcher: hookMatcherFor(reading.settings), timeoutMs: REQUESTED_HOOK_TIMEOUT_MS });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
api.logger.error(`pushary: OpenClaw refused the tool list (${String(error)}), so every tool call is checked against it by name instead`);
|
|
85
|
+
api.on(BEFORE_TOOL_CALL_HOOK, handler, { timeoutMs: REQUESTED_HOOK_TIMEOUT_MS });
|
|
86
|
+
}
|
|
87
|
+
api.logger.info(`pushary: asking before ${gatedToolsLine(reading.settings)}, waiting up to ${Math.round(holdMs / MILLISECONDS_PER_SECOND)} s for an answer`);
|
|
88
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OpenClawBeforeToolCallEvent } from './host-api.js';
|
|
2
|
+
export declare const QUESTION_SUMMARY_MAX = 160;
|
|
3
|
+
export declare const CONTEXT_MAX = 500;
|
|
4
|
+
export declare const ACTION_MAX = 500;
|
|
5
|
+
export declare const ACTION_BODY_MAX = 4000;
|
|
6
|
+
export declare const TOOL_TARGET_MAX = 80;
|
|
7
|
+
export declare const TOOL_NAME_MAX = 100;
|
|
8
|
+
export declare const APPROVAL_TITLE_MAX = 80;
|
|
9
|
+
export declare const APPROVAL_DESCRIPTION_MAX = 512;
|
|
10
|
+
export interface ApprovalQuestion {
|
|
11
|
+
readonly question: string;
|
|
12
|
+
readonly context: string;
|
|
13
|
+
readonly toolName: string;
|
|
14
|
+
readonly toolTarget: string | undefined;
|
|
15
|
+
readonly action: string;
|
|
16
|
+
readonly actionBody: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const truncated: (text: string, max: number) => string;
|
|
19
|
+
export declare const approvalQuestionFor: (event: OpenClawBeforeToolCallEvent, agentName: string) => ApprovalQuestion;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { redactSecrets, redactSecretsDeep } from './redaction.js';
|
|
2
|
+
import { SESSIONS_SPAWN_TOOL, SPAWN_FOLDER_PARAMETER } from './protected-writes.js';
|
|
3
|
+
import { AGENT_LABEL } from './settings.js';
|
|
4
|
+
export const QUESTION_SUMMARY_MAX = 160;
|
|
5
|
+
export const CONTEXT_MAX = 500;
|
|
6
|
+
export const ACTION_MAX = 500;
|
|
7
|
+
export const ACTION_BODY_MAX = 4000;
|
|
8
|
+
export const TOOL_TARGET_MAX = 80;
|
|
9
|
+
export const TOOL_NAME_MAX = 100;
|
|
10
|
+
export const APPROVAL_TITLE_MAX = 80;
|
|
11
|
+
export const APPROVAL_DESCRIPTION_MAX = 512;
|
|
12
|
+
const COMMAND_PARAMETERS = ['command', 'code'];
|
|
13
|
+
const WORKDIR_PARAMETER = 'workdir';
|
|
14
|
+
const TARGET_WORDS = 2;
|
|
15
|
+
const ELLIPSIS = '…';
|
|
16
|
+
const collapsed = (text) => text.split(/\s+/).filter(Boolean).join(' ');
|
|
17
|
+
export const truncated = (text, max) => text.length <= max ? text : `${text.slice(0, max - ELLIPSIS.length)}${ELLIPSIS}`;
|
|
18
|
+
const stringParameter = (params, name) => {
|
|
19
|
+
const value = params[name];
|
|
20
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
21
|
+
};
|
|
22
|
+
const commandOf = (event) => COMMAND_PARAMETERS.map(name => stringParameter(event.params, name)).find(value => value !== undefined);
|
|
23
|
+
const serializedParams = (params) => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.stringify(params) ?? '';
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const questionFor = (toolName, summary, command, folder) => {
|
|
32
|
+
if (command !== undefined)
|
|
33
|
+
return `Allow ${AGENT_LABEL} to run \`${summary}\`?`;
|
|
34
|
+
if (folder !== undefined)
|
|
35
|
+
return `Allow ${AGENT_LABEL} to start a session in \`${truncated(collapsed(folder), QUESTION_SUMMARY_MAX)}\`?`;
|
|
36
|
+
return `Allow ${AGENT_LABEL} to use ${toolName}${summary ? `: \`${summary}\`` : ''}?`;
|
|
37
|
+
};
|
|
38
|
+
const contextLines = (event, agentName) => {
|
|
39
|
+
const workdir = stringParameter(event.params, WORKDIR_PARAMETER) ?? stringParameter(event.params, SPAWN_FOLDER_PARAMETER);
|
|
40
|
+
return [
|
|
41
|
+
`${agentName} wants to use its ${event.toolName} tool.`,
|
|
42
|
+
...(workdir ? [`Working directory: ${workdir}`] : []),
|
|
43
|
+
...(event.params.elevated === true ? ['It asked for elevated mode, outside the sandbox.'] : []),
|
|
44
|
+
];
|
|
45
|
+
};
|
|
46
|
+
const targetOf = (command) => {
|
|
47
|
+
if (command === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
const head = collapsed(command).split(' ').slice(0, TARGET_WORDS).join(' ');
|
|
50
|
+
return head ? truncated(head, TOOL_TARGET_MAX) : undefined;
|
|
51
|
+
};
|
|
52
|
+
export const approvalQuestionFor = (event, agentName) => {
|
|
53
|
+
const toolName = truncated(event.toolName.trim(), TOOL_NAME_MAX);
|
|
54
|
+
const command = commandOf(event);
|
|
55
|
+
const raw = command ?? serializedParams(event.params);
|
|
56
|
+
const body = redactSecrets(raw);
|
|
57
|
+
const summary = truncated(collapsed(body), QUESTION_SUMMARY_MAX);
|
|
58
|
+
return {
|
|
59
|
+
question: questionFor(toolName, summary, command, event.toolName === SESSIONS_SPAWN_TOOL ? stringParameter(event.params, SPAWN_FOLDER_PARAMETER) : undefined),
|
|
60
|
+
context: truncated(redactSecrets(contextLines(event, agentName).join(' ')), CONTEXT_MAX),
|
|
61
|
+
toolName,
|
|
62
|
+
toolTarget: targetOf(command === undefined ? undefined : body),
|
|
63
|
+
action: truncated(collapsed(body), ACTION_MAX),
|
|
64
|
+
actionBody: truncated(redactSecretsDeep(raw), ACTION_BODY_MAX),
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const API_KEY_VARIABLE = "PUSHARY_API_KEY";
|
|
2
|
+
export declare const BASE_URL_VARIABLE = "PUSHARY_BASE_URL";
|
|
3
|
+
export declare const CONFIG_FILE_VARIABLE = "PUSHARY_CONFIG_FILE";
|
|
4
|
+
export declare const DEFAULT_BASE_URL = "https://pushary.com";
|
|
5
|
+
export type Environment = Readonly<Record<string, string | undefined>>;
|
|
6
|
+
export interface PusharyConnection {
|
|
7
|
+
readonly baseUrl: string;
|
|
8
|
+
readonly apiKey: string;
|
|
9
|
+
readonly machineId: string | null;
|
|
10
|
+
}
|
|
11
|
+
export type ConnectionReading = {
|
|
12
|
+
readonly kind: 'ready';
|
|
13
|
+
readonly connection: PusharyConnection;
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: 'missing_api_key';
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: 'invalid_base_url';
|
|
18
|
+
};
|
|
19
|
+
export interface ConnectionSources {
|
|
20
|
+
readonly environment: Environment;
|
|
21
|
+
readonly homeDirectory: string;
|
|
22
|
+
readonly readText: (path: string) => string | null;
|
|
23
|
+
readonly hostName: () => string | null;
|
|
24
|
+
}
|
|
25
|
+
export declare const readPusharyConnection: (sources: ConnectionSources) => ConnectionReading;
|
|
26
|
+
export declare const processConnectionSources: () => ConnectionSources;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir, hostname } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isValidApiKey } from './api-key-shape.js';
|
|
6
|
+
import { isPusharyHost } from './pushary-hosts.js';
|
|
7
|
+
export const API_KEY_VARIABLE = 'PUSHARY_API_KEY';
|
|
8
|
+
export const BASE_URL_VARIABLE = 'PUSHARY_BASE_URL';
|
|
9
|
+
export const CONFIG_FILE_VARIABLE = 'PUSHARY_CONFIG_FILE';
|
|
10
|
+
export const DEFAULT_BASE_URL = 'https://pushary.com';
|
|
11
|
+
const PUSHARY_DIRECTORY = '.pushary';
|
|
12
|
+
const PUSHARY_CONFIG_FILE = 'config.json';
|
|
13
|
+
const SHARED_MACHINE_ID_FILE = 'machine-id';
|
|
14
|
+
const SHARED_MACHINE_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/;
|
|
15
|
+
const HOSTNAME_MACHINE_ID_LENGTH = 8;
|
|
16
|
+
const PUSHARY_PROTOCOL = 'https:';
|
|
17
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
const parsedJson = (text) => {
|
|
19
|
+
if (text === null)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const usableApiKey = (value) => {
|
|
29
|
+
if (typeof value !== 'string')
|
|
30
|
+
return null;
|
|
31
|
+
const key = value.trim();
|
|
32
|
+
return isValidApiKey(key) ? key : null;
|
|
33
|
+
};
|
|
34
|
+
const pusharyPath = (sources, file) => join(sources.homeDirectory, PUSHARY_DIRECTORY, file);
|
|
35
|
+
const configFilePath = (sources) => {
|
|
36
|
+
const configured = sources.environment[CONFIG_FILE_VARIABLE]?.trim();
|
|
37
|
+
return configured ? configured : pusharyPath(sources, PUSHARY_CONFIG_FILE);
|
|
38
|
+
};
|
|
39
|
+
const apiKeyFrom = (sources) => {
|
|
40
|
+
const fromEnvironment = usableApiKey(sources.environment[API_KEY_VARIABLE]);
|
|
41
|
+
if (fromEnvironment !== null)
|
|
42
|
+
return fromEnvironment;
|
|
43
|
+
const config = parsedJson(sources.readText(configFilePath(sources)));
|
|
44
|
+
return isRecord(config) ? usableApiKey(config.apiKey) : null;
|
|
45
|
+
};
|
|
46
|
+
const isPusharyOrigin = (url) => url.protocol === PUSHARY_PROTOCOL
|
|
47
|
+
&& url.port === ''
|
|
48
|
+
&& url.username === ''
|
|
49
|
+
&& url.password === ''
|
|
50
|
+
&& isPusharyHost(url.hostname);
|
|
51
|
+
const baseUrlFrom = (environment) => {
|
|
52
|
+
const configured = environment[BASE_URL_VARIABLE]?.trim();
|
|
53
|
+
if (!configured)
|
|
54
|
+
return DEFAULT_BASE_URL;
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(configured);
|
|
57
|
+
return isPusharyOrigin(url) ? url.origin : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const hostnameMachineId = (sources) => {
|
|
64
|
+
const host = sources.hostName();
|
|
65
|
+
return host ? createHash('sha256').update(host, 'utf8').digest('hex').slice(0, HOSTNAME_MACHINE_ID_LENGTH) : null;
|
|
66
|
+
};
|
|
67
|
+
const machineIdFrom = (sources) => {
|
|
68
|
+
const shared = sources.readText(pusharyPath(sources, SHARED_MACHINE_ID_FILE))?.trim() ?? '';
|
|
69
|
+
return SHARED_MACHINE_ID_PATTERN.test(shared) ? shared : hostnameMachineId(sources);
|
|
70
|
+
};
|
|
71
|
+
export const readPusharyConnection = (sources) => {
|
|
72
|
+
const baseUrl = baseUrlFrom(sources.environment);
|
|
73
|
+
if (baseUrl === null)
|
|
74
|
+
return { kind: 'invalid_base_url' };
|
|
75
|
+
const apiKey = apiKeyFrom(sources);
|
|
76
|
+
if (apiKey === null)
|
|
77
|
+
return { kind: 'missing_api_key' };
|
|
78
|
+
return { kind: 'ready', connection: { baseUrl, apiKey, machineId: machineIdFrom(sources) } };
|
|
79
|
+
};
|
|
80
|
+
const readTextFile = (path) => {
|
|
81
|
+
try {
|
|
82
|
+
return readFileSync(path, 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const currentHostName = () => {
|
|
89
|
+
try {
|
|
90
|
+
return hostname() || null;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
export const processConnectionSources = () => ({
|
|
97
|
+
environment: process.env,
|
|
98
|
+
homeDirectory: homedir(),
|
|
99
|
+
readText: readTextFile,
|
|
100
|
+
hostName: currentHostName,
|
|
101
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const APPROVE_ANSWER = "yes";
|
|
2
|
+
export declare const STOP_HANDOFF_ACTION = "stop";
|
|
3
|
+
export declare const DECISION_STOP_STATUSES: readonly string[];
|
|
4
|
+
export interface DecisionStopFields {
|
|
5
|
+
readonly status?: unknown;
|
|
6
|
+
readonly handoffAction?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare const isDecisionStoppedReply: (reply: DecisionStopFields) => boolean;
|
|
9
|
+
export declare const isApprovingAnswer: (answer: string) => boolean;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Generated by scripts/generate-editor-redaction.mjs.
|
|
2
|
+
export const APPROVE_ANSWER = 'yes';
|
|
3
|
+
export const STOP_HANDOFF_ACTION = 'stop';
|
|
4
|
+
export const DECISION_STOP_STATUSES = ['cancelled', 'unavailable', 'stopped', 'missing'];
|
|
5
|
+
export const isDecisionStoppedReply = (reply) => reply.handoffAction === STOP_HANDOFF_ACTION || (typeof reply.status === 'string' && DECISION_STOP_STATUSES.includes(reply.status));
|
|
6
|
+
export const isApprovingAnswer = (answer) => answer === APPROVE_ANSWER;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ApprovalQuestion } from './approval-question.js';
|
|
2
|
+
import { type ConnectionReading } from './connection.js';
|
|
3
|
+
import type { GateOutcome } from './gate.js';
|
|
4
|
+
import type { OpenClawApprovalDecision, OpenClawBeforeToolCallResult } from './host-api.js';
|
|
5
|
+
export declare const SETUP_COMMAND = "npx pushary@latest setup";
|
|
6
|
+
export declare const DEVICES_URL = "https://pushary.com/dashboard/agent/settings";
|
|
7
|
+
export declare const HAND_BACK_DECISIONS: readonly OpenClawApprovalDecision[];
|
|
8
|
+
export type ConnectionProblem = Exclude<ConnectionReading, {
|
|
9
|
+
readonly kind: 'ready';
|
|
10
|
+
}>['kind'];
|
|
11
|
+
export type HandBackSurface = 'openclaw_prompt' | 'none';
|
|
12
|
+
export declare const openClawResultFor: (outcome: GateOutcome, question: ApprovalQuestion, handBackSurface: HandBackSurface) => OpenClawBeforeToolCallResult | undefined;
|
|
13
|
+
export declare const notConnectedResultFor: (problem: ConnectionProblem, toolName: string) => OpenClawBeforeToolCallResult;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { APPROVAL_DESCRIPTION_MAX, APPROVAL_TITLE_MAX, truncated } from './approval-question.js';
|
|
2
|
+
import { API_KEY_VARIABLE, BASE_URL_VARIABLE } from './connection.js';
|
|
3
|
+
import { MIN_HOST_VERSION } from './host-version.js';
|
|
4
|
+
export const SETUP_COMMAND = 'npx pushary@latest setup';
|
|
5
|
+
export const DEVICES_URL = 'https://pushary.com/dashboard/agent/settings';
|
|
6
|
+
export const HAND_BACK_DECISIONS = ['allow-once', 'deny'];
|
|
7
|
+
const SECONDS_PER_MINUTE = 60;
|
|
8
|
+
const MILLISECONDS_PER_SECOND = 1_000;
|
|
9
|
+
const blocked = (blockReason) => ({ block: true, blockReason });
|
|
10
|
+
const waitedFor = (heldMs) => {
|
|
11
|
+
const seconds = Math.round(heldMs / MILLISECONDS_PER_SECOND);
|
|
12
|
+
if (seconds < SECONDS_PER_MINUTE)
|
|
13
|
+
return `${seconds} seconds`;
|
|
14
|
+
const minutes = Math.round(seconds / SECONDS_PER_MINUTE);
|
|
15
|
+
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
|
16
|
+
};
|
|
17
|
+
const unavailableReason = (toolName, outcome) => {
|
|
18
|
+
if (outcome.failure === 'unauthorized') {
|
|
19
|
+
return `Pushary rejected the API key, so this ${toolName} call was blocked. Nothing ran. Run ${SETUP_COMMAND} again to get a new key, then restart the OpenClaw gateway.`;
|
|
20
|
+
}
|
|
21
|
+
if (outcome.failure === 'tool_error' || outcome.failure === 'refused') {
|
|
22
|
+
return `Pushary refused the approval request, so this ${toolName} call was blocked. Nothing ran. Answer or cancel your open questions in Pushary, then try again.`;
|
|
23
|
+
}
|
|
24
|
+
return `Pushary could not be reached, so nobody could approve this ${toolName} call and it was blocked. Nothing ran. Try again in a moment.`;
|
|
25
|
+
};
|
|
26
|
+
const handedBack = (question) => ({
|
|
27
|
+
requireApproval: {
|
|
28
|
+
title: truncated(question.question, APPROVAL_TITLE_MAX),
|
|
29
|
+
description: truncated(`${question.context} ${question.action}`, APPROVAL_DESCRIPTION_MAX),
|
|
30
|
+
severity: 'warning',
|
|
31
|
+
allowedDecisions: [...HAND_BACK_DECISIONS],
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const noPromptToHandBackTo = (toolName) => blocked(`Your Pushary delivery mode keeps this approval off your phone, and this OpenClaw is older than ${MIN_HOST_VERSION}, so it cannot ask in its own prompt. This ${toolName} call was blocked. Update OpenClaw, or change the delivery mode in Pushary.`);
|
|
35
|
+
export const openClawResultFor = (outcome, question, handBackSurface) => {
|
|
36
|
+
const tool = question.toolName;
|
|
37
|
+
switch (outcome.kind) {
|
|
38
|
+
case 'approved':
|
|
39
|
+
return undefined;
|
|
40
|
+
case 'denied':
|
|
41
|
+
return blocked(`The person you asked in Pushary did not approve this ${tool} call, so it was blocked. Do not retry it. Ask them what to do instead.`);
|
|
42
|
+
case 'unanswered':
|
|
43
|
+
return blocked(`Nobody answered the Pushary approval within ${waitedFor(outcome.heldMs)}, so this ${tool} call was blocked. Nothing ran.`);
|
|
44
|
+
case 'stopped':
|
|
45
|
+
return blocked(`Pushary stopped this request, so this ${tool} call was blocked. Stop and wait for instructions.`);
|
|
46
|
+
case 'cancelled':
|
|
47
|
+
return blocked(`This ${tool} call was cancelled before anyone answered the Pushary approval. Nothing ran.`);
|
|
48
|
+
case 'undeliverable':
|
|
49
|
+
return blocked(`No phone, browser or Slack is connected to Pushary, so nobody could approve this ${tool} call and it was blocked. Connect one at ${DEVICES_URL}.`);
|
|
50
|
+
case 'handed_back':
|
|
51
|
+
return handBackSurface === 'openclaw_prompt' ? handedBack(question) : noPromptToHandBackTo(tool);
|
|
52
|
+
case 'unavailable':
|
|
53
|
+
return blocked(unavailableReason(tool, outcome));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
export const notConnectedResultFor = (problem, toolName) => problem === 'missing_api_key'
|
|
57
|
+
? blocked(`Pushary is not set up for OpenClaw on this machine: ${API_KEY_VARIABLE} is missing, so this ${toolName} call was blocked. Run ${SETUP_COMMAND}, or add ${API_KEY_VARIABLE} to ~/.openclaw/.env, then restart the OpenClaw gateway.`)
|
|
58
|
+
: blocked(`${BASE_URL_VARIABLE} is not https://pushary.com or https://www.pushary.com, so this ${toolName} call was blocked. Remove it, then restart the OpenClaw gateway.`);
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ApprovalQuestion } from './approval-question.js';
|
|
2
|
+
import { type CallFailure, type PusharyQuestions } from './pushary-client.js';
|
|
3
|
+
export declare const ASK_ATTEMPTS = 3;
|
|
4
|
+
export declare const ASK_RETRY_PAUSE_MS = 1000;
|
|
5
|
+
export declare const POLL_RETRY_PAUSES_MS: readonly [number, ...number[]];
|
|
6
|
+
export declare const UNANSWERED_POLL_PAUSE_MS = 2000;
|
|
7
|
+
export declare const LATE_ANSWER_POLL_MS = 1000;
|
|
8
|
+
export declare const WITHDRAW_ATTEMPTS = 3;
|
|
9
|
+
export declare const WITHDRAW_RETRY_PAUSE_MS = 500;
|
|
10
|
+
export type GateFailure = CallFailure | 'unreadable_reply';
|
|
11
|
+
export type GateOutcome = {
|
|
12
|
+
readonly kind: 'approved';
|
|
13
|
+
} | {
|
|
14
|
+
readonly kind: 'denied';
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: 'unanswered';
|
|
17
|
+
readonly heldMs: number;
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: 'stopped';
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: 'cancelled';
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: 'undeliverable';
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: 'handed_back';
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: 'unavailable';
|
|
28
|
+
readonly failure: GateFailure;
|
|
29
|
+
};
|
|
30
|
+
export interface GateClock {
|
|
31
|
+
readonly monotonicMs: () => number;
|
|
32
|
+
readonly wallMs: () => number;
|
|
33
|
+
readonly sleep: (ms: number, signal: AbortSignal | undefined) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export interface GateRequest {
|
|
36
|
+
readonly question: ApprovalQuestion;
|
|
37
|
+
readonly agentName: string;
|
|
38
|
+
readonly requestId: string;
|
|
39
|
+
readonly holdMs: number;
|
|
40
|
+
readonly withdrawReserveMs: number;
|
|
41
|
+
readonly sessionId?: string;
|
|
42
|
+
readonly machineId?: string;
|
|
43
|
+
readonly signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
export interface GateDependencies {
|
|
46
|
+
readonly questions: PusharyQuestions;
|
|
47
|
+
readonly clock: GateClock;
|
|
48
|
+
}
|
|
49
|
+
export declare const runApprovalGate: (request: GateRequest, dependencies: GateDependencies) => Promise<GateOutcome>;
|