Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf7b2344f | ||
|
|
3793175894 | ||
|
|
b70f246622 | ||
|
|
cdbdb47474 | ||
|
|
689e61081d | ||
|
|
c83f48b821 | ||
|
|
db5e6c5b48 | ||
|
|
b9dcf19c3f | ||
|
|
e3a714d5f0 | ||
|
|
08cec8cb1c | ||
|
|
e4cbffbaa9 | ||
|
|
f178aab641 |
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
|
||||
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
|
||||
// adopt call.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
|
||||
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
|
||||
func suggestedCall(t *testing.T, msg string) map[string]any {
|
||||
t.Helper()
|
||||
i := strings.Index(msg, "claude_code_wait ")
|
||||
if i < 0 {
|
||||
t.Fatalf("no suggested call in: %s", msg)
|
||||
}
|
||||
rest := msg[i+len("claude_code_wait "):]
|
||||
start := strings.Index(rest, "{")
|
||||
if start < 0 {
|
||||
t.Fatalf("no JSON object in: %s", msg)
|
||||
}
|
||||
depth, end := 0, -1
|
||||
for j, c := range rest[start:] {
|
||||
switch c {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = start + j + 1
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
t.Fatalf("unbalanced JSON object in: %s", msg)
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
|
||||
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
|
||||
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
|
||||
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
clock := t0
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
|
||||
// regression here is caught the same way as the Stop hook's.
|
||||
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
||||
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
|
||||
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
||||
}
|
||||
args := suggestedCall(t, msg)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,12 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
|
||||
"%s, and mark it watched: claude_code_wait "+
|
||||
"{\"op\":\"watched\",\"session\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address, watcherAdvice(w),
|
||||
session, w.ID)
|
||||
"{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address,
|
||||
watcherAdvice(w), session, w.By, w.ID)
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
|
||||
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
|
||||
"{\"op\":\"said\",\"session\":%q,\"id\":%q}.", w.ID, w.What, session, w.ID)
|
||||
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
|
||||
}
|
||||
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
|
||||
@@ -113,15 +113,34 @@ func readRefusalCounters(waitsDir, session string) map[string]int {
|
||||
return m
|
||||
}
|
||||
|
||||
// writeRefusalCounters writes the counter file beside the waits file. Its caller decides what an error here
|
||||
// means: harmless when the stop is already allowed, but when the stop was about to be refused, a cap that
|
||||
// cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1 item 2).
|
||||
// writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every
|
||||
// other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which
|
||||
// already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as
|
||||
// before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined
|
||||
// node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an
|
||||
// error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a
|
||||
// cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1
|
||||
// item 2).
|
||||
//
|
||||
// Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record —
|
||||
// every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first
|
||||
// time, the waits folder itself; removing a file that was never there is not an error.
|
||||
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
|
||||
path := refusalCountersPath(waitsDir, session)
|
||||
if len(m) == 0 {
|
||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(refusalCountersPath(waitsDir, session), raw, 0o600)
|
||||
if err := fsys.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.WriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
|
||||
|
||||
@@ -170,13 +170,72 @@ func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
|
||||
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-6"))
|
||||
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
|
||||
// 1749, follow-up).
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
|
||||
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
|
||||
// every session.
|
||||
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
|
||||
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("a stop with no waits created the waits folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
|
||||
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var counters map[string]int
|
||||
if json.Unmarshal(raw, &counters) != nil || len(counters) != 0 {
|
||||
t.Fatalf("the counter file was not reset: %s", raw)
|
||||
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
|
||||
t.Fatalf("the counter file: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
|
||||
// counter file rather than leaving a stale "{}" behind.
|
||||
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
path := filepath.Join(waitsDir, "sess-8.json")
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
|
||||
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
|
||||
t.Fatalf("the counter file was not written: %v", err)
|
||||
}
|
||||
|
||||
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
|
||||
var stdout2, stderr2 bytes.Buffer
|
||||
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
|
||||
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,3 +332,31 @@ func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
|
||||
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
|
||||
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
|
||||
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
|
||||
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
|
||||
// filesystem only through the helper's request/response protocol bumps its count.
|
||||
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
was := fsys
|
||||
fsys = f
|
||||
t.Cleanup(func() { fsys = was })
|
||||
|
||||
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
|
||||
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counted.asked.Load() == 0 {
|
||||
t.Fatal("the counter file was written directly, never through the helper")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
|
||||
t.Fatalf("the written counters: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,3 +703,23 @@ func TestNatsResticsFilesFillFromTheBackupPlacement(t *testing.T) {
|
||||
b.assign(t, "restic", plan("# a\n/srv/a\n", "# a\nstate\n"))
|
||||
eventually(t, "a module taken away refills backups.conf", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n" })
|
||||
}
|
||||
|
||||
// defaults writes the defaults a module's manifest declares as mesh-config resolves them (source default), for a
|
||||
// moved module whose templates read config values (issue 1780).
|
||||
func (b *aBus) defaults(t *testing.T, module string, manifest []byte) {
|
||||
t.Helper()
|
||||
var m struct {
|
||||
Config map[string]struct {
|
||||
Default json.RawMessage `json:"default"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, d := range m.Config {
|
||||
if len(d.Default) > 0 && string(d.Default) != "null" {
|
||||
b.put(t, ConfigBucket, node+"."+module+"."+key, fmt.Sprintf(`{"node":%q,"module":%q,"key":%q,"value":%s,`+
|
||||
`"source":"default"}`, node, module, key, d.Default))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ func TestNatsTheEnvironmentOfTheCataloguesModulesIsWhatTheOldFormComposed(t *tes
|
||||
if !manifest.Moved || len(manifest.Items) == 0 {
|
||||
t.Fatalf("%s is not a moved contributor of items", m)
|
||||
}
|
||||
b.defaults(t, m, read(m+"/module.json"))
|
||||
texts := map[string]string{}
|
||||
var planned []PlannedItem
|
||||
for _, it := range manifest.Items {
|
||||
|
||||
@@ -63,6 +63,7 @@ func TestNatsTheSessionFilesFilledFromTheirContributorsItemsAreWhatTheOldFormCom
|
||||
if !c.Moved || len(c.Shell) != 0 {
|
||||
t.Fatalf("%s is not moved or still holds shell entries", m)
|
||||
}
|
||||
b.defaults(t, m, []byte(read(m+"/module.json")))
|
||||
texts := map[string]string{}
|
||||
var planned []PlannedItem
|
||||
for _, it := range c.Items {
|
||||
|
||||
+36
-5
@@ -256,11 +256,22 @@ export class MinioClient {
|
||||
if (argv.some((a) => a.includes(this.rootPassword))) {
|
||||
throw new Error("refused to run mc: its command line would carry the root password (novox/hq issue 282)");
|
||||
}
|
||||
const { stdout } = await execFileAsync(this.mcBin, argv, {
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, MC_HOST_mesh: mcHost(this.baseUrl, this.rootUser, this.rootPassword) },
|
||||
});
|
||||
return stdout.trim();
|
||||
const mcHostValue = mcHost(this.baseUrl, this.rootUser, this.rootPassword);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(this.mcBin, argv, {
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, MC_HOST_mesh: mcHostValue },
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
// A fresh error with only the redacted message: execFile's own carries the whole command line, and
|
||||
// its cmd, stdout and stderr properties would carry it on (issue 1766).
|
||||
const known = [mcHostValue, this.rootPassword, encodeURIComponent(this.rootPassword), ...secretArgs(argv)];
|
||||
const e = err as { stderr?: unknown; message?: string };
|
||||
const reason = typeof e.stderr === "string" && e.stderr.trim() !== "" ? e.stderr.trim() : (e.message ?? String(err));
|
||||
const shown = argv.filter((a) => a !== this.mcConfigDir).join(" ");
|
||||
throw new Error(redactSecrets(`mc ${shown} failed: ${reason}`, known));
|
||||
}
|
||||
}
|
||||
|
||||
// --- SigV4 request plumbing ---------------------------------------------
|
||||
@@ -360,6 +371,26 @@ function uriEncode(str: string, encodeSlash: boolean): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The values that follow --secret-key and --access-key in an argument list. */
|
||||
function secretArgs(argv: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
argv.forEach((a, i) => { if ((a === "--secret-key" || a === "--access-key") && i + 1 < argv.length) out.push(argv[i + 1]); });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one redaction every `mc` failure goes through (issue 1766): the token after --secret-key or
|
||||
* --access-key (as a separate word or after "="), any MC_HOST_ value, and each known secret, wherever they
|
||||
* appear in the text, become "[redacted]".
|
||||
*/
|
||||
export function redactSecrets(text: string, known: string[] = []): string {
|
||||
let out = text
|
||||
.replace(/(--(?:secret|access)-key(?:=|\s+))('[^']*'|"[^"]*"|\S+)/g, "$1[redacted]")
|
||||
.replace(/(MC_HOST_\w+=)\S+/g, "$1[redacted]");
|
||||
for (const k of known) if (k !== "") out = out.split(k).join("[redacted]");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MC_HOST_<alias> URL `mc` reads an alias from: the endpoint with the credentials in its userinfo,
|
||||
* each percent-encoded so a reserved character in either cannot end it early.
|
||||
|
||||
@@ -36,6 +36,9 @@ export function linkPolicy(bucket: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** A link key's secret: 40 hex characters, minio's limit for a secret key (issue 1766). */
|
||||
const LINK_SECRET = /^[0-9a-f]{40}$/;
|
||||
|
||||
export function linkSecretPath(dir: string, bucket: string): string {
|
||||
return join(dir, `${bucket}.secret`);
|
||||
}
|
||||
@@ -47,9 +50,12 @@ export async function ensureLinkKey(admin: LinkAdmin, dir: string, bucket: strin
|
||||
const path = linkSecretPath(dir, bucket);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
let secret = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
|
||||
// A stored secret of the wrong shape (an earlier version made 64 characters; minio takes at most 40) is
|
||||
// replaced, and its key made again (issue 1766).
|
||||
if (!LINK_SECRET.test(secret)) secret = "";
|
||||
if (secret !== "" && (await admin.accessKeyExists(id))) return;
|
||||
if (secret === "") {
|
||||
secret = randomBytes(32).toString("hex");
|
||||
secret = randomBytes(20).toString("hex");
|
||||
writeFileSync(path, secret + "\n", { mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
@@ -115,3 +115,58 @@ test("the link key id is the one the object-store tool derives (cmd/object-store
|
||||
// 1516/AC1
|
||||
assert.equal(linkKeyId("brother"), "link0b4bd77cec705cf5");
|
||||
});
|
||||
|
||||
// Issue 1766: a link key's secret is minio's length, and no mc failure carries an argument's secret.
|
||||
|
||||
import { chmodSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { MinioClient } from "../client.ts";
|
||||
|
||||
test("a link key's secret is 40 characters, and a stored 64-character secret is replaced", async () => {
|
||||
// 1766/AC1
|
||||
const { minio, dir } = setup();
|
||||
await ensureLinkKey(minio, dir, "fresh");
|
||||
assert.equal(minio.keys.get(linkKeyId("fresh"))!.secret.length, 40);
|
||||
assert.match(minio.keys.get(linkKeyId("fresh"))!.secret, /^[0-9a-f]{40}$/);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const old = "ab".repeat(32);
|
||||
writeFileSync(linkSecretPath(dir, "old"), old + "\n");
|
||||
await ensureLinkKey(minio, dir, "old");
|
||||
const made = minio.keys.get(linkKeyId("old"))!.secret;
|
||||
assert.equal(made.length, 40);
|
||||
assert.notEqual(made, old);
|
||||
assert.equal(readFileSync(linkSecretPath(dir, "old"), "utf8").trim(), made);
|
||||
});
|
||||
|
||||
function failingMc(): { client: MinioClient; root: string } {
|
||||
const d = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "minio-mc-"));
|
||||
const bin = join(d, "mc");
|
||||
// Says its arguments and its alias on stderr, then fails, the way a refused command does.
|
||||
writeFileSync(bin, '#!/bin/sh\necho "args: $*" >&2\necho "env: MC_HOST_mesh=$MC_HOST_mesh" >&2\nexit 1\n');
|
||||
chmodSync(bin, 0o755);
|
||||
const root = "RootPass-s3cret/ok";
|
||||
const client = new MinioClient({ endpoint: "http://127.0.0.1:9000", rootUser: "root", rootPassword: root, region: "us-east-1", mcBin: bin, mcConfigDir: d });
|
||||
return { client, root };
|
||||
}
|
||||
|
||||
test("an mc failure's message holds no --secret-key value and no MC_HOST value", async () => {
|
||||
// 1766/AC2
|
||||
const { client, root } = failingMc();
|
||||
const secret = "SuperSecretValue0123456789";
|
||||
const err: any = await client.createAccessKey("b", "ACCESSKEYID1234567", secret).then(() => null, (e) => e);
|
||||
assert.ok(err, "the failure is raised");
|
||||
for (const text of [String(err.message), String(err.stack ?? ""), JSON.stringify(Object.getOwnPropertyNames(err).map((k) => err[k]))]) {
|
||||
assert.ok(!text.includes(secret), "no secret key");
|
||||
assert.ok(!text.includes("ACCESSKEYID1234567"), "no access key");
|
||||
assert.ok(!text.includes(root) && !text.includes(encodeURIComponent(root)), "no root password");
|
||||
assert.ok(!text.includes("root:"), "no MC_HOST value");
|
||||
}
|
||||
assert.match(err.message, /\[redacted\]/);
|
||||
});
|
||||
|
||||
test("a consumer key's create passes the minted secret unchanged", async () => {
|
||||
// 1766/AC3
|
||||
const s = setup();
|
||||
await s.h.create(provision);
|
||||
assert.equal(s.minio.keys.get("brother-key")!.secret, "consumer-secret");
|
||||
});
|
||||
|
||||
+12
-5
@@ -21,17 +21,24 @@ They are those both workstations ran, with three changes:
|
||||
- **scrollback:** `saveLines` 10000 instead of 2000.
|
||||
|
||||
The palette (background, foreground and the sixteen colours) is today's, unchanged. So are the
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it. The bundle's
|
||||
`Resources()` is the one source of these values. The manifest test holds the manifest to it, and
|
||||
`xterm_colours` compares against it.
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it.
|
||||
|
||||
Every one of these values is a **config value** (ADR 0348; issue 1656) with today's value as its default:
|
||||
`face`, `size` (the predefined `font-size`, 6 to 72), `save-lines`, `term-name`, `render-font`, `bold-mode`,
|
||||
`always-bold-mode`, `allow-bold-fonts`, `background`, `foreground`, `color0` to `color15` (each `#rrggbb`),
|
||||
and the two clipboard bindings `copy-binding` and `paste-binding`. `files/xresources-normal` holds only
|
||||
`${config:<key>}` placeholders, so a machine changes its font size through mesh-config. The manifest test holds
|
||||
the file to the keys, and the file filled with the defaults to the text every machine had before.
|
||||
The tools keep no copy of the values: `xterm_font` and `xterm_colours` read the `XTerm*` lines of the rendered
|
||||
resources file `xorg` writes (`~/.config/xorg/xresources`) as the module's value, and say so when it is not there.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-terminal-emulator.open` | d | a terminal in the session: the login shell, or a command run through it (`$SHELL -lc`), in a directory (the home by default), with an optional title, and `hold` to keep it open. It starts as a transient unit of the account's service manager (`systemd-run --user`), so it outlives the call and every runtime restart |
|
||||
| `xterm_font` | r/a | the face and size in force and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's default |
|
||||
| `xterm_font` | r/a | the face and size in force beside the module's value (from the rendered resources), and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's value from the rendered resources |
|
||||
|
||||
## What it leaves found
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
// todaysResources is the file every machine had before the module took config values (novox/hq issue 1656).
|
||||
var todaysResources = strings.Join([]string{
|
||||
"! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they",
|
||||
"! outrank any `*name` resource of yours and touch no other X program.",
|
||||
"XTerm*termName: xterm-256color",
|
||||
"XTerm*saveLines: 10000",
|
||||
"XTerm*renderFont: true",
|
||||
"XTerm*faceName: JetBrainsMono Nerd Font",
|
||||
"XTerm*faceSize: 12",
|
||||
"XTerm*boldMode: false",
|
||||
"XTerm*alwaysBoldMode: false",
|
||||
"XTerm*allowBoldFonts: true",
|
||||
"XTerm*background: #000000",
|
||||
"XTerm*foreground: #a8a8a8",
|
||||
"XTerm*color0: #000000",
|
||||
"XTerm*color8: #666666",
|
||||
"XTerm*color1: #9e1828",
|
||||
"XTerm*color9: #bc5766",
|
||||
"XTerm*color2: #008800",
|
||||
"XTerm*color10: #61a171",
|
||||
"XTerm*color3: #d2bb4b",
|
||||
"XTerm*color11: #e7db52",
|
||||
"XTerm*color4: #414171",
|
||||
"XTerm*color12: #5085af",
|
||||
"XTerm*color5: #963c59",
|
||||
"XTerm*color13: #a97a99",
|
||||
"XTerm*color6: #418179",
|
||||
"XTerm*color14: #6ba4a4",
|
||||
"XTerm*color7: #bebebe",
|
||||
"XTerm*color15: #ffffff",
|
||||
"XTerm*VT100.Translations: #override \\n\\",
|
||||
" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\",
|
||||
" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
var placeholder = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
|
||||
// asText is a default as the controller writes a value into a file: a string as it is, a number without a
|
||||
// needless fraction, a boolean as true or false.
|
||||
func asText(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
}
|
||||
t.Fatalf("a default of no type: %v", v)
|
||||
return ""
|
||||
}
|
||||
|
||||
func template(t *testing.T) string {
|
||||
t.Helper()
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
return m.Shell[0].Code
|
||||
}
|
||||
|
||||
// 1656/AC1: every value the resources set is a config value with today's value as its default.
|
||||
func TestEveryResourceValueIsAConfigValueWithTodaysDefault(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
want := map[string]any{
|
||||
"face": "JetBrainsMono Nerd Font", "size": 12.0, "save-lines": 10000.0, "term-name": "xterm-256color",
|
||||
"render-font": true, "bold-mode": false, "always-bold-mode": false, "allow-bold-fonts": true,
|
||||
"background": "#000000", "foreground": "#a8a8a8",
|
||||
"color0": "#000000", "color1": "#9e1828", "color2": "#008800", "color3": "#d2bb4b",
|
||||
"color4": "#414171", "color5": "#963c59", "color6": "#418179", "color7": "#bebebe",
|
||||
"color8": "#666666", "color9": "#bc5766", "color10": "#61a171", "color11": "#e7db52",
|
||||
"color12": "#5085af", "color13": "#a97a99", "color14": "#6ba4a4", "color15": "#ffffff",
|
||||
"copy-binding": "Ctrl Shift ~Alt <Key> C", "paste-binding": "Ctrl Shift ~Alt <Key> V",
|
||||
}
|
||||
if len(m.Config) != len(want) {
|
||||
t.Errorf("declares %d config values, want %d", len(m.Config), len(want))
|
||||
}
|
||||
for key, def := range want {
|
||||
c, ok := m.Config[key]
|
||||
if !ok {
|
||||
t.Errorf("%s: not declared", key)
|
||||
continue
|
||||
}
|
||||
if c.Default != def || c.Required || strings.TrimSpace(c.Description) == "" {
|
||||
t.Errorf("%s: %+v, want default %v and a description", key, c, def)
|
||||
}
|
||||
wantType := map[bool]string{true: "string"}[true]
|
||||
switch def.(type) {
|
||||
case float64:
|
||||
wantType = "number"
|
||||
case bool:
|
||||
wantType = "boolean"
|
||||
}
|
||||
if c.Type != wantType {
|
||||
t.Errorf("%s: type %q, want %q", key, c.Type, wantType)
|
||||
}
|
||||
}
|
||||
size := m.Config["size"]
|
||||
if size.Predefined != "font-size" || size.Minimum == nil || *size.Minimum != 6 || size.Maximum == nil || *size.Maximum != 72 {
|
||||
t.Errorf("size: %+v, want the predefined font-size from 6 to 72", size)
|
||||
}
|
||||
names := []string{"background", "foreground"}
|
||||
for i := 0; i < 16; i++ {
|
||||
names = append(names, "color"+strconv.Itoa(i))
|
||||
}
|
||||
for _, key := range names {
|
||||
c := m.Config[key]
|
||||
re, err := regexp.Compile("^(?:" + c.Pattern + ")$")
|
||||
if err != nil || c.Pattern == "" {
|
||||
t.Errorf("%s: pattern %q", key, c.Pattern)
|
||||
continue
|
||||
}
|
||||
if !re.MatchString("#0a1B2c") || re.MatchString("red") || re.MatchString("#12345") || re.MatchString("#1234567") {
|
||||
t.Errorf("%s: pattern %q is not #rrggbb", key, c.Pattern)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"copy-binding", "paste-binding"} {
|
||||
re := regexp.MustCompile("^(?:" + m.Config[key].Pattern + ")$")
|
||||
if !re.MatchString("Ctrl Shift ~Alt <Key> C") || re.MatchString("a: b") || re.MatchString("a\nb") || re.MatchString(`a\`) {
|
||||
t.Errorf("%s: pattern %q lets a value end the resource", key, m.Config[key].Pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC2: no literal value is left in the resources, and every declared key is used.
|
||||
func TestEveryResourceValueIsAPlaceholderOfADeclaredKeyAndEveryKeyIsUsed(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
text := template(t)
|
||||
used := map[string]bool{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
switch {
|
||||
case l == "", strings.HasPrefix(l, "!"):
|
||||
continue
|
||||
case strings.HasPrefix(l, "XTerm*VT100.Translations: #override"):
|
||||
continue
|
||||
}
|
||||
_, value, ok := strings.Cut(l, ": ")
|
||||
if strings.HasPrefix(l, " ") {
|
||||
// a translation line: `<binding>: <action> \n\`
|
||||
value, _, _ = strings.Cut(strings.TrimSpace(l), ": ")
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("not a resource: %q", l)
|
||||
continue
|
||||
}
|
||||
f := placeholder.FindStringSubmatch(value)
|
||||
if f == nil || f[0] != value {
|
||||
t.Errorf("a literal value, not a ${config:<key>}: %q", l)
|
||||
continue
|
||||
}
|
||||
if _, declared := m.Config[f[1]]; !declared {
|
||||
t.Errorf("%q names a key the module does not declare", l)
|
||||
}
|
||||
used[f[1]] = true
|
||||
}
|
||||
for key := range m.Config {
|
||||
if !used[key] {
|
||||
t.Errorf("%s is declared and no resource uses it", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC3: filled with the defaults, the resources are the file every machine had.
|
||||
func TestFilledWithTheDefaultsTheResourcesAreTodaysFileByteForByte(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
got := placeholder.ReplaceAllStringFunc(template(t), func(ref string) string {
|
||||
c, ok := m.Config[placeholder.FindStringSubmatch(ref)[1]]
|
||||
if !ok {
|
||||
t.Fatalf("%s: no such key", ref)
|
||||
}
|
||||
return asText(t, c.Default)
|
||||
})
|
||||
if got != todaysResources {
|
||||
t.Fatalf("filled with the defaults:\n%s\nwant:\n%s", got, todaysResources)
|
||||
}
|
||||
}
|
||||
|
||||
// renderedWith is the resources file xorg writes with this module's piece filled from the defaults, and
|
||||
// the given values changed.
|
||||
func renderedWith(t *testing.T, changed map[string]string) string {
|
||||
t.Helper()
|
||||
m := readManifest(t)
|
||||
text := placeholder.ReplaceAllStringFunc(template(t), func(ref string) string {
|
||||
key := placeholder.FindStringSubmatch(ref)[1]
|
||||
if v, ok := changed[key]; ok {
|
||||
return v
|
||||
}
|
||||
return asText(t, m.Config[key].Default)
|
||||
})
|
||||
return "! The mesh's X resources (module xorg)\nXft.dpi: 96\n# xterm\n" + text + "Xft.rgba: rgb\n"
|
||||
}
|
||||
|
||||
func deskWithResources(t *testing.T, rendered string, f *fake) xterm {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "xresources")
|
||||
if rendered != "" {
|
||||
if err := os.WriteFile(path, []byte(rendered), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return xterm{d: f.desk(true), resources: path}
|
||||
}
|
||||
|
||||
// 1656/AC4: the tools' defaults are what the module's resources were rendered with, not constants.
|
||||
func TestFontReportsTheDefaultTheModuleRenderedNotAConstant(t *testing.T) {
|
||||
f := &fake{query: "XTerm*faceName:\tInter\nXTerm*faceSize:\t13\n"}
|
||||
x := deskWithResources(t, renderedWith(t, map[string]string{"size": "10", "face": "Fira Code"}), f)
|
||||
got, err := x.font(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j := asJSON(got); !strings.Contains(j, `"default":{"face":"Fira Code","size":10}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4
|
||||
func TestColoursReportTheDefaultTheModuleRenderedNotAConstant(t *testing.T) {
|
||||
f := &fake{query: "XTerm*color1:\t#aa0000\nXTerm*foreground:\t#112233\nXTerm*color2:\trgb:00/88/01\n"}
|
||||
x := deskWithResources(t, renderedWith(t, map[string]string{"color1": "#aa0000", "foreground": "#112233"}), f)
|
||||
got, err := x.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := asJSON(got)
|
||||
for _, want := range []string{`{"name":"color1","value":"#aa0000","default":"#aa0000"}`,
|
||||
`{"name":"foreground","value":"#112233","default":"#112233"}`,
|
||||
`{"name":"color2","value":"#008801","default":"#008800","differs":true}`} {
|
||||
if !strings.Contains(j, want) {
|
||||
t.Errorf("lacks %s in %s", want, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4: without the rendered file there is no default to report, said loudly.
|
||||
func TestWithoutTheRenderedResourcesTheToolsSayWhy(t *testing.T) {
|
||||
x := deskWithResources(t, "", &fake{})
|
||||
if _, err := x.font(context.Background(), desktop.Args{}); err == nil || !strings.Contains(err.Error(), "xresources") {
|
||||
t.Fatalf("font: %v", err)
|
||||
}
|
||||
if _, err := x.colours(context.Background(), desktop.Args{}); err == nil || !strings.Contains(err.Error(), "xresources") {
|
||||
t.Fatalf("colours: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4: tools.go holds no copy of the defaults.
|
||||
func TestTheToolsHoldNoCopyOfTheDefaults(t *testing.T) {
|
||||
src, err := os.ReadFile("tools.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, lit := range []string{"JetBrainsMono", "#a8a8a8", "#9e1828", "xterm-256color", "Face =", "Size =", "func Resources"} {
|
||||
if strings.Contains(string(src), lit) {
|
||||
t.Errorf("tools.go holds %q", lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway")})); err != nil {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway"), resources: filepath.Join(desktop.Home(), ResourcesFile)})); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func tools(x xterm) []stdio.Tool {
|
||||
"resources set the default again at the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"face": desktop.Str("a font family, as fc-list names it"),
|
||||
"size": desktop.Num("points, 6 to 48"),
|
||||
"size": desktop.Num("points, 6 to 72"),
|
||||
}),
|
||||
Run: call(x.font),
|
||||
},
|
||||
|
||||
@@ -11,47 +11,50 @@ import (
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type xterm struct{ d desktop.Desk }
|
||||
|
||||
// The module's defaults: the face research 026/04 chose and both workstations' palette.
|
||||
const (
|
||||
Face = "JetBrainsMono Nerd Font"
|
||||
Size = 12
|
||||
)
|
||||
|
||||
// Palette is the colours both workstations ran, by resource name, in the order they are written.
|
||||
var Palette = [][2]string{
|
||||
{"background", "#000000"}, {"foreground", "#a8a8a8"},
|
||||
{"color0", "#000000"}, {"color8", "#666666"},
|
||||
{"color1", "#9e1828"}, {"color9", "#bc5766"},
|
||||
{"color2", "#008800"}, {"color10", "#61a171"},
|
||||
{"color3", "#d2bb4b"}, {"color11", "#e7db52"},
|
||||
{"color4", "#414171"}, {"color12", "#5085af"},
|
||||
{"color5", "#963c59"}, {"color13", "#a97a99"},
|
||||
{"color6", "#418179"}, {"color14", "#6ba4a4"},
|
||||
{"color7", "#bebebe"}, {"color15", "#ffffff"},
|
||||
type xterm struct {
|
||||
d desktop.Desk
|
||||
resources string
|
||||
}
|
||||
|
||||
// Resources is the module's contribution to the X resources (the xresources slot `normal`), as the
|
||||
// manifest carries it; the manifest test holds the two to one text.
|
||||
func Resources() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n")
|
||||
b.WriteString("! outrank any `*name` resource of yours and touch no other X program.\n")
|
||||
for _, kv := range [][2]string{
|
||||
{"termName", "xterm-256color"}, {"saveLines", "10000"}, {"renderFont", "true"},
|
||||
{"faceName", Face}, {"faceSize", strconv.Itoa(Size)},
|
||||
{"boldMode", "false"}, {"alwaysBoldMode", "false"}, {"allowBoldFonts", "true"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
// PaletteNames are the colour resources xterm_colours reports, in the order the resources write them.
|
||||
// Their values are the module's config values, read from the rendered resources (Rendered), never held here.
|
||||
var PaletteNames = []string{
|
||||
"background", "foreground",
|
||||
"color0", "color8", "color1", "color9", "color2", "color10", "color3", "color11",
|
||||
"color4", "color12", "color5", "color13", "color6", "color14", "color7", "color15",
|
||||
}
|
||||
|
||||
// ResourcesFile is where xorg writes the X resources, under the account's home: this module's resources are
|
||||
// its contribution to the `normal` slot, filled with the module's config values.
|
||||
const ResourcesFile = ".config/xorg/xresources"
|
||||
|
||||
// Rendered is the XTerm* resources of a rendered resources file, by name: `XTerm*faceSize: 12` is faceSize 12.
|
||||
// The translations, which span lines, and every resource of another program are left out.
|
||||
func Rendered(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
name, scoped := strings.CutPrefix(strings.TrimSpace(k), "XTerm*")
|
||||
if ok && scoped && !strings.Contains(name, ".") {
|
||||
out[name] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
for _, kv := range Palette {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
return out
|
||||
}
|
||||
|
||||
// defaults are the resources the module's config values rendered, as xorg placed them: what the module sets, so
|
||||
// what a terminal has unless the session's own resources say otherwise.
|
||||
func (x xterm) defaults() (map[string]string, error) {
|
||||
text, err := os.ReadFile(x.resources)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the module's resources are not where xorg places them (%s): is xorg assigned to this machine, "+
|
||||
"and has its xresources file been written? %w", x.resources, err)
|
||||
}
|
||||
b.WriteString("XTerm*VT100.Translations: #override \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n")
|
||||
return b.String()
|
||||
got := Rendered(string(text))
|
||||
if len(got) == 0 {
|
||||
return nil, fmt.Errorf("%s holds no XTerm* resource: the xterm module's resources are not in it", x.resources)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// openScript runs in the new terminal: into the directory, then the login shell, or a command
|
||||
@@ -124,6 +127,10 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
def, err := x.defaults()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
face := a.Opt("face", "")
|
||||
size, sized, err := a.Number("size")
|
||||
@@ -142,8 +149,8 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
res = append(res, "XTerm*faceName: "+face)
|
||||
}
|
||||
if sized {
|
||||
if size < 6 || size > 48 {
|
||||
return nil, fmt.Errorf("size is from 6 to 48 points")
|
||||
if size < 6 || size > 72 {
|
||||
return nil, fmt.Errorf("size is from 6 to 72 points")
|
||||
}
|
||||
res = append(res, "XTerm*faceSize: "+strconv.FormatFloat(size, 'f', -1, 64))
|
||||
}
|
||||
@@ -156,9 +163,13 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
return nil, q.Err()
|
||||
}
|
||||
inForce := Effective(q.Stdout, "faceName")
|
||||
var points any = def["faceSize"]
|
||||
if n, err := strconv.ParseFloat(def["faceSize"], 64); err == nil {
|
||||
points = n
|
||||
}
|
||||
answer := map[string]any{
|
||||
"session": s.Display, "face": inForce, "size": Effective(q.Stdout, "faceSize"),
|
||||
"default": map[string]any{"face": Face, "size": Size},
|
||||
"default": map[string]any{"face": def["faceName"], "size": points},
|
||||
}
|
||||
if inForce != "" {
|
||||
m := x.d.Run(ctx, env, nil, "fc-match", "-f", "%{family[0]}|%{style[0]}|%{file}", inForce)
|
||||
@@ -175,7 +186,7 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
|
||||
var rgbSpec = regexp.MustCompile(`^rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})$`)
|
||||
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#9e1828`) as #rrggbb; anything else (a colour name) as it is.
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#rrggbb`) as #rrggbb; anything else (a colour name) as it is.
|
||||
func Hex(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if m := rgbSpec.FindStringSubmatch(v); m != nil {
|
||||
@@ -191,6 +202,10 @@ func Hex(v string) string {
|
||||
}
|
||||
|
||||
func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
def, err := x.defaults()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, s, err := x.d.InSession(ctx, "xrdb", "-query")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -205,9 +220,9 @@ func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
Differs bool `json:"differs,omitempty"`
|
||||
}
|
||||
var out []colour
|
||||
for _, kv := range Palette {
|
||||
v := Hex(Effective(res.Stdout, kv[0]))
|
||||
out = append(out, colour{kv[0], v, kv[1], v != kv[1]})
|
||||
for _, name := range PaletteNames {
|
||||
v, d := Hex(Effective(res.Stdout, name)), Hex(def[name])
|
||||
out = append(out, colour{name, v, d, v != d})
|
||||
}
|
||||
cursor := Hex(Effective(res.Stdout, "cursorColor"))
|
||||
return map[string]any{"session": s.Display, "colours": out, "cursor": cursor}, nil
|
||||
|
||||
@@ -19,7 +19,8 @@ type manifest struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Tools []string `json:"tools"`
|
||||
Config map[string]configKey `json:"config"`
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
@@ -37,6 +38,18 @@ type manifest struct {
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type configKey struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Pattern string `json:"pattern"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
Predefined string `json:"predefined"`
|
||||
Default any `json:"default"`
|
||||
Required bool `json:"required"`
|
||||
Trusted bool `json:"trusted"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
@@ -99,10 +112,8 @@ func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xresources" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
if m.Shell[0].Code != Resources() {
|
||||
t.Fatal("the manifest's resources are not the ones the bundle describes: regenerate the manifest from Resources()")
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(Resources()), "\n") {
|
||||
code := m.Shell[0].Code
|
||||
for _, l := range strings.Split(strings.TrimSpace(code), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "!"), strings.HasPrefix(l, " "):
|
||||
case strings.HasPrefix(l, "#"):
|
||||
@@ -111,8 +122,8 @@ func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T
|
||||
t.Errorf("a resource of another program, or of every one: %q", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Resources(), "XTerm*faceName: JetBrainsMono Nerd Font") || !strings.Contains(Resources(), "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the chosen face and the clipboard keys")
|
||||
if !strings.Contains(code, "XTerm*faceName: ${config:face}") || !strings.Contains(code, "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the face and the clipboard keys")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +213,11 @@ func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
f := &fake{query: q}
|
||||
got, err := xterm{d: f.desk(true)}.colours(context.Background(), desktop.Args{})
|
||||
rendered := filepath.Join(t.TempDir(), "xresources")
|
||||
if err := os.WriteFile(rendered, []byte(renderedWith(t, nil)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := xterm{d: f.desk(true), resources: rendered}.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,14 +225,14 @@ func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
if !strings.Contains(j, `{"name":"color1","value":"#ff0000","default":"#9e1828","differs":true}`) || !strings.Contains(j, `{"name":"foreground","value":"#a8a8a8","default":"#a8a8a8"}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
if _, err := (xterm{d: f.desk(true), resources: rendered}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
t.Fatal("a face that is not installed")
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
if _, err := (xterm{d: f.desk(true), resources: rendered}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
t.Fatal("size out of range")
|
||||
}
|
||||
f.ran, f.stdin = nil, nil
|
||||
got, err = xterm{d: f.desk(true)}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
got, err = xterm{d: f.desk(true), resources: rendered}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they
|
||||
! outrank any `*name` resource of yours and touch no other X program.
|
||||
XTerm*termName: xterm-256color
|
||||
XTerm*saveLines: 10000
|
||||
XTerm*renderFont: true
|
||||
XTerm*faceName: JetBrainsMono Nerd Font
|
||||
XTerm*faceSize: 12
|
||||
XTerm*boldMode: false
|
||||
XTerm*alwaysBoldMode: false
|
||||
XTerm*allowBoldFonts: true
|
||||
XTerm*background: #000000
|
||||
XTerm*foreground: #a8a8a8
|
||||
XTerm*color0: #000000
|
||||
XTerm*color8: #666666
|
||||
XTerm*color1: #9e1828
|
||||
XTerm*color9: #bc5766
|
||||
XTerm*color2: #008800
|
||||
XTerm*color10: #61a171
|
||||
XTerm*color3: #d2bb4b
|
||||
XTerm*color11: #e7db52
|
||||
XTerm*color4: #414171
|
||||
XTerm*color12: #5085af
|
||||
XTerm*color5: #963c59
|
||||
XTerm*color13: #a97a99
|
||||
XTerm*color6: #418179
|
||||
XTerm*color14: #6ba4a4
|
||||
XTerm*color7: #bebebe
|
||||
XTerm*color15: #ffffff
|
||||
XTerm*termName: ${config:term-name}
|
||||
XTerm*saveLines: ${config:save-lines}
|
||||
XTerm*renderFont: ${config:render-font}
|
||||
XTerm*faceName: ${config:face}
|
||||
XTerm*faceSize: ${config:size}
|
||||
XTerm*boldMode: ${config:bold-mode}
|
||||
XTerm*alwaysBoldMode: ${config:always-bold-mode}
|
||||
XTerm*allowBoldFonts: ${config:allow-bold-fonts}
|
||||
XTerm*background: ${config:background}
|
||||
XTerm*foreground: ${config:foreground}
|
||||
XTerm*color0: ${config:color0}
|
||||
XTerm*color8: ${config:color8}
|
||||
XTerm*color1: ${config:color1}
|
||||
XTerm*color9: ${config:color9}
|
||||
XTerm*color2: ${config:color2}
|
||||
XTerm*color10: ${config:color10}
|
||||
XTerm*color3: ${config:color3}
|
||||
XTerm*color11: ${config:color11}
|
||||
XTerm*color4: ${config:color4}
|
||||
XTerm*color12: ${config:color12}
|
||||
XTerm*color5: ${config:color5}
|
||||
XTerm*color13: ${config:color13}
|
||||
XTerm*color6: ${config:color6}
|
||||
XTerm*color14: ${config:color14}
|
||||
XTerm*color7: ${config:color7}
|
||||
XTerm*color15: ${config:color15}
|
||||
XTerm*VT100.Translations: #override \n\
|
||||
Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \n\
|
||||
Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)
|
||||
${config:copy-binding}: copy-selection(CLIPBOARD) \n\
|
||||
${config:paste-binding}: insert-selection(CLIPBOARD)
|
||||
|
||||
@@ -21,6 +21,175 @@
|
||||
"xterm_font",
|
||||
"xterm_colours"
|
||||
],
|
||||
"config": {
|
||||
"face": {
|
||||
"type": "string",
|
||||
"description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).",
|
||||
"pattern": "[A-Za-z0-9 ._-]{1,64}",
|
||||
"default": "JetBrainsMono Nerd Font"
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
|
||||
"minimum": 6,
|
||||
"maximum": 72,
|
||||
"predefined": "font-size",
|
||||
"default": 12
|
||||
},
|
||||
"save-lines": {
|
||||
"type": "number",
|
||||
"description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.",
|
||||
"minimum": 0,
|
||||
"maximum": 1000000,
|
||||
"default": 10000
|
||||
},
|
||||
"term-name": {
|
||||
"type": "string",
|
||||
"description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.",
|
||||
"pattern": "[A-Za-z0-9._-]{1,64}",
|
||||
"default": "xterm-256color"
|
||||
},
|
||||
"render-font": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.",
|
||||
"default": true
|
||||
},
|
||||
"bold-mode": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.",
|
||||
"default": false
|
||||
},
|
||||
"always-bold-mode": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.",
|
||||
"default": false
|
||||
},
|
||||
"allow-bold-fonts": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"type": "string",
|
||||
"description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#000000"
|
||||
},
|
||||
"foreground": {
|
||||
"type": "string",
|
||||
"description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#a8a8a8"
|
||||
},
|
||||
"color0": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#000000"
|
||||
},
|
||||
"color1": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#9e1828"
|
||||
},
|
||||
"color2": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#008800"
|
||||
},
|
||||
"color3": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#d2bb4b"
|
||||
},
|
||||
"color4": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#414171"
|
||||
},
|
||||
"color5": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#963c59"
|
||||
},
|
||||
"color6": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#418179"
|
||||
},
|
||||
"color7": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#bebebe"
|
||||
},
|
||||
"color8": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#666666"
|
||||
},
|
||||
"color9": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#bc5766"
|
||||
},
|
||||
"color10": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#61a171"
|
||||
},
|
||||
"color11": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#e7db52"
|
||||
},
|
||||
"color12": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#5085af"
|
||||
},
|
||||
"color13": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#a97a99"
|
||||
},
|
||||
"color14": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#6ba4a4"
|
||||
},
|
||||
"color15": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#ffffff"
|
||||
},
|
||||
"copy-binding": {
|
||||
"type": "string",
|
||||
"description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.",
|
||||
"pattern": "[^:\\\\\\n]+",
|
||||
"default": "Ctrl Shift ~Alt <Key> C"
|
||||
},
|
||||
"paste-binding": {
|
||||
"type": "string",
|
||||
"description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.",
|
||||
"pattern": "[^:\\\\\\n]+",
|
||||
"default": "Ctrl Shift ~Alt <Key> V"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"collection": "environment",
|
||||
|
||||
Reference in New Issue
Block a user