feat(security,timer): harden auth and add multi-instance coordination
- Extract JASPER_URL into a shared constants module - Pass login credentials via a temp curl config file to avoid exposure in the process argument list (ps/proc) - Replace vim.ui.input secret prompt with vim.fn.inputsecret() - Add -s (silent) flag to all curl calls to suppress progress output - Guard curl output parser against missing newline in stdout - Track per-activity shared timestamp file (/tmp/jasper_<id>.last_activity) so the inactivity watchdog skips auto-pause when another Neovim instance is still active on the same task - Clean up leftover uv_timer on repeated begin_tracking calls - Remove shared activity file on teardown only when this instance wrote it
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local JASPER_URL = "https://jasper.4sigma.it"
|
local constants = require("jasper.constants")
|
||||||
|
local JASPER_URL = constants.JASPER_URL
|
||||||
|
|
||||||
--- Run a curl command and return the parsed JSON body + HTTP status code.
|
--- Run a curl command and return the parsed JSON body + HTTP status code.
|
||||||
--- @param args string[] full curl argument list (without the URL, which is last)
|
--- @param args string[] full curl argument list (without the URL, which is last)
|
||||||
@@ -22,6 +23,9 @@ local function curl(args)
|
|||||||
|
|
||||||
-- Split body and status code
|
-- Split body and status code
|
||||||
local last_newline = result.stdout:match(".*\n()")
|
local last_newline = result.stdout:match(".*\n()")
|
||||||
|
if not last_newline then
|
||||||
|
return nil, 0, "curl: unexpected output format (no newline)"
|
||||||
|
end
|
||||||
local status_code = tonumber(result.stdout:sub(last_newline)) or 0
|
local status_code = tonumber(result.stdout:sub(last_newline)) or 0
|
||||||
local body = result.stdout:sub(1, last_newline - 2) -- strip trailing \n + status line
|
local body = result.stdout:sub(1, last_newline - 2) -- strip trailing \n + status line
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ end
|
|||||||
local function get(path, token)
|
local function get(path, token)
|
||||||
return curl({
|
return curl({
|
||||||
"curl",
|
"curl",
|
||||||
|
"-s",
|
||||||
"-H",
|
"-H",
|
||||||
"Authorization: Token " .. token,
|
"Authorization: Token " .. token,
|
||||||
JASPER_URL .. path,
|
JASPER_URL .. path,
|
||||||
@@ -51,6 +56,7 @@ end
|
|||||||
local function post(path, token, form)
|
local function post(path, token, form)
|
||||||
local args = {
|
local args = {
|
||||||
"curl",
|
"curl",
|
||||||
|
"-s",
|
||||||
"-X",
|
"-X",
|
||||||
"POST",
|
"POST",
|
||||||
"-H",
|
"-H",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local JASPER_URL = "https://jasper.4sigma.it"
|
local constants = require("jasper.constants")
|
||||||
|
local JASPER_URL = constants.JASPER_URL
|
||||||
|
|
||||||
--- @return string path to the token cache file
|
--- @return string path to the token cache file
|
||||||
local function token_file_path()
|
local function token_file_path()
|
||||||
@@ -46,16 +47,29 @@ end
|
|||||||
--- @return string|nil token
|
--- @return string|nil token
|
||||||
--- @return string|nil error message
|
--- @return string|nil error message
|
||||||
function M.login(username, password)
|
function M.login(username, password)
|
||||||
|
-- Write credentials to a temp curl config file so they never appear in
|
||||||
|
-- the process argument list (ps aux / /proc/<pid>/cmdline).
|
||||||
|
local tmpfile = os.tmpname()
|
||||||
|
local cf = io.open(tmpfile, "w")
|
||||||
|
if not cf then
|
||||||
|
return nil, "cannot create temp file for curl config"
|
||||||
|
end
|
||||||
|
cf:write('--data-urlencode "username=' .. username .. '"\n')
|
||||||
|
cf:write('--data-urlencode "password=' .. password .. '"\n')
|
||||||
|
cf:close()
|
||||||
|
|
||||||
local result = vim.system({
|
local result = vim.system({
|
||||||
"curl",
|
"curl",
|
||||||
"-s",
|
"-s",
|
||||||
"--data-urlencode",
|
"-X",
|
||||||
"username=" .. username,
|
"POST",
|
||||||
"--data-urlencode",
|
"--config",
|
||||||
"password=" .. password,
|
tmpfile,
|
||||||
JASPER_URL .. "/api/v1/token/login/",
|
JASPER_URL .. "/api/v1/token/login/",
|
||||||
}, { text = true }):wait()
|
}, { text = true }):wait()
|
||||||
|
|
||||||
|
os.remove(tmpfile)
|
||||||
|
|
||||||
if result.code ~= 0 then
|
if result.code ~= 0 then
|
||||||
return nil, "curl error (exit " .. result.code .. ")"
|
return nil, "curl error (exit " .. result.code .. ")"
|
||||||
end
|
end
|
||||||
@@ -84,7 +98,8 @@ function M.prompt_login(callback)
|
|||||||
callback(nil)
|
callback(nil)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
vim.ui.input({ prompt = "Jasper password: ", secret = true }, function(password)
|
-- vim.fn.inputsecret() is native Neovim: hides typed characters in all environments
|
||||||
|
local password = vim.fn.inputsecret("Jasper password: ")
|
||||||
if not password or password == "" then
|
if not password or password == "" then
|
||||||
vim.notify("[Jasper] Login cancelled", vim.log.levels.WARN)
|
vim.notify("[Jasper] Login cancelled", vim.log.levels.WARN)
|
||||||
callback(nil)
|
callback(nil)
|
||||||
@@ -99,7 +114,6 @@ function M.prompt_login(callback)
|
|||||||
vim.notify("[Jasper] Login successful", vim.log.levels.INFO)
|
vim.notify("[Jasper] Login successful", vim.log.levels.INFO)
|
||||||
callback(token)
|
callback(token)
|
||||||
end)
|
end)
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
6
lua/jasper/constants.lua
Normal file
6
lua/jasper/constants.lua
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
--- jasper/constants.lua
|
||||||
|
--- Shared constants used across the plugin.
|
||||||
|
|
||||||
|
return {
|
||||||
|
JASPER_URL = "https://jasper.4sigma.it",
|
||||||
|
}
|
||||||
@@ -14,10 +14,81 @@ local state = {
|
|||||||
running = false,
|
running = false,
|
||||||
-- inactivity_ms: milliseconds of silence before the timer is auto-paused
|
-- inactivity_ms: milliseconds of silence before the timer is auto-paused
|
||||||
inactivity_ms = 10 * 60 * 1000,
|
inactivity_ms = 10 * 60 * 1000,
|
||||||
|
-- last epoch (seconds) this instance wrote to the shared activity file
|
||||||
|
last_activity_time = nil, ---@type number|nil
|
||||||
uv_timer = nil, ---@type uv.uv_timer_t|nil
|
uv_timer = nil, ---@type uv.uv_timer_t|nil
|
||||||
augroup = nil, ---@type number|nil
|
augroup = nil, ---@type number|nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Shared-activity timestamp (multi-instance coordination)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- Path of the shared last-activity file for the current attivita_id.
|
||||||
|
--- @return string|nil
|
||||||
|
local function activity_file_path()
|
||||||
|
if not state.attivita_id then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return "/tmp/jasper_" .. tostring(state.attivita_id) .. ".last_activity"
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Write the current epoch (seconds) to the shared activity file.
|
||||||
|
local function touch_activity_file()
|
||||||
|
local path = activity_file_path()
|
||||||
|
if not path then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = os.time()
|
||||||
|
local f = io.open(path, "w")
|
||||||
|
if f then
|
||||||
|
f:write(tostring(now))
|
||||||
|
f:close()
|
||||||
|
state.last_activity_time = now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Return true if another instance has recorded activity within `inactivity_ms`.
|
||||||
|
local function other_instance_active()
|
||||||
|
local path = activity_file_path()
|
||||||
|
if not path then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local f = io.open(path, "r")
|
||||||
|
if not f then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local raw = f:read("*l")
|
||||||
|
f:close()
|
||||||
|
local ts = tonumber(raw)
|
||||||
|
if not ts then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local elapsed_ms = (os.time() - ts) * 1000
|
||||||
|
return elapsed_ms < state.inactivity_ms
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Remove the shared activity file on exit, but only if this instance was
|
||||||
|
--- the last to write it. If another instance wrote a newer timestamp, leave
|
||||||
|
--- the file intact so the other instance's watchdog keeps working correctly.
|
||||||
|
local function cleanup_activity_file()
|
||||||
|
local path = activity_file_path()
|
||||||
|
if not path or not state.last_activity_time then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local f = io.open(path, "r")
|
||||||
|
if not f then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local raw = f:read("*l")
|
||||||
|
f:close()
|
||||||
|
local ts = tonumber(raw)
|
||||||
|
-- Only remove if the timestamp on disk matches what we wrote last
|
||||||
|
if ts and ts == state.last_activity_time then
|
||||||
|
os.remove(path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Helpers
|
-- Helpers
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -47,6 +118,12 @@ local function arm_inactivity_watchdog()
|
|||||||
if not state.running then
|
if not state.running then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- Don't pause if another instance with the same attivita_id is still active
|
||||||
|
if other_instance_active() then
|
||||||
|
notify("Inactivity detected, but another instance is active — keeping timer running")
|
||||||
|
arm_inactivity_watchdog()
|
||||||
|
return
|
||||||
|
end
|
||||||
local ok, err = api.stop_timer(state.token, state.timer_id)
|
local ok, err = api.stop_timer(state.token, state.timer_id)
|
||||||
if ok then
|
if ok then
|
||||||
state.running = false
|
state.running = false
|
||||||
@@ -104,6 +181,7 @@ function M.on_activity()
|
|||||||
if not state.timer_id then
|
if not state.timer_id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
touch_activity_file()
|
||||||
|
|
||||||
if not state.running then
|
if not state.running then
|
||||||
-- Resume after an inactivity pause
|
-- Resume after an inactivity pause
|
||||||
@@ -131,6 +209,13 @@ local function begin_tracking(token, timer_id)
|
|||||||
state.token = token
|
state.token = token
|
||||||
state.timer_id = timer_id
|
state.timer_id = timer_id
|
||||||
|
|
||||||
|
-- Close any leftover uv_timer from a previous (unexpected) call
|
||||||
|
if state.uv_timer then
|
||||||
|
state.uv_timer:stop()
|
||||||
|
state.uv_timer:close()
|
||||||
|
state.uv_timer = nil
|
||||||
|
end
|
||||||
|
|
||||||
-- Create the libuv timer used for inactivity detection
|
-- Create the libuv timer used for inactivity detection
|
||||||
state.uv_timer = vim.uv.new_timer()
|
state.uv_timer = vim.uv.new_timer()
|
||||||
|
|
||||||
@@ -209,6 +294,7 @@ function M.teardown()
|
|||||||
state.uv_timer:close()
|
state.uv_timer:close()
|
||||||
state.uv_timer = nil
|
state.uv_timer = nil
|
||||||
end
|
end
|
||||||
|
cleanup_activity_file()
|
||||||
if state.running and state.timer_id then
|
if state.running and state.timer_id then
|
||||||
local ok, err = api.stop_timer(state.token, state.timer_id)
|
local ok, err = api.stop_timer(state.token, state.timer_id)
|
||||||
if ok then
|
if ok then
|
||||||
|
|||||||
Reference in New Issue
Block a user