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