@rubytech/create-maxy-code 0.1.216 → 0.1.219
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/package.json +1 -1
- package/payload/platform/lib/mcp-eager/dist/index.d.ts +0 -55
- package/payload/platform/lib/mcp-eager/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-eager/dist/index.js +0 -76
- package/payload/platform/lib/mcp-eager/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-eager/src/index.ts +0 -101
- package/payload/platform/plugins/admin/hooks/__tests__/hook-emit-stale-lock-ttl.test.sh +102 -0
- package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +52 -112
- package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +29 -1
- package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +20 -1
- package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +0 -72
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -18
- package/payload/platform/plugins/browser/mcp/dist/index.js +19 -19
- package/payload/platform/plugins/browser/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/docs/references/platform.md +10 -17
- package/payload/platform/plugins/email/mcp/dist/index.js +12 -12
- package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/index.js +8 -8
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/url-get/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/url-get/mcp/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +0 -14
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +25 -69
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +12 -31
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +3 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +55 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +23 -17
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +94 -73
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts +4 -0
- package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/rc-life.js +16 -0
- package/payload/platform/services/claude-session-manager/dist/rc-life.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +12 -46
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +24 -73
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +3 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +4 -5
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +4 -5
- package/payload/server/public/assets/AdminShell-BcxEZnpu.js +1 -0
- package/payload/server/public/assets/{admin-DjGGOx2K.js → admin-DMo5DWKH.js} +1 -1
- package/payload/server/public/assets/{data-WBEvtRV-.js → data-D6IQdm0R.js} +1 -1
- package/payload/server/public/assets/{graph-DsOLsIPf.js → graph-PkZKVkWD.js} +1 -1
- package/payload/server/public/data.html +2 -2
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +2 -2
- package/payload/server/server.js +0 -6
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts +0 -116
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts.map +0 -1
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js +0 -125
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js.map +0 -1
- package/payload/premium-plugins/writer-craft/lib/mcp-eager/package.json +0 -7
- package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +0 -1
package/package.json
CHANGED
|
@@ -20,41 +20,6 @@
|
|
|
20
20
|
*/
|
|
21
21
|
/** Wire-format metadata key the Claude Code SDK reads. */
|
|
22
22
|
export declare const ALWAYS_LOAD_META_KEY: "anthropic/alwaysLoad";
|
|
23
|
-
/**
|
|
24
|
-
* Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
|
|
25
|
-
* `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
|
|
26
|
-
* children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
|
|
27
|
-
* inheriting the env can therefore decide at registration AND invocation
|
|
28
|
-
* time whether it is loaded inside the admin seat.
|
|
29
|
-
*
|
|
30
|
-
* Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
|
|
31
|
-
* — this is a duplicate at the library layer so plugins that cannot import
|
|
32
|
-
* from the admin plugin (a sibling, not a dependency) share the same rule.
|
|
33
|
-
*/
|
|
34
|
-
export declare function isAdminSeat(env?: NodeJS.ProcessEnv): boolean;
|
|
35
|
-
/**
|
|
36
|
-
* MCP refusal envelope returned when the admin seat invokes a specialist-
|
|
37
|
-
* owned tool. The text names the dispatch route so the model's next action
|
|
38
|
-
* is the `Agent` dispatch rather than a dead end.
|
|
39
|
-
*/
|
|
40
|
-
export declare function adminBlockedResponse(toolName: string, owner: string): {
|
|
41
|
-
content: {
|
|
42
|
-
type: "text";
|
|
43
|
-
text: string;
|
|
44
|
-
}[];
|
|
45
|
-
isError: true;
|
|
46
|
-
};
|
|
47
|
-
/**
|
|
48
|
-
* Wrap a tool handler so any admin-seat invocation short-circuits with the
|
|
49
|
-
* refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
|
|
50
|
-
*
|
|
51
|
-
* Specialist seats pass through unchanged: this is the primitive Task 529
|
|
52
|
-
* defines for closing the in-message authoring path that Task 516 could
|
|
53
|
-
* not reach. The handler signature is preserved so existing call sites
|
|
54
|
-
* substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
|
|
55
|
-
* owner, handler))` mechanically.
|
|
56
|
-
*/
|
|
57
|
-
export declare function withAdminGate<H extends (...args: any[]) => any>(toolName: string, owner: string, handler: H): H;
|
|
58
23
|
/**
|
|
59
24
|
* Loose structural type for any object exposing a `registerTool` method. The
|
|
60
25
|
* MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
|
|
@@ -92,25 +57,5 @@ type LocalZodRawShape = Record<string, unknown>;
|
|
|
92
57
|
* the consumer's own zod version) infers the handler args.
|
|
93
58
|
*/
|
|
94
59
|
export declare function eagerTool<Args extends LocalZodRawShape>(server: EagerCapableServer, name: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
|
|
95
|
-
/**
|
|
96
|
-
* Loose structural type for the legacy four-arg `server.tool(name, desc,
|
|
97
|
-
* schema, handler)` call shape — used when the admin seat must register a
|
|
98
|
-
* specialist-owned tool as deferred (not eager) so it does not appear in
|
|
99
|
-
* the admin's eager tool list.
|
|
100
|
-
*/
|
|
101
|
-
type DeferredCapableServer = {
|
|
102
|
-
tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any;
|
|
103
|
-
};
|
|
104
|
-
/**
|
|
105
|
-
* Register a tool that is eager for specialists but deferred + admin-gated
|
|
106
|
-
* for the admin seat. The owning specialist name is named here so the
|
|
107
|
-
* refusal envelope (and the `[admin-tool-gate]` log line) carries it.
|
|
108
|
-
*
|
|
109
|
-
* Use for every tool whose owner is a specialist (database-operator, writer-
|
|
110
|
-
* craft, research-assistant, personal-assistant, content-producer). The
|
|
111
|
-
* admin seat sees the tool only via ToolSearch — and any invocation from the
|
|
112
|
-
* admin seat refuses with the dispatch hint.
|
|
113
|
-
*/
|
|
114
|
-
export declare function eagerToolGated<Args extends LocalZodRawShape>(server: EagerCapableServer & DeferredCapableServer, name: string, owner: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
|
|
115
60
|
export {};
|
|
116
61
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE;;;;;;;GAOG;AAEH,KAAK,kBAAkB,GAAG;IAAE,YAAY,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;CAAE,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,IAAI,SAAS,gBAAgB,EACrD,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAUN"}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ALWAYS_LOAD_META_KEY = void 0;
|
|
4
|
-
exports.isAdminSeat = isAdminSeat;
|
|
5
|
-
exports.adminBlockedResponse = adminBlockedResponse;
|
|
6
|
-
exports.withAdminGate = withAdminGate;
|
|
7
4
|
exports.eagerTool = eagerTool;
|
|
8
|
-
exports.eagerToolGated = eagerToolGated;
|
|
9
5
|
/**
|
|
10
6
|
* MCP eager-load helper.
|
|
11
7
|
*
|
|
@@ -28,59 +24,6 @@ exports.eagerToolGated = eagerToolGated;
|
|
|
28
24
|
*/
|
|
29
25
|
/** Wire-format metadata key the Claude Code SDK reads. */
|
|
30
26
|
exports.ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad";
|
|
31
|
-
/**
|
|
32
|
-
* Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
|
|
33
|
-
* `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
|
|
34
|
-
* children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
|
|
35
|
-
* inheriting the env can therefore decide at registration AND invocation
|
|
36
|
-
* time whether it is loaded inside the admin seat.
|
|
37
|
-
*
|
|
38
|
-
* Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
|
|
39
|
-
* — this is a duplicate at the library layer so plugins that cannot import
|
|
40
|
-
* from the admin plugin (a sibling, not a dependency) share the same rule.
|
|
41
|
-
*/
|
|
42
|
-
function isAdminSeat(env = process.env) {
|
|
43
|
-
return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* MCP refusal envelope returned when the admin seat invokes a specialist-
|
|
47
|
-
* owned tool. The text names the dispatch route so the model's next action
|
|
48
|
-
* is the `Agent` dispatch rather than a dead end.
|
|
49
|
-
*/
|
|
50
|
-
function adminBlockedResponse(toolName, owner) {
|
|
51
|
-
return {
|
|
52
|
-
content: [
|
|
53
|
-
{
|
|
54
|
-
type: "text",
|
|
55
|
-
text: `Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
|
|
56
|
-
`Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
|
|
57
|
-
},
|
|
58
|
-
],
|
|
59
|
-
isError: true,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Wrap a tool handler so any admin-seat invocation short-circuits with the
|
|
64
|
-
* refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
|
|
65
|
-
*
|
|
66
|
-
* Specialist seats pass through unchanged: this is the primitive Task 529
|
|
67
|
-
* defines for closing the in-message authoring path that Task 516 could
|
|
68
|
-
* not reach. The handler signature is preserved so existing call sites
|
|
69
|
-
* substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
|
|
70
|
-
* owner, handler))` mechanically.
|
|
71
|
-
*/
|
|
72
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
-
function withAdminGate(toolName, owner, handler) {
|
|
74
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
-
const wrapped = ((...args) => {
|
|
76
|
-
if (isAdminSeat(process.env)) {
|
|
77
|
-
process.stderr.write(`[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`);
|
|
78
|
-
return adminBlockedResponse(toolName, owner);
|
|
79
|
-
}
|
|
80
|
-
return handler(...args);
|
|
81
|
-
});
|
|
82
|
-
return wrapped;
|
|
83
|
-
}
|
|
84
27
|
/**
|
|
85
28
|
* Register an MCP tool that the Claude Code SDK eager-loads into the
|
|
86
29
|
* model's prompt at session boot, bypassing the ToolSearch round-trip.
|
|
@@ -103,23 +46,4 @@ handler) {
|
|
|
103
46
|
_meta: { [exports.ALWAYS_LOAD_META_KEY]: true },
|
|
104
47
|
}, handler);
|
|
105
48
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Register a tool that is eager for specialists but deferred + admin-gated
|
|
108
|
-
* for the admin seat. The owning specialist name is named here so the
|
|
109
|
-
* refusal envelope (and the `[admin-tool-gate]` log line) carries it.
|
|
110
|
-
*
|
|
111
|
-
* Use for every tool whose owner is a specialist (database-operator, writer-
|
|
112
|
-
* craft, research-assistant, personal-assistant, content-producer). The
|
|
113
|
-
* admin seat sees the tool only via ToolSearch — and any invocation from the
|
|
114
|
-
* admin seat refuses with the dispatch hint.
|
|
115
|
-
*/
|
|
116
|
-
function eagerToolGated(server, name, owner, description, inputSchema,
|
|
117
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
118
|
-
handler) {
|
|
119
|
-
if (isAdminSeat(process.env)) {
|
|
120
|
-
server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
eagerTool(server, name, description, inputSchema, handler);
|
|
124
|
-
}
|
|
125
49
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AA4DA,8BAiBC;AA7ED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC7C,QAAA,oBAAoB,GAAG,sBAA+B,CAAC;AA0BpE;;;;;;;;;;;;GAYG;AACH,SAAgB,SAAS,CACvB,MAA0B,EAC1B,IAAY,EACZ,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,MAAM,CAAC,YAAY,CACjB,IAAI,EACJ;QACE,WAAW;QACX,WAAW;QACX,KAAK,EAAE,EAAE,CAAC,4BAAoB,CAAC,EAAE,IAAI,EAAE;KACxC,EACD,OAAO,CACR,CAAC;AACJ,CAAC"}
|
|
@@ -21,72 +21,6 @@
|
|
|
21
21
|
/** Wire-format metadata key the Claude Code SDK reads. */
|
|
22
22
|
export const ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad" as const;
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
* Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
|
|
26
|
-
* `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
|
|
27
|
-
* children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
|
|
28
|
-
* inheriting the env can therefore decide at registration AND invocation
|
|
29
|
-
* time whether it is loaded inside the admin seat.
|
|
30
|
-
*
|
|
31
|
-
* Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
|
|
32
|
-
* — this is a duplicate at the library layer so plugins that cannot import
|
|
33
|
-
* from the admin plugin (a sibling, not a dependency) share the same rule.
|
|
34
|
-
*/
|
|
35
|
-
export function isAdminSeat(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
36
|
-
return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* MCP refusal envelope returned when the admin seat invokes a specialist-
|
|
41
|
-
* owned tool. The text names the dispatch route so the model's next action
|
|
42
|
-
* is the `Agent` dispatch rather than a dead end.
|
|
43
|
-
*/
|
|
44
|
-
export function adminBlockedResponse(toolName: string, owner: string): {
|
|
45
|
-
content: { type: "text"; text: string }[];
|
|
46
|
-
isError: true;
|
|
47
|
-
} {
|
|
48
|
-
return {
|
|
49
|
-
content: [
|
|
50
|
-
{
|
|
51
|
-
type: "text",
|
|
52
|
-
text:
|
|
53
|
-
`Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
|
|
54
|
-
`Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
|
|
55
|
-
},
|
|
56
|
-
],
|
|
57
|
-
isError: true,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Wrap a tool handler so any admin-seat invocation short-circuits with the
|
|
63
|
-
* refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
|
|
64
|
-
*
|
|
65
|
-
* Specialist seats pass through unchanged: this is the primitive Task 529
|
|
66
|
-
* defines for closing the in-message authoring path that Task 516 could
|
|
67
|
-
* not reach. The handler signature is preserved so existing call sites
|
|
68
|
-
* substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
|
|
69
|
-
* owner, handler))` mechanically.
|
|
70
|
-
*/
|
|
71
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
-
export function withAdminGate<H extends (...args: any[]) => any>(
|
|
73
|
-
toolName: string,
|
|
74
|
-
owner: string,
|
|
75
|
-
handler: H,
|
|
76
|
-
): H {
|
|
77
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
-
const wrapped = ((...args: any[]) => {
|
|
79
|
-
if (isAdminSeat(process.env)) {
|
|
80
|
-
process.stderr.write(
|
|
81
|
-
`[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`,
|
|
82
|
-
);
|
|
83
|
-
return adminBlockedResponse(toolName, owner);
|
|
84
|
-
}
|
|
85
|
-
return handler(...args);
|
|
86
|
-
}) as unknown as H;
|
|
87
|
-
return wrapped;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
24
|
/**
|
|
91
25
|
* Loose structural type for any object exposing a `registerTool` method. The
|
|
92
26
|
* MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
|
|
@@ -142,38 +76,3 @@ export function eagerTool<Args extends LocalZodRawShape>(
|
|
|
142
76
|
handler,
|
|
143
77
|
);
|
|
144
78
|
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Loose structural type for the legacy four-arg `server.tool(name, desc,
|
|
148
|
-
* schema, handler)` call shape — used when the admin seat must register a
|
|
149
|
-
* specialist-owned tool as deferred (not eager) so it does not appear in
|
|
150
|
-
* the admin's eager tool list.
|
|
151
|
-
*/
|
|
152
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
-
type DeferredCapableServer = { tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any };
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Register a tool that is eager for specialists but deferred + admin-gated
|
|
157
|
-
* for the admin seat. The owning specialist name is named here so the
|
|
158
|
-
* refusal envelope (and the `[admin-tool-gate]` log line) carries it.
|
|
159
|
-
*
|
|
160
|
-
* Use for every tool whose owner is a specialist (database-operator, writer-
|
|
161
|
-
* craft, research-assistant, personal-assistant, content-producer). The
|
|
162
|
-
* admin seat sees the tool only via ToolSearch — and any invocation from the
|
|
163
|
-
* admin seat refuses with the dispatch hint.
|
|
164
|
-
*/
|
|
165
|
-
export function eagerToolGated<Args extends LocalZodRawShape>(
|
|
166
|
-
server: EagerCapableServer & DeferredCapableServer,
|
|
167
|
-
name: string,
|
|
168
|
-
owner: string,
|
|
169
|
-
description: string,
|
|
170
|
-
inputSchema: Args,
|
|
171
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
172
|
-
handler: (...args: any[]) => any,
|
|
173
|
-
): void {
|
|
174
|
-
if (isAdminSeat(process.env)) {
|
|
175
|
-
server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
eagerTool(server, name, description, inputSchema, handler);
|
|
179
|
-
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Task 569 — stale-lockdir TTL reclamation in _hook_acquire_lock and
|
|
3
|
+
# _drain_acquire_lock. A backdated lockdir is force-removed on acquire;
|
|
4
|
+
# a freshly held lockdir is not.
|
|
5
|
+
set -u
|
|
6
|
+
|
|
7
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
8
|
+
LIB="$ROOT/lib/hook-emit.sh"
|
|
9
|
+
DRAIN="$ROOT/post-tool-use-agent.sh"
|
|
10
|
+
[[ -f "$LIB" ]] || { echo "FAIL: $LIB missing"; exit 1; }
|
|
11
|
+
[[ -f "$DRAIN" ]] || { echo "FAIL: $DRAIN missing"; exit 1; }
|
|
12
|
+
|
|
13
|
+
TMP=$(mktemp -d)
|
|
14
|
+
export MAXY_HOOK_BUFFER_DIR="$TMP/buffers"
|
|
15
|
+
export MAXY_HOOK_SERVER_LOG="$TMP/server.log"
|
|
16
|
+
# Tight test budget — 20 × 10ms = 200ms ceiling so the negative case
|
|
17
|
+
# returns quickly.
|
|
18
|
+
export MAXY_HOOK_LOCK_ATTEMPTS=20
|
|
19
|
+
export MAXY_HOOK_LOCK_SLEEP=0.01
|
|
20
|
+
export MAXY_HOOK_LOCK_TTL_SECONDS=2
|
|
21
|
+
mkdir -p "$MAXY_HOOK_BUFFER_DIR"
|
|
22
|
+
|
|
23
|
+
PASS=0; FAIL=0
|
|
24
|
+
pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
|
|
25
|
+
fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
|
|
26
|
+
|
|
27
|
+
# shellcheck disable=SC1090
|
|
28
|
+
source "$LIB" || { fail "source library"; exit 1; }
|
|
29
|
+
pass "source library"
|
|
30
|
+
|
|
31
|
+
# Test 1: a stale lockdir (mtime backdated 10s; TTL=2s) is reclaimed and
|
|
32
|
+
# acquire succeeds.
|
|
33
|
+
LOCK1="$TMP/lock-stale"
|
|
34
|
+
mkdir "$LOCK1"
|
|
35
|
+
# Backdate mtime by 10s. -t needs YYYYMMDDhhmm.SS form; portable form
|
|
36
|
+
# uses -d on GNU and -A/-r on BSD, so we use python to set it.
|
|
37
|
+
python3 -c "import os,time; os.utime('$LOCK1', (time.time()-10, time.time()-10))"
|
|
38
|
+
if _hook_acquire_lock "$LOCK1"; then
|
|
39
|
+
pass "stale lockdir reclaimed (emit-path)"
|
|
40
|
+
else
|
|
41
|
+
fail "stale lockdir not reclaimed within budget (emit-path)"
|
|
42
|
+
fi
|
|
43
|
+
# Verify observability line.
|
|
44
|
+
if grep -q "\[hook-propagate-error\] reason=stale-lock-reclaimed lockdir=$LOCK1" "$MAXY_HOOK_SERVER_LOG"; then
|
|
45
|
+
pass "stale-lock-reclaimed observability line emitted"
|
|
46
|
+
else
|
|
47
|
+
fail "stale-lock-reclaimed line missing from server.log"
|
|
48
|
+
fi
|
|
49
|
+
rmdir "$LOCK1" 2>/dev/null || true
|
|
50
|
+
|
|
51
|
+
# Test 2: a fresh lockdir (mtime = now) is NOT reclaimed and acquire
|
|
52
|
+
# returns 1 within the attempt budget.
|
|
53
|
+
LOCK2="$TMP/lock-fresh"
|
|
54
|
+
mkdir "$LOCK2"
|
|
55
|
+
START=$(date +%s)
|
|
56
|
+
if _hook_acquire_lock "$LOCK2"; then
|
|
57
|
+
fail "fresh lockdir wrongly reclaimed (emit-path)"
|
|
58
|
+
else
|
|
59
|
+
END=$(date +%s)
|
|
60
|
+
pass "fresh lockdir not reclaimed; acquire timed out (emit-path, ${END}-${START}s)"
|
|
61
|
+
fi
|
|
62
|
+
# Verify no stale-lock-reclaimed line was written for LOCK2.
|
|
63
|
+
if grep -q "lockdir=$LOCK2 ageSeconds=" "$MAXY_HOOK_SERVER_LOG"; then
|
|
64
|
+
fail "fresh lockdir wrongly logged as stale (emit-path)"
|
|
65
|
+
else
|
|
66
|
+
pass "no stale-lock log for fresh lockdir (emit-path)"
|
|
67
|
+
fi
|
|
68
|
+
rmdir "$LOCK2" 2>/dev/null || true
|
|
69
|
+
|
|
70
|
+
# Test 3: same property for the drainer-side function. Extract
|
|
71
|
+
# _drain_acquire_lock + its age helper from post-tool-use-agent.sh and
|
|
72
|
+
# source them in isolation.
|
|
73
|
+
DRAIN_LIB="$TMP/drain-lock.sh"
|
|
74
|
+
awk '
|
|
75
|
+
/^_drain_lockdir_age_seconds\(\)/,/^}/ { print; next }
|
|
76
|
+
/^_drain_acquire_lock\(\)/,/^}/ { print; next }
|
|
77
|
+
' "$DRAIN" > "$DRAIN_LIB"
|
|
78
|
+
[[ -s "$DRAIN_LIB" ]] || { fail "drain lock extract empty"; }
|
|
79
|
+
# shellcheck disable=SC1090
|
|
80
|
+
source "$DRAIN_LIB" || fail "source drain lib"
|
|
81
|
+
|
|
82
|
+
LOCK3="$TMP/lock-drain-stale"
|
|
83
|
+
mkdir "$LOCK3"
|
|
84
|
+
python3 -c "import os,time; os.utime('$LOCK3', (time.time()-10, time.time()-10))"
|
|
85
|
+
if _drain_acquire_lock "$LOCK3"; then
|
|
86
|
+
pass "stale lockdir reclaimed (drain-path)"
|
|
87
|
+
else
|
|
88
|
+
fail "stale lockdir not reclaimed (drain-path)"
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
LOCK4="$TMP/lock-drain-fresh"
|
|
92
|
+
mkdir "$LOCK4"
|
|
93
|
+
if _drain_acquire_lock "$LOCK4"; then
|
|
94
|
+
fail "fresh lockdir wrongly reclaimed (drain-path)"
|
|
95
|
+
else
|
|
96
|
+
pass "fresh lockdir not reclaimed; acquire timed out (drain-path)"
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
rm -rf "$TMP"
|
|
100
|
+
echo "----"
|
|
101
|
+
echo "$PASS pass, $FAIL fail"
|
|
102
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Task
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# Task 571 — the orchestration-only block has been removed. Admin's standard
|
|
3
|
+
# tool surface (Write/Edit/MultiEdit/Bash/WebFetch/WebSearch) is callable
|
|
4
|
+
# directly from the admin seat; delegation is a doctrine bias enforced in
|
|
5
|
+
# IDENTITY.md, not a platform refusal. This test pins the post-571 contract:
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
7
|
+
# - Admin-direct Bash/Edit/Write/WebFetch all return exit 0 with no
|
|
8
|
+
# `reason=orchestration-only` or `reason=specialist-owned` line.
|
|
9
|
+
# - The resource-specific hook blocks (code-dir, entitlement, account.json,
|
|
10
|
+
# pending-actions, base64-guard, approval-queue) are unaffected and live
|
|
11
|
+
# in their own dedicated tests.
|
|
8
12
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# with no `parent_tool_use_id` but `MAXY_SPECIALIST=<name>` on env, set
|
|
12
|
-
# by pty-spawner.ts:1601). Calls with neither marker AND no
|
|
13
|
-
# `MAXY_SESSION_ROLE=admin` fail closed with `reason=missing-marker` so a
|
|
14
|
-
# future spawn shape cannot silently regress.
|
|
13
|
+
# Reintroducing the orchestration-only block requires a doctrine flip — this
|
|
14
|
+
# test fails first and surfaces the change loudly.
|
|
15
15
|
|
|
16
16
|
set -u
|
|
17
17
|
|
|
@@ -33,135 +33,75 @@ PASS=0; FAIL=0
|
|
|
33
33
|
pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
|
|
34
34
|
fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
|
|
35
35
|
|
|
36
|
-
# The hook receives the parent session's transcript_path even on subagent
|
|
37
|
-
# calls in Claude Code 2.1.x. The fixture mirrors production.
|
|
38
36
|
SESSION_TRANSCRIPT="/tmp/sess/44283ae1-2b82-4bd7-b8af-99a11141ed4b.jsonl"
|
|
39
|
-
SUBAGENT_PARENT_ID="toolu_01QFC4W32wFUyHy6WJMNjHf6"
|
|
40
37
|
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
local tool="$1" parent_id="$2" expected_rc="$3" expected_pattern="$4" label="$5"
|
|
48
|
-
local role="${MAXY_SESSION_ROLE_OVERRIDE-admin}"
|
|
49
|
-
local specialist="${MAXY_SPECIALIST_OVERRIDE-}"
|
|
38
|
+
# run_admin_direct <tool> <expected_rc> <label>
|
|
39
|
+
# Pipes a minimal PreToolUse payload to the hook with MAXY_SESSION_ROLE=admin
|
|
40
|
+
# (the realistic admin spawn shape) and asserts the rc and that no
|
|
41
|
+
# orchestration-only / specialist-owned refusal line appears on stderr.
|
|
42
|
+
run_admin_direct() {
|
|
43
|
+
local tool="$1" expected_rc="$2" label="$3"
|
|
50
44
|
local input_json
|
|
51
45
|
input_json=$(python3 -c '
|
|
52
46
|
import json, sys
|
|
53
|
-
|
|
47
|
+
print(json.dumps({
|
|
54
48
|
"hook_event_name": "PreToolUse",
|
|
55
49
|
"tool_name": sys.argv[1],
|
|
56
|
-
"tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi"},
|
|
50
|
+
"tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi", "url": "https://example.com"},
|
|
57
51
|
"transcript_path": sys.argv[2],
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if parent:
|
|
61
|
-
payload["parent_tool_use_id"] = parent
|
|
62
|
-
print(json.dumps(payload, separators=(",", ":")))
|
|
63
|
-
' "$tool" "$SESSION_TRANSCRIPT" "$parent_id")
|
|
52
|
+
}, separators=(",", ":")))
|
|
53
|
+
' "$tool" "$SESSION_TRANSCRIPT")
|
|
64
54
|
local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
|
|
65
55
|
local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
|
|
66
56
|
printf '%s' "$input_json" | \
|
|
67
|
-
env -u MAXY_SESSION_ROLE
|
|
68
|
-
MAXY_SESSION_ROLE="$role" MAXY_SPECIALIST="$specialist" \
|
|
57
|
+
env -u MAXY_SPECIALIST MAXY_SESSION_ROLE=admin \
|
|
69
58
|
bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
|
|
70
59
|
local rc=$?
|
|
71
60
|
if [[ "$rc" -ne "$expected_rc" ]]; then
|
|
72
61
|
fail "$label: expected exit $expected_rc, got $rc. Stderr: $(cat "$stderr_file")"
|
|
73
62
|
return
|
|
74
63
|
fi
|
|
75
|
-
if
|
|
76
|
-
fail "$label: stderr
|
|
64
|
+
if grep -qE 'reason=orchestration-only|reason=specialist-owned|reason=missing-marker' "$stderr_file"; then
|
|
65
|
+
fail "$label: stderr carries a removed-gate reason line. Got: $(cat "$stderr_file")"
|
|
77
66
|
return
|
|
78
67
|
fi
|
|
79
68
|
pass "$label"
|
|
80
69
|
}
|
|
81
70
|
|
|
82
|
-
# ── Admin-direct
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"Test 6: admin-direct WebSearch rejected"
|
|
95
|
-
|
|
96
|
-
# ── In-window subagent passthrough (parent_tool_use_id non-empty) ───────
|
|
97
|
-
run Write "$SUBAGENT_PARENT_ID" 0 "\[admin-tool-gate\] role=subagent parent_tool_use_id=${SUBAGENT_PARENT_ID} .*tool=Write decision=allow" \
|
|
98
|
-
"Test 7: subagent Write passes through"
|
|
99
|
-
run Edit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Edit decision=allow' \
|
|
100
|
-
"Test 8: subagent Edit passes through"
|
|
101
|
-
run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Bash decision=allow' \
|
|
102
|
-
"Test 9: subagent Bash passes through (Task 559 regression — coding-assistant git pull)"
|
|
103
|
-
run WebFetch "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=WebFetch decision=allow' \
|
|
104
|
-
"Test 10: subagent WebFetch passes through"
|
|
105
|
-
run MultiEdit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=MultiEdit decision=allow' \
|
|
106
|
-
"Test 11: subagent MultiEdit passes through"
|
|
107
|
-
|
|
108
|
-
# ── Unrelated tools untouched ───────────────────────────────────────────
|
|
109
|
-
run Read "" 0 '' \
|
|
110
|
-
"Test 12: admin-direct Read passes (Read is admin-keep-list)"
|
|
111
|
-
|
|
112
|
-
# ── Task 560: admin-direct block emits propagation record ──────────────
|
|
113
|
-
# Admin-direct path: MAXY_SESSION_ROLE=admin, no MAXY_SPECIALIST, no parent.
|
|
114
|
-
PROP_TMPDIR=$(mktemp -d); TMPFILES+=("$PROP_TMPDIR")
|
|
115
|
-
export MAXY_HOOK_BUFFER_DIR="$PROP_TMPDIR/buffers"
|
|
116
|
-
export MAXY_HOOK_SERVER_LOG="$PROP_TMPDIR/server.log"
|
|
117
|
-
SESSION="task560-test-sess"
|
|
118
|
-
PROP_INPUT=$(python3 -c '
|
|
71
|
+
# ── Admin-direct passthrough (the Task 571 regression boundary) ──────────
|
|
72
|
+
run_admin_direct Bash 0 "Test 1: admin-direct Bash passes through"
|
|
73
|
+
run_admin_direct Edit 0 "Test 2: admin-direct Edit passes through (path /tmp/x is outside protected dirs)"
|
|
74
|
+
run_admin_direct Write 0 "Test 3: admin-direct Write passes through"
|
|
75
|
+
run_admin_direct MultiEdit 0 "Test 4: admin-direct MultiEdit passes through"
|
|
76
|
+
run_admin_direct WebFetch 0 "Test 5: admin-direct WebFetch passes through"
|
|
77
|
+
run_admin_direct WebSearch 0 "Test 6: admin-direct WebSearch passes through"
|
|
78
|
+
run_admin_direct Read 0 "Test 7: admin-direct Read passes through"
|
|
79
|
+
|
|
80
|
+
# ── Resource-specific blocks unaffected: protected code-dir Write still refuses ──
|
|
81
|
+
# Verifies the deletion was surgical — code-dir-protection-ui still fires.
|
|
82
|
+
PROTECTED_INPUT=$(python3 -c '
|
|
119
83
|
import json, sys
|
|
120
|
-
print(json.dumps({
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
84
|
+
print(json.dumps({
|
|
85
|
+
"hook_event_name": "PreToolUse",
|
|
86
|
+
"tool_name": "Write",
|
|
87
|
+
"tool_input": {"file_path": "/srv/maxy/platform/ui/app/page.tsx", "content": "x"},
|
|
88
|
+
"transcript_path": sys.argv[1],
|
|
89
|
+
}, separators=(",", ":")))
|
|
90
|
+
' "$SESSION_TRANSCRIPT")
|
|
91
|
+
PROT_STDOUT=$(mktemp); TMPFILES+=("$PROT_STDOUT")
|
|
92
|
+
PROT_STDERR=$(mktemp); TMPFILES+=("$PROT_STDERR")
|
|
93
|
+
printf '%s' "$PROTECTED_INPUT" | \
|
|
125
94
|
env -u MAXY_SPECIALIST MAXY_SESSION_ROLE=admin \
|
|
126
|
-
bash "$HOOK" admin
|
|
127
|
-
|
|
128
|
-
if [[
|
|
129
|
-
|
|
130
|
-
&& grep -q '"reason":"orchestration-only"' "$BUF"; then
|
|
131
|
-
pass "Test 13b: admin-direct Bash block emits Task 560 propagation record"
|
|
95
|
+
bash "$HOOK" admin >"$PROT_STDOUT" 2>"$PROT_STDERR"
|
|
96
|
+
PROT_RC=$?
|
|
97
|
+
if [[ "$PROT_RC" -eq 2 ]] && grep -q 'cannot modify platform UI source' "$PROT_STDERR"; then
|
|
98
|
+
pass "Test 8: admin-direct Write to platform/ui/ still blocked (resource-specific block intact)"
|
|
132
99
|
else
|
|
133
|
-
fail "Test
|
|
100
|
+
fail "Test 8: expected platform/ui code-dir block, got rc=$PROT_RC stdout=$(cat "$PROT_STDOUT") stderr=$(cat "$PROT_STDERR")"
|
|
134
101
|
fi
|
|
135
|
-
unset MAXY_HOOK_BUFFER_DIR MAXY_HOOK_SERVER_LOG
|
|
136
|
-
|
|
137
|
-
# ── Task 566: maxy-MCP-Agent-spawned specialist passthrough ────────────
|
|
138
|
-
# MCP-Agent specialists are separate top-level claude rc processes with no
|
|
139
|
-
# parent_tool_use_id; pty-spawner stamps MAXY_SPECIALIST=<name> on the env.
|
|
140
|
-
MAXY_SPECIALIST_OVERRIDE=content-producer \
|
|
141
|
-
run Edit "" 0 '\[admin-tool-gate\] role=subagent .*specialist=content-producer .*tool=Edit decision=allow source=mcp-specialist' \
|
|
142
|
-
"Test 14: mcp-specialist Edit passes through (Task 566)"
|
|
143
|
-
|
|
144
|
-
MAXY_SPECIALIST_OVERRIDE=coding-assistant \
|
|
145
|
-
run Bash "" 0 '\[admin-tool-gate\] role=subagent .*specialist=coding-assistant .*tool=Bash decision=allow source=mcp-specialist' \
|
|
146
|
-
"Test 15: mcp-specialist Bash passes through (Task 559 regression class)"
|
|
147
|
-
|
|
148
|
-
MAXY_SPECIALIST_OVERRIDE=research-assistant \
|
|
149
|
-
run WebFetch "" 0 '\[admin-tool-gate\] role=subagent .*specialist=research-assistant .*tool=WebFetch decision=allow source=mcp-specialist' \
|
|
150
|
-
"Test 16: mcp-specialist WebFetch passes through"
|
|
151
|
-
|
|
152
|
-
# ── Task 566: missing-marker fail-closed ───────────────────────────────
|
|
153
|
-
# No parent_tool_use_id, no MAXY_SPECIALIST, MAXY_SESSION_ROLE not 'admin'.
|
|
154
|
-
MAXY_SESSION_ROLE_OVERRIDE="" MAXY_SPECIALIST_OVERRIDE="" \
|
|
155
|
-
run Edit "" 2 '\[admin-tool-gate\] role=unknown .*tool=Edit decision=block reason=missing-marker' \
|
|
156
|
-
"Test 17: missing-marker fail-closed (no positive role marker)"
|
|
157
|
-
|
|
158
|
-
# ── Task 566: combined parent_tool_use_id + MAXY_SPECIALIST is also subagent ──
|
|
159
|
-
MAXY_SPECIALIST_OVERRIDE=database-operator \
|
|
160
|
-
run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*specialist=database-operator .*tool=Bash decision=allow source=task-subagent\+mcp-specialist' \
|
|
161
|
-
"Test 18: combined parent + specialist resolves as task-subagent+mcp-specialist"
|
|
162
102
|
|
|
163
103
|
echo
|
|
164
|
-
echo "──────── pre-tool-use admin-tool-gate test summary ────────"
|
|
104
|
+
echo "──────── pre-tool-use admin-tool-gate test summary (post-Task 571) ────────"
|
|
165
105
|
echo "PASS: $PASS"
|
|
166
106
|
echo "FAIL: $FAIL"
|
|
167
107
|
|
|
@@ -14,11 +14,39 @@
|
|
|
14
14
|
# (tests) without flock(1).
|
|
15
15
|
: "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
|
|
16
16
|
: "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
|
|
17
|
+
# Task 569 — TTL for orphaned lockdirs left behind when a holder dies
|
|
18
|
+
# between `mkdir lock` and `rmdir lock` (SIGKILL, OOM, systemd restart).
|
|
19
|
+
# Default 5s is far longer than the legitimate hold (microseconds for a
|
|
20
|
+
# rename or append) and shorter than operator-noticeable propagation lag.
|
|
21
|
+
: "${MAXY_HOOK_LOCK_TTL_SECONDS:=5}"
|
|
22
|
+
|
|
23
|
+
# _hook_lockdir_age_seconds <lockdir> — prints integer age in seconds, or
|
|
24
|
+
# -1 if the lockdir cannot be stat'd. Portable across Linux (`stat -c`)
|
|
25
|
+
# and macOS (`stat -f`).
|
|
26
|
+
_hook_lockdir_age_seconds() {
|
|
27
|
+
local lockdir="$1" mtime now
|
|
28
|
+
mtime=$(stat -c %Y "$lockdir" 2>/dev/null || stat -f %m "$lockdir" 2>/dev/null)
|
|
29
|
+
[[ -z "$mtime" ]] && { echo -1; return; }
|
|
30
|
+
now=$(date +%s)
|
|
31
|
+
echo $((now - mtime))
|
|
32
|
+
}
|
|
17
33
|
|
|
18
34
|
# _hook_acquire_lock <lockdir> — returns 0 on success, 1 on timeout.
|
|
35
|
+
# On each mkdir failure, if the lockdir is older than
|
|
36
|
+
# MAXY_HOOK_LOCK_TTL_SECONDS, the orphaned holder is presumed dead: the
|
|
37
|
+
# lockdir is force-removed, a stale-lock-reclaimed observability line is
|
|
38
|
+
# emitted, and one mkdir retry is attempted before resuming the spin.
|
|
19
39
|
_hook_acquire_lock() {
|
|
20
|
-
local lockdir="$1" i=0
|
|
40
|
+
local lockdir="$1" i=0 age
|
|
21
41
|
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
42
|
+
age=$(_hook_lockdir_age_seconds "$lockdir")
|
|
43
|
+
if [[ "$age" -ge "$MAXY_HOOK_LOCK_TTL_SECONDS" ]]; then
|
|
44
|
+
rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" 2>/dev/null
|
|
45
|
+
mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
|
|
46
|
+
printf '[hook-propagate-error] reason=stale-lock-reclaimed lockdir=%s ageSeconds=%s\n' \
|
|
47
|
+
"$lockdir" "$age" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
|
|
48
|
+
mkdir "$lockdir" 2>/dev/null && return 0
|
|
49
|
+
fi
|
|
22
50
|
i=$((i+1))
|
|
23
51
|
[[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
|
|
24
52
|
sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
|