viber-channel 0.5.3 → 0.7.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.
- package/README.md +28 -0
- package/lib/agent_tools.ts +201 -0
- package/lib/auth.ts +50 -1
- package/lib/bridge_core.ts +1019 -0
- package/lib/bridge_spawn.ts +122 -0
- package/lib/bridge_tool_host.ts +191 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +351 -0
- package/lib/conversation.ts +23 -9
- package/lib/dm_stream.ts +287 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/heartbeat.ts +95 -0
- package/lib/instance.ts +164 -0
- package/lib/lockfile.ts +36 -0
- package/lib/messages.ts +32 -0
- package/lib/ollama_chat.ts +106 -0
- package/lib/parent_watchdog.ts +276 -0
- package/lib/peers.ts +113 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +303 -0
- package/lib/supervisor_config.ts +140 -0
- package/package.json +9 -2
- package/viber-channel.ts +376 -71
- package/viber-codex-bridge.ts +1261 -0
- package/viber-codex-supervisor.ts +96 -0
- package/viber-gemma-bridge.ts +169 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge spawn adapter (#280 step-12, slice 3).
|
|
3
|
+
*
|
|
4
|
+
* Turns an `AgentSpec` into a real launched `viber-codex-bridge` child process and
|
|
5
|
+
* adapts it to the Supervisor's `ChildHandle`. The node `spawn` primitive is
|
|
6
|
+
* injected so the mapping (command, args, env, lifecycle wiring) is unit-testable
|
|
7
|
+
* without launching anything.
|
|
8
|
+
*
|
|
9
|
+
* Per-agent identity (critical): each child must register its OWN Viber instance.
|
|
10
|
+
* The supervisor process may itself have been launched with VIBER_INSTANCE_TOKEN /
|
|
11
|
+
* VIBER_INSTANCE_ID in its environment; if those leaked into every child, all
|
|
12
|
+
* agents would share one instance identity and evict each other's membership (the
|
|
13
|
+
* known shared-auth gotcha). So the adapter strips them from the child env, forcing
|
|
14
|
+
* the bridge's default "register a fresh instance" path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
18
|
+
import type { AgentSpec, ChildHandle, SpawnFn } from "./supervisor.ts";
|
|
19
|
+
|
|
20
|
+
/** Minimal child surface the adapter consumes (matches node's ChildProcess). */
|
|
21
|
+
export interface SpawnedProcess {
|
|
22
|
+
readonly pid?: number;
|
|
23
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
24
|
+
once(
|
|
25
|
+
event: "exit",
|
|
26
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
27
|
+
): unknown;
|
|
28
|
+
/**
|
|
29
|
+
* Launch failures (ENOENT for a missing command, EACCES, …) arrive here as an
|
|
30
|
+
* async event — NOT a synchronous throw — so the adapter must listen for it or
|
|
31
|
+
* a failed launch would leave the agent "running" with a dead child.
|
|
32
|
+
*/
|
|
33
|
+
once(event: "error", listener: (err: Error) => void): unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The spawn primitive (injected; defaults to node's child_process.spawn). */
|
|
37
|
+
export type NodeSpawnLike = (
|
|
38
|
+
command: string,
|
|
39
|
+
args: string[],
|
|
40
|
+
options: {
|
|
41
|
+
env: NodeJS.ProcessEnv;
|
|
42
|
+
stdio: ("ignore" | "inherit" | "pipe")[];
|
|
43
|
+
},
|
|
44
|
+
) => SpawnedProcess;
|
|
45
|
+
|
|
46
|
+
export interface BridgeSpawnOptions {
|
|
47
|
+
/** Launcher command. Default: the `viber-codex-bridge` bin on PATH. */
|
|
48
|
+
command?: string;
|
|
49
|
+
/** Args prepended before each agent's own args (e.g. a script path for `bun`). */
|
|
50
|
+
baseArgs?: string[];
|
|
51
|
+
/** Spawn primitive (injected for tests). Default: node child_process.spawn. */
|
|
52
|
+
spawnImpl?: NodeSpawnLike;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a `SpawnFn` for the Supervisor that launches the Codex bridge per agent.
|
|
57
|
+
*
|
|
58
|
+
* `AgentSpec.tier` → `VIBER_AGENT_TIER`; `AgentSpec.args` → bridge CLI flags;
|
|
59
|
+
* `AgentSpec.env` is merged last (so a spec can override). `VIBER_INSTANCE_TOKEN`
|
|
60
|
+
* / `VIBER_INSTANCE_ID` are stripped unless the spec explicitly re-supplies them.
|
|
61
|
+
*/
|
|
62
|
+
export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
|
|
63
|
+
const command = opts.command ?? "viber-codex-bridge";
|
|
64
|
+
const baseArgs = opts.baseArgs ?? [];
|
|
65
|
+
const spawnImpl = opts.spawnImpl ?? (nodeSpawn as unknown as NodeSpawnLike);
|
|
66
|
+
|
|
67
|
+
return (spec: AgentSpec): ChildHandle => {
|
|
68
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
69
|
+
// Force a fresh per-agent instance (see file header).
|
|
70
|
+
delete env.VIBER_INSTANCE_TOKEN;
|
|
71
|
+
delete env.VIBER_INSTANCE_ID;
|
|
72
|
+
// #309 step-17: NEVER inherit the identity flag/role from the parent
|
|
73
|
+
// supervisor — a leaked VIBER_AGENT_NAME_EXPLICIT would make an auto id
|
|
74
|
+
// (agent-1) claim itself as an explicit name. Strip them here and re-derive
|
|
75
|
+
// authoritatively below (codex-309-review blocking #1).
|
|
76
|
+
delete env.VIBER_AGENT_NAME_EXPLICIT;
|
|
77
|
+
delete env.VIBER_AGENT_ROLE;
|
|
78
|
+
env.VIBER_AGENT_TIER = spec.tier;
|
|
79
|
+
if (spec.env) Object.assign(env, spec.env);
|
|
80
|
+
// Identity is set LAST, AFTER spec.env, so neither the inherited env nor a
|
|
81
|
+
// config `env` block can spoof the explicit-name flag or the role — the
|
|
82
|
+
// spec fields are the only source of truth (codex-309-review blocking #1).
|
|
83
|
+
// Surface the agent id as the bridge's Viber instance label so distinct
|
|
84
|
+
// agents are identifiable in the control plane (participants strip / recipient
|
|
85
|
+
// picker) instead of all showing the same generic "Codex bridge" label.
|
|
86
|
+
env.VIBER_CODEX_BRIDGE_LABEL = spec.agentId;
|
|
87
|
+
// Only an EXPLICIT name is claimed as the agent's name (the label is always
|
|
88
|
+
// set, even for auto ids); the separate flag is the safe signal. `else delete`
|
|
89
|
+
// closes the spoof path even if spec.env smuggled the flag in.
|
|
90
|
+
if (spec.nameExplicit) env.VIBER_AGENT_NAME_EXPLICIT = "1";
|
|
91
|
+
else delete env.VIBER_AGENT_NAME_EXPLICIT;
|
|
92
|
+
// The role is untrusted text the bridge sanitizes before injecting it.
|
|
93
|
+
if (spec.role !== undefined) env.VIBER_AGENT_ROLE = spec.role;
|
|
94
|
+
else delete env.VIBER_AGENT_ROLE;
|
|
95
|
+
|
|
96
|
+
const child = spawnImpl(command, [...baseArgs, ...spec.args], {
|
|
97
|
+
env,
|
|
98
|
+
// stdin + stdout ignored, stderr inherited. stdout must NOT be inherited:
|
|
99
|
+
// if viber-channel runs over stdio MCP transport, the parent's stdout is
|
|
100
|
+
// the protocol wire and a child's stray stdout line would corrupt it.
|
|
101
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
pid: child.pid,
|
|
106
|
+
kill: (signal?: NodeJS.Signals) => child.kill(signal),
|
|
107
|
+
onExit: (cb) => {
|
|
108
|
+
// Deliver exactly one terminal callback whether the child exits or
|
|
109
|
+
// fails to launch ("error"). A launch failure maps to a crash
|
|
110
|
+
// (null code/signal) so the supervisor runs its backoff path.
|
|
111
|
+
let fired = false;
|
|
112
|
+
const fire = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
113
|
+
if (fired) return;
|
|
114
|
+
fired = true;
|
|
115
|
+
cb(code, signal);
|
|
116
|
+
};
|
|
117
|
+
child.once("exit", (code, signal) => fire(code, signal));
|
|
118
|
+
child.once("error", () => fire(null, null));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge_tool_host.ts — in-process MCP tool host for the Codex bridge (#317 iii).
|
|
3
|
+
*
|
|
4
|
+
* The bridge hosts a streamable-HTTP MCP server on loopback; `codex app-server`
|
|
5
|
+
* connects to it BY URL (mcp_servers.<name>.url). Because the server runs INSIDE
|
|
6
|
+
* the bridge process (no child spawn), the tools read the bridge's LIVE context
|
|
7
|
+
* (rotating token, current-turn conversation) by reference — never a snapshot.
|
|
8
|
+
* This is the same `agent_tools.ts` the Claude MCP uses: one source, no double
|
|
9
|
+
* maintenance.
|
|
10
|
+
*
|
|
11
|
+
* Security (viber-dev-315): the server binds to 127.0.0.1 only and requires an
|
|
12
|
+
* ephemeral bearer token, so no other local process can post into the channel
|
|
13
|
+
* through it.
|
|
14
|
+
*
|
|
15
|
+
* Anti-double-post (#317 #D): the bridge auto-posts a turn's final text only when
|
|
16
|
+
* NO outbound tool succeeded that turn. The host reports a SUCCESSFUL
|
|
17
|
+
* send_message / message_agent via `onOutboundSuccess` so the caller can suppress
|
|
18
|
+
* the auto-post. A FAILED outbound does NOT report success → the auto-post
|
|
19
|
+
* fallback still fires (no silent void). list_agents is read-only and never
|
|
20
|
+
* reports outbound.
|
|
21
|
+
*/
|
|
22
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
23
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
24
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
+
import {
|
|
26
|
+
listAgents,
|
|
27
|
+
messageAgent,
|
|
28
|
+
sendMessage,
|
|
29
|
+
type AgentToolsContext,
|
|
30
|
+
type AgentToolResult,
|
|
31
|
+
} from "./agent_tools.js";
|
|
32
|
+
|
|
33
|
+
/** The three channel tools, named so codex (and the elicitation whitelist) match. */
|
|
34
|
+
export const BRIDGE_TOOL_NAMES = ["send_message", "list_agents", "message_agent"] as const;
|
|
35
|
+
export type BridgeToolName = (typeof BRIDGE_TOOL_NAMES)[number];
|
|
36
|
+
|
|
37
|
+
/** Outbound tools whose SUCCESS suppresses the bridge's final auto-post. */
|
|
38
|
+
const OUTBOUND_TOOLS: ReadonlySet<string> = new Set(["send_message", "message_agent"]);
|
|
39
|
+
|
|
40
|
+
const TOOL_DEFS = [
|
|
41
|
+
{
|
|
42
|
+
name: "send_message",
|
|
43
|
+
description:
|
|
44
|
+
"Post your reply into the current Viber conversation. `text` is the conversational reply — " +
|
|
45
|
+
"read aloud AND shown as Markdown, so keep it short and natural but use light Markdown (short " +
|
|
46
|
+
"lists, numbered steps, **bold**) for readability; put actions/questions here, visibly. " +
|
|
47
|
+
"`artifact` is optional HEAVY/LONG content (big code, large tables, long analyses, json, " +
|
|
48
|
+
"sanitized html) shown in a side viewer; when used, keep `text` a brief summary pointing to it. " +
|
|
49
|
+
"Calling this IS your reply for the turn.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
text: { type: "string", description: "Spoken/conversational reply. Plain sentences." },
|
|
54
|
+
artifact: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
content: { type: "string" },
|
|
58
|
+
format: { type: "string", enum: ["markdown", "code", "json", "html"] },
|
|
59
|
+
},
|
|
60
|
+
required: ["content"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ["text"],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "list_agents",
|
|
70
|
+
description:
|
|
71
|
+
"List the OTHER agents (instances) in this project you can DM. Returns each agent's id, " +
|
|
72
|
+
"label, runtime kind, and whether it is online. Use it to find an id before message_agent.",
|
|
73
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "message_agent",
|
|
77
|
+
description:
|
|
78
|
+
"Send a direct message to another agent in this project. Pass the target agent's instance id " +
|
|
79
|
+
"(from list_agents) and the text. Opens or reuses a private 1:1 DM and posts your message; " +
|
|
80
|
+
"the agent's reply arrives back on this channel. Fails if the target agent is offline.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
instance_id: { type: "string", description: "Target agent's instance id (from list_agents)." },
|
|
85
|
+
text: { type: "string", description: "Message to send to the target agent." },
|
|
86
|
+
},
|
|
87
|
+
required: ["instance_id", "text"],
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
export interface BridgeToolHost {
|
|
94
|
+
/** URL codex connects to: http://127.0.0.1:<port>/mcp */
|
|
95
|
+
url: string;
|
|
96
|
+
/** Ephemeral bearer token value the host requires (pass to codex via env). */
|
|
97
|
+
bearerToken: string;
|
|
98
|
+
/** Stop the HTTP server. */
|
|
99
|
+
stop(): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface BridgeToolHostOptions {
|
|
103
|
+
/** Live bridge context (per-turn conversation, rotating token, DM streaming). */
|
|
104
|
+
ctx: AgentToolsContext;
|
|
105
|
+
/** Called with the tool name when an OUTBOUND tool (send_message/message_agent) SUCCEEDS. */
|
|
106
|
+
onOutboundSuccess: (tool: BridgeToolName) => void;
|
|
107
|
+
/** stderr-style logger. */
|
|
108
|
+
log: (line: string) => void;
|
|
109
|
+
/** Generate the ephemeral bearer token (injected for testability). */
|
|
110
|
+
makeToken: () => string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Dispatch a single tool call to the shared lib and, on a successful outbound,
|
|
115
|
+
* report it. Exported for unit testing without spinning up the HTTP server.
|
|
116
|
+
*/
|
|
117
|
+
export async function dispatchBridgeTool(
|
|
118
|
+
name: string,
|
|
119
|
+
args: Record<string, unknown>,
|
|
120
|
+
opts: Pick<BridgeToolHostOptions, "ctx" | "onOutboundSuccess">,
|
|
121
|
+
): Promise<AgentToolResult> {
|
|
122
|
+
let result: AgentToolResult;
|
|
123
|
+
if (name === "list_agents") {
|
|
124
|
+
result = await listAgents(opts.ctx);
|
|
125
|
+
} else if (name === "message_agent") {
|
|
126
|
+
result = await messageAgent(opts.ctx, args);
|
|
127
|
+
} else if (name === "send_message") {
|
|
128
|
+
result = await sendMessage(opts.ctx, args);
|
|
129
|
+
} else {
|
|
130
|
+
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
131
|
+
}
|
|
132
|
+
// Only a SUCCESSFUL outbound suppresses the auto-post (#317 #D: success, not attempt).
|
|
133
|
+
if (OUTBOUND_TOOLS.has(name) && result.isError !== true) {
|
|
134
|
+
opts.onOutboundSuccess(name as BridgeToolName);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildServer(opts: BridgeToolHostOptions): Server {
|
|
140
|
+
const mcp = new Server({ name: "viber-channel-tools", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
141
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS as unknown as object[] }));
|
|
142
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
143
|
+
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
|
|
144
|
+
try {
|
|
145
|
+
return await dispatchBridgeTool(request.params.name, args, opts);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// The shared lib rethrows ConversationTokenExpiredError (host-specific
|
|
148
|
+
// teardown). In the bridge we surface it as a tool error rather than
|
|
149
|
+
// killing the turn mid-flight: the stream's own next op hits 401 and the
|
|
150
|
+
// refresh/abort path handles renewal. NOT reported as outbound success.
|
|
151
|
+
opts.log(`[bridge-tool-host] tool ${request.params.name} threw: ${String(err)}\n`);
|
|
152
|
+
return { isError: true, content: [{ type: "text", text: `Tool error: ${String(err)}` }] };
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return mcp;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Start the loopback MCP HTTP host. Returns the URL + bearer for codex config.
|
|
160
|
+
* Stateless transport → a fresh Server+transport per request (SDK requirement).
|
|
161
|
+
*/
|
|
162
|
+
export function startBridgeToolHost(opts: BridgeToolHostOptions): BridgeToolHost {
|
|
163
|
+
const bearerToken = opts.makeToken();
|
|
164
|
+
const server = Bun.serve({
|
|
165
|
+
hostname: "127.0.0.1",
|
|
166
|
+
port: 0,
|
|
167
|
+
async fetch(req) {
|
|
168
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
169
|
+
if (auth !== `Bearer ${bearerToken}`) {
|
|
170
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
171
|
+
status: 401,
|
|
172
|
+
headers: { "content-type": "application/json" },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const mcp = buildServer(opts);
|
|
176
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
177
|
+
sessionIdGenerator: undefined,
|
|
178
|
+
enableJsonResponse: true,
|
|
179
|
+
});
|
|
180
|
+
await mcp.connect(transport);
|
|
181
|
+
return transport.handleRequest(req);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const url = `http://127.0.0.1:${server.port}/mcp`;
|
|
185
|
+
opts.log(`[bridge-tool-host] MCP tools on ${url} (loopback, bearer-guarded)\n`);
|
|
186
|
+
return {
|
|
187
|
+
url,
|
|
188
|
+
bearerToken,
|
|
189
|
+
stop: () => server.stop(true),
|
|
190
|
+
};
|
|
191
|
+
}
|
package/lib/channel_session.ts
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
* conversation_id here. A respawned channel process reads this file to find the
|
|
6
6
|
* existing conversation instead of minting a new one — avoiding duplicates (#257).
|
|
7
7
|
*
|
|
8
|
-
* Namespaced by `(VIBER_BASE_URL,
|
|
8
|
+
* Namespaced by `(VIBER_BASE_URL, instance_key)` since #269, where `instance_key`
|
|
9
|
+
* is the server-issued `instance_id` (or the `instance_token` when the id is
|
|
10
|
+
* unknown, e.g. an env-injected agent). This replaces the old
|
|
11
|
+
* `(client_fingerprint, VIBER_CHANNEL_SESSION_ID)` namespacing so:
|
|
9
12
|
* - dev and staging channels coexist on the same machine without colliding;
|
|
10
|
-
* - two *different*
|
|
11
|
-
* never reattach to each other's
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
* - channel respawns within one launch inherit the same session id and find
|
|
17
|
-
* the same handle — reattach still works (#257).
|
|
13
|
+
* - two *different* instances (different projects, OR two parallel agents of the
|
|
14
|
+
* same project) each have their own handle and never reattach to each other's
|
|
15
|
+
* conversation — the instance is the isolation axis now, no fingerprint or
|
|
16
|
+
* session id required (#259);
|
|
17
|
+
* - channel respawns reusing the same persisted instance_token inherit the same
|
|
18
|
+
* instance_id and find the same handle — reattach still works (#257).
|
|
18
19
|
*/
|
|
19
20
|
import { createHash } from "node:crypto";
|
|
20
21
|
import { join } from "node:path";
|
|
@@ -26,22 +27,24 @@ export interface ChannelHandle {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
* Return the session file path for a given base URL
|
|
30
|
-
* id, under `dir`.
|
|
30
|
+
* Return the session file path for a given base URL and instance key, under `dir`.
|
|
31
31
|
*
|
|
32
32
|
* The filename is `channel-session-{8-hex}.json` where the hex is a stable
|
|
33
|
-
* SHA-256 prefix of `${baseUrl}\n${
|
|
34
|
-
*
|
|
35
|
-
* the
|
|
33
|
+
* SHA-256 prefix of `${baseUrl}\n${instanceKey}`. Since #269 the handle is
|
|
34
|
+
* namespaced by the **server-issued instance** (its `instance_id`, or the
|
|
35
|
+
* `instance_token` when the id is unknown) instead of the local
|
|
36
|
+
* `(fingerprint, sessionId)` pair: one durable instance ⇒ one handle. Two
|
|
37
|
+
* parallel agents of the same project hold distinct instances, so they get
|
|
38
|
+
* distinct handles and never reattach to each other's conversation — without
|
|
39
|
+
* relying on `VIBER_CHANNEL_SESSION_ID`.
|
|
36
40
|
*/
|
|
37
41
|
export function sessionFilePath(
|
|
38
42
|
baseUrl: string,
|
|
39
|
-
|
|
40
|
-
sessionId: string,
|
|
43
|
+
instanceKey: string,
|
|
41
44
|
dir: string,
|
|
42
45
|
): string {
|
|
43
46
|
const suffix = createHash("sha256")
|
|
44
|
-
.update(`${baseUrl}\n${
|
|
47
|
+
.update(`${baseUrl}\n${instanceKey}`)
|
|
45
48
|
.digest("hex")
|
|
46
49
|
.slice(0, 8);
|
|
47
50
|
return join(dir, `channel-session-${suffix}.json`);
|
package/lib/connect.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
appendFileSync,
|
|
15
|
+
chmodSync,
|
|
15
16
|
existsSync,
|
|
16
17
|
mkdirSync,
|
|
17
18
|
readFileSync,
|
|
@@ -106,7 +107,15 @@ function writeAuthJson(
|
|
|
106
107
|
auth.target_conversation_id = data.target_conversation_id;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
// mode 0o600: auth.json holds the project_token (and later the instance_token)
|
|
111
|
+
// — owner-read-only on POSIX (no-op on Windows). chmod afterwards too, since
|
|
112
|
+
// `mode` only applies on file creation (tightens an older loose auth.json).
|
|
113
|
+
writeFileSync(authPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
114
|
+
try {
|
|
115
|
+
chmodSync(authPath, 0o600);
|
|
116
|
+
} catch {
|
|
117
|
+
/* best-effort — some filesystems (or Windows) reject chmod */
|
|
118
|
+
}
|
|
110
119
|
writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
|
|
111
120
|
|
|
112
121
|
// Append `.viber/` to .gitignore if not already present.
|