@parall/agent-core 1.42.0 → 1.43.0

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.
Files changed (45) hide show
  1. package/dist/bin/channel-exec.d.ts +4 -0
  2. package/dist/bin/channel-exec.d.ts.map +1 -0
  3. package/dist/bin/channel-exec.js +246 -0
  4. package/dist/channel-capability.d.ts +16 -0
  5. package/dist/channel-capability.d.ts.map +1 -0
  6. package/dist/channel-capability.js +155 -0
  7. package/dist/channel-token.d.ts +19 -0
  8. package/dist/channel-token.d.ts.map +1 -0
  9. package/dist/channel-token.js +73 -0
  10. package/dist/event-format.d.ts.map +1 -1
  11. package/dist/event-format.js +21 -16
  12. package/dist/gateway-base.d.ts +15 -0
  13. package/dist/gateway-base.d.ts.map +1 -1
  14. package/dist/gateway-base.js +113 -38
  15. package/dist/gateway-lane-flow.d.ts +20 -1
  16. package/dist/gateway-lane-flow.d.ts.map +1 -1
  17. package/dist/gateway-lane-flow.js +78 -6
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -0
  21. package/dist/platform-config.d.ts +15 -0
  22. package/dist/platform-config.d.ts.map +1 -1
  23. package/dist/platform-config.js +28 -0
  24. package/dist/prompt-fragments.d.ts +1 -1
  25. package/dist/prompt-fragments.d.ts.map +1 -1
  26. package/dist/prompt-fragments.js +29 -7
  27. package/dist/skills/index.js +1 -1
  28. package/dist/skills/parall-platform.d.ts +1 -1
  29. package/dist/skills/parall-platform.d.ts.map +1 -1
  30. package/dist/skills/parall-platform.js +23 -4
  31. package/dist/types.d.ts +5 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/bin/channel-exec.ts +262 -0
  35. package/src/channel-capability.ts +187 -0
  36. package/src/channel-token.ts +92 -0
  37. package/src/event-format.ts +21 -16
  38. package/src/gateway-base.ts +137 -39
  39. package/src/gateway-lane-flow.ts +92 -4
  40. package/src/index.ts +3 -0
  41. package/src/platform-config.ts +44 -0
  42. package/src/prompt-fragments.ts +29 -7
  43. package/src/skills/index.ts +1 -1
  44. package/src/skills/parall-platform.ts +23 -4
  45. package/src/types.ts +5 -1
@@ -0,0 +1,4 @@
1
+ declare function escapeCmdArgument(arg: string): string;
2
+ declare function escapeCmdCommand(command: string): string;
3
+ export { escapeCmdArgument, escapeCmdCommand };
4
+ //# sourceMappingURL=channel-exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-exec.d.ts","sourceRoot":"","sources":["../../src/bin/channel-exec.ts"],"names":[],"mappings":"AA0LA,iBAAS,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQ9C;AAED,iBAAS,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEjD;AA4BD,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,CAAC"}
@@ -0,0 +1,246 @@
1
+ // channel-exec — the short-lived exec form of the channel-capability
2
+ // credential engine. Invoked by the pointer shims the materializer drops on
3
+ // the capability PATH (e.g. `lark-cli` → `node <this file> --channel feishu
4
+ // --bin lark-cli --skip-dir <shimDir> -- <args…>`): mints a fresh short-lived
5
+ // token from the platform on EVERY call, injects it as the vendor CLI's own
6
+ // documented env credentials, resolves the real binary past the shim
7
+ // directory, and execs it with stdio passthrough.
8
+ //
9
+ // This is a process entry point, not a library — importing it runs main().
10
+ // It lives inside @parall/agent-core so it ships atomically with the bridge
11
+ // (image / daemon bundle), never depending on agent-writable install state.
12
+ // Design: docs/engineering-design/agent-capability-fragments-design.md §5.1.
13
+ import { spawnSync } from 'node:child_process';
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { CHANNEL_POINTER_MAGIC } from '../channel-capability.js';
18
+ import { ChannelTokenError, mintChannelToken } from '../channel-token.js';
19
+ // The vendor binary each channel targets is fixed HERE, not taken from the
20
+ // caller — channel-exec must never mint a token and hand it to a
21
+ // caller-named program. `--bin` is deliberately not an argument, so a request
22
+ // like `--channel feishu --bin evil` (with a tampered PATH) cannot redirect
23
+ // the minted token to an attacker-chosen executable.
24
+ const CHANNEL_BIN = { feishu: 'lark-cli' };
25
+ function fail(prefix, msg, code) {
26
+ // Write to fd 2 SYNCHRONOUSLY: process.exit() right after an async
27
+ // process.stderr.write() can truncate the message when stderr is a pipe
28
+ // (notably on Windows) — and this path carries the install hint / revocation
29
+ // message the agent must relay to the user. writeSync flushes before exit;
30
+ // exit still runs so fail() keeps its `never` contract.
31
+ try {
32
+ fs.writeSync(2, `${prefix}: ${msg}\n`);
33
+ }
34
+ catch {
35
+ // best-effort — still exit with the intended status
36
+ }
37
+ process.exit(code);
38
+ }
39
+ function parseArgs(argv) {
40
+ const out = { channel: '', skipDir: '', rest: [] };
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ if (a === '--') {
44
+ out.rest = argv.slice(i + 1);
45
+ break;
46
+ }
47
+ if (a === '--channel')
48
+ out.channel = argv[++i] ?? '';
49
+ else if (a === '--skip-dir')
50
+ out.skipDir = argv[++i] ?? '';
51
+ else
52
+ fail('channel-exec', `unknown argument ${a}`, 2);
53
+ }
54
+ if (!out.channel) {
55
+ fail('channel-exec', 'usage: channel-exec --channel <type> [--skip-dir <dir>] -- <args…>', 2);
56
+ }
57
+ return out;
58
+ }
59
+ // A candidate that carries the pointer magic in its head is one of OUR
60
+ // pointers (or a stray copy of one) — never the real vendor binary. This
61
+ // makes self-recursion structurally impossible even when the skip-dir hint
62
+ // is wrong or the pointer got copied elsewhere on PATH.
63
+ function isCapabilityPointer(candidate) {
64
+ try {
65
+ const fd = fs.openSync(candidate, 'r');
66
+ try {
67
+ const buf = Buffer.alloc(256);
68
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
69
+ return buf.toString('utf8', 0, n).includes(CHANNEL_POINTER_MAGIC);
70
+ }
71
+ finally {
72
+ fs.closeSync(fd);
73
+ }
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ // Resolve the REAL vendor binary along PATH, skipping the shim's own
80
+ // directory (realpath comparison, so symlinked layouts don't fool it).
81
+ function resolveRealBin(bin, skipDir) {
82
+ const isWin = process.platform === 'win32';
83
+ // npm on Windows installs `<bin>` (sh) + `<bin>.cmd` + `<bin>.ps1`; the sh
84
+ // file is not spawnable by CreateProcess, so prefer the .cmd/.exe forms.
85
+ const exts = isWin ? ['.cmd', '.exe', '.bat'] : [''];
86
+ let skipReal = null;
87
+ if (skipDir) {
88
+ try {
89
+ skipReal = fs.realpathSync(skipDir);
90
+ }
91
+ catch {
92
+ skipReal = null;
93
+ }
94
+ }
95
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
96
+ if (!dir)
97
+ continue;
98
+ let real;
99
+ try {
100
+ real = fs.realpathSync(dir);
101
+ }
102
+ catch {
103
+ continue;
104
+ }
105
+ if (skipReal && real === skipReal)
106
+ continue;
107
+ for (const ext of exts) {
108
+ const candidate = path.join(dir, bin + ext);
109
+ try {
110
+ if (fs.statSync(candidate).isFile() && !isCapabilityPointer(candidate))
111
+ return candidate;
112
+ }
113
+ catch {
114
+ // keep scanning
115
+ }
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ // The exact lark-cli env credentials the broker OWNS: the values it injects
121
+ // plus the ambient auth inputs it must strip so nothing widens the identity
122
+ // beyond the minted grant. Scrubbing is confined to THESE names — never a
123
+ // blanket LARKSUITE_CLI_* wipe — so legitimate operator controls
124
+ // (LARKSUITE_CLI_CONFIG_DIR / LOG_DIR / CONTENT_SAFETY_MODE / …) survive.
125
+ const FEISHU_OWNED_ENV = [
126
+ // injected (canonical values set below)
127
+ 'LARKSUITE_CLI_APP_ID',
128
+ 'LARKSUITE_CLI_BRAND',
129
+ 'LARKSUITE_CLI_TENANT_ACCESS_TOKEN',
130
+ 'LARKSUITE_CLI_DEFAULT_AS',
131
+ 'LARKSUITE_CLI_STRICT_MODE',
132
+ // ambient auth bypass inputs that must not reach the child
133
+ 'LARKSUITE_CLI_APP_SECRET',
134
+ 'LARKSUITE_CLI_USER_ACCESS_TOKEN',
135
+ 'LARKSUITE_CLI_AUTH_PROXY',
136
+ 'LARKSUITE_CLI_PROXY_KEY',
137
+ ];
138
+ // Per-channel env injection: the ONLY channel-specific knowledge in this
139
+ // entry.
140
+ function buildChildEnv(channel, minted) {
141
+ const env = { ...process.env };
142
+ if (channel === 'feishu') {
143
+ // Delete the broker-owned names case-insensitively: Windows env keys are
144
+ // case-insensitive and a plain-object spread keeps the host's casing, so
145
+ // an uppercase-only delete would miss e.g. `Larksuite_Cli_Auth_Proxy` (a
146
+ // bypass leak) or leave a stale-cased duplicate shadowing the injected
147
+ // token. Non-owned LARKSUITE_CLI_* operator settings are left untouched.
148
+ const owned = new Set(FEISHU_OWNED_ENV);
149
+ for (const key of Object.keys(env)) {
150
+ if (owned.has(key.toUpperCase()))
151
+ delete env[key];
152
+ }
153
+ env.LARKSUITE_CLI_APP_ID = minted.app_id;
154
+ env.LARKSUITE_CLI_BRAND = minted.brand || 'feishu';
155
+ env.LARKSUITE_CLI_TENANT_ACCESS_TOKEN = minted.token;
156
+ // Bot lock: the credential is the app's, not a human's.
157
+ env.LARKSUITE_CLI_DEFAULT_AS = 'bot';
158
+ env.LARKSUITE_CLI_STRICT_MODE = 'bot';
159
+ return env;
160
+ }
161
+ fail('channel-exec', `unsupported channel type "${channel}"`, 2);
162
+ }
163
+ // Windows argv-preserving quoting, ported from cross-spawn (the npm-ecosystem
164
+ // standard for correctly launching .cmd shims). Node's `shell: true` does NOT
165
+ // escape arguments — it concatenates them, so JSON/message args split or
166
+ // inject on cmd.exe — and CreateProcess cannot run a .cmd directly. The proven
167
+ // path is cmd.exe /d /s /c with each token escaped for BOTH the CreateProcess
168
+ // argv layer and the cmd.exe metachar layer (double-escaped because a .cmd
169
+ // re-parses). Still Windows-only and NO Windows CI: validate on a real Windows
170
+ // host before the first Windows selfhost user (the round-trip tests lock the
171
+ // escaping rules, not the live cmd.exe behavior).
172
+ // cmd.exe metacharacters, matching cross-spawn's metaCharsRegExp (v7.0.6). ONE
173
+ // set shared by BOTH the command and the arguments — cross-spawn uses a single
174
+ // regex for both, and a split set is a real correctness hole: a metachar
175
+ // caret-escaped in the command path but left bare in an argument (or the
176
+ // reverse) survives only one of cmd.exe's two parse passes. Includes the
177
+ // separators (space, `;`, `,`) and glob chars (`*`, `?`) so a quoted argument's
178
+ // separators are still neutralized for the `.cmd` re-parse, exactly as
179
+ // cross-spawn does.
180
+ const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
181
+ function escapeCmdArgument(arg) {
182
+ let out = `${arg}`;
183
+ // Double backslashes before a quote, and trailing backslashes, then wrap.
184
+ out = out.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1');
185
+ out = `"${out}"`;
186
+ // cmd metachars, escaped twice (the .cmd forwarder re-parses).
187
+ out = out.replace(CMD_META_CHARS, '^$1').replace(CMD_META_CHARS, '^$1');
188
+ return out;
189
+ }
190
+ function escapeCmdCommand(command) {
191
+ return command.replace(CMD_META_CHARS, '^$1');
192
+ }
193
+ function runReal(realBin, args, env, prefix) {
194
+ const isWinScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(realBin);
195
+ const result = isWinScript
196
+ ? spawnSync(process.env.comspec || 'cmd.exe', [
197
+ '/d',
198
+ '/s',
199
+ '/c',
200
+ `"${[escapeCmdCommand(realBin), ...args.map(escapeCmdArgument)].join(' ')}"`,
201
+ ], { stdio: 'inherit', env, windowsVerbatimArguments: true })
202
+ : spawnSync(realBin, args, { stdio: 'inherit', env });
203
+ if (result.error) {
204
+ fail(prefix, `failed to run ${realBin}: ${String(result.error)}`, 1);
205
+ }
206
+ if (result.signal) {
207
+ process.kill(process.pid, result.signal);
208
+ // Unreachable in practice; satisfy the `never` contract if the signal is trapped.
209
+ process.exit(1);
210
+ }
211
+ process.exit(result.status === null ? 1 : result.status);
212
+ }
213
+ // Exported for round-trip unit tests (Windows spawn can't run in CI).
214
+ export { escapeCmdArgument, escapeCmdCommand };
215
+ async function main() {
216
+ const { channel, skipDir, rest } = parseArgs(process.argv.slice(2));
217
+ const bin = CHANNEL_BIN[channel];
218
+ if (!bin)
219
+ fail('channel-exec', `unsupported channel type "${channel}"`, 2);
220
+ const prefix = bin;
221
+ let minted;
222
+ try {
223
+ minted = await mintChannelToken(channel, process.env);
224
+ }
225
+ catch (err) {
226
+ if (err instanceof ChannelTokenError)
227
+ fail(prefix, err.message, 1);
228
+ fail(prefix, String(err), 1);
229
+ }
230
+ const real = resolveRealBin(bin, skipDir);
231
+ if (!real) {
232
+ const hint = channel === 'feishu'
233
+ ? 'Install it once with:\n npm i -g @larksuite/cli && npx skills add larksuite/cli -y -g'
234
+ : 'Install it and retry.';
235
+ fail(prefix, `the ${bin} binary is not installed. ${hint}`, 127);
236
+ }
237
+ runReal(real, rest, buildChildEnv(channel, minted), prefix);
238
+ }
239
+ // Run main() only when invoked AS A PROGRAM (the pointer shim runs
240
+ // `node <this>`), not when a test imports this module for the escape helpers.
241
+ // Holds in both layouts: npm (argv[1] = channel-exec.js) and bundle
242
+ // (argv[1] = parall-channel-exec.js, which is also this module's own url).
243
+ const invokedPath = process.argv[1];
244
+ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
245
+ void main();
246
+ }
@@ -0,0 +1,16 @@
1
+ import type { GatewayLogger } from './dispatch-adapter.js';
2
+ import type { AgentCapability } from './platform-config.js';
3
+ export declare const CAPABILITY_FEISHU_CLI = "feishu-cli";
4
+ export declare const CHANNEL_POINTER_MAGIC = "parall channel capability pointer";
5
+ export declare function capabilityBinDir(stateDir: string): string;
6
+ export declare function channelExecEntryPath(): string;
7
+ /**
8
+ * Idempotently reconcile local capability pointers with the delivered
9
+ * capability list. Call at boot (after the first config fetch) and on every
10
+ * config refresh. Never throws — a pointer write failure must not take down
11
+ * a config refresh (the capability simply stays unusable until the next pass).
12
+ */
13
+ export declare function materializeChannelCapabilities(stateDir: string, capabilities: AgentCapability[], log?: GatewayLogger): void;
14
+ export declare function renderPosixPointer(nodeExecPath: string, entryJsPath: string, binDir: string, channel: string): string;
15
+ export declare function renderCmdPointer(nodeExecPath: string, entryJsPath: string, binDir: string, channel: string): string;
16
+ //# sourceMappingURL=channel-capability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-capability.d.ts","sourceRoot":"","sources":["../src/channel-capability.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAoB5D,eAAO,MAAM,qBAAqB,eAAe,CAAC;AAMlD,eAAO,MAAM,qBAAqB,sCAAsC,CAAC;AAMzE,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAcD,wBAAgB,oBAAoB,IAAI,MAAM,CAK7C;AAED;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,eAAe,EAAE,EAC/B,GAAG,CAAC,EAAE,aAAa,GAClB,IAAI,CAMN;AAuED,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,MAAM,CAkBR;AAED,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,MAAM,CAUR"}
@@ -0,0 +1,155 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // Runtime-side materializer for channel capabilities — a pure PLACEMENT
5
+ // layer. The server declares WHAT the agent has (agents.capabilities[]);
6
+ // the credential logic lives in this package's channel-exec entry
7
+ // (bin/channel-exec.ts, reusing channel-token.ts); this module only drops
8
+ // constant POINTER shims onto the capability PATH so the vendor command name
9
+ // (e.g. `lark-cli`, which the official skills invoke directly) routes into
10
+ // that entry. Pointers reference channel-exec by IN-PACKAGE ABSOLUTE PATH —
11
+ // no PATH lookup, no dependence on agent-writable install state; the engine
12
+ // ships atomically with the bridge.
13
+ //
14
+ // Revocation keeps the pointer in place (nothing to remove): the pointer's
15
+ // content is independent of grant state, and revocation semantics are
16
+ // enforced by the platform mint endpoint (403 with an agent-readable
17
+ // message). Deleting would let PATH fall through to a directly-installed
18
+ // real CLI past the mint gate — the failure mode the old deny-stub existed
19
+ // to prevent; a constant pointer prevents it structurally.
20
+ // Design: docs/engineering-design/agent-capability-fragments-design.md §5.1.
21
+ export const CAPABILITY_FEISHU_CLI = 'feishu-cli';
22
+ // Marker embedded in every generated pointer. channel-exec skips any PATH
23
+ // candidate whose head carries it, so a pointer (or a stray copy of one) can
24
+ // never be mistaken for the real vendor binary — self-recursion is
25
+ // structurally impossible even if the skip-dir hint is wrong.
26
+ export const CHANNEL_POINTER_MAGIC = 'parall channel capability pointer';
27
+ // SSOT for the shim directory: bridges prepend this to the child PATH once,
28
+ // unconditionally — the directory is constant, its content tracks grants, so
29
+ // a grant reaches even long-lived subprocesses (the shell re-resolves PATH
30
+ // per command) without any respawn.
31
+ export function capabilityBinDir(stateDir) {
32
+ return path.join(stateDir, 'bin');
33
+ }
34
+ // Absolute path of the channel-exec entry, resolved across BOTH distribution
35
+ // layouts — mirroring the daemon's resolveBbBrowserDaemonPath:
36
+ // - npm / hosted image: agent-core's dist tree exists on disk →
37
+ // ./bin/channel-exec.js sits under this module's dir.
38
+ // - standalone bundle (@parall/daemon / CDN self-update / desktop): esbuild
39
+ // flattens this module INTO bundle/parall-claude-agent.js, so ./bin/…
40
+ // doesn't exist; the bundle ships parall-channel-exec.js as a sibling flat
41
+ // artifact (scripts/bundle-daemon.mjs), resolved next to this file.
42
+ // Prefer the sibling (bundle) and fall through to the in-package path (dev /
43
+ // npm), like the bb-browser resolver — the two candidates are mutually
44
+ // exclusive so order only affects which stat wins in the impossible case that
45
+ // both exist.
46
+ export function channelExecEntryPath() {
47
+ const selfDir = path.dirname(fileURLToPath(import.meta.url));
48
+ const sibling = path.join(selfDir, 'parall-channel-exec.js');
49
+ if (fs.existsSync(sibling))
50
+ return sibling;
51
+ return fileURLToPath(new URL('./bin/channel-exec.js', import.meta.url));
52
+ }
53
+ /**
54
+ * Idempotently reconcile local capability pointers with the delivered
55
+ * capability list. Call at boot (after the first config fetch) and on every
56
+ * config refresh. Never throws — a pointer write failure must not take down
57
+ * a config refresh (the capability simply stays unusable until the next pass).
58
+ */
59
+ export function materializeChannelCapabilities(stateDir, capabilities, log) {
60
+ try {
61
+ reconcileFeishuCli(stateDir, capabilities, log);
62
+ }
63
+ catch (err) {
64
+ log?.warn(`channel capability materialization failed: ${String(err)}`);
65
+ }
66
+ }
67
+ function reconcileFeishuCli(stateDir, capabilities, log) {
68
+ const binDir = capabilityBinDir(stateDir);
69
+ const posixPath = path.join(binDir, 'lark-cli');
70
+ const granted = capabilities.some((c) => c.key === CAPABILITY_FEISHU_CLI);
71
+ const hadPointer = fs.existsSync(posixPath);
72
+ // Write/refresh pointers when GRANTED, or when a pointer already exists (a
73
+ // previously-granted, now-revoked agent). The refresh-on-revoke case is
74
+ // load-bearing for bundle self-update: the pointer embeds channel-exec's
75
+ // ABSOLUTE path, which resolves through the daemon's `current` symlink into
76
+ // a versioned dir; after an upgrade prunes the old version, a stale retained
77
+ // pointer would fail MODULE_NOT_FOUND instead of reaching the mint 403.
78
+ // Re-rendering every pass keeps the pointer aimed at the LIVE channel-exec,
79
+ // so a revoked agent still gets the self-explanatory 403. A NEVER-granted
80
+ // agent (no pointer) is left untouched — the operator's own lark-cli install
81
+ // stays clean.
82
+ if (!granted && !hadPointer)
83
+ return;
84
+ fs.mkdirSync(binDir, { recursive: true });
85
+ const entry = channelExecEntryPath();
86
+ // The node binary is referenced by ABSOLUTE path (process.execPath), not the
87
+ // bare name `node`: packaged installs (desktop / daemon bundle) embed the
88
+ // runtime as `parall-node` with no plain `node` on PATH, so a bare `node`
89
+ // would die before channel-exec ever runs and break bundle parity at the
90
+ // last hop. process.execPath is the very node currently running the bridge —
91
+ // the parall-node in a bundle, the system node under npm.
92
+ const nodeExec = process.execPath;
93
+ writePointerIfChanged(posixPath, renderPosixPointer(nodeExec, entry, binDir, 'feishu'), log);
94
+ // Windows companion: cmd/PowerShell resolve executables via PATHEXT and
95
+ // ignore extensionless shebang files. (The agent's own Bash tool on Windows
96
+ // is git-bash, which uses the sh pointer above — the .cmd is the cmd/
97
+ // PowerShell fallback; its %* follows standard batch semantics, same as any
98
+ // npm-installed .cmd bin.)
99
+ writePointerIfChanged(path.join(binDir, 'lark-cli.cmd'), renderCmdPointer(nodeExec, entry, binDir, 'feishu'), log);
100
+ }
101
+ function writePointerIfChanged(filePath, content, log) {
102
+ let existing = null;
103
+ try {
104
+ existing = fs.readFileSync(filePath, 'utf8');
105
+ }
106
+ catch {
107
+ existing = null;
108
+ }
109
+ if (existing !== content) {
110
+ fs.writeFileSync(filePath, content, { mode: 0o755 });
111
+ log?.info(`channel capability: pointer materialized (${path.basename(filePath)})`);
112
+ }
113
+ // Mode is enforced even when content is unchanged (a prior partial write
114
+ // or umask drift must not leave the pointer non-executable).
115
+ fs.chmodSync(filePath, 0o755);
116
+ }
117
+ // Pointer content is versioned HERE (never delivered by the server — config
118
+ // carries declarations, not code). It embeds the entry's AND the bin dir's
119
+ // absolute paths at write time — no $(dirname)/external commands (a minimal
120
+ // PATH must not break the pointer), and the skip hint cannot drift. A
121
+ // package upgrade that moves paths changes the rendered content, and the
122
+ // boot-time materialize pass rewrites it (self-healing).
123
+ // The target binary is NOT passed on the command line — channel-exec derives
124
+ // it from the channel (feishu → lark-cli), so a caller cannot redirect the
125
+ // minted token to a different program. binDir is still passed as the skip hint.
126
+ export function renderPosixPointer(nodeExecPath, entryJsPath, binDir, channel) {
127
+ return [
128
+ '#!/bin/sh',
129
+ `# Generated by @parall/agent-core — ${CHANNEL_POINTER_MAGIC} (do not edit).`,
130
+ '# Credential + exec logic lives in the agent-core package; revocation is',
131
+ '# enforced by the platform mint endpoint, so this pointer stays constant.',
132
+ // Strip Node preload-hijack vars BEFORE launching node: NODE_OPTIONS
133
+ // (e.g. --require=/evil.js) and NODE_PATH would execute caller-supplied code
134
+ // at interpreter startup — BEFORE channel-exec's own env scrub, i.e. before
135
+ // the mint. The pointer is a platform-authored trust-boundary artifact whose
136
+ // whole job is a CONTROLLED launch of the broker (absolute node, magic
137
+ // guard, skip-dir); this closes the same env-hijack class for node startup
138
+ // that the absolute node path closes for PATH, keeping the launch deterministic.
139
+ 'unset NODE_OPTIONS NODE_PATH',
140
+ // "$@" preserves argv exactly (this is the agent's main path via git-bash).
141
+ `exec "${nodeExecPath}" "${entryJsPath}" --channel ${channel} --skip-dir "${binDir}" -- "$@"`,
142
+ '',
143
+ ].join('\n');
144
+ }
145
+ export function renderCmdPointer(nodeExecPath, entryJsPath, binDir, channel) {
146
+ return [
147
+ '@echo off',
148
+ `rem Generated by @parall/agent-core - ${CHANNEL_POINTER_MAGIC} (do not edit).`,
149
+ // Clear Node preload-hijack vars before launching node (see the sh pointer).
150
+ 'set "NODE_OPTIONS="',
151
+ 'set "NODE_PATH="',
152
+ `"${nodeExecPath}" "${entryJsPath}" --channel ${channel} --skip-dir "${binDir}" -- %*`,
153
+ '',
154
+ ].join('\r\n');
155
+ }
@@ -0,0 +1,19 @@
1
+ export interface MintedChannelToken {
2
+ channel_type: string;
3
+ token_type: string;
4
+ app_id: string;
5
+ brand: string;
6
+ token: string;
7
+ expires_at: string;
8
+ }
9
+ export declare class ChannelTokenError extends Error {
10
+ }
11
+ /**
12
+ * Exchange the agent's channel grant for a short-lived provider token.
13
+ * Deliberately NO local caching at any layer here: the parall round trip is
14
+ * tens of ms (the expensive vendor exchange is absorbed by the server-side
15
+ * Redis cache), and every call re-passing the server gate is what makes
16
+ * revocation and re-credentialing bite on the very next invocation.
17
+ */
18
+ export declare function mintChannelToken(channelType: string, env: NodeJS.ProcessEnv): Promise<MintedChannelToken>;
19
+ //# sourceMappingURL=channel-token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-token.d.ts","sourceRoot":"","sources":["../src/channel-token.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,qBAAa,iBAAkB,SAAQ,KAAK;CAAG;AAI/C;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,CAAC,UAAU,GACrB,OAAO,CAAC,kBAAkB,CAAC,CA2D7B"}
@@ -0,0 +1,73 @@
1
+ // Channel-capability credential client — the ONE place that talks to the
2
+ // platform mint endpoint. Consumed today by the short-lived exec form
3
+ // (bin/channel-exec.ts); the future resident forms (proxy / mcp, hosted by
4
+ // the bridge process) reuse this module so swimlane routing, error
5
+ // presentation, and any future retry/telemetry policy stay single-sourced.
6
+ // Design: docs/engineering-design/agent-capability-fragments-design.md §5.
7
+ // Thrown for every mint failure; `message` is written for the AGENT to read
8
+ // (and relay to the user when the platform says the capability is revoked).
9
+ export class ChannelTokenError extends Error {
10
+ }
11
+ const MINT_TIMEOUT_MS = 10_000;
12
+ /**
13
+ * Exchange the agent's channel grant for a short-lived provider token.
14
+ * Deliberately NO local caching at any layer here: the parall round trip is
15
+ * tens of ms (the expensive vendor exchange is absorbed by the server-side
16
+ * Redis cache), and every call re-passing the server gate is what makes
17
+ * revocation and re-credentialing bite on the very next invocation.
18
+ */
19
+ export async function mintChannelToken(channelType, env) {
20
+ const apiUrl = (env.PRLL_API_URL || '').replace(/\/+$/, '');
21
+ const apiKey = env.PRLL_API_KEY || '';
22
+ const orgId = env.PRLL_ORG_ID || '';
23
+ if (!apiUrl || !apiKey || !orgId) {
24
+ throw new ChannelTokenError('PRLL_API_URL/PRLL_API_KEY/PRLL_ORG_ID missing from the environment');
25
+ }
26
+ const headers = {
27
+ 'Content-Type': 'application/json',
28
+ Authorization: `Bearer ${apiKey}`,
29
+ };
30
+ // Swimlane routing must ride the mint request exactly like every SDK call —
31
+ // a bridge deployed in a PR swimlane mints against its own API/DB.
32
+ const swimlane = (env.PRLL_SWIMLANE_NAME || '').trim();
33
+ if (swimlane)
34
+ headers['X-Prll-Swimlane'] = swimlane;
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), MINT_TIMEOUT_MS);
37
+ let resp;
38
+ try {
39
+ resp = await fetch(`${apiUrl}/api/v1/orgs/${encodeURIComponent(orgId)}/agents/me/channel-token`, {
40
+ method: 'POST',
41
+ headers,
42
+ body: JSON.stringify({ channel_type: channelType }),
43
+ signal: controller.signal,
44
+ });
45
+ }
46
+ catch (err) {
47
+ throw new ChannelTokenError(`could not reach the Parall platform to authenticate (${String(err)}); channel actions are unavailable right now`);
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ if (!resp.ok) {
53
+ let msg = `platform returned HTTP ${resp.status}`;
54
+ try {
55
+ const body = (await resp.json());
56
+ if (body?.error?.message)
57
+ msg = body.error.message;
58
+ }
59
+ catch {
60
+ // non-JSON error body: keep the status-line message
61
+ }
62
+ throw new ChannelTokenError(msg);
63
+ }
64
+ // Validate every field the caller actually injects into the vendor CLI, not
65
+ // just `token`: a 2xx missing app_id/brand would otherwise launch the real
66
+ // CLI with an incomplete credential env and fail opaquely downstream.
67
+ const minted = (await resp.json());
68
+ const nonEmpty = (v) => typeof v === 'string' && v.length > 0;
69
+ if (!minted || !nonEmpty(minted.token) || !nonEmpty(minted.app_id) || !nonEmpty(minted.brand)) {
70
+ throw new ChannelTokenError('platform returned an unusable token payload');
71
+ }
72
+ return minted;
73
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"event-format.d.ts","sourceRoot":"","sources":["../src/event-format.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAS1D,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAkIzD;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAEtE;AA2DD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAK/D;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CAkBnE"}
1
+ {"version":3,"file":"event-format.d.ts","sourceRoot":"","sources":["../src/event-format.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAS1D,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAkIzD;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAEtE;AAgED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAK/D;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CAkBnE"}
@@ -174,22 +174,27 @@ function buildSendMessageHint(event) {
174
174
  return `\n<system-reminder>To communicate, use the CLI: \`parall messages send\` / \`parall dm\`. Your plain text output is not delivered.</system-reminder>`;
175
175
  }
176
176
  if (event.type === 'channel_message') {
177
- // Provider metadata is best-effort (the gateway's connection lookup can
178
- // fail) never instruct the agent to invoke a made-up clip alias.
179
- const clipLabel = event.channelProvider
180
- ? `the \`${event.channelProvider}\` clip's`
181
- : "your channel provider clip's";
182
- const apiLabel = event.channelProvider ?? 'external platform';
183
- const target = event.channelExternalConversationId
184
- ? `{"chat_id": "${event.channelExternalConversationId}", "text": "..."}`
185
- : `{"chat_id": "<conversation id>", "text": "..."}`;
186
- // Offer the in-thread alternative whenever the inbound message id is
187
- // known — otherwise the hint nudges every threaded conversation toward a
188
- // new top-level message.
189
- const threadAlt = event.channelExternalMessageId
190
- ? ` To reply in-thread to this specific message, use {"message_id": "${event.channelExternalMessageId}", "text": "..."} instead.`
191
- : '';
192
- return `\n<system-reminder>To reply, invoke ${clipLabel} \`send_message\` command with ${target} — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider ${apiLabel} API when needed.</system-reminder>`;
177
+ // Single-path routing (multi-channel-architecture-design §6): with the
178
+ // `<provider>-cli` capability granted, the vendor CLI on PATH is THE
179
+ // reply path; without it there is no outbound path at all — say so
180
+ // instead of pointing at the retired provider clip.
181
+ // channelCliCapable alone decides: only feishu mints exist today, so a
182
+ // live grant implies Feishu even when the cosmetic provider-label lookup
183
+ // failed (event.channelProvider undefined).
184
+ if (event.channelCliCapable) {
185
+ const convRef = event.channelExternalConversationId
186
+ ? `chat_id "${event.channelExternalConversationId}"`
187
+ : 'the conversation id named in this event';
188
+ // Offer the in-thread alternative whenever the inbound message id is
189
+ // known otherwise the hint nudges every threaded conversation toward
190
+ // a new top-level message.
191
+ const threadAlt = event.channelExternalMessageId
192
+ ? ` To reply threaded to this specific message, reference message_id "${event.channelExternalMessageId}".`
193
+ : '';
194
+ return `\n<system-reminder>To reply, use the official Feishu CLI on your PATH: send a message to ${convRef} with \`lark-cli im\` (see \`lark-cli im --help\` for send syntax; auth is provisioned automatically).${threadAlt} lark-cli is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
195
+ }
196
+ const platform = event.channelProvider ?? 'the external platform';
197
+ return `\n<system-reminder>This message arrived from ${platform}, but outbound replies are currently disabled for this org (no channel capability granted). Do NOT attempt to reply on the external platform. If action is needed, surface it inside Parall (\`parall messages send\` / \`parall dm\`). Your plain text output is not delivered anywhere.</system-reminder>`;
193
198
  }
194
199
  if (event.type === 'external_trigger' || event.targetId.startsWith('xtr_')) {
195
200
  return `\n<system-reminder>This external trigger is incoming-only. Your plain text output is not sent back to the external provider. To communicate in Parall, use \`parall messages send\` / \`parall dm\`; provider-specific outbound actions require a separate capability.</system-reminder>`;
@@ -61,6 +61,14 @@ export type ParallGatewayOptions = {
61
61
  * this directory. Absent → legacy received/ack flow (openclaw / hermes).
62
62
  */
63
63
  dispatchContextDir?: string;
64
+ /**
65
+ * Live view of the agent's platform-granted capability keys (bridges wire
66
+ * PlatformConfigManager.capabilities().map(c => c.key)). Read at
67
+ * channel-event build time so the reply hint routes to the capability
68
+ * affordance (e.g. feishu-cli → lark-cli) — the single outbound path.
69
+ * Absent/empty → the hint states outbound is disabled.
70
+ */
71
+ getCapabilityKeys?: () => string[];
64
72
  onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
65
73
  onSessionReady?: (state: {
66
74
  activeSessionId?: string;
@@ -86,6 +94,10 @@ export declare class ParallAgentGateway {
86
94
  private readonly dispatchedTasks;
87
95
  private readonly channelConnectionProviders;
88
96
  private readonly dispatchedMessages;
97
+ readonly typedRedriveBackoff: Map<string, {
98
+ failures: number;
99
+ until: number;
100
+ }>;
89
101
  private readonly forkStates;
90
102
  private readonly dispatchState;
91
103
  private sessionId;
@@ -122,6 +134,8 @@ export declare class ParallAgentGateway {
122
134
  private laneFlowHost;
123
135
  private dispatchLaneGroup;
124
136
  private consumeTypedDispatch;
137
+ private ackDispatchEvent;
138
+ private clearTypedDispatchDedupe;
125
139
  private buildDispatchContext;
126
140
  private isSessionNotLiveError;
127
141
  private createInputStep;
@@ -144,6 +158,7 @@ export declare class ParallAgentGateway {
144
158
  private buildMessageDispatchDecision;
145
159
  private handleMessage;
146
160
  private handleMessageRedrive;
161
+ private handleTaskAssignmentRedrive;
147
162
  private consumeMessageWorkItem;
148
163
  private handleTaskAssignment;
149
164
  private handleTaskDispatch;