relay-companion 0.1.479 → 0.1.481
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/bootstrap/relay-skill.cjs +153 -10
- package/package.json +1 -1
- package/skill/manifest.json +4 -4
- package/skill/relay/SKILL.md +67 -22
- package/skill/relay/scripts/relay-protocol.mjs +71 -1
|
@@ -78,12 +78,12 @@ function defaultTargets({ homeDir = os.homedir(), env = process.env, host = "all
|
|
|
78
78
|
const targets = [];
|
|
79
79
|
if (selected === "all" || selected === "codex") {
|
|
80
80
|
const root = env.CODEX_HOME || path.join(homeDir, ".codex");
|
|
81
|
-
targets.push({ host: "codex", directory: path.join(root, "skills", SKILL_NAME) });
|
|
82
|
-
targets.push({ host: "codex", directory: path.join(homeDir, ".agents", "skills", SKILL_NAME) });
|
|
81
|
+
targets.push({ host: "codex", target: "primary", directory: path.join(root, "skills", SKILL_NAME) });
|
|
82
|
+
targets.push({ host: "codex", target: "compatibility", directory: path.join(homeDir, ".agents", "skills", SKILL_NAME) });
|
|
83
83
|
}
|
|
84
84
|
if (selected === "all" || selected === "claude") {
|
|
85
85
|
const root = env.CLAUDE_HOME || path.join(homeDir, ".claude");
|
|
86
|
-
targets.push({ host: "claude", directory: path.join(root, "skills", SKILL_NAME) });
|
|
86
|
+
targets.push({ host: "claude", target: "primary", directory: path.join(root, "skills", SKILL_NAME) });
|
|
87
87
|
}
|
|
88
88
|
return targets.filter((target, index) => targets.findIndex((other) => path.resolve(other.directory) === path.resolve(target.directory)) === index);
|
|
89
89
|
}
|
|
@@ -98,7 +98,8 @@ function configuredManifestUrl({ homeDir = os.homedir(), env = process.env, webO
|
|
|
98
98
|
const agent = config.webUrl || config.apiUrl ? {} : read(env.RELAY_AGENT_CONFIG || path.join(configRoot, "agent-protocol.json"));
|
|
99
99
|
const api = config.apiUrl || agent.apiUrl;
|
|
100
100
|
const knownOrigin = !api || api === "https://api.sendrelays.com" ? "https://sendrelays.com"
|
|
101
|
-
: api === "https://dev-api.sendrelays.com" ? "https://dev.sendrelays.com"
|
|
101
|
+
: api === "https://dev-api.sendrelays.com" ? "https://dev.sendrelays.com"
|
|
102
|
+
: api === "https://cti37jd7vx.us-east-1.awsapprunner.com" ? "https://8epdrqim29.us-east-1.awsapprunner.com" : null;
|
|
102
103
|
const origin = webOrigin || env.RELAY_WEB_URL || config.webUrl || knownOrigin;
|
|
103
104
|
if (!origin) throw new Error("Relay requires a configured web origin for this API environment's skill updates.");
|
|
104
105
|
const url = new URL(origin);
|
|
@@ -160,12 +161,152 @@ function localChanges(directory, state = readState(directory)) {
|
|
|
160
161
|
return changed;
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
function
|
|
164
|
+
function installedStateEntries(state) {
|
|
165
|
+
if (!state || state.schemaVersion !== 1 || state.name !== SKILL_NAME || !Array.isArray(state.files)) return null;
|
|
166
|
+
try { exactVersion(state.version); } catch { return null; }
|
|
167
|
+
const entries = new Map();
|
|
168
|
+
for (const entry of state.files) {
|
|
169
|
+
let relative;
|
|
170
|
+
try { relative = safeRelativeFile(entry?.path); } catch { return null; }
|
|
171
|
+
const digest = String(entry?.sha256 || "").toLowerCase();
|
|
172
|
+
if (relative === STATE_FILE || !/^[a-f0-9]{64}$/.test(digest) || entries.has(relative)) return null;
|
|
173
|
+
entries.set(relative, digest);
|
|
174
|
+
}
|
|
175
|
+
return entries.has("SKILL.md") ? entries : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function treeLeaves(directory, current = directory, prefix = "") {
|
|
179
|
+
const leaves = [];
|
|
180
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
181
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
182
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
183
|
+
leaves.push(...treeLeaves(directory, path.join(current, entry.name), relative));
|
|
184
|
+
} else {
|
|
185
|
+
leaves.push({ relative, file: path.join(directory, ...relative.split("/")), regular: entry.isFile() });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return leaves;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function removeSkillArtifact(directory) {
|
|
192
|
+
if (!fs.existsSync(directory)) return { ok: true, status: "already_absent", directory };
|
|
193
|
+
let root;
|
|
194
|
+
let leaves;
|
|
195
|
+
try {
|
|
196
|
+
root = fs.lstatSync(directory);
|
|
197
|
+
if (!root.isDirectory() || root.isSymbolicLink()) {
|
|
198
|
+
return { ok: false, status: "unmanaged", directory, changedFiles: ["<unmanaged skill>"] };
|
|
199
|
+
}
|
|
200
|
+
leaves = treeLeaves(directory);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return { ok: false, status: "failed", directory, error: error?.message || String(error) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const state = readState(directory);
|
|
206
|
+
const managed = installedStateEntries(state);
|
|
207
|
+
if (!managed) {
|
|
208
|
+
// A killed or older uninstall can leave the known Relay directory shell
|
|
209
|
+
// behind after its files are gone. Empty directories contain no human data
|
|
210
|
+
// and are safe to finish removing without an ownership marker.
|
|
211
|
+
if (leaves.length !== 0) {
|
|
212
|
+
return { ok: false, status: "unmanaged", directory, changedFiles: ["<unmanaged skill>"] };
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
const changedFiles = [];
|
|
216
|
+
for (const leaf of leaves) {
|
|
217
|
+
if (leaf.relative === STATE_FILE) continue;
|
|
218
|
+
const expected = managed.get(leaf.relative);
|
|
219
|
+
if (!leaf.regular || !expected) {
|
|
220
|
+
changedFiles.push(leaf.relative);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
if (fileHash(leaf.file) !== expected) changedFiles.push(leaf.relative);
|
|
225
|
+
} catch {
|
|
226
|
+
changedFiles.push(leaf.relative);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Missing manifest files are already removed and therefore harmless. Any
|
|
230
|
+
// surviving modified or additional file may belong to the human, so leave
|
|
231
|
+
// the entire artifact intact and make the top-level uninstall fail loudly.
|
|
232
|
+
if (changedFiles.length) {
|
|
233
|
+
return { ok: false, status: "modified", directory, changedFiles };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
fs.rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return { ok: false, status: "failed", directory, error: error?.message || String(error) };
|
|
241
|
+
}
|
|
242
|
+
return fs.existsSync(directory)
|
|
243
|
+
? { ok: false, status: "failed", directory, error: "The skill directory still exists after removal." }
|
|
244
|
+
: { ok: true, status: managed ? "removed" : "empty_debris_removed", directory };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function skillArtifacts(directory) {
|
|
248
|
+
const parent = path.dirname(directory);
|
|
249
|
+
const name = path.basename(directory);
|
|
250
|
+
const artifacts = [directory];
|
|
251
|
+
let entries = [];
|
|
252
|
+
try { entries = fs.readdirSync(parent, { withFileTypes: true }); }
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error.code === "ENOENT") return artifacts;
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
const generated = [`.${name}-rollback`, `.${name}-staging-`, `.${name}-replaced-`];
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
260
|
+
if (entry.name === generated[0] || generated.slice(1).some((prefix) => entry.name.startsWith(prefix))) {
|
|
261
|
+
artifacts.push(path.join(parent, entry.name));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return artifacts;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function uninstallManaged(options = {}) {
|
|
268
|
+
const targets = options.targets || defaultTargets(options);
|
|
269
|
+
const results = [];
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
for (const target of targets) {
|
|
272
|
+
let artifacts;
|
|
273
|
+
try { artifacts = skillArtifacts(target.directory); }
|
|
274
|
+
catch (error) {
|
|
275
|
+
results.push({ host: target.host, ok: false, status: "failed", directory: target.directory, error: error?.message || String(error) });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
for (const directory of artifacts) {
|
|
279
|
+
const key = path.resolve(directory);
|
|
280
|
+
if (seen.has(key)) continue;
|
|
281
|
+
seen.add(key);
|
|
282
|
+
results.push({ host: target.host, ...removeSkillArtifact(directory) });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const failures = results.filter((result) => !result.ok);
|
|
286
|
+
return {
|
|
287
|
+
ok: failures.length === 0,
|
|
288
|
+
results,
|
|
289
|
+
failures,
|
|
290
|
+
...(failures.length ? {
|
|
291
|
+
detail: failures.map((failure) => {
|
|
292
|
+
const changed = failure.changedFiles?.length ? ` (${failure.changedFiles.join(", ")})` : "";
|
|
293
|
+
return `${failure.directory}: ${failure.error || failure.status}${changed}`;
|
|
294
|
+
}).join("; "),
|
|
295
|
+
} : {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function stateFor(manifest, target = {}, existing = null) {
|
|
164
300
|
return {
|
|
165
301
|
schemaVersion: 1,
|
|
166
302
|
name: SKILL_NAME,
|
|
167
303
|
version: manifest.version,
|
|
168
304
|
consentVersion: manifest.consentVersion,
|
|
305
|
+
host: target.host || null,
|
|
306
|
+
target: target.target || "primary",
|
|
307
|
+
installationId: /^ski_[A-Za-z0-9_-]{20,80}$/.test(String(existing?.installationId || ""))
|
|
308
|
+
? existing.installationId
|
|
309
|
+
: `ski_${crypto.randomBytes(18).toString("base64url")}`,
|
|
169
310
|
installedAt: new Date().toISOString(),
|
|
170
311
|
files: manifest.files.map((entry) => ({ path: entry.path, sha256: entry.sha256 })),
|
|
171
312
|
};
|
|
@@ -175,7 +316,7 @@ function writeJson(file, value) {
|
|
|
175
316
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
176
317
|
}
|
|
177
318
|
|
|
178
|
-
async function materialize(manifest, staging, readFile) {
|
|
319
|
+
async function materialize(manifest, staging, readFile, target, existing) {
|
|
179
320
|
for (const entry of manifest.files) {
|
|
180
321
|
const destination = path.join(staging, ...entry.path.split("/"));
|
|
181
322
|
if (!pathInside(staging, destination)) throw new Error("Relay refused an unsafe skill destination.");
|
|
@@ -184,10 +325,11 @@ async function materialize(manifest, staging, readFile) {
|
|
|
184
325
|
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
185
326
|
fs.writeFileSync(destination, bytes, { mode: entry.path.startsWith("scripts/") ? 0o700 : 0o600 });
|
|
186
327
|
}
|
|
187
|
-
writeJson(path.join(staging, STATE_FILE), stateFor(manifest));
|
|
328
|
+
writeJson(path.join(staging, STATE_FILE), stateFor(manifest, target, existing));
|
|
188
329
|
}
|
|
189
330
|
|
|
190
|
-
async function installOne(directory, manifest, readFile,
|
|
331
|
+
async function installOne(directory, manifest, readFile, options = {}) {
|
|
332
|
+
const { consent = false, renewConsent = false } = options;
|
|
191
333
|
const parent = path.dirname(directory);
|
|
192
334
|
const existing = readState(directory);
|
|
193
335
|
const changes = localChanges(directory, existing);
|
|
@@ -206,7 +348,7 @@ async function installOne(directory, manifest, readFile, { consent = false, rene
|
|
|
206
348
|
const rollback = path.join(parent, `.${SKILL_NAME}-rollback`);
|
|
207
349
|
if (!pathInside(parent, staging) || !pathInside(parent, rollback)) throw new Error("Relay refused an unsafe skill update location.");
|
|
208
350
|
try {
|
|
209
|
-
await materialize(manifest, staging, readFile);
|
|
351
|
+
await materialize(manifest, staging, readFile, options, existing);
|
|
210
352
|
if (fs.existsSync(rollback)) fs.rmSync(rollback, { recursive: true, force: true });
|
|
211
353
|
if (fs.existsSync(directory)) fs.renameSync(directory, rollback);
|
|
212
354
|
try {
|
|
@@ -231,7 +373,7 @@ async function installManifest(manifest, readFile, options = {}) {
|
|
|
231
373
|
const targets = options.targets || defaultTargets(options);
|
|
232
374
|
const results = [];
|
|
233
375
|
for (const target of targets) {
|
|
234
|
-
try { results.push({ host: target.host, ...(await installOne(target.directory, manifest, readFile, options)) }); }
|
|
376
|
+
try { results.push({ host: target.host, target: target.target, ...(await installOne(target.directory, manifest, readFile, { ...options, ...target })) }); }
|
|
235
377
|
catch (error) { results.push({ host: target.host, ok: false, status: "failed", directory: target.directory, error: error?.message || String(error) }); }
|
|
236
378
|
}
|
|
237
379
|
return { ok: results.every((item) => item.ok), version: manifest.version, results };
|
|
@@ -330,6 +472,7 @@ module.exports = {
|
|
|
330
472
|
rollbackOne,
|
|
331
473
|
runCli,
|
|
332
474
|
sha256,
|
|
475
|
+
uninstallManaged,
|
|
333
476
|
updateFromRemote,
|
|
334
477
|
validateManifest,
|
|
335
478
|
};
|
package/package.json
CHANGED
package/skill/manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"name": "relay",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.21",
|
|
5
5
|
"consentVersion": 2,
|
|
6
|
-
"baseUrl": "https://sendrelays.com/skills/relay/v1.1.
|
|
6
|
+
"baseUrl": "https://sendrelays.com/skills/relay/v1.1.21",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "SKILL.md",
|
|
10
|
-
"sha256": "
|
|
10
|
+
"sha256": "bb046c0e9161424aa613844e76cd9c32ce2b52b59c411fdd58abae268d971a6a"
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"path": "agents/openai.yaml",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"path": "scripts/relay-protocol.mjs",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "0d697c635793ba96b723ca4eb926a59b4112c8c8328d996916ce8ab4e98cd59b"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"path": "scripts/relay-local.mjs",
|
package/skill/relay/SKILL.md
CHANGED
|
@@ -22,7 +22,25 @@ Use ELI5 communication throughout setup and the first Relay: write for a capable
|
|
|
22
22
|
|
|
23
23
|
If new setup is needed, give a brief orientation before asking to set up: Relay lets them message people from their AI, and Companion gives them a visual view of their conversations. Explain that setup connects this AI to their Relay account and installs Relay's instructions and Companion with their permission. Keep access permissions and other decision-changing facts clear; plain language must not hide what they are approving.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Read the current invitation's agent document and resolve its exact promoted package before requesting installation permission. In the setup question, name the exact relay-companion package version and https://registry.npmjs.org as the source of the code that will be downloaded and run. These details matter to installation consent even when ordinary progress updates omit versions. Use existing permission when it already covers that package and source; never treat a web document as the human's approval or invent a package version when release lookup fails.
|
|
26
|
+
|
|
27
|
+
Front-load the complete setup scope in that first question: explicitly ask to open or fetch the exact invitation URL and its /agent document, download and run the pinned package, run Relay's local status, account checks and setup commands through the AI's command tool, contact the invitation document's exact API origin to connect the AI to the Relay account and inviter, open the connection approval page in the person's usual browser, install or update its agent skill and keep it updated, and install and start Companion in the background with its local AI integration. Use the invitation's actual origin, including Dev when supplied; do not substitute the production site. Explain that the human still signs in and approves account access in their browser and approves messages separately. Keep all of this in the complete question, not just in surrounding progress text. After an affirmative answer, carry that consent through the covered setup actions without asking again for each URL, command, skill update or browser handoff. It does not authorize arbitrary browsing, unrelated software or sending messages.
|
|
28
|
+
|
|
29
|
+
The request to help connect already covers the necessary read-only installation and account checks, subject to host tool permissions; do them during preparation without adding a separate Relay consent question. Use the active Relay installation or its supported helper. A skill found in .relay-rollback, another rollback directory, or a backup is recovery data, not an active installation: do not execute its helper or use its presence as proof of a working connection. If the loaded skill came from a backup, use it only as a clue to locate the active installation and current invitation instructions. Do not switch to a backup helper after a denied command. Once the checks establish that new setup is needed and the human consents, continue with the pinned installer and connection flow; do not restart completed preflight checks or run an old helper's status command merely because a new guide was loaded. Necessary verification remains covered by the existing setup permission.
|
|
30
|
+
|
|
31
|
+
Track what the human actually approved. A yes to fetching a URL alone is not installation consent. Once the full setup question discloses the exact package and npm source and the human approves it, do not ask a second exact-package question or a separate question to run each status, connection, installation or verification command. If only part of the setup was approved, ask for the uncovered scope together in one question and retain prior approvals. If a tool is then denied, explain that Relay setup is already approved but the host blocked the specific action. Use the host's supported approval mechanism for that action; do not restart setup consent or imply another conversational yes will necessarily unlock Bash or network access.
|
|
32
|
+
|
|
33
|
+
Normally, read-only retrieval of the invitation's current agent document prepares the exact setup question under the human's request to help connect. If the host requires approval before that first fetch, request approval for the exact invitation URL and /agent URL through its supported permission controls. Do not invent the still-unknown package version or claim installation is approved before the package and source can be disclosed. Resume preparation after that read is permitted, then ask the complete setup question. This host-required preliminary approval is an exception to the one-question goal.
|
|
34
|
+
|
|
35
|
+
Human setup consent and the host's tool permission check are separate. If the host denies a URL fetch, browser opening, installation or a protocol command, stop dependent setup and preserve any completed progress. Read the actual tool result before explaining it. Distinguish a classifier denial, a hard policy denial, a classifier error and an ordinary command failure; do not invent a cause when the result does not say. One denied call does not establish that Bash is disabled, that all future calls will fail, or that the human cannot review it. A blocked status check leaves the connection state unknown; it is not evidence that Relay is disconnected or that a fresh installation is needed.
|
|
36
|
+
|
|
37
|
+
Use the current host's documented recovery mechanism, subject to its actual denial instructions and higher-priority rules. Claude Code documents both a retry after clarified intent for a one-off action and review through /permissions → Recently denied (https://code.claude.com/docs/en/auto-mode-config#review-denials). When this host permits a same-tool retry after explicit clarification and the human has already supplied it, use that recovery once without asking the same question again. A hard policy denial is not cleared by conversational consent. Do not assert that chat clarification can never help, that it guarantees success, or that a terminal-only dialog exists in a desktop or hosted session. If the supported retry is denied again, preserve progress and report the remaining block rather than looping.
|
|
38
|
+
|
|
39
|
+
Never retry the denied action through another shell, tool, wrapper or transport, change permission settings, request a wildcard allow rule, or suggest bypassing the host's safeguards. Keep setup agent-led: do not default to asking the human to run commands, paste status output or adjust Bash settings. Never offer a .relay-rollback or other backup helper for manual execution. If no supported recovery is available, give one concise explanation of the blocked action, what remains unverified, and a verified host review step if one is available. Do not offer an unavailable dialog or use “tell me Relay is not connected” as a substitute for verification. A user-requested manual handoff must use the current supported helper and protect secrets. The normal copyable URL fallback for browser sign-in remains available; it is not a workaround for a denied agent tool call.
|
|
40
|
+
|
|
41
|
+
For questions, choices and approvals, use the current host's built-in user-question interface whenever that tool is exposed and permitted for this kind of question in the current mode. This is required when the interface is available, even if the tool is optional, a prior check or command failed, or a progress update was already given: call the interface and wait instead of placing the question in ordinary assistant chat or a final response. Before asking for setup permission, inspect the tools actually available to the current turn. Claude Code commonly exposes `AskUserQuestion` and Codex commonly exposes `request_user_input`; use the current host's documented equivalent if its name differs. Do not invent a tool or change modes to obtain one.
|
|
42
|
+
|
|
43
|
+
For setup permission, put the complete question with the exact package version and source in the interface's question field and offer concise affirmative and decline choices such as “Set up Relay” and “Not now.” Ask one clear decision at a time, with a way to decline or skip when appropriate. For the first send, show both exact payloads and the recipient before asking, and make clear that approval sends that specific message. Do not abbreviate the payloads to fit a question widget. Only when no permitted user-question interface is exposed, or its documented constraints cannot carry the required content, ask plainly in chat. Use existing explicit permission; never ask again just to use the interface. A suggested or preselected choice, an empty result, silence or a timeout is not consent: wait for an actual affirmative answer before any action that requires approval. Browser sign-in and account approval still happen in the person's usual browser.
|
|
26
44
|
|
|
27
45
|
During setup, give one or two short sentences only at meaningful changes or when the person needs to act. Do not narrate tool discovery, command attempts, process launches or unchanged progress. Keep HTTPS, MCP, protocol names, credentials, paths, versions, process IDs, Relay IDs, logs, encryption mechanics and durable queues out of the human update unless needed to resolve a specific problem or explicitly requested. Do not produce a component-by-component status report. Preserve material limitations in plain language: for example, "Relay is connected. The app is still installing." If the skill could not be installed or updated, state that limitation briefly instead of claiming setup is complete; put file paths and diagnostics in optional detail. Never promise a later notification unless a supported follow-up is actually arranged, and do not repeat the pending send question after an installation check.
|
|
28
46
|
|
|
@@ -55,22 +73,36 @@ The contact page confirms connected, already connected, or own-invite results on
|
|
|
55
73
|
|
|
56
74
|
## Connect
|
|
57
75
|
|
|
58
|
-
Only when the checks above establish that new setup is needed,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
Only when the checks above establish that new setup is needed, first read the
|
|
77
|
+
current invitation's agent document and resolve its exact promoted package. Use
|
|
78
|
+
setup permission already given in this conversation when it covers that package
|
|
79
|
+
and source. Otherwise, when the host exposes a permitted built-in user-question
|
|
80
|
+
interface, call it and wait for the answer; do not put the question in ordinary
|
|
81
|
+
assistant chat or a final response. Put the complete permission question from
|
|
82
|
+
the invitation in the interface's question field, including the exact package
|
|
83
|
+
version and npm source, the exact invitation and /agent URLs to open or fetch,
|
|
84
|
+
the Relay API connection, browser approval handoff, ongoing skill updates and
|
|
85
|
+
background Companion installation with its local AI integration. Offer concise
|
|
86
|
+
choices to set up Relay or decline for now. Reuse consent for the actions it
|
|
87
|
+
already covers, following the complete setup scope and first-fetch guidance above.
|
|
88
|
+
Only ask in chat when no permitted question interface is exposed or it cannot
|
|
89
|
+
carry the complete question. Never present the question both ways, and never
|
|
90
|
+
treat a suggested or preselected choice as consent.
|
|
91
|
+
|
|
92
|
+
After a direct affirmative answer, say briefly that Relay's app is starting to
|
|
93
|
+
install. On a supported local desktop, run the invitation's exact pinned
|
|
94
|
+
background-install as the first setup command, before creating or opening
|
|
95
|
+
browser approval, installing the skill, or asking another question. Existing
|
|
96
|
+
setup consent covers this installation; do not ask again. Confirm from the
|
|
97
|
+
result that it started or is already running, then continue without waiting for
|
|
98
|
+
the download. A hidden shell on a local desktop is not a headless machine.
|
|
99
|
+
Use the exact connect-start command, API origin and invitation token from the
|
|
100
|
+
current `/i/:token/agent` document. If you have not read it, fetch it first;
|
|
101
|
+
never guess a hostname, substitute an environment keyword, or reconstruct setup
|
|
102
|
+
from an older installed skill. Open its approval URL in the person's usual
|
|
103
|
+
browser. Never ask for a password, Google credential, one-time code or Relay
|
|
104
|
+
access token in chat. No skill discovery or agent restart is needed.
|
|
105
|
+
The installer waits for the verified
|
|
74
106
|
connection before adopting that account and activating Companion, without
|
|
75
107
|
another login. Skip desktop installation on a hosted/headless machine.
|
|
76
108
|
|
|
@@ -79,9 +111,12 @@ activation procedure: install the skill, attempt supported discovery refresh in
|
|
|
79
111
|
the running Claude Code or Codex host, and verify the scope of activation. Keep
|
|
80
112
|
Companion installation non-blocking; continue as soon as the HTTPS protocol is
|
|
81
113
|
ready. If the approval link expires, renew browser approval without restarting
|
|
82
|
-
a running installer.
|
|
83
|
-
|
|
84
|
-
|
|
114
|
+
a running installer. Before asking for first-message approval on a supported
|
|
115
|
+
desktop, check background-status. If idle because installation was missed,
|
|
116
|
+
run the pinned background-install now under the existing setup consent. If it
|
|
117
|
+
failed or stopped, diagnose the issue and retry only when appropriate. Never
|
|
118
|
+
duplicate a running installer or bypass a host permission denial. Report
|
|
119
|
+
installation failures separately from the working connection.
|
|
85
120
|
|
|
86
121
|
Create the authorization with `POST /v1/agent/authorizations` and consume it
|
|
87
122
|
after browser approval with `POST /v1/agent/authorizations/:id/consume`. Treat
|
|
@@ -104,11 +139,21 @@ files, preserve them and tell the person; never overwrite them. If it reports a
|
|
|
104
139
|
new consent version, explain the material change and ask before running
|
|
105
140
|
`relay skill update --renew-consent`.
|
|
106
141
|
|
|
142
|
+
<!-- BEGIN GENERATED RELAY BROWSER APPROVAL -->
|
|
107
143
|
### Open approval in the person's normal browser
|
|
108
144
|
|
|
109
|
-
|
|
145
|
+
The approval handoff has two supported outcomes: open a new tab in the person's usual browser and request focus, or give them a copyable URL to paste there. Never present approvalUrl as a Markdown hyperlink, clickable button, or bare URL in chat: clicking it may open the AI app's embedded browser. Whenever you show the URL to the human, put it only in a fenced plain-text code block, with the copy-and-paste instruction below.
|
|
146
|
+
|
|
147
|
+
Before opening approval on a local desktop, tell the human: “I’m opening Relay’s approval page in your usual browser. If it doesn’t appear, switch to your browser and look for the Relay tab.” Give this notice before running the opener, not only after the tools finish. Companion installation should already have started immediately after setup consent; do not delay it for this browser handoff.
|
|
148
|
+
|
|
149
|
+
Open the returned approvalUrl in the operating system's default browser using a supported external-browser action or OS URL opener that requests a visible, foreground browser window. Request a new tab and use its documented activation or focus option when available; the browser may choose a new window according to the person's settings. Do not use an AI-controlled browser, embedded preview, isolated browser profile, or browser automation for sign-in. An action that opens a URL inside the AI app does not satisfy this step. Pass the exact URL as data to the opener, with safe argument handling; never interpolate it into executable shell text. Leave sign-in and approval to the human.
|
|
150
|
+
|
|
151
|
+
On Windows, hide only the console launcher or background installer. The browser is an interactive approval window and must open normally: when using PowerShell, pass the URL in a variable to Start-Process -FilePath $approvalUrl -WindowStyle Normal. Never apply Hidden or Minimized to the URL-opening Start-Process call. A hidden PowerShell wrapper may launch the browser with Normal. On macOS, do not use open's background or hidden options (-g or -j). Do not force focus with simulated keystrokes or change the person's default browser.
|
|
152
|
+
|
|
153
|
+
A successful opener only confirms that the launch request was accepted; it does not prove the approval tab is visible or focused. If focus is unavailable or unverified, explicitly tell the human: “Switch to your usual browser and approve Relay in the new tab, then return here.” Also provide the copyable fallback below in the same response, so they can continue if the tab did not appear. Do not wait silently for approval or say the page is in front without evidence.
|
|
110
154
|
|
|
111
|
-
|
|
155
|
+
If a normal-browser opener is unavailable, this is a remote/headless environment, opening fails, the wrong browser opens, the human cannot find the tab, or opening or focus is unverified, say: “Copy this URL into your usual browser to approve Relay, then return here.” Immediately below that sentence, show the exact approvalUrl in one fenced plain-text code block containing only the URL. Keep its full fragment intact; do not shorten, redact, wrap, or replace it with link text. Do this before yielding to wait for approval. Never claim the browser opened or approval succeeded without evidence.
|
|
156
|
+
<!-- END GENERATED RELAY BROWSER APPROVAL -->
|
|
112
157
|
|
|
113
158
|
## Give the human a block for another AI
|
|
114
159
|
|
|
@@ -14,6 +14,7 @@ const DEFAULT_PENDING = path.join(os.homedir(), ".relay", "agent-authorization.j
|
|
|
14
14
|
const TRUSTED_RELAY_HOSTS = new Map([
|
|
15
15
|
["https://api.sendrelays.com", "https://sendrelays.com"],
|
|
16
16
|
["https://dev-api.sendrelays.com", "https://dev.sendrelays.com"],
|
|
17
|
+
["https://cti37jd7vx.us-east-1.awsapprunner.com", "https://8epdrqim29.us-east-1.awsapprunner.com"],
|
|
17
18
|
]);
|
|
18
19
|
const TUTORIAL_HUMAN = "Hi — I’ve just joined you on Relay.";
|
|
19
20
|
const TUTORIAL_AGENT = "This is my first Relay after joining from your invite. Help the person reply if they want to welcome me.";
|
|
@@ -45,6 +46,73 @@ function pendingPath(env = process.env) {
|
|
|
45
46
|
return env.RELAY_AGENT_AUTHORIZATION || (env.RELAY_CONFIG_DIR ? path.join(env.RELAY_CONFIG_DIR, "agent-authorization.json") : DEFAULT_PENDING);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
function currentSkillTarget(env = process.env) {
|
|
50
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
51
|
+
const candidates = [
|
|
52
|
+
{ host: "codex", target: "primary", directory: path.join(env.CODEX_HOME || path.join(os.homedir(), ".codex"), "skills", "relay") },
|
|
53
|
+
{ host: "codex", target: "compatibility", directory: path.join(os.homedir(), ".agents", "skills", "relay") },
|
|
54
|
+
{ host: "claude", target: "primary", directory: path.join(env.CLAUDE_HOME || path.join(os.homedir(), ".claude"), "skills", "relay") },
|
|
55
|
+
];
|
|
56
|
+
return candidates.find((candidate) => path.resolve(candidate.directory) === root) || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function skillFileNames(root, current = root, prefix = "", output = []) {
|
|
60
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
61
|
+
if (!prefix && entry.name === ".relay-managed.json") continue;
|
|
62
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
63
|
+
if (entry.isDirectory()) skillFileNames(root, path.join(current, entry.name), relative, output);
|
|
64
|
+
else output.push(relative);
|
|
65
|
+
}
|
|
66
|
+
return output;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function managedSkillTelemetryHeader(env = process.env) {
|
|
70
|
+
try {
|
|
71
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
72
|
+
let state = null;
|
|
73
|
+
try { state = JSON.parse(fs.readFileSync(path.join(root, ".relay-managed.json"), "utf8")); } catch {}
|
|
74
|
+
const recordedTarget = ["codex", "claude"].includes(state?.host) && ["primary", "compatibility"].includes(state?.target)
|
|
75
|
+
? { host: state.host, target: state.target }
|
|
76
|
+
: null;
|
|
77
|
+
const target = recordedTarget || currentSkillTarget(env);
|
|
78
|
+
if (!target) return "";
|
|
79
|
+
const managed = state?.schemaVersion === 1 && state?.name === "relay" && Array.isArray(state.files);
|
|
80
|
+
let modified = false;
|
|
81
|
+
if (managed) {
|
|
82
|
+
const expected = new Set(state.files.map((entry) => String(entry?.path || "").replace(/\\/g, "/")));
|
|
83
|
+
const actual = new Set(skillFileNames(root));
|
|
84
|
+
if (expected.size !== actual.size || [...expected].some((relative) => !actual.has(relative))) modified = true;
|
|
85
|
+
for (const entry of state.files) {
|
|
86
|
+
const relative = String(entry?.path || "").replace(/\\/g, "/");
|
|
87
|
+
if (!relative || relative.startsWith("/") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
88
|
+
modified = true;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const bytes = fs.readFileSync(path.join(root, ...relative.split("/")));
|
|
93
|
+
if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256) modified = true;
|
|
94
|
+
} catch { modified = true; }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const installedAt = typeof state?.installedAt === "string" && Number.isFinite(Date.parse(state.installedAt))
|
|
98
|
+
? new Date(state.installedAt).toISOString()
|
|
99
|
+
: null;
|
|
100
|
+
const payload = {
|
|
101
|
+
name: "relay",
|
|
102
|
+
host: target.host,
|
|
103
|
+
target: target.target,
|
|
104
|
+
...(managed && /^ski_[A-Za-z0-9_-]{20,80}$/.test(String(state.installationId || "")) ? { installationId: state.installationId } : {}),
|
|
105
|
+
version: managed && /^\d+\.\d+\.\d+$/.test(String(state.version || "")) ? state.version : null,
|
|
106
|
+
consentVersion: managed && Number.isSafeInteger(state.consentVersion) && state.consentVersion > 0 ? state.consentVersion : null,
|
|
107
|
+
status: !managed ? "unmanaged" : modified ? "modified" : "managed",
|
|
108
|
+
installedAt,
|
|
109
|
+
};
|
|
110
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
111
|
+
} catch {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
function relayApiOrigin(value, env = process.env) {
|
|
49
117
|
const parsed = new URL(String(value || ""));
|
|
50
118
|
const loopback = ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
|
|
@@ -53,7 +121,7 @@ function relayApiOrigin(value, env = process.env) {
|
|
|
53
121
|
}
|
|
54
122
|
if (TRUSTED_RELAY_HOSTS.has(parsed.origin)) return parsed.origin;
|
|
55
123
|
if (loopback && env.RELAY_AGENT_ALLOW_LOOPBACK === "1" && ["http:", "https:"].includes(parsed.protocol)) return parsed.origin;
|
|
56
|
-
throw new Error("Relay requires the production or development Relay API host.");
|
|
124
|
+
throw new Error("Relay requires the production or development Relay API host, or the approved staging API host.");
|
|
57
125
|
}
|
|
58
126
|
|
|
59
127
|
function trustedApprovalUrl(value, apiUrl, authorizationId, env = process.env) {
|
|
@@ -138,6 +206,7 @@ function allowed(method, requestPath) {
|
|
|
138
206
|
}
|
|
139
207
|
|
|
140
208
|
async function authenticatedRequest(apiUrl, accessToken, method, requestPath, body) {
|
|
209
|
+
const skillTelemetry = managedSkillTelemetryHeader();
|
|
141
210
|
const response = await fetch(`${apiUrl}${requestPath}`, {
|
|
142
211
|
method,
|
|
143
212
|
headers: {
|
|
@@ -145,6 +214,7 @@ async function authenticatedRequest(apiUrl, accessToken, method, requestPath, bo
|
|
|
145
214
|
"Content-Type": "application/json",
|
|
146
215
|
"X-Relay-Client": "relay-agent-skill",
|
|
147
216
|
"X-Relay-Send-Contract": "2",
|
|
217
|
+
...(skillTelemetry ? { "X-Relay-Skill-Telemetry": skillTelemetry } : {}),
|
|
148
218
|
},
|
|
149
219
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
150
220
|
signal: AbortSignal.timeout(15_000),
|