relic-mcp 0.3.0 → 0.3.2
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/relic-mcp.js +352 -36
- package/package.json +1 -1
- package/skills/relic/SKILL.md +30 -6
- package/src/publish.ts +115 -1
- package/src/server.ts +210 -12
- package/src/state.ts +312 -33
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"name": "relic",
|
|
12
12
|
"description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
|
|
13
|
-
"version": "0.3.
|
|
13
|
+
"version": "0.3.2",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"author": {
|
|
16
16
|
"name": "The Bushido Collective",
|
|
@@ -21,6 +21,6 @@
|
|
|
21
21
|
}
|
|
22
22
|
],
|
|
23
23
|
"metadata": {
|
|
24
|
-
"version": "0.3.
|
|
24
|
+
"version": "0.3.2"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relic",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
|
|
5
5
|
"mcpServers": "./mcp-servers.json",
|
|
6
6
|
"author": {
|
package/dist/relic-mcp.js
CHANGED
|
@@ -642,9 +642,19 @@ function deriveRendererClass(content, filename) {
|
|
|
642
642
|
return "binary";
|
|
643
643
|
}
|
|
644
644
|
// src/state.ts
|
|
645
|
-
import {
|
|
645
|
+
import { execFile } from "node:child_process";
|
|
646
|
+
import {
|
|
647
|
+
mkdir,
|
|
648
|
+
readFile as readFile2,
|
|
649
|
+
realpath,
|
|
650
|
+
rename,
|
|
651
|
+
stat,
|
|
652
|
+
writeFile
|
|
653
|
+
} from "node:fs/promises";
|
|
646
654
|
import { homedir } from "node:os";
|
|
647
|
-
import { dirname, join } from "node:path";
|
|
655
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
656
|
+
import { fileURLToPath } from "node:url";
|
|
657
|
+
import { promisify } from "node:util";
|
|
648
658
|
function publishStatePath() {
|
|
649
659
|
const override = process.env["RELIC_PUBLISH_STATE"];
|
|
650
660
|
if (override !== undefined && override.length > 0)
|
|
@@ -652,27 +662,137 @@ function publishStatePath() {
|
|
|
652
662
|
const configRoot = process.env["XDG_CONFIG_HOME"] !== undefined && process.env["XDG_CONFIG_HOME"].length > 0 ? process.env["XDG_CONFIG_HOME"] : join(homedir(), ".config");
|
|
653
663
|
return join(configRoot, "relic-mcp", "publish-state.json");
|
|
654
664
|
}
|
|
655
|
-
var
|
|
656
|
-
|
|
657
|
-
|
|
665
|
+
var runFile = promisify(execFile);
|
|
666
|
+
function normalizeGitRemote(remote) {
|
|
667
|
+
const value = remote.trim().replace(/^git\+/i, "");
|
|
658
668
|
try {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
669
|
+
const url = new URL(value);
|
|
670
|
+
if (url.protocol === "file:") {
|
|
671
|
+
return fileURLToPath(url).replaceAll("\\", "/").replace(/\/+$/g, "").replace(/\.git$/i, "");
|
|
672
|
+
}
|
|
673
|
+
if (url.hostname.length > 0) {
|
|
674
|
+
const host = url.port.length > 0 ? `${url.hostname}:${url.port}` : url.hostname;
|
|
675
|
+
const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
|
|
676
|
+
return `${host}/${path}`.toLowerCase();
|
|
677
|
+
}
|
|
678
|
+
} catch {}
|
|
679
|
+
const scp = value.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/);
|
|
680
|
+
if (scp?.[1] !== undefined && scp[2] !== undefined) {
|
|
681
|
+
const path = scp[2].replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
|
|
682
|
+
return `${scp[1]}/${path}`.toLowerCase();
|
|
683
|
+
}
|
|
684
|
+
return value.replaceAll("\\", "/").replace(/\/+$/g, "").replace(/\.git$/i, "");
|
|
685
|
+
}
|
|
686
|
+
async function gitOutput(directory, args) {
|
|
687
|
+
try {
|
|
688
|
+
const { stdout } = await runFile("git", ["-C", directory, ...args], {
|
|
689
|
+
encoding: "utf8",
|
|
690
|
+
maxBuffer: 1024 * 1024
|
|
691
|
+
});
|
|
692
|
+
const output = String(stdout).trim();
|
|
693
|
+
return output.length === 0 ? undefined : output;
|
|
694
|
+
} catch {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function resolveSourceIdentity(path) {
|
|
699
|
+
const absolutePath = resolve(path);
|
|
700
|
+
const sourceDirectory = dirname(absolutePath);
|
|
701
|
+
const repositoryRoot = await gitOutput(sourceDirectory, [
|
|
702
|
+
"rev-parse",
|
|
703
|
+
"--show-toplevel"
|
|
704
|
+
]);
|
|
705
|
+
if (repositoryRoot !== undefined) {
|
|
706
|
+
const repositoryPrefix = await gitOutput(sourceDirectory, [
|
|
707
|
+
"rev-parse",
|
|
708
|
+
"--show-prefix"
|
|
709
|
+
]);
|
|
710
|
+
const repositoryPath = `${repositoryPrefix ?? ""}${basename(absolutePath)}`.replaceAll("\\", "/");
|
|
711
|
+
const remoteNames = (await gitOutput(repositoryRoot, ["remote"]) ?? "").split(/\r?\n/).filter((name) => name.length > 0).sort();
|
|
712
|
+
const remoteName = remoteNames.includes("origin") ? "origin" : remoteNames[0];
|
|
713
|
+
if (remoteName !== undefined) {
|
|
714
|
+
const remote = await gitOutput(repositoryRoot, [
|
|
715
|
+
"remote",
|
|
716
|
+
"get-url",
|
|
717
|
+
remoteName
|
|
718
|
+
]);
|
|
719
|
+
if (remote !== undefined) {
|
|
720
|
+
const repository = normalizeGitRemote(remote);
|
|
721
|
+
return {
|
|
722
|
+
identity: `git-remote:${encodeURIComponent(repository)}:` + encodeURIComponent(repositoryPath),
|
|
723
|
+
kind: "git_remote",
|
|
724
|
+
path: repositoryPath,
|
|
725
|
+
repository,
|
|
726
|
+
description: `${repositoryPath} in ${repository}`
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const commonDirectory = await gitOutput(repositoryRoot, [
|
|
731
|
+
"rev-parse",
|
|
732
|
+
"--git-common-dir"
|
|
733
|
+
]);
|
|
734
|
+
if (commonDirectory !== undefined) {
|
|
735
|
+
const repository = await realpath(isAbsolute(commonDirectory) ? commonDirectory : resolve(repositoryRoot, commonDirectory));
|
|
736
|
+
return {
|
|
737
|
+
identity: `git-common-dir:${encodeURIComponent(repository)}:` + encodeURIComponent(repositoryPath),
|
|
738
|
+
kind: "git_common_dir",
|
|
739
|
+
path: repositoryPath,
|
|
740
|
+
repository,
|
|
741
|
+
description: `${repositoryPath} in Git repository ${repository}`
|
|
742
|
+
};
|
|
666
743
|
}
|
|
667
|
-
throw new Error(`could not read publish state at ${publishStatePath()}: ${code}`);
|
|
668
744
|
}
|
|
669
|
-
const
|
|
745
|
+
const canonicalPath = await realpath(absolutePath);
|
|
746
|
+
return {
|
|
747
|
+
identity: `realpath:${encodeURIComponent(canonicalPath)}`,
|
|
748
|
+
kind: "realpath",
|
|
749
|
+
path: canonicalPath,
|
|
750
|
+
description: canonicalPath
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
var writeChain = Promise.resolve();
|
|
754
|
+
async function loadPublishState(relicId) {
|
|
755
|
+
const parsed = await readWholeFile(publishStatePath());
|
|
756
|
+
return validatePublishStateEntry(parsed?.relics?.[relicId], relicId);
|
|
757
|
+
}
|
|
758
|
+
function isSourceIdentity(value) {
|
|
759
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
const source = value;
|
|
763
|
+
return typeof source["identity"] === "string" && (source["kind"] === "git_remote" || source["kind"] === "git_common_dir" || source["kind"] === "realpath") && typeof source["path"] === "string" && (source["repository"] === undefined || typeof source["repository"] === "string") && typeof source["description"] === "string";
|
|
764
|
+
}
|
|
765
|
+
function validatePublishStateEntry(entry, relicId) {
|
|
670
766
|
if (entry === undefined)
|
|
671
767
|
return;
|
|
672
|
-
if (
|
|
673
|
-
throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` +
|
|
768
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
769
|
+
throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + `relic ${relicId}.`);
|
|
674
770
|
}
|
|
675
|
-
|
|
771
|
+
const candidate = entry;
|
|
772
|
+
if (typeof candidate["key"] !== "string" || typeof candidate["publish_token"] !== "string" || typeof candidate["version"] !== "number" || !Number.isSafeInteger(candidate["version"]) || candidate["version"] < 1 || candidate["source"] !== undefined && !isSourceIdentity(candidate["source"])) {
|
|
773
|
+
throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + `relic ${relicId}.`);
|
|
774
|
+
}
|
|
775
|
+
return candidate;
|
|
776
|
+
}
|
|
777
|
+
async function loadPublishedSource(source) {
|
|
778
|
+
const parsed = await readWholeFile(publishStatePath());
|
|
779
|
+
const sources = parsed?.sources;
|
|
780
|
+
if (sources === undefined)
|
|
781
|
+
return;
|
|
782
|
+
if (sources === null || typeof sources !== "object" || Array.isArray(sources)) {
|
|
783
|
+
throw new Error(`publish state at ${publishStatePath()} holds a malformed source index.`);
|
|
784
|
+
}
|
|
785
|
+
const relicId = sources[source.identity];
|
|
786
|
+
if (relicId === undefined)
|
|
787
|
+
return;
|
|
788
|
+
if (typeof relicId !== "string" || relicId.length === 0) {
|
|
789
|
+
throw new Error(`publish state at ${publishStatePath()} holds a malformed source match.`);
|
|
790
|
+
}
|
|
791
|
+
const state = validatePublishStateEntry(parsed?.relics?.[relicId], relicId);
|
|
792
|
+
if (state?.source?.identity !== source.identity) {
|
|
793
|
+
throw new Error(`publish state at ${publishStatePath()} holds a source match without ` + "its relic entry.");
|
|
794
|
+
}
|
|
795
|
+
return { relic_id: relicId, state, source: state.source };
|
|
676
796
|
}
|
|
677
797
|
async function savePublishState(relicId, state) {
|
|
678
798
|
const run = writeChain.then(() => writeEntry(relicId, state));
|
|
@@ -686,9 +806,33 @@ async function writeEntry(relicId, state) {
|
|
|
686
806
|
const dir = dirname(path);
|
|
687
807
|
await mkdir(dir, { mode: 448, recursive: true });
|
|
688
808
|
const existing = await readWholeFile(path);
|
|
689
|
-
const
|
|
809
|
+
const storedRelics = existing?.["relics"];
|
|
810
|
+
if (storedRelics !== undefined && (storedRelics === null || typeof storedRelics !== "object" || Array.isArray(storedRelics))) {
|
|
811
|
+
throw new Error(`publish state at ${path} holds malformed relic entries.`);
|
|
812
|
+
}
|
|
813
|
+
const oldEntry = storedRelics?.[relicId];
|
|
814
|
+
const relics = {
|
|
815
|
+
...storedRelics,
|
|
816
|
+
[relicId]: {
|
|
817
|
+
...oldEntry !== null && typeof oldEntry === "object" && !Array.isArray(oldEntry) ? oldEntry : {},
|
|
818
|
+
...state
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
const storedSources = existing?.["sources"];
|
|
822
|
+
if (storedSources !== undefined && (storedSources === null || typeof storedSources !== "object" || Array.isArray(storedSources))) {
|
|
823
|
+
throw new Error(`publish state at ${path} holds a malformed source index.`);
|
|
824
|
+
}
|
|
825
|
+
const sources = {
|
|
826
|
+
...storedSources,
|
|
827
|
+
...state.source === undefined ? {} : { [state.source.identity]: relicId }
|
|
828
|
+
};
|
|
829
|
+
const next = {
|
|
830
|
+
...existing,
|
|
831
|
+
relics,
|
|
832
|
+
...storedSources === undefined && state.source === undefined ? {} : { sources }
|
|
833
|
+
};
|
|
690
834
|
const temp = `${path}.tmp`;
|
|
691
|
-
await writeFile(temp, `${JSON.stringify(
|
|
835
|
+
await writeFile(temp, `${JSON.stringify(next, null, 2)}
|
|
692
836
|
`, {
|
|
693
837
|
mode: 384
|
|
694
838
|
});
|
|
@@ -706,7 +850,7 @@ async function readWholeFile(path) {
|
|
|
706
850
|
}
|
|
707
851
|
try {
|
|
708
852
|
const parsed = JSON.parse(raw);
|
|
709
|
-
if (parsed === null || typeof parsed !== "object") {
|
|
853
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
710
854
|
throw new Error("not an object");
|
|
711
855
|
}
|
|
712
856
|
return parsed;
|
|
@@ -739,8 +883,56 @@ class ServerRefusal extends Error {
|
|
|
739
883
|
this.problem = problem;
|
|
740
884
|
}
|
|
741
885
|
}
|
|
886
|
+
function republishToolCall(relicId, path) {
|
|
887
|
+
return {
|
|
888
|
+
name: "relic_republish",
|
|
889
|
+
arguments: { relic_id: relicId, path }
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
async function lookupResolvedSource(resolvedPath, deps) {
|
|
893
|
+
let source;
|
|
894
|
+
try {
|
|
895
|
+
source = await (deps.identifySource ?? resolveSourceIdentity)(resolvedPath);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
const code = error.code;
|
|
898
|
+
throw new PublishError(code === "ENOENT" ? "source_not_found" : "source_unreadable", `could not resolve source identity for ${resolvedPath}: ` + error.message, { path: resolvedPath });
|
|
899
|
+
}
|
|
900
|
+
try {
|
|
901
|
+
const published = await loadPublishedSource(source);
|
|
902
|
+
return {
|
|
903
|
+
resolved_path: resolvedPath,
|
|
904
|
+
source,
|
|
905
|
+
...published === undefined ? {} : {
|
|
906
|
+
match: {
|
|
907
|
+
relic_id: published.relic_id,
|
|
908
|
+
version: published.state.version,
|
|
909
|
+
source: published.source
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
} catch (error) {
|
|
914
|
+
throw new PublishError("local_state_unreadable", error.message);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function lookupPublishedSource(path, deps) {
|
|
918
|
+
return lookupResolvedSource(deps.files.resolve(path), deps);
|
|
919
|
+
}
|
|
742
920
|
async function publish(input, deps) {
|
|
743
921
|
const source = await readSource(input.path, deps.files);
|
|
922
|
+
const sourceLookup = await lookupResolvedSource(source.resolvedPath, deps);
|
|
923
|
+
if (sourceLookup.match !== undefined && input.force_new !== true) {
|
|
924
|
+
const match = sourceLookup.match;
|
|
925
|
+
const republishCall = republishToolCall(match.relic_id, source.resolvedPath);
|
|
926
|
+
const cost = "a second URL that nobody holding the first one will ever see";
|
|
927
|
+
throw new PublishError("source_already_published", `${match.source.description} is already version ${match.version} of ` + `relic ${match.relic_id}. Publishing it as new would cost ${cost}. ` + `Call relic_republish(${JSON.stringify(republishCall.arguments)}) ` + "instead. Set force_new to true only when you intend a separate relic.", {
|
|
928
|
+
relic_id: match.relic_id,
|
|
929
|
+
version: match.version,
|
|
930
|
+
source_identity: match.source.identity,
|
|
931
|
+
source_description: match.source.description,
|
|
932
|
+
cost,
|
|
933
|
+
republish_call: republishCall
|
|
934
|
+
});
|
|
935
|
+
}
|
|
744
936
|
const challenge = await postJson(deps, `${deps.serviceOrigin}/api/challenge`, {});
|
|
745
937
|
const sizeLimit = Number(challenge["size_limit_bytes"]);
|
|
746
938
|
const sizeBasis = String(challenge["size_basis"]);
|
|
@@ -793,7 +985,8 @@ async function publish(input, deps) {
|
|
|
793
985
|
await savePublishState(relicId, {
|
|
794
986
|
key: encodeKey(key),
|
|
795
987
|
publish_token: publishToken,
|
|
796
|
-
version: 1
|
|
988
|
+
version: 1,
|
|
989
|
+
source: sourceLookup.source
|
|
797
990
|
});
|
|
798
991
|
} catch (error) {
|
|
799
992
|
throw new PublishError("local_state_write_failed", "the relic is published and the link works, but recording it for " + `later republishing failed, so it cannot be republished from this ` + `machine: ${error.message}`, { relic_id: relicId, url });
|
|
@@ -968,11 +1161,12 @@ async function republish(input, deps) {
|
|
|
968
1161
|
var TOOL_NAME = "relic_publish";
|
|
969
1162
|
var DESCRIBE_TOOL_NAME = "relic_describe_client";
|
|
970
1163
|
var REPUBLISH_TOOL_NAME = "relic_republish";
|
|
1164
|
+
var LOOKUP_TOOL_NAME = "relic_lookup_source";
|
|
971
1165
|
var MAX_TTL_DAYS = 3650;
|
|
972
1166
|
var TOOL_DEFINITION = {
|
|
973
1167
|
name: TOOL_NAME,
|
|
974
1168
|
title: "Publish a relic",
|
|
975
|
-
description: "Encrypt a file on this machine and publish it as a relic, returning a " + "
|
|
1169
|
+
description: "Encrypt a file on this machine and publish it as a new relic, returning " + "a shareable URL. Publishing an update this way costs a second URL that " + "nobody holding the first one will ever see; use relic_republish instead " + "so the existing URL keeps working. The encryption key is generated " + "locally and never sent to the service. Takes a filesystem path, never " + "inline content.",
|
|
976
1170
|
inputSchema: {
|
|
977
1171
|
type: "object",
|
|
978
1172
|
properties: {
|
|
@@ -989,6 +1183,11 @@ var TOOL_DEFINITION = {
|
|
|
989
1183
|
minimum: 1,
|
|
990
1184
|
maximum: MAX_TTL_DAYS,
|
|
991
1185
|
description: "Optional. Gives the relic a lifetime in days. Omit it and the " + "relic is kept until it is deleted. Shorter is better for " + "sensitive content."
|
|
1186
|
+
},
|
|
1187
|
+
force_new: {
|
|
1188
|
+
type: "boolean",
|
|
1189
|
+
default: false,
|
|
1190
|
+
description: "Optional. Publish a deliberately separate relic even when this " + "machine already published the same source. Defaults to false. Use " + "only when you want two independent URLs for one file."
|
|
992
1191
|
}
|
|
993
1192
|
},
|
|
994
1193
|
required: ["path"],
|
|
@@ -1083,6 +1282,60 @@ var REPUBLISH_TOOL_DEFINITION = {
|
|
|
1083
1282
|
additionalProperties: false
|
|
1084
1283
|
}
|
|
1085
1284
|
};
|
|
1285
|
+
var LOOKUP_TOOL_DEFINITION = {
|
|
1286
|
+
name: LOOKUP_TOOL_NAME,
|
|
1287
|
+
title: "Look up a published source",
|
|
1288
|
+
description: "Look up whether this machine already published a file and return the " + "relic id needed by relic_republish. Reads local publish state only and " + "never calls the service.",
|
|
1289
|
+
inputSchema: {
|
|
1290
|
+
type: "object",
|
|
1291
|
+
properties: {
|
|
1292
|
+
path: {
|
|
1293
|
+
type: "string",
|
|
1294
|
+
description: "Filesystem path to the source to look up."
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
required: ["path"],
|
|
1298
|
+
additionalProperties: false
|
|
1299
|
+
},
|
|
1300
|
+
outputSchema: {
|
|
1301
|
+
type: "object",
|
|
1302
|
+
properties: {
|
|
1303
|
+
found: { type: "boolean" },
|
|
1304
|
+
relic_id: { type: ["string", "null"] },
|
|
1305
|
+
version: { type: ["integer", "null"], minimum: 1 },
|
|
1306
|
+
resolved_path: { type: "string" },
|
|
1307
|
+
source_identity: { type: "string" },
|
|
1308
|
+
source_description: { type: "string" },
|
|
1309
|
+
republish_call: {
|
|
1310
|
+
type: ["object", "null"],
|
|
1311
|
+
properties: {
|
|
1312
|
+
name: { type: "string", const: REPUBLISH_TOOL_NAME },
|
|
1313
|
+
arguments: {
|
|
1314
|
+
type: "object",
|
|
1315
|
+
properties: {
|
|
1316
|
+
relic_id: { type: "string" },
|
|
1317
|
+
path: { type: "string" }
|
|
1318
|
+
},
|
|
1319
|
+
required: ["relic_id", "path"],
|
|
1320
|
+
additionalProperties: false
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
required: ["name", "arguments"],
|
|
1324
|
+
additionalProperties: false
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
required: [
|
|
1328
|
+
"found",
|
|
1329
|
+
"relic_id",
|
|
1330
|
+
"version",
|
|
1331
|
+
"resolved_path",
|
|
1332
|
+
"source_identity",
|
|
1333
|
+
"source_description",
|
|
1334
|
+
"republish_call"
|
|
1335
|
+
],
|
|
1336
|
+
additionalProperties: false
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1086
1339
|
var DESCRIBE_TOOL_DEFINITION = {
|
|
1087
1340
|
name: DESCRIBE_TOOL_NAME,
|
|
1088
1341
|
title: "Describe the Relic client",
|
|
@@ -1093,13 +1346,22 @@ var DESCRIBE_TOOL_DEFINITION = {
|
|
|
1093
1346
|
additionalProperties: false
|
|
1094
1347
|
}
|
|
1095
1348
|
};
|
|
1096
|
-
var SERVER_VERSION = "0.3.
|
|
1349
|
+
var SERVER_VERSION = "0.3.2";
|
|
1097
1350
|
var SERVER_INFO = {
|
|
1098
1351
|
name: "relic",
|
|
1099
1352
|
title: "Relic",
|
|
1100
1353
|
version: SERVER_VERSION
|
|
1101
1354
|
};
|
|
1102
1355
|
var CAPABILITIES = { tools: {} };
|
|
1356
|
+
var INSTRUCTIONS = `Relic encrypts a file on this machine and uploads only ciphertext. The key lives in the URL fragment, which browsers never send to a server.
|
|
1357
|
+
|
|
1358
|
+
Five things that change how you should act:
|
|
1359
|
+
|
|
1360
|
+
1. The link is the credential. Anyone holding it, fragment included, can read the file. Do not paste it into a tracker, a log, or a public channel.
|
|
1361
|
+
2. Publishing puts the key in this transcript. That is structural rather than a defect, and worth saying plainly when you hand the link over.
|
|
1362
|
+
3. If a source was published before, use relic_republish so its URL keeps working. Publishing it as new costs a second URL that nobody holding the first one will ever see. relic_publish refuses by default, relic_lookup_source recovers the id, and force_new is only for a deliberate second link.
|
|
1363
|
+
4. A relic can be republished only from the machine that published it, which is where its key and publish token are stored. Anywhere else it refuses, and no retry changes that.
|
|
1364
|
+
5. Rendered HTML and JSX run in an isolated frame with no network access. Inline the styles, scripts, fonts, and images a page needs, because a CDN reference renders as nothing. Decide that before you write the file.`;
|
|
1103
1365
|
async function handleMessage(message, deps) {
|
|
1104
1366
|
if (message.id === undefined)
|
|
1105
1367
|
return;
|
|
@@ -1117,7 +1379,8 @@ async function handleMessage(message, deps) {
|
|
|
1117
1379
|
result: {
|
|
1118
1380
|
protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
|
|
1119
1381
|
capabilities: CAPABILITIES,
|
|
1120
|
-
serverInfo: SERVER_INFO
|
|
1382
|
+
serverInfo: SERVER_INFO,
|
|
1383
|
+
instructions: INSTRUCTIONS
|
|
1121
1384
|
}
|
|
1122
1385
|
};
|
|
1123
1386
|
case "initialize": {
|
|
@@ -1128,7 +1391,8 @@ async function handleMessage(message, deps) {
|
|
|
1128
1391
|
result: {
|
|
1129
1392
|
protocolVersion: isSupportedVersion(asked) ? asked : PROTOCOL_VERSION,
|
|
1130
1393
|
capabilities: CAPABILITIES,
|
|
1131
|
-
serverInfo: SERVER_INFO
|
|
1394
|
+
serverInfo: SERVER_INFO,
|
|
1395
|
+
instructions: INSTRUCTIONS
|
|
1132
1396
|
}
|
|
1133
1397
|
};
|
|
1134
1398
|
}
|
|
@@ -1141,6 +1405,7 @@ async function handleMessage(message, deps) {
|
|
|
1141
1405
|
result: {
|
|
1142
1406
|
tools: [
|
|
1143
1407
|
TOOL_DEFINITION,
|
|
1408
|
+
LOOKUP_TOOL_DEFINITION,
|
|
1144
1409
|
REPUBLISH_TOOL_DEFINITION,
|
|
1145
1410
|
DESCRIBE_TOOL_DEFINITION
|
|
1146
1411
|
]
|
|
@@ -1171,13 +1436,16 @@ async function callTool(id2, params, deps) {
|
|
|
1171
1436
|
key_transmitted_to_service: false,
|
|
1172
1437
|
plaintext_transmitted_to_service: false,
|
|
1173
1438
|
ciphertext_destination: "object storage, via a signed URL",
|
|
1174
|
-
local_publish_state: "relic id, key, and publish token per relic, written 0600 under
|
|
1439
|
+
local_publish_state: "relic id, source identity, key, and publish token per relic, " + "written 0600 under the user config directory; key and token " + "are never printed or sent",
|
|
1175
1440
|
service_origin: deps.serviceOrigin
|
|
1176
1441
|
},
|
|
1177
1442
|
isError: false
|
|
1178
1443
|
}
|
|
1179
1444
|
};
|
|
1180
1445
|
}
|
|
1446
|
+
if (params["name"] === LOOKUP_TOOL_NAME) {
|
|
1447
|
+
return callLookup(id2, params, deps);
|
|
1448
|
+
}
|
|
1181
1449
|
if (params["name"] === REPUBLISH_TOOL_NAME) {
|
|
1182
1450
|
return callRepublish(id2, params, deps);
|
|
1183
1451
|
}
|
|
@@ -1194,8 +1462,17 @@ async function callTool(id2, params, deps) {
|
|
|
1194
1462
|
if (!ttlDays.ok) {
|
|
1195
1463
|
return errorResponse(id2, ERROR_CODES.invalidParams, `\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` + "omitted to keep the relic until it is deleted");
|
|
1196
1464
|
}
|
|
1465
|
+
const forceNew = args["force_new"];
|
|
1466
|
+
if (forceNew !== undefined && typeof forceNew !== "boolean") {
|
|
1467
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`force_new` must be a boolean or omitted");
|
|
1468
|
+
}
|
|
1197
1469
|
try {
|
|
1198
|
-
const result = await publish({
|
|
1470
|
+
const result = await publish({
|
|
1471
|
+
path,
|
|
1472
|
+
filename,
|
|
1473
|
+
ttl_days: ttlDays.days,
|
|
1474
|
+
force_new: forceNew === true
|
|
1475
|
+
}, deps);
|
|
1199
1476
|
return {
|
|
1200
1477
|
jsonrpc: "2.0",
|
|
1201
1478
|
id: id2,
|
|
@@ -1219,6 +1496,44 @@ async function callTool(id2, params, deps) {
|
|
|
1219
1496
|
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
1220
1497
|
}
|
|
1221
1498
|
}
|
|
1499
|
+
async function callLookup(id2, params, deps) {
|
|
1500
|
+
const args = params["arguments"] ?? {};
|
|
1501
|
+
const path = args["path"];
|
|
1502
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
1503
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
|
|
1504
|
+
}
|
|
1505
|
+
try {
|
|
1506
|
+
const lookup = await lookupPublishedSource(path, deps);
|
|
1507
|
+
const match = lookup.match;
|
|
1508
|
+
const republishCall = match === undefined ? null : republishToolCall(match.relic_id, lookup.resolved_path);
|
|
1509
|
+
const structuredContent = {
|
|
1510
|
+
found: match !== undefined,
|
|
1511
|
+
relic_id: match?.relic_id ?? null,
|
|
1512
|
+
version: match?.version ?? null,
|
|
1513
|
+
resolved_path: lookup.resolved_path,
|
|
1514
|
+
source_identity: lookup.source.identity,
|
|
1515
|
+
source_description: lookup.source.description,
|
|
1516
|
+
republish_call: republishCall
|
|
1517
|
+
};
|
|
1518
|
+
return {
|
|
1519
|
+
jsonrpc: "2.0",
|
|
1520
|
+
id: id2,
|
|
1521
|
+
result: {
|
|
1522
|
+
content: [
|
|
1523
|
+
{
|
|
1524
|
+
type: "text",
|
|
1525
|
+
text: match === undefined ? `No prior relic is recorded for ${lookup.source.description}.` : `Found ${lookup.source.description} as version ` + `${match.version} of relic ${match.relic_id}.
|
|
1526
|
+
` + `Call relic_republish(${JSON.stringify(republishCall?.arguments)}).`
|
|
1527
|
+
}
|
|
1528
|
+
],
|
|
1529
|
+
structuredContent,
|
|
1530
|
+
isError: false
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1222
1537
|
async function callRepublish(id2, params, deps) {
|
|
1223
1538
|
const args = params["arguments"] ?? {};
|
|
1224
1539
|
const relicId = args["relic_id"];
|
|
@@ -1323,13 +1638,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
|
|
|
1323
1638
|
what coarse class it was declared as, the publishing IP, and when it was
|
|
1324
1639
|
fetched. Never the contents, and never the key.
|
|
1325
1640
|
|
|
1326
|
-
What this keeps on disk: for each relic you publish, its id,
|
|
1327
|
-
publish token, in a 0600 file under your user config directory.
|
|
1328
|
-
|
|
1641
|
+
What this keeps on disk: for each relic you publish, its id, source identity,
|
|
1642
|
+
key, and publish token, in a 0600 file under your user config directory. The
|
|
1643
|
+
source index lets a fresh session find the id for relic_republish. The key and
|
|
1644
|
+
token let that republish keep the same URL, and they are why republishing works
|
|
1329
1645
|
only on the machine that published. The token's SHA-256 is the only copy the
|
|
1330
|
-
service ever holds, and neither secret is ever printed or logged. Deleting
|
|
1331
|
-
|
|
1332
|
-
|
|
1646
|
+
service ever holds, and neither secret is ever printed or logged. Deleting the
|
|
1647
|
+
file changes nothing for existing links; it only ends this machine's ability
|
|
1648
|
+
to update those relics.
|
|
1333
1649
|
|
|
1334
1650
|
What this does NOT protect against: the key is returned to your agent in the
|
|
1335
1651
|
URL, so it enters the model's context and your session transcript. That is
|
|
@@ -1440,8 +1756,8 @@ function createHttpHandler(deps, options = {}) {
|
|
|
1440
1756
|
import { spawnSync } from "node:child_process";
|
|
1441
1757
|
import { copyFile, mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1442
1758
|
import { homedir as homedir2 } from "node:os";
|
|
1443
|
-
import { dirname as dirname2, join as join2, resolve } from "node:path";
|
|
1444
|
-
import { fileURLToPath } from "node:url";
|
|
1759
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
1760
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1445
1761
|
|
|
1446
1762
|
// src/install.ts
|
|
1447
1763
|
var HARNESSES = [
|
|
@@ -1669,7 +1985,7 @@ function parseArgs(argv) {
|
|
|
1669
1985
|
return options;
|
|
1670
1986
|
}
|
|
1671
1987
|
function packageRoot() {
|
|
1672
|
-
return
|
|
1988
|
+
return resolve2(dirname2(fileURLToPath2(import.meta.url)), "..");
|
|
1673
1989
|
}
|
|
1674
1990
|
async function exists(path) {
|
|
1675
1991
|
return readFile3(path).then(() => true).catch(() => false);
|
package/package.json
CHANGED
package/skills/relic/SKILL.md
CHANGED
|
@@ -24,16 +24,34 @@ enters the conversation, so it is never in the transcript, never in a model
|
|
|
24
24
|
context window, and never in whatever stores those. Do not read a file into
|
|
25
25
|
context and pass its text; pass where it lives.
|
|
26
26
|
|
|
27
|
+
When the task is to update the same source, call `relic_lookup_source` first:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
relic_lookup_source(path: "/Users/me/Downloads/report.html")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It reads local machine state only. If the source was published before, it
|
|
34
|
+
returns the relic id and the exact `relic_republish` call. This works across
|
|
35
|
+
Git worktrees and clones of the same remote, so a fresh session does not need
|
|
36
|
+
to retain the id from the first publish.
|
|
37
|
+
|
|
38
|
+
Do not publish an update as a new relic. That costs a second URL that nobody
|
|
39
|
+
holding the first one will ever see. `relic_publish` enforces this: when local
|
|
40
|
+
state matches the source, it refuses and points to `relic_republish`.
|
|
41
|
+
|
|
27
42
|
Optional arguments worth knowing:
|
|
28
43
|
|
|
29
44
|
- `filename` overrides the display name shown to the recipient.
|
|
30
45
|
- `ttl_days` gives the link a lifetime in days, 1 to 3650. A relic is kept
|
|
31
46
|
until it is deleted unless you set one. Shorter is better for anything
|
|
32
47
|
sensitive: when the content should stop being available, say when.
|
|
48
|
+
- `force_new` deliberately creates a separate relic from a source this machine
|
|
49
|
+
already published. Use it only when two independent URLs are the goal, never
|
|
50
|
+
to get past the update refusal.
|
|
33
51
|
|
|
34
|
-
The result reports the relic as version 1 and its id.
|
|
35
|
-
|
|
36
|
-
`
|
|
52
|
+
The result reports the relic as version 1 and its id. The client records that
|
|
53
|
+
id with the source locally, so a later session can recover it with
|
|
54
|
+
`relic_lookup_source`.
|
|
37
55
|
|
|
38
56
|
## Republishing
|
|
39
57
|
|
|
@@ -85,9 +103,15 @@ Also worth one line, unprompted, the first time in a session:
|
|
|
85
103
|
## What the recipient gets
|
|
86
104
|
|
|
87
105
|
A page that fetches the ciphertext, decrypts it in their browser, and renders
|
|
88
|
-
by type. Markdown, code, images, and plain text render inline. HTML
|
|
89
|
-
a sandboxed frame on a separate origin, so a published page cannot
|
|
90
|
-
key or the service. Anything else offers a download.
|
|
106
|
+
by type. Markdown, code, images, and plain text render inline. HTML and JSX
|
|
107
|
+
render in a sandboxed frame on a separate origin, so a published page cannot
|
|
108
|
+
reach the key or the service. Anything else offers a download.
|
|
109
|
+
|
|
110
|
+
That frame has **no network access**: its policy permits no remote source at
|
|
111
|
+
all, so a page cannot fetch, beacon, or load an external image, font, or
|
|
112
|
+
script. Inline what a page needs when you generate it, because a CDN
|
|
113
|
+
reference renders as nothing. The upside is that a relic cannot phone home or
|
|
114
|
+
learn the recipient's IP address.
|
|
91
115
|
|
|
92
116
|
They need the whole URL including the `#...` part. A link truncated at the `#`
|
|
93
117
|
is a page that cannot decrypt anything, and that is the most common way sharing
|