relic-mcp 0.3.1 → 0.3.3
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 +350 -41
- package/package.json +1 -1
- package/skills/relic/SKILL.md +26 -3
- package/src/publish.ts +115 -1
- package/src/server.ts +191 -21
- 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.3",
|
|
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.3"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relic",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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,13 @@ 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;
|
|
1166
|
+
var VERSION_HISTORY_DISCLOSURE = "Anyone holding a relic's link can fetch every version it has ever held, " + "so republishing does not withdraw earlier content.";
|
|
972
1167
|
var TOOL_DEFINITION = {
|
|
973
1168
|
name: TOOL_NAME,
|
|
974
1169
|
title: "Publish a relic",
|
|
975
|
-
description: "Encrypt a file on this machine and publish it as a relic, returning a " + "
|
|
1170
|
+
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. " + VERSION_HISTORY_DISCLOSURE + " The encryption key is generated locally and never sent to the service. " + "Takes a filesystem path, never " + "inline content.",
|
|
976
1171
|
inputSchema: {
|
|
977
1172
|
type: "object",
|
|
978
1173
|
properties: {
|
|
@@ -989,6 +1184,11 @@ var TOOL_DEFINITION = {
|
|
|
989
1184
|
minimum: 1,
|
|
990
1185
|
maximum: MAX_TTL_DAYS,
|
|
991
1186
|
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."
|
|
1187
|
+
},
|
|
1188
|
+
force_new: {
|
|
1189
|
+
type: "boolean",
|
|
1190
|
+
default: false,
|
|
1191
|
+
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
1192
|
}
|
|
993
1193
|
},
|
|
994
1194
|
required: ["path"],
|
|
@@ -1028,7 +1228,7 @@ var TOOL_DEFINITION = {
|
|
|
1028
1228
|
var REPUBLISH_TOOL_DEFINITION = {
|
|
1029
1229
|
name: REPUBLISH_TOOL_NAME,
|
|
1030
1230
|
title: "Republish a relic",
|
|
1031
|
-
description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + "Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
|
|
1231
|
+
description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
|
|
1032
1232
|
inputSchema: {
|
|
1033
1233
|
type: "object",
|
|
1034
1234
|
properties: {
|
|
@@ -1083,6 +1283,60 @@ var REPUBLISH_TOOL_DEFINITION = {
|
|
|
1083
1283
|
additionalProperties: false
|
|
1084
1284
|
}
|
|
1085
1285
|
};
|
|
1286
|
+
var LOOKUP_TOOL_DEFINITION = {
|
|
1287
|
+
name: LOOKUP_TOOL_NAME,
|
|
1288
|
+
title: "Look up a published source",
|
|
1289
|
+
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.",
|
|
1290
|
+
inputSchema: {
|
|
1291
|
+
type: "object",
|
|
1292
|
+
properties: {
|
|
1293
|
+
path: {
|
|
1294
|
+
type: "string",
|
|
1295
|
+
description: "Filesystem path to the source to look up."
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
required: ["path"],
|
|
1299
|
+
additionalProperties: false
|
|
1300
|
+
},
|
|
1301
|
+
outputSchema: {
|
|
1302
|
+
type: "object",
|
|
1303
|
+
properties: {
|
|
1304
|
+
found: { type: "boolean" },
|
|
1305
|
+
relic_id: { type: ["string", "null"] },
|
|
1306
|
+
version: { type: ["integer", "null"], minimum: 1 },
|
|
1307
|
+
resolved_path: { type: "string" },
|
|
1308
|
+
source_identity: { type: "string" },
|
|
1309
|
+
source_description: { type: "string" },
|
|
1310
|
+
republish_call: {
|
|
1311
|
+
type: ["object", "null"],
|
|
1312
|
+
properties: {
|
|
1313
|
+
name: { type: "string", const: REPUBLISH_TOOL_NAME },
|
|
1314
|
+
arguments: {
|
|
1315
|
+
type: "object",
|
|
1316
|
+
properties: {
|
|
1317
|
+
relic_id: { type: "string" },
|
|
1318
|
+
path: { type: "string" }
|
|
1319
|
+
},
|
|
1320
|
+
required: ["relic_id", "path"],
|
|
1321
|
+
additionalProperties: false
|
|
1322
|
+
}
|
|
1323
|
+
},
|
|
1324
|
+
required: ["name", "arguments"],
|
|
1325
|
+
additionalProperties: false
|
|
1326
|
+
}
|
|
1327
|
+
},
|
|
1328
|
+
required: [
|
|
1329
|
+
"found",
|
|
1330
|
+
"relic_id",
|
|
1331
|
+
"version",
|
|
1332
|
+
"resolved_path",
|
|
1333
|
+
"source_identity",
|
|
1334
|
+
"source_description",
|
|
1335
|
+
"republish_call"
|
|
1336
|
+
],
|
|
1337
|
+
additionalProperties: false
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1086
1340
|
var DESCRIBE_TOOL_DEFINITION = {
|
|
1087
1341
|
name: DESCRIBE_TOOL_NAME,
|
|
1088
1342
|
title: "Describe the Relic client",
|
|
@@ -1093,21 +1347,22 @@ var DESCRIBE_TOOL_DEFINITION = {
|
|
|
1093
1347
|
additionalProperties: false
|
|
1094
1348
|
}
|
|
1095
1349
|
};
|
|
1096
|
-
var SERVER_VERSION = "0.3.
|
|
1350
|
+
var SERVER_VERSION = "0.3.3";
|
|
1097
1351
|
var SERVER_INFO = {
|
|
1098
1352
|
name: "relic",
|
|
1099
1353
|
title: "Relic",
|
|
1100
1354
|
version: SERVER_VERSION
|
|
1101
1355
|
};
|
|
1102
1356
|
var CAPABILITIES = { tools: {} };
|
|
1103
|
-
var INSTRUCTIONS = `Relic
|
|
1357
|
+
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.
|
|
1104
1358
|
|
|
1105
|
-
|
|
1359
|
+
Five things that change how you should act:
|
|
1106
1360
|
|
|
1107
1361
|
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.
|
|
1108
1362
|
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.
|
|
1109
|
-
3.
|
|
1110
|
-
4.
|
|
1363
|
+
3. Check existing sources with relic_lookup_source. Use relic_republish when found; relic_publish otherwise costs a second URL. ${VERSION_HISTORY_DISCLOSURE}
|
|
1364
|
+
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.
|
|
1365
|
+
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.`;
|
|
1111
1366
|
async function handleMessage(message, deps) {
|
|
1112
1367
|
if (message.id === undefined)
|
|
1113
1368
|
return;
|
|
@@ -1151,6 +1406,7 @@ async function handleMessage(message, deps) {
|
|
|
1151
1406
|
result: {
|
|
1152
1407
|
tools: [
|
|
1153
1408
|
TOOL_DEFINITION,
|
|
1409
|
+
LOOKUP_TOOL_DEFINITION,
|
|
1154
1410
|
REPUBLISH_TOOL_DEFINITION,
|
|
1155
1411
|
DESCRIBE_TOOL_DEFINITION
|
|
1156
1412
|
]
|
|
@@ -1181,13 +1437,16 @@ async function callTool(id2, params, deps) {
|
|
|
1181
1437
|
key_transmitted_to_service: false,
|
|
1182
1438
|
plaintext_transmitted_to_service: false,
|
|
1183
1439
|
ciphertext_destination: "object storage, via a signed URL",
|
|
1184
|
-
local_publish_state: "relic id, key, and publish token per relic, written 0600 under
|
|
1440
|
+
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",
|
|
1185
1441
|
service_origin: deps.serviceOrigin
|
|
1186
1442
|
},
|
|
1187
1443
|
isError: false
|
|
1188
1444
|
}
|
|
1189
1445
|
};
|
|
1190
1446
|
}
|
|
1447
|
+
if (params["name"] === LOOKUP_TOOL_NAME) {
|
|
1448
|
+
return callLookup(id2, params, deps);
|
|
1449
|
+
}
|
|
1191
1450
|
if (params["name"] === REPUBLISH_TOOL_NAME) {
|
|
1192
1451
|
return callRepublish(id2, params, deps);
|
|
1193
1452
|
}
|
|
@@ -1204,8 +1463,17 @@ async function callTool(id2, params, deps) {
|
|
|
1204
1463
|
if (!ttlDays.ok) {
|
|
1205
1464
|
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");
|
|
1206
1465
|
}
|
|
1466
|
+
const forceNew = args["force_new"];
|
|
1467
|
+
if (forceNew !== undefined && typeof forceNew !== "boolean") {
|
|
1468
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`force_new` must be a boolean or omitted");
|
|
1469
|
+
}
|
|
1207
1470
|
try {
|
|
1208
|
-
const result = await publish({
|
|
1471
|
+
const result = await publish({
|
|
1472
|
+
path,
|
|
1473
|
+
filename,
|
|
1474
|
+
ttl_days: ttlDays.days,
|
|
1475
|
+
force_new: forceNew === true
|
|
1476
|
+
}, deps);
|
|
1209
1477
|
return {
|
|
1210
1478
|
jsonrpc: "2.0",
|
|
1211
1479
|
id: id2,
|
|
@@ -1219,7 +1487,8 @@ async function callTool(id2, params, deps) {
|
|
|
1219
1487
|
|
|
1220
1488
|
` + (result.relic_expires_at === null ? "It does not expire; it is kept until it is deleted. " : `Expires ${result.relic_expires_at}. `) + "Anyone with this link, " + "including its fragment, can read the file. The key is in the " + "fragment and it is now in this transcript. This machine can " + `republish it later; the link will not change.
|
|
1221
1489
|
` + isolationNote(result.renderer_class) + `What Relic knows: ${result.disclosure_url}`
|
|
1222
|
-
}
|
|
1490
|
+
},
|
|
1491
|
+
{ type: "text", text: VERSION_HISTORY_DISCLOSURE }
|
|
1223
1492
|
],
|
|
1224
1493
|
structuredContent: result,
|
|
1225
1494
|
isError: false
|
|
@@ -1229,6 +1498,44 @@ async function callTool(id2, params, deps) {
|
|
|
1229
1498
|
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
1230
1499
|
}
|
|
1231
1500
|
}
|
|
1501
|
+
async function callLookup(id2, params, deps) {
|
|
1502
|
+
const args = params["arguments"] ?? {};
|
|
1503
|
+
const path = args["path"];
|
|
1504
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
1505
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
|
|
1506
|
+
}
|
|
1507
|
+
try {
|
|
1508
|
+
const lookup = await lookupPublishedSource(path, deps);
|
|
1509
|
+
const match = lookup.match;
|
|
1510
|
+
const republishCall = match === undefined ? null : republishToolCall(match.relic_id, lookup.resolved_path);
|
|
1511
|
+
const structuredContent = {
|
|
1512
|
+
found: match !== undefined,
|
|
1513
|
+
relic_id: match?.relic_id ?? null,
|
|
1514
|
+
version: match?.version ?? null,
|
|
1515
|
+
resolved_path: lookup.resolved_path,
|
|
1516
|
+
source_identity: lookup.source.identity,
|
|
1517
|
+
source_description: lookup.source.description,
|
|
1518
|
+
republish_call: republishCall
|
|
1519
|
+
};
|
|
1520
|
+
return {
|
|
1521
|
+
jsonrpc: "2.0",
|
|
1522
|
+
id: id2,
|
|
1523
|
+
result: {
|
|
1524
|
+
content: [
|
|
1525
|
+
{
|
|
1526
|
+
type: "text",
|
|
1527
|
+
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}.
|
|
1528
|
+
` + `Call relic_republish(${JSON.stringify(republishCall?.arguments)}).`
|
|
1529
|
+
}
|
|
1530
|
+
],
|
|
1531
|
+
structuredContent,
|
|
1532
|
+
isError: false
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1232
1539
|
async function callRepublish(id2, params, deps) {
|
|
1233
1540
|
const args = params["arguments"] ?? {};
|
|
1234
1541
|
const relicId = args["relic_id"];
|
|
@@ -1259,7 +1566,8 @@ async function callRepublish(id2, params, deps) {
|
|
|
1259
1566
|
|
|
1260
1567
|
` + (result.relic_expires_at === null ? "The relic does not expire; it is kept until it is deleted." : `Expires ${result.relic_expires_at}.`) + `
|
|
1261
1568
|
` + `What Relic knows: ${result.disclosure_url}`
|
|
1262
|
-
}
|
|
1569
|
+
},
|
|
1570
|
+
{ type: "text", text: VERSION_HISTORY_DISCLOSURE }
|
|
1263
1571
|
],
|
|
1264
1572
|
structuredContent: result,
|
|
1265
1573
|
isError: false
|
|
@@ -1333,13 +1641,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
|
|
|
1333
1641
|
what coarse class it was declared as, the publishing IP, and when it was
|
|
1334
1642
|
fetched. Never the contents, and never the key.
|
|
1335
1643
|
|
|
1336
|
-
What this keeps on disk: for each relic you publish, its id,
|
|
1337
|
-
publish token, in a 0600 file under your user config directory.
|
|
1338
|
-
|
|
1644
|
+
What this keeps on disk: for each relic you publish, its id, source identity,
|
|
1645
|
+
key, and publish token, in a 0600 file under your user config directory. The
|
|
1646
|
+
source index lets a fresh session find the id for relic_republish. The key and
|
|
1647
|
+
token let that republish keep the same URL, and they are why republishing works
|
|
1339
1648
|
only on the machine that published. The token's SHA-256 is the only copy the
|
|
1340
|
-
service ever holds, and neither secret is ever printed or logged. Deleting
|
|
1341
|
-
|
|
1342
|
-
|
|
1649
|
+
service ever holds, and neither secret is ever printed or logged. Deleting the
|
|
1650
|
+
file changes nothing for existing links; it only ends this machine's ability
|
|
1651
|
+
to update those relics.
|
|
1343
1652
|
|
|
1344
1653
|
What this does NOT protect against: the key is returned to your agent in the
|
|
1345
1654
|
URL, so it enters the model's context and your session transcript. That is
|
|
@@ -1450,8 +1759,8 @@ function createHttpHandler(deps, options = {}) {
|
|
|
1450
1759
|
import { spawnSync } from "node:child_process";
|
|
1451
1760
|
import { copyFile, mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1452
1761
|
import { homedir as homedir2 } from "node:os";
|
|
1453
|
-
import { dirname as dirname2, join as join2, resolve } from "node:path";
|
|
1454
|
-
import { fileURLToPath } from "node:url";
|
|
1762
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
1763
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1455
1764
|
|
|
1456
1765
|
// src/install.ts
|
|
1457
1766
|
var HARNESSES = [
|
|
@@ -1679,7 +1988,7 @@ function parseArgs(argv) {
|
|
|
1679
1988
|
return options;
|
|
1680
1989
|
}
|
|
1681
1990
|
function packageRoot() {
|
|
1682
|
-
return
|
|
1991
|
+
return resolve2(dirname2(fileURLToPath2(import.meta.url)), "..");
|
|
1683
1992
|
}
|
|
1684
1993
|
async function exists(path) {
|
|
1685
1994
|
return readFile3(path).then(() => true).catch(() => false);
|
package/package.json
CHANGED
package/skills/relic/SKILL.md
CHANGED
|
@@ -24,16 +24,39 @@ 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
|
+
|
|
42
|
+
Anyone holding a relic's link can fetch every version it has ever held, so
|
|
43
|
+
republishing does not withdraw earlier content. Republishing moves the artifact
|
|
44
|
+
forward without retracting what came before. Deleting the relic still removes
|
|
45
|
+
every version.
|
|
46
|
+
|
|
27
47
|
Optional arguments worth knowing:
|
|
28
48
|
|
|
29
49
|
- `filename` overrides the display name shown to the recipient.
|
|
30
50
|
- `ttl_days` gives the link a lifetime in days, 1 to 3650. A relic is kept
|
|
31
51
|
until it is deleted unless you set one. Shorter is better for anything
|
|
32
52
|
sensitive: when the content should stop being available, say when.
|
|
53
|
+
- `force_new` deliberately creates a separate relic from a source this machine
|
|
54
|
+
already published. Use it only when two independent URLs are the goal, never
|
|
55
|
+
to get past the update refusal.
|
|
33
56
|
|
|
34
|
-
The result reports the relic as version 1 and its id.
|
|
35
|
-
|
|
36
|
-
`
|
|
57
|
+
The result reports the relic as version 1 and its id. The client records that
|
|
58
|
+
id with the source locally, so a later session can recover it with
|
|
59
|
+
`relic_lookup_source`.
|
|
37
60
|
|
|
38
61
|
## Republishing
|
|
39
62
|
|